← 문제로

36. 강화 결과 완료 콜백과 취소의 경합

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 강화 결과 완료 콜백과 취소의 경합

난이도: 최상

요약

EnhancementService.Start(B)는 백그라운드 Task 안에서 강화 결과를 계산한 뒤 request.Context.ApplyResult(...)를 호출해 최종 상태에 반영한다. 그런데 이 콜백은 request.Context(A)를 그 순간에 다시 읽어 접근할 뿐, 그 사이 다른 스레드가 Cancel을 호출해 컨텍스트를 정리(Disposenull 대입)했는지 여부를 전혀 확인하지 않는다. Cancel과 완료 콜백 사이에는 어떤 동기화도, 취소 여부를 원자적으로 검사하고 반영을 막는 장치도 없다. 그 결과 두 가지 방식으로 요구사항이 깨진다: (1) 완료 콜백이 request.Context를 읽는 순간과 ApplyResult를 호출하는 순간 사이에 Cancel이 끼어들면 이미 정리된(혹은 null이 된) 컨텍스트에 결과가 반영되려다 NullReferenceException이 나거나, Cancelrequest.Context = null 대입을 아직 하지 않은 찰나에 완료 콜백이 이미 로컬로 캡처해둔(구버전) 컨텍스트 참조를 통해 이미 Dispose된 객체에 결과를 써버린다. (2) 반대로 Cancel이 이미 끝난 뒤에도 완료 콜백이 취소 여부를 확인하지 않으므로, "취소된 요청의 결과는 반영되면 안 된다"는 요구사항 자체가 지켜지지 않는다.

문제점

  • 분류: 비동기 완료 콜백과 취소 처리 사이의 동기화 부재로 인한 경합 조건
  • 증상: 플레이어가 강화 요청 직후 접속을 끊거나 새 강화를 예약해 이전 요청이 취소되는데, 하필 그 취소 시점과 강화 계산 완료 시점이 겹치면 (1) 드물게 NullReferenceException이 발생해 워커 스레드가 죽거나, (2) 예외 없이 조용히 넘어가지만 실제로는 취소되었어야 할 강화 결과가 캐릭터 상태에 반영되어버린다(플레이어 입장에서는 취소를 요청했는데 아이템이 바뀌어 있는 정합성 버그).
  • 재현 조건: Start로 요청을 시작한 직후, 강화 계산이 끝나기 전(약 50ms 내)에 다른 스레드에서 Cancel을 호출하되, CancelDispose()/Context = null 실행과 완료 콜백의 request.Context.ApplyResult(...) 실행 순서를 뒤섞으면(스트레스 반복 실행 시) 두 실패 모드 모두 관찰할 수 있다.
  • 근본 원인:
    • (A) EnhancementRequest.Context가 그냥 평범한 필드로 선언되어 있어, 여러 스레드가 동시에 읽고 쓸 수 있는데도 원자적 읽기/쓰기나 락으로 보호되지 않는다. 취소 여부(IsCancelled)와 컨텍스트 참조가 하나의 원자적 상태로 묶여 있지 않아 "취소됐으면 컨텍스트가 확실히 안 보인다"는 보장이 없다.
    • (B) 완료 콜백이 결과를 반영하기 직전에 "이 요청이 취소되지 않았는가"를 검사하지 않고, 검사와 반영 사이에 취소가 끼어들 수 없다는 보장(원자성)도 없다. 취소와 완료가 각자 독립적으로 컨텍스트에 접근하는 구조 자체가 문제다.

수정안

요청의 상태(대기/완료/취소)를 락으로 보호된 단일 상태 머신으로 관리하고, "완료 처리"와 "취소 처리" 중 오직 하나만 컨텍스트에 접근하도록 상호 배제한다. 컨텍스트 정리는 그 상태 전이가 취소 쪽으로 확정된 뒤에만 일어나게 한다.

using System;
using System.Threading;
using System.Threading.Tasks;

public sealed class EnhancementContext
{
    public int CharacterId;
    public int ResultItemId;
    public bool Applied;

    public void ApplyResult(int resultItemId)
    {
        ResultItemId = resultItemId;
        Applied = true;
    }

    public void Dispose()
    {
        CharacterId = -1;
    }
}

public enum RequestState
{
    Pending,
    Completed,
    Cancelled,
}

public sealed class EnhancementRequest
{
    private readonly object _gate = new();
    private EnhancementContext _context;
    private RequestState _state = RequestState.Pending;

    public EnhancementRequest(EnhancementContext context)
    {
        _context = context;
    }

    public Task ComputeTask;

    // 계산 완료 시 호출된다. 이미 취소되었다면 아무 것도 하지 않는다.
    public void TryComplete(int resultItemId)
    {
        EnhancementContext contextToApply = null;
        lock (_gate)
        {
            if (_state != RequestState.Pending)
                return; // 이미 취소됨 — 결과를 버린다.

            _state = RequestState.Completed;
            contextToApply = _context;
        }

        // 락 밖에서 반영: 이 시점 이후 Cancel 이 호출돼도 상태는 이미
        // Completed 로 확정되었으므로 Cancel 은 아무 일도 하지 않는다.
        contextToApply.ApplyResult(resultItemId);
    }

    // 취소 요청. 이미 완료되었다면 컨텍스트를 건드리지 않는다.
    public void TryCancel()
    {
        EnhancementContext contextToDispose = null;
        lock (_gate)
        {
            if (_state != RequestState.Pending)
                return; // 이미 완료됨 — 결과가 이미 반영되었으니 취소해도 되돌리지 않는다.

            _state = RequestState.Cancelled;
            contextToDispose = _context;
            _context = null;
        }

        contextToDispose.Dispose();
    }
}

public sealed class EnhancementService
{
    public EnhancementRequest Start(EnhancementContext context)
    {
        var request = new EnhancementRequest(context);

        request.ComputeTask = Task.Run(() =>
        {
            int resultItemId = ComputeEnhancementResult(context.CharacterId);
            request.TryComplete(resultItemId);
        });

        return request;
    }

    public void Cancel(EnhancementRequest request)
    {
        request.TryCancel();
    }

    private static int ComputeEnhancementResult(int characterId)
    {
        Thread.Sleep(50);
        return characterId * 7 % 100;
    }
}

핵심은 _statelock으로 보호된 단일 진실 공급원(single source of truth)으로 만들어, "완료로 확정"과 "취소로 확정"이 서로 배타적(mutually exclusive)이 되게 한 것이다. TryCompleteTryCancel 둘 다 락 안에서 상태를 먼저 확정 짓고, 실제 부수효과(ApplyResult/Dispose)는 락 밖에서 수행해 락을 오래 쥐지 않으면서도 "둘 중 정확히 하나만 컨텍스트에 접근한다"는 보장을 얻는다.

더 나은 설계

  • 락 기반 상태 머신(채택안): 구현이 단순하고 상태 전이가 명확하다. 요청 처리량이 매우 높을 때는 요청마다 락 획득/해제 비용이 누적될 수 있지만, 강화처럼 상대적으로 빈도가 낮고 지연이 있는 작업에는 적합하다.
  • CancellationToken + Interlocked.CompareExchange 기반 상태 전이: .NET 의 표준 취소 메커니즘을 이용해 계산 자체를 취소 가능하게 만들고(협조적 취소), 완료/취소 확정은 Interlocked.CompareExchange로 락 없이 원자적으로 처리. 락이 없어 미세하게 더 빠르지만, "계산 도중 취소 신호를 어디서 체크할지"를 세밀하게 설계해야 해서 구현 복잡도가 높다.
  • 참조 계수(reference counting) 기반 컨텍스트: 컨텍스트를 즉시 Dispose하지 않고 참조 카운트가 0이 될 때만 실제로 정리. 컨텍스트를 여러 비동기 작업이 동시에 참조할 수 있는 더 일반적인 상황에 확장하기 좋지만, 이 시나리오처럼 요청-컨텍스트가 1:1인 경우엔 과설계일 수 있다.
  • 트레이드오프: 요청당 지연시간이 이미 수십 ms 단위(원거리 조회 포함)인 강화 시스템에서는 락 기반 상태 머신의 오버헤드가 무시할 만한 수준이라 채택안이 실용적이다. 반면 초당 수만 건의 완료/취소가 몰리는 초저지연 경로라면 락-프리(lock-free) 상태 전이를 고려할 가치가 있다.

면접 포인트

  • "비동기 작업의 취소는 실제로 계산을 중단시키는 것이 아니라, 계산 결과가 최종 상태에 반영되는 것을 막는 것"이라는 관점을 설명할 수 있어야 한다 — 계산 자체는 계속 진행 중일 수 있으므로, 완료와 취소를 "누가 먼저 최종 상태를 확정 짓는가"의 경쟁으로 모델링해야 한다.
  • 왜 "취소 플래그를 그냥 읽어서 확인"하는 방식(TOCTOU: check-then-act)이 충분하지 않은지, 그리고 상태 확정을 하나의 원자적 전이로 묶는 것이 왜 필요한지 설명할 수 있어야 한다.
  • 락을 쥔 상태에서 부수효과(ApplyResult/Dispose)를 호출하지 않고, 상태만 확정한 뒤 락 밖에서 호출하는 이유(락 보유 시간 최소화, 콜백 안에서 재진입 데드락 위험 회피)를 설명할 수 있어야 한다.