게임 코드에서 콜백은 피하기 어렵다. 스킬 캐스팅 완료, 비동기 로딩 완료, 서버 응답 처리, 타이머 만료처럼 "지금은 아니고 나중에 실행할 코드"가 계속 생긴다. 전통적으로는 함수 포인터, std::function, 엔진별 delegate를 많이 쓴다.
문제는 콜백이 단순 함수 호출을 넘어 소유권을 함께 가져야 할 때다. 예를 들어 로딩 작업이 끝날 때 임시 버퍼를 넘기거나, 서버 요청 객체를 한 번만 소비하거나, 취소 가능한 작업 핸들을 콜백 안에 묶고 싶을 수 있다. 이때 std::function은 복사 가능해야 하므로 std::unique_ptr 같은 move-only 객체를 캡처한 람다를 담을 수 없다.
C++23의 std::move_only_function은 이 빈틈을 메운다. 이름 그대로 복사할 수 없고 이동만 가능한 함수 래퍼다. 게임 런타임에서 "한 번 등록하고, 한 번 옮기고, 정해진 시점에 실행"하는 콜백 모델과 잘 맞는다.
std::function이 막히는 지점
다음 코드는 의도는 자연스럽지만 std::function에 넣을 수 없다. 람다가 std::unique_ptr을 소유하기 때문에 복사 불가능해졌기 때문이다.
#include <functional>
#include <memory>
#include <vector>
struct LoadBuffer
{
std::vector<std::byte> bytes;
};
void Register(std::function<void()> callback);
void RequestAsset()
{
auto buffer = std::make_unique<LoadBuffer>();
Register([buf = std::move(buffer)]() mutable
{
// 로딩 완료 후 한 번만 소비하고 싶다.
buf->bytes.clear();
});
}
std::function은 내부에 들어온 callable을 복사할 수 있어야 한다. 그래서 move-only 캡처를 가진 람다와 충돌한다. 우회하려고 shared_ptr로 바꾸면 생명주기는 편해지지만, 실제 소유권이 흐려지고 참조 카운트 비용도 생긴다.
move_only_function으로 의도를 코드에 드러내기
std::move_only_function<void()>을 쓰면 콜백 자체가 이동 전용이 된다. 등록한 쪽에서 큐나 작업 객체로 넘기고 나면 원래 변수는 더 이상 소유하지 않는다.
#include <functional>
#include <memory>
#include <vector>
using GameCallback = std::move_only_function<void()>;
class CompletionQueue
{
public:
void Push(GameCallback callback)
{
callbacks_.push_back(std::move(callback));
}
void Flush()
{
for (auto& callback : callbacks_)
{
if (callback)
callback();
}
callbacks_.clear();
}
private:
std::vector<GameCallback> callbacks_;
};
void RequestAsset(CompletionQueue& queue)
{
auto buffer = std::make_unique<std::vector<std::byte>>();
queue.Push([buf = std::move(buffer)]() mutable
{
// buf는 이 콜백이 단독 소유한다.
// 실행 후 callbacks_.clear()와 함께 정리된다.
buf->clear();
});
}
여기서 중요한 점은 성능보다 의미다. move_only_function이 항상 힙 할당을 없애준다고 가정하면 안 된다. 구현체와 캡처 크기에 따라 내부 버퍼를 쓸 수도 있고 할당할 수도 있다. 하지만 "복사되지 않는 콜백"이라는 제약을 타입으로 표현할 수 있다. 이 제약은 게임 코드에서 버그를 줄이는 데 꽤 크다.
게임 서버 타이머에 적용하기
서버 쪽에서는 타이머와 비동기 응답에 잘 맞는다. 예를 들어 전투 서버에서 지연 스킬을 예약한다고 하자.
#include <chrono>
#include <functional>
#include <queue>
struct TimerJob
{
std::chrono::steady_clock::time_point due;
std::move_only_function<void()> run;
};
class TimerSystem
{
public:
void Add(std::chrono::milliseconds delay,
std::move_only_function<void()> job)
{
jobs_.push(TimerJob{
std::chrono::steady_clock::now() + delay,
std::move(job)
});
}
private:
struct Compare
{
bool operator()(const TimerJob& a, const TimerJob& b) const
{
return a.due > b.due;
}
};
std::priority_queue<TimerJob, std::vector<TimerJob>, Compare> jobs_;
};
이 구조에서는 타이머 작업이 복사되지 않는다. 네트워크 요청 컨텍스트, 스킬 실행 상태, 일회성 보상 지급 객체처럼 "중복 실행되면 안 되는 데이터"를 콜백 안에 안전하게 묶을 수 있다. 특히 게임 서버처럼 한 요청의 소유권이 명확해야 하는 코드에서 shared_ptr 남발을 줄이는 효과가 있다.
핫패스에는 신중하게 쓰기
std::move_only_function은 편하지만 만능은 아니다. 매 프레임 수만 번 호출되는 이동, 충돌, AI 갱신 루프에 무작정 넣으면 타입 소거 호출 비용과 잠재적 할당이 부담이 될 수 있다.
핫패스에서는 여전히 템플릿, 함수 포인터, 고정 배열 기반 작업 큐가 더 낫다. 반대로 다음 조건이면 move_only_function이 실용적이다.
- 콜백 등록 횟수가 프레임당 많지 않다.
- 콜백이 실행 시점까지 자원을 단독 소유해야 한다.
- 복사 금지가 코드 안정성에 도움이 된다.
- 엔진 경계보다 게임 로직, 서버 로직, 툴 코드에 가깝다.
정리
std::move_only_function은 std::function의 상위 호환이 아니라 다른 의도를 가진 도구다. 복사 가능한 콜백이 필요하면 std::function이 여전히 자연스럽다. 하지만 소유권 있는 콜백, 한 번만 소비되는 작업, 비동기 완료 처리처럼 move-only 의미가 중요한 곳에서는 C++23다운 좋은 선택지가 된다.
게임 개발에서는 성능만큼 생명주기 실수가 치명적이다. std::move_only_function은 "이 콜백은 복사되면 안 된다"는 규칙을 주석이 아니라 타입으로 고정해 준다. 그 한 줄의 제약이 서버 요청, 로딩 작업, 타이머 시스템에서 꽤 많은 애매함을 줄여준다.
'C#·C++ 모던' 카테고리의 다른 글
| C++23 std::optional 모나딕 연산으로 게임 조회 로직의 분기와 복사 줄이기 (0) | 2026.07.12 |
|---|---|
| C# 12 람다 기본값으로 게임 이벤트 필터를 더 작게 설계하기 (0) | 2026.07.07 |
| C++23 std::scope_exit로 게임 상태 복구 코드를 안전하게 만들기 (0) | 2026.06.30 |
| C# 함수 포인터(delegate*)로 게임 핫패스의 delegate 힙 할당 없애기 (1) | 2026.06.28 |
| C++ 23 std::flat_map으로 게임 룩업 테이블의 캐시 미스 잡기 (0) | 2026.06.23 |