부트캠프 이번 주차 과제는 유니티로 메타버스 Zep과 유사한 환경의 게임을 만드는 것이었다. Zep이라고는 하지만 사실상 zep의 핵심은 웹소켓 기반 네트워크 동시성이다. 즉 이해하기 쉽게 zep을 예로 들었지만 결국말하자면 상호작용이 들어간 2D 탑뷰 게임을 만드는 과제였다.
유니티 첫 주차다 보니 유니티의 기본적인 기능을 2D로 구현해보는 것이 과제의 목표였고, 나는 다크소울 관련 아이디어가 떠올라 if 스토리가 들어간 2D 탑뷰 다크소울 팬게임을 만들었다. 물론 기간이 7일뿐이라 그럴듯하게는 못 만들었지만, 오늘 거의 다 구현을 끝냈는데 컷씬 시스템에서 치명적인 버그가 발생했다.
간혹 재생되지 않는 컷씬이 있었다. 처음엔 카메라 문제라고 생각했지만, 알고 보니 컷씬 종료와 씬 전환이 동시에 일어나는 상황에서 문제가 발생하고 있었다.
이상적인 흐름:
컷씬 실행 → 컷씬 종료 → 씬 전환 → 새 컷씬 실행
실제 발생한 흐름:
컷씬 실행 → 컷씬 종료 시작 + 씬 전환 → 새 컷씬 실행 시도 →
아직 이전 컷씬이 살아있음→ 새 컷씬 실행 차단
로그를 확인해보니 mainCamera의 CinemachineBrain 컴포넌트를 못 찾고 있었다. 하지만 씬에 배치된 카메라에는 문제가 없었다.
시도: CameraManager의 CinemachineBrain 초기화 타이밍을 Start → Awake → 프로퍼티로 변경
결과: 카메라 관련 에러 로그는 사라졌지만, 여전히 컷씬은 재생되지 않았다. 카메라보다 더 근본적인 문제가 있었다.
모든 씬 관련 인스턴스 매니저의 OnDestroy에 ServiceLocator.OnSceneUnloaded(_currentScene);를 일괄 추가했다.
내가 구현한 ServiceLocator는 싱글톤 생명주기를 가진 인스턴스는 ServiceLocator가 주입해주고, 씬에 종속된 인스턴스는 역으로 본인이 직접 의존성을 등록하는 방식이다. 명시적으로 정리 코드를 추가했지만 여전히 해결되지 않았다.
로그를 다시 확인해보니 "컷씬이 이미 재생 중"이라는 메시지가 출력되고 있었다. CutsceneManager의 _isPlaying 플래그에 걸려서 실행이 차단되고 있었다.
이제서야 문제가 컷씬 종료와 씬 전환이 동시에 일어나는 씬에서만 발생한다는 것을 확인했다. PlayCutscene이 비동기로 처리되다 보니 다음과 같은 타이밍 이슈가 발생한 것이다:
컷씬 종료 시작 + 씬 전환 → 새 씬의 Start() 실행 →
새 컷씬 PlayCutscene 호출 → 아직 이전 컷씬의 finally 블록 미실행
SceneManager.sceneLoaded 이벤트 사용 시도SceneManager.sceneLoaded += OnSceneLoaded;를 추가해서 씬 로드 시 StopCutscene()을 실행하도록 했다.
결과: 오히려 더 나빠졌다. 컷씬 시작보다 StopCutscene이 더 빨라서 아예 컷씬 기능이 작동하지 않았다. 씬의 파괴(OnDestroy)보다 새 씬의 Start가 먼저 실행되기 때문에 이 방법으로는 근본적인 해결이 불가능했다.
여기서 내가 비동기 메서드의 제어권과 생명주기 관리를 제대로 이해하지 못하고 있다는 생각이 들었다.
그래서 CancellationToken을 활용해서 적극적으로 취소요청을 보내보기로했다
PlayCutscene이 CancellationToken을 받도록 수정CutsceneManager 내부의 CancellationTokenSource와 GetCancellationTokenOnDestroy()를 CreateLinkedTokenSource로 결합PlayCutscene을 호출하는 모든 매니저가 IAsyncManager 인터페이스를 상속받도록 변경void Start()
{
InitializeManager();
}
public void InitializeManager()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();
_cutsceneManager = ServiceLocator.Get<CutsceneManager>();
if (_cutsceneManager != null)
{
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
_cancellationTokenSource.Token,
this.GetCancellationTokenOnDestroy());
_cutsceneManager.PlayCutscene(linkedCts.Token, _introCutscene,
_playerController, _lothric).Forget();
}
}
결과: 단 하나도 해결되지 않았다. 예전과 완전히 동일한 상태였다.
CancellationToken을 명시적으로 넘겨주고는 있었지만 여전히 타이망의 문제는 조금도 해결이 되지 않았다.
문제는 .Forget()을 사용하면서 비동기 작업의 제어권을 완전히 놓아버렸다는 것이다.
결국 PlayCutscene 최상단에 다음 코드를 추가하는 것으로 해결했다:
public async UniTask PlayCutscene(CancellationToken token, ...)
{
// 이전 컷씬이 완전히 종료될 때까지 대기
while (_isPlaying)
{
await UniTask.Yield(PlayerLoopTiming.Update, token);
}
// ... 나머지 로직
}
이렇게 하면 새 컷씬이 시작을 시도할 때, 이전 컷씬의 finally 블록에서 _isPlaying = false가 실행될 때까지 대기하게 된다.
하지만...
사실 개발자라면 저 소스코드 생김새만 봐도 이건 아닌데...? 하는 불안감이 들것이다. 사실 나도 이제 이게 지금 작업의 마무리 단계가 아니라 한창 진행중이었다면 절대 사용하지 않았을 방식이었다.
저 패턴은 Busy Waiting이라고 하여 OS에서는 원하는 자원을 얻기 위해 기다리는 것이 아니라 권한을 얻을 때까지 확인하는 방식이라고 한다. 다만 CPU의 자원을 쓸데 없이 낭비하기 때문에 좋지 않은 쓰레드 동기화 방식이라고...
이런 상황에서는 TaskCompletionSource나 SemaphoreSlim을 사용하는 것이 정답이라고한다.
비동기 작업의 완료를 수동으로 신호할 수 있는 도구다. "이 작업이 끝났어!"라고 명시적으로 알려줄 수 있으며, 다른 코드가 그 신호를 await으로 기다릴 수 있다. while 루프 없이도 정확한 타이밍에 동기화가 가능하다.
private TaskCompletionSource<bool> _cutsceneCompletionSource;
public async UniTask PlayCutscene(...)
{
// 이전 컷씬 완료 대기
if (_cutsceneCompletionSource != null)
{
await _cutsceneCompletionSource.Task;
}
_cutsceneCompletionSource = new TaskCompletionSource<bool>();
_isPlaying = true;
try
{
// 컷씬 로직
}
finally
{
_isPlaying = false;
_cutsceneCompletionSource?.TrySetResult(true); // 완료 신호
}
}
동시 실행 개수를 제한하는 신호등 역할을 한다. 컷씬처럼 "한 번에 하나만 실행"이 필요한 경우, WaitAsync()로 차례를 기다렸다가 Release()로 다음에게 양보하는 방식으로 동작한다.
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async UniTask PlayCutscene(...)
{
await _semaphore.WaitAsync(); // 한 번에 하나만 실행
try
{
_isPlaying = true;
// 컷씬 로직
}
finally
{
_isPlaying = false;
_semaphore.Release(); // 다음 차례에게 양보
}
}
비동기 프로그래밍 자체는 익숙했지만, 기존에 사용했던 비동기 메서드는 대부분 반환값을 받는 외부 API 통신이었다. 즉, await로 결과를 받아 처리하는 케이스가 많았고, UniTask, UniTaskVoid, async void를 구분해서 사용할 필요성을 크게 느끼지 못했다.
하지만 이번 경험을 통해 비동기 메서드라 할지라도 생명주기 관리와 제어 흐름을 완전히 놓아버려서는 안 된다는 것을 깨달았다. .Forget()이나 async void를 사용하면 예외 처리도 불가능하고, 작업의 완료 시점을 알 수 없어 타이밍 이슈가 발생할 수 있다.
결국 이번 케이스는 async void를 사용하지는 않았지만, async void를 쓰면 안 되는 이유와 본질적으로 같은 문제였다. 비동기 작업의 제어권을 놓지 않고, 적절한 동기화 메커니즘을 사용하는 것이 중요하다.