C#·C++ 모던

C++23 std::move_only_function으로 소유권 있는 게임 콜백 안전하게 넘기기

kr-gamedev 2026. 7. 5. 09:02

게임 코드에서 콜백은 피하기 어렵다. 스킬 캐스팅 완료, 비동기 로딩 완료, 서버 응답 처리, 타이머 만료처럼 "지금은 아니고 나중에 실행할 코드"가 계속 생긴다. 전통적으로는 함수 포인터, 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_functionstd::function의 상위 호환이 아니라 다른 의도를 가진 도구다. 복사 가능한 콜백이 필요하면 std::function이 여전히 자연스럽다. 하지만 소유권 있는 콜백, 한 번만 소비되는 작업, 비동기 완료 처리처럼 move-only 의미가 중요한 곳에서는 C++23다운 좋은 선택지가 된다.

게임 개발에서는 성능만큼 생명주기 실수가 치명적이다. std::move_only_function은 "이 콜백은 복사되면 안 된다"는 규칙을 주석이 아니라 타입으로 고정해 준다. 그 한 줄의 제약이 서버 요청, 로딩 작업, 타이머 시스템에서 꽤 많은 애매함을 줄여준다.