36. 강화 결과 완료 콜백과 취소의 경합
난이도 최상해설 — 강화 결과 완료 콜백과 취소의 경합
난이도: 최상
요약
EnhancementService.Start(B)는 백그라운드 Task 안에서 강화 결과를 계산한 뒤 request.Context.ApplyResult(...)를 호출해 최종 상태에 반영한다. 그런데 이 콜백은 request.Context(A)를 그 순간에 다시 읽어 접근할 뿐, 그 사이 다른 스레드가 Cancel을 호출해 컨텍스트를 정리(Dispose 후 null 대입)했는지 여부를 전혀 확인하지 않는다. Cancel과 완료 콜백 사이에는 어떤 동기화도, 취소 여부를 원자적으로 검사하고 반영을 막는 장치도 없다. 그 결과 두 가지 방식으로 요구사항이 깨진다: (1) 완료 콜백이 request.Context를 읽는 순간과 ApplyResult를 호출하는 순간 사이에 Cancel이 끼어들면 이미 정리된(혹은 null이 된) 컨텍스트에 결과가 반영되려다 NullReferenceException이 나거나, Cancel이 request.Context = null 대입을 아직 하지 않은 찰나에 완료 콜백이 이미 로컬로 캡처해둔(구버전) 컨텍스트 참조를 통해 이미 Dispose된 객체에 결과를 써버린다. (2) 반대로 Cancel이 이미 끝난 뒤에도 완료 콜백이 취소 여부를 확인하지 않으므로, "취소된 요청의 결과는 반영되면 안 된다"는 요구사항 자체가 지켜지지 않는다.
문제점
- 분류: 비동기 완료 콜백과 취소 처리 사이의 동기화 부재로 인한 경합 조건
- 증상: 플레이어가 강화 요청 직후 접속을 끊거나 새 강화를 예약해 이전 요청이 취소되는데, 하필 그 취소 시점과 강화 계산 완료 시점이 겹치면 (1) 드물게
NullReferenceException이 발생해 워커 스레드가 죽거나, (2) 예외 없이 조용히 넘어가지만 실제로는 취소되었어야 할 강화 결과가 캐릭터 상태에 반영되어버린다(플레이어 입장에서는 취소를 요청했는데 아이템이 바뀌어 있는 정합성 버그). - 재현 조건:
Start로 요청을 시작한 직후, 강화 계산이 끝나기 전(약 50ms 내)에 다른 스레드에서Cancel을 호출하되,Cancel의Dispose()/Context = null실행과 완료 콜백의request.Context.ApplyResult(...)실행 순서를 뒤섞으면(스트레스 반복 실행 시) 두 실패 모드 모두 관찰할 수 있다. - 근본 원인:
- (A)
EnhancementRequest.Context가 그냥 평범한 필드로 선언되어 있어, 여러 스레드가 동시에 읽고 쓸 수 있는데도 원자적 읽기/쓰기나 락으로 보호되지 않는다. 취소 여부(IsCancelled)와 컨텍스트 참조가 하나의 원자적 상태로 묶여 있지 않아 "취소됐으면 컨텍스트가 확실히 안 보인다"는 보장이 없다. - (B) 완료 콜백이 결과를 반영하기 직전에 "이 요청이 취소되지 않았는가"를 검사하지 않고, 검사와 반영 사이에 취소가 끼어들 수 없다는 보장(원자성)도 없다. 취소와 완료가 각자 독립적으로 컨텍스트에 접근하는 구조 자체가 문제다.
- (A)
수정안
요청의 상태(대기/완료/취소)를 락으로 보호된 단일 상태 머신으로 관리하고, "완료 처리"와 "취소 처리" 중 오직 하나만 컨텍스트에 접근하도록 상호 배제한다. 컨텍스트 정리는 그 상태 전이가 취소 쪽으로 확정된 뒤에만 일어나게 한다.
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;
}
}
핵심은 _state를 lock으로 보호된 단일 진실 공급원(single source of truth)으로 만들어, "완료로 확정"과 "취소로 확정"이 서로 배타적(mutually exclusive)이 되게 한 것이다. TryComplete와 TryCancel 둘 다 락 안에서 상태를 먼저 확정 짓고, 실제 부수효과(ApplyResult/Dispose)는 락 밖에서 수행해 락을 오래 쥐지 않으면서도 "둘 중 정확히 하나만 컨텍스트에 접근한다"는 보장을 얻는다.
더 나은 설계
- 락 기반 상태 머신(채택안): 구현이 단순하고 상태 전이가 명확하다. 요청 처리량이 매우 높을 때는 요청마다 락 획득/해제 비용이 누적될 수 있지만, 강화처럼 상대적으로 빈도가 낮고 지연이 있는 작업에는 적합하다.
CancellationToken+Interlocked.CompareExchange기반 상태 전이: .NET 의 표준 취소 메커니즘을 이용해 계산 자체를 취소 가능하게 만들고(협조적 취소), 완료/취소 확정은Interlocked.CompareExchange로 락 없이 원자적으로 처리. 락이 없어 미세하게 더 빠르지만, "계산 도중 취소 신호를 어디서 체크할지"를 세밀하게 설계해야 해서 구현 복잡도가 높다.- 참조 계수(reference counting) 기반 컨텍스트: 컨텍스트를 즉시
Dispose하지 않고 참조 카운트가 0이 될 때만 실제로 정리. 컨텍스트를 여러 비동기 작업이 동시에 참조할 수 있는 더 일반적인 상황에 확장하기 좋지만, 이 시나리오처럼 요청-컨텍스트가 1:1인 경우엔 과설계일 수 있다. - 트레이드오프: 요청당 지연시간이 이미 수십 ms 단위(원거리 조회 포함)인 강화 시스템에서는 락 기반 상태 머신의 오버헤드가 무시할 만한 수준이라 채택안이 실용적이다. 반면 초당 수만 건의 완료/취소가 몰리는 초저지연 경로라면 락-프리(lock-free) 상태 전이를 고려할 가치가 있다.
면접 포인트
- "비동기 작업의 취소는 실제로 계산을 중단시키는 것이 아니라, 계산 결과가 최종 상태에 반영되는 것을 막는 것"이라는 관점을 설명할 수 있어야 한다 — 계산 자체는 계속 진행 중일 수 있으므로, 완료와 취소를 "누가 먼저 최종 상태를 확정 짓는가"의 경쟁으로 모델링해야 한다.
- 왜 "취소 플래그를 그냥 읽어서 확인"하는 방식(TOCTOU: check-then-act)이 충분하지 않은지, 그리고 상태 확정을 하나의 원자적 전이로 묶는 것이 왜 필요한지 설명할 수 있어야 한다.
- 락을 쥔 상태에서 부수효과(
ApplyResult/Dispose)를 호출하지 않고, 상태만 확정한 뒤 락 밖에서 호출하는 이유(락 보유 시간 최소화, 콜백 안에서 재진입 데드락 위험 회피)를 설명할 수 있어야 한다.
해설 — 강화 결과 완료 콜백과 취소의 경합 (use-after-free)
난이도: 최상
요약
EnhancementService::Start(B)가 만든 워커 스레드는 계산이 끝나면 request->context->ApplyResult(result)를 호출한다. 이때 request->context(A)는 단순한 raw 포인터 필드이고, Cancel이 다른 스레드에서 delete request->context; request->context = nullptr;을 실행하는 시점과 아무런 동기화 없이 경합한다. 워커 스레드가 request->context를 읽어 ApplyResult를 호출하려는 바로 그 순간에 Cancel이 같은 포인터를 delete해버리면, 워커 스레드는 이미 해제된 메모리에 대해 가상함수 호출(ApplyResult는 비가상이지만 멤버 접근 자체가 해제된 메모리를 대상으로 함)을 수행하게 되어 정의되지 않은 동작(use-after-free)이 발생한다. 운이 나쁘면 그 메모리가 이미 다른 목적으로 재할당되어 있어 엉뚱한 데이터가 캐릭터 상태인 것처럼 덮어써지거나, 크래시가 발생한다. 반대로 타이밍이 살짝 달라 ApplyResult 호출이 delete보다 먼저 완료되면, 취소되었어야 할 요청의 결과가 조용히 반영되어버려 정합성이 깨진다.
문제점
- 분류: 소유권이 불분명한 raw 포인터에 대한 동기화 없는 동시 접근 (해제와 접근의 경합)
- 증상: 플레이어가 강화 요청 직후 접속을 끊어 요청이 취소되는 타이밍이 계산 완료와 겹치면, (1) 드물게(타이밍에 따라) 크래시나 손상된 메모리 접근으로 인한 알 수 없는 오류가 발생하고, (2) 크래시가 나지 않는 타이밍에서는 취소되었어야 할 강화 결과가 캐릭터 상태에 반영되어버린다. AddressSanitizer 로 재현하면 명확한 heap-use-after-free 리포트를 얻을 수 있다.
- 재현 조건:
Start로 요청을 만든 직후(계산이 끝나기 전, 약 50ms 내) 다른 스레드에서Cancel을 호출해delete request->context가 실행되는 타이밍과, 워커 스레드가request->context->ApplyResult(...)를 호출하는 타이밍이 겹치도록 반복 실행하면(스트레스 테스트) 재현할 수 있다. - 근본 원인:
- (A)
EnhancementRequest::context가 소유권이 불명확한 raw 포인터로 선언되어 있다. 누가 이 포인터의 수명을 최종적으로 책임지는지(워커 스레드인지,Cancel을 호출하는 스레드인지)가 코드상 정의되어 있지 않다. - (B) 워커 스레드가
request->context를 접근하기 직전에 "아직 유효한 포인터인가"를 검사할 방법이 없고, 검사와 실제 사용(ApplyResult호출) 사이에 원자성도 없다.Cancel의delete와 워커의 역참조가 어떤 락이나 동기화 장치로도 상호 배제되지 않는다.
- (A)
수정안
컨텍스트의 수명을 std::shared_ptr로 관리해 "마지막까지 들고 있는 쪽이 해제"하게 만들고, 취소/완료 확정을 std::mutex + 상태 플래그로 상호 배제한다. Cancel은 더 이상 직접 delete하지 않고, "이 요청은 취소로 확정되었다"는 상태만 원자적으로 세팅한 뒤 컨텍스트에 대한 자신의 참조를 놓는다(마지막 참조가 사라질 때 shared_ptr가 자동으로 해제).
#include <chrono>
#include <memory>
#include <mutex>
#include <thread>
class EnhancementContext
{
public:
int characterId = 0;
int resultItemId = 0;
bool applied = false;
void ApplyResult(int item)
{
resultItemId = item;
applied = true;
}
};
enum class RequestState
{
Pending,
Completed,
Cancelled,
};
class EnhancementRequest
{
public:
explicit EnhancementRequest(std::shared_ptr<EnhancementContext> context)
: context_(std::move(context))
{
}
// 계산 완료 시 호출된다. 이미 취소되었다면 아무 것도 하지 않는다.
void TryComplete(int resultItemId)
{
std::shared_ptr<EnhancementContext> contextToApply;
{
std::lock_guard<std::mutex> lock(mutex_);
if (state_ != RequestState::Pending)
return; // 이미 취소됨 — 결과를 버린다.
state_ = RequestState::Completed;
contextToApply = context_; // shared_ptr 복사 — 락 밖에서도 안전하게 살아있다.
}
contextToApply->ApplyResult(resultItemId);
}
// 취소 요청. 이미 완료되었다면 컨텍스트를 건드리지 않는다.
void TryCancel()
{
std::lock_guard<std::mutex> lock(mutex_);
if (state_ != RequestState::Pending)
return; // 이미 완료됨 — 결과가 이미 반영되었으니 되돌리지 않는다.
state_ = RequestState::Cancelled;
context_.reset(); // 우리쪽 참조만 놓는다. 워커가 이미 복사해간 shared_ptr이
// 있다면(TryComplete 진행 중) 그쪽이 마지막 참조가 되어
// 안전하게 살아있다가 그쪽 소멸 시 해제된다.
}
std::thread worker;
private:
std::mutex mutex_;
std::shared_ptr<EnhancementContext> context_;
RequestState state_ = RequestState::Pending;
};
class EnhancementService
{
public:
std::shared_ptr<EnhancementRequest> Start(std::shared_ptr<EnhancementContext> context)
{
int characterId = context->characterId;
auto request = std::make_shared<EnhancementRequest>(std::move(context));
request->worker = std::thread([request, characterId]()
{
int result = ComputeEnhancementResult(characterId);
request->TryComplete(result);
});
return request;
}
void Cancel(const std::shared_ptr<EnhancementRequest>& request)
{
request->TryCancel();
}
private:
static int ComputeEnhancementResult(int characterId)
{
std::this_thread::sleep_for(std::chrono::milliseconds(50));
return (characterId * 7) % 100;
}
};
핵심 변경은 두 가지다. 첫째, 컨텍스트 수명을 shared_ptr로 옮겨 "누가 마지막으로 들고 있느냐"가 곧 "누가 해제하느냐"가 되게 함으로써 raw delete가 만드는 UAF 창을 원천적으로 없앴다. 둘째, state_를 mutex로 보호된 단일 상태로 만들어 완료와 취소가 서로 배타적으로 확정되게 했다 — TryComplete와 TryCancel 둘 다 락 안에서 상태를 먼저 확정하고, 실제 컨텍스트 접근(ApplyResult/reset)은 그 확정 이후에 이뤄지므로 "확정된 상태와 실제로 일어나는 부수효과"가 항상 일치한다.
더 나은 설계
shared_ptr+mutex상태 머신(채택안): 소유권 문제와 경합 문제를 동시에 해결하며, 표준 라이브러리만으로 구현 가능해 이식성이 좋다. 다만 원자적 참조 카운트 증감 비용(shared_ptr복사)이 있고, 워커 스레드를join/detach하는 생명주기 관리는 별도로 신경써야 한다(위 스니펫은 지면상 생략했지만 실제로는EnhancementRequest소멸 전worker.join()이 필요하다).std::atomic<RequestState>+ 이중 확인(double-checked) 없이compare_exchange로 상태만 전이: 락 없이 상태 전이는 원자적으로 하되, 컨텍스트 접근 자체는 여전히shared_ptr에 위임. 락 경합을 줄일 수 있지만 메모리 순서(memory order)를 정확히 지정해야 하는 복잡도가 늘어난다.- 작업 큐 + 단일 처리 스레드로 직렬화: 완료/취소 이벤트를 모두 하나의 처리 스레드가 순차적으로 소비하는 큐에 넣어, 애초에 동시 접근 자체가 발생하지 않게 만드는 설계. 동기화 프리미티브가 거의 필요 없어지는 대신, 그 처리 스레드가 병목이 될 수 있다.
- 트레이드오프: 강화처럼 요청당 지연이 이미 존재하고 동시 요청 수가 극단적으로 많지 않은 도메인에서는
shared_ptr+mutex조합이 안전성과 구현 단순성 면에서 가장 균형 잡힌 선택이다. 초저지연·초고빈도 경로라면 락-프리 상태 전이나 직렬화 큐를 고려할 만하다.
면접 포인트
- raw 포인터로 표현된 "누가 이 객체를 해제할 책임이 있는가"라는 질문에 답이 없는 코드는 그 자체로 위험 신호라는 점 — 소유권을
shared_ptr/unique_ptr로 명시하는 것이 왜 동시성 버그를 원천적으로 줄이는지 설명할 수 있어야 한다. - "완료 확정"과 "취소 확정"을 하나의 상호 배타적 상태 전이로 모델링해야 하는 이유, 그리고 상태 전이와 실제 부수효과(메모리 해제, 결과 반영)의 순서를 어떻게 락으로 묶어야 하는지 설명할 수 있어야 한다.
- AddressSanitizer/ThreadSanitizer 같은 도구가 이런 종류의 경합을 어떻게 잡아내는지, 그리고 실서비스에서 이런 버그가 "가끔씩만" 재현되는 이유(타이밍 의존성)를 설명할 수 있어야 한다.