C#·C++ 모던

C++23 std::scope_exit로 게임 상태 복구 코드를 안전하게 만들기

kr-gamedev 2026. 6. 30. 09:31

게임 코드는 성공 경로보다 실패 경로에서 더 자주 망가집니다. 스킬을 시전하다가 마나만 빠지고 쿨타임이 안 걸리거나, 로딩 중 예외가 나서 임시 플래그가 그대로 남거나, AI 상태를 바꾸다 중간 return 때문에 이동 잠금이 풀리지 않는 식입니다.

이런 문제는 대부분 “시작할 때 바꾼 상태를 끝날 때 반드시 되돌린다”는 규칙이 코드에 흩어져 있어서 생깁니다. C++에서는 오래전부터 RAII 패턴으로 해결해 왔고, C++23의 std::scope_exit는 이 패턴을 표준 문법처럼 간결하게 쓸 수 있게 해줍니다.

핵심은 간단합니다. 현재 스코프를 빠져나갈 때 실행할 정리 코드를 미리 등록합니다. 성공, 실패, 조기 return 여부와 관계없이 실행되므로 게임 플레이 코드의 “복구 누락”을 줄이는 데 효과적입니다.

1. 조기 return이 많은 게임 코드의 문제

예를 들어 스킬 시전 중 캐릭터 입력을 잠그는 코드가 있다고 합시다.

bool CastSkill(Character& character, Skill& skill)
{
    character.SetInputLocked(true);

    if (!skill.HasEnoughMana(character))
        return false;

    if (!skill.CanTarget(character.GetTarget()))
        return false;

    skill.ConsumeMana(character);
    skill.SpawnEffect();

    character.SetInputLocked(false);
    return true;
}

이 코드는 실패 경로에서 SetInputLocked(false)가 호출되지 않습니다. 실제 프로젝트에서는 이런 실수가 훨씬 찾기 어렵습니다. 중간에 로그, 애니메이션, 네트워크 요청, 에셋 로딩이 섞이기 때문입니다.

std::scope_exit를 쓰면 정리 책임을 시작 지점 근처에 둘 수 있습니다.

#include <scope>

bool CastSkill(Character& character, Skill& skill)
{
    character.SetInputLocked(true);

    std::scope_exit unlockInput = [&]
    {
        character.SetInputLocked(false);
    };

    if (!skill.HasEnoughMana(character))
        return false;

    if (!skill.CanTarget(character.GetTarget()))
        return false;

    skill.ConsumeMana(character);
    skill.SpawnEffect();

    return true;
}

이제 함수가 어디서 끝나든 입력 잠금은 풀립니다. 코드 리뷰에서도 “잠갔으면 바로 아래에 해제 예약이 있는가”만 보면 됩니다.

2. 성공할 때는 복구하지 않는 롤백 패턴

모든 정리가 항상 실행되어야 하는 것은 아닙니다. 아이템 지급, 세이브 갱신, 인벤토리 이동처럼 성공하면 유지하고 실패하면 되돌려야 하는 작업도 많습니다.

이때는 release()를 사용합니다.

#include <scope>

bool MoveItem(Inventory& from, Inventory& to, ItemId itemId)
{
    Item item = from.Remove(itemId);

    std::scope_exit rollback = [&]
    {
        from.Add(item);
    };

    if (!to.CanAdd(item))
        return false;

    to.Add(item);

    rollback.release();
    return true;
}

처음에는 “실패하면 원래 인벤토리에 다시 넣는다”는 복구 계획을 세웁니다. 마지막까지 성공한 경우에만 release()로 복구를 취소합니다. 트랜잭션처럼 생각하면 쉽습니다.

이 방식은 게임 서버에서도 유용합니다. 보상 지급, 재화 차감, 퀘스트 상태 변경처럼 순서가 중요한 코드에서 실패 경로를 명확히 만들 수 있습니다.

3. Unreal C++에서도 같은 사고방식이 통한다

Unreal 프로젝트가 아직 C++23 표준 라이브러리를 바로 쓰기 어려운 환경이라면, 엔진 제공 도구나 작은 로컬 유틸로 같은 패턴을 적용할 수 있습니다. 중요한 것은 문법 자체보다 “상태 변경과 복구 예약을 붙여 둔다”는 설계입니다.

bool UMyAbility::ActivateAbility(ACharacter* Character)
{
    Character->DisableInput(nullptr);

    auto RestoreInput = MakeScopeExit([&]
    {
        Character->EnableInput(nullptr);
    });

    if (!HasCost())
    {
        return false;
    }

    if (!CommitAbility())
    {
        return false;
    }

    PlayMontage();
    return true;
}

프로젝트에서 MakeScopeExit 같은 헬퍼를 쓰고 있다면 팀 컨벤션에 맞추면 됩니다. C++23 환경에서는 표준 std::scope_exit로 통일할 수 있다는 점이 장점입니다.

4. SOLID 관점에서 보면 책임 분리다

scope_exit는 단순 편의 문법이 아닙니다. SRP, 즉 단일 책임 원칙에도 잘 맞습니다.

스킬 함수의 주 책임은 “스킬을 시전한다”입니다. 입력 잠금 해제, 임시 상태 복구, 실패 시 롤백 같은 정리 코드는 부가 책임입니다. 이 부가 책임이 함수 아래쪽에 흩어지면 본래 흐름을 읽기 어려워집니다.

scope_exit를 쓰면 상태를 바꾼 위치에서 복구 정책까지 함께 선언할 수 있습니다.

boss.SetPhaseChanging(true);

std::scope_exit resetPhaseFlag = [&]
{
    boss.SetPhaseChanging(false);
};

LoadPhaseData();
SpawnPhaseActors();
ApplyPhasePattern();

이 코드는 “보스가 페이즈 변경 중임을 표시하고, 이 스코프를 나가면 반드시 해제한다”는 의도를 바로 보여줍니다. 상태 관리 코드가 명령형 나열에서 작은 안전 장치로 바뀝니다.

결론

C++23의 std::scope_exit는 게임 코드에서 자주 발생하는 복구 누락, 조기 return 버그, 실패 경로 중복을 줄여 줍니다.

특히 입력 잠금, 임시 플래그, 인벤토리 이동, 서버 보상 처리처럼 “시작하면 반드시 끝 처리가 필요한” 코드에 잘 맞습니다. 성공 시 유지해야 하는 변경은 release()로 롤백을 취소하면 됩니다.

핵심은 상태를 바꾸는 순간 복구 책임도 함께 적는 것입니다. 작은 습관이지만, 플레이 중 한 번씩 터지는 애매한 상태 꼬임을 줄이는 데 꽤 강력한 도구가 됩니다.