20. 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황
난이도 상해설 — 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
OnConfirm(commit)과 OnDisconnect(취소)가 공유 거래 상태를 락 없이 플래그만으로 조율한다.
(C) 둘 다 !t.Committed 를 검사하고 행동하는 check-then-act 라, commit 이 Committed=true
직후 아직 자산 이동을 진행하는 사이 OnDisconnect 가 (이전에 읽은) !Committed 로 취소에
들어가거나, 그 반대 인터리빙이 생겨 commit 과 취소가 동시에 일어난다. (B) Commit 의 교환이
"아이템들 → 골드" 를 여러 단계로 비원자 적용하므로, 중간에 한쪽 인벤토리가 세션 정리로
사라지거나 MoveItem/MoveGold 가 실패하면 부분 적용(A의 아이템은 갔는데 B의 골드는 안 옴 →
복제/증발)으로 끝난다 — 반환값도 무시한다. (A) commit 진입 가드가 단순 플래그라 이중 confirm·
재진입 시 이중 commit 가능. 정답 한 줄: 거래에 상태기계(Pending→Committing→Committed|
Cancelled)를 두고 락/CAS 로 commit↔취소를 상호배제하며, 자산 교환은 검증→all-or-nothing(에스크로
또는 단일 트랜잭션+롤백)으로 원자화하라.
변별: content_gamelogic/problem3(두 거래자의 교환 경합/원자성 자체)와 달리, 본 문제의 간판은 세션 종료(접속 끊김)가 거래 commit 과 경합할 때의 "정리 vs commit" 조율과 부분 commit 롤백이다(세션 생명주기 축). session_network/problem11(강제종료 vs 정상진행)과도 도메인은 가깝지만, 여기서는 "2인 원자 교환의 부분 적용/이중 처리" 가 초점.
문제점
(C) commit vs 취소 비원자 가드 — 이중 처리/commit+취소 동시 (동시성·정합) ★간판
- 분류 태그: check-then-act / race / TOCTOU.
- 증상:
OnConfirm스레드가!t.Committed통과 →Commit진입 →Committed=true후 교환 진행. 같은 순간OnDisconnect스레드가 (조금 전 읽은)!t.Committed로 취소 분기에 들어가_trades.Remove/세션 맵 정리. 결과: 교환은 절반 적용됐는데 거래는 "취소" 로 지워지고 양쪽_playerToTrade가 제거돼 어디서도 롤백을 못 한다. 반대로 취소가 먼저 플래그를 세워도 commit 은Cancelled를 보지 않아 그대로 진행 → 취소된 거래가 commit. - 재현조건: confirm 으로 commit 진입과 동시에 한쪽 disconnect.
- 근본 원인: commit/취소가 단일 임계구역·단일 상태 전이가 아니라 독립 플래그 검사.
(B) 비원자 자산 교환 — 부분 적용(복제/증발) (정합) ★간판
- 분류 태그: non-atomic multi-step / missing rollback / ignored return value.
- 증상:
MoveItem들 →MoveGold들을 순차로 적용하고 반환값을 무시한다. 중간에- B 가 disconnect 되어 B 인벤토리가 정리됐다면
MoveItem(A→B)는 사라진 대상에 넣어 유실, MoveGold가 잔액 부족/실패로 false 를 반환해도 앞서 옮긴 아이템은 그대로 → A는 아이템을 잃고 B는 골드를 안 줌(전형적 거래 복제/증발 버그).
- B 가 disconnect 되어 B 인벤토리가 정리됐다면
- 근본 원인: 교환이 트랜잭션이 아니다(선검증·all-or-nothing·실패 시 롤백 없음).
(A) 이중 commit 가드 부실 — 이중 지급 (정합/동시성)
- 분류 태그: check-then-act / idempotency.
- 증상:
!t.Committed만 보고Commit. 두 스레드가 동시에 마지막 confirm 을 넣으면 둘 다 통과해Commit이 두 번 호출될 수 있다(아이템/골드 이중 이동). - 근본 원인: commit 전이가 원자적 1회(CAS/락)로 보장되지 않음.
(참고) 비스레드세이프 Dictionary + 세션 정리 경합
_trades/_playerToTrade동시 접근은 자료구조 손상. 또한 인벤토리 모듈의 세션 정리와 거래 commit 사이의 순서(거래를 먼저 freeze 했는지)가 정의돼 있지 않다.
수정안
핵심: 거래 단위 락 + 상태기계로 commit↔취소 상호배제, 교환은 선검증 후 all-or-nothing. 가장 견고한 형태는 확정 시 에스크로(임시 보관) 로 옮겨두고 commit 은 "에스크로→상대" 이동, 취소는 "에스크로→원주인" 반환으로 만드는 것.
enum TradeState { Pending, Committing, Committed, Cancelled }
public class TradeSession
{
public long TradeId;
public TradeOffer A, B;
public TradeState State = TradeState.Pending;
public readonly object Gate = new();
}
public void OnConfirm(long tradeId, long playerId)
{
TradeSession t;
lock (_mapGate) { if (!_trades.TryGetValue(tradeId, out t)) return; }
bool doCommit = false;
lock (t.Gate)
{
if (t.State != TradeState.Pending) return; // 취소/완료된 거래엔 무시
if (playerId == t.A.PlayerId) t.A.Confirmed = true;
else if (playerId == t.B.PlayerId) t.B.Confirmed = true;
else return;
if (t.A.Confirmed && t.B.Confirmed)
{
t.State = TradeState.Committing; // 단일 전이 → 취소와 상호배제
doCommit = true;
}
}
if (doCommit) Commit(t);
}
private void Commit(TradeSession t)
{
// 선검증: 두 플레이어를 고정 순서로 락(데드락 회피)하고 모든 전제 확인
long lo = Math.Min(t.A.PlayerId, t.B.PlayerId), hi = Math.Max(t.A.PlayerId, t.B.PlayerId);
lock (_inv.LockFor(lo)) lock (_inv.LockFor(hi))
{
if (!_inv.CanMoveAll(t.A) || !_inv.CanMoveAll(t.B))
{
Cancel(t, "precondition"); // 하나라도 불가 → 통째로 취소
return;
}
// 모두 가능 확인 후에야 실제 이동(원자적으로) — 실패하면 즉시 롤백
if (!_inv.ApplyAtomic(t.A, t.B)) { Cancel(t, "apply"); return; }
}
lock (t.Gate) { t.State = TradeState.Committed; }
Cleanup(t);
}
public void OnDisconnect(long playerId)
{
long tradeId;
lock (_mapGate) { if (!_playerToTrade.TryGetValue(playerId, out tradeId)) return; }
if (!_trades.TryGetValue(tradeId, out var t)) return;
lock (t.Gate)
{
if (t.State == TradeState.Committing || t.State == TradeState.Committed)
return; // 이미 commit 경로 — 취소 금지
t.State = TradeState.Cancelled; // Pending 만 취소 가능
}
// 에스크로 모델이면 에스크로 반환. 단계 적용 모델이면 아무것도 안 옮겼으므로 정리만.
Cleanup(t);
}
포인트
State전이를 거래 락 안에서 단일화 → commit(Committing)과 취소(Cancelled)는 둘 중 하나만 일어난다(이중 commit·commit+취소 동시 제거).- 교환은 두 플레이어를 고정 순서로 락 → 전제 검증(CanMoveAll) → ApplyAtomic(실패 시 롤백) 로 all-or-nothing. 부분 적용/증발/복제 제거.
- 더 견고하게는 에스크로: 확정 시 양쪽 자산을 거래 에스크로로 이동 → commit 은 에스크로→ 상대, 취소/접속종료는 에스크로→원주인. 어느 시점에 끊겨도 자산이 항상 어딘가에 정확히 존재.
- 맵은 락(또는
ConcurrentDictionary)으로 보호. 인벤토리 락 순서를 id 오름차순으로 고정해 데드락 회피.
더 나은 설계 (+트레이드오프)
- 에스크로 + 2단계(준비/확정): 확정=에스크로 입고(준비), commit=배분, 취소=반납. 크래시에도 에스크로 잔존분으로 복구. 트레이드오프: 에스크로 저장/정리 잡 필요.
- 영속 트랜잭션(DB): 교환을 단일 DB 트랜잭션으로(또는 사가+보상). 다중 서버/크래시 안전. 트레이드오프: 지연·DB 부하.
- 접속종료 grace 윈도우: 끊김 직후 짧게 거래를 freeze 했다가 미복귀 시 취소 → 순간 끊김에 거래가 날아가는 UX 완화. 트레이드오프: 그동안 자산 잠금/타임아웃.
- 멱등 commit 키: (tradeId)로 commit 을 멱등화해 재시도/중복 호출에도 1회만 적용.
면접 포인트 (예상 질문)
- commit 과 disconnect-취소가 각각
!Committed만 보고 행동하면 어떤 인터리빙에서 "교환은 절반 적용됐는데 거래는 취소" 가 되는가? 상태기계로 어떻게 막는가? - 교환을 "아이템들 옮기고 골드 옮기기" 로 순차 처리하면 어디서 복제/증발이 나는가? 에스크로 모델이 왜 어느 시점 끊김에도 안전한가?
- 두 플레이어 인벤토리를 락으로 보호할 때 데드락을 어떻게 피하는가(락 순서)?
해설 — 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
onConfirm(commit)과 onDisconnect(취소)가 공유 거래 상태를 락 없이 플래그만으로 조율한다.
(C) 둘 다 !t.committed 를 검사하고 행동하는 check-then-act 라, commit 진행 중 다른 스레드의
onDisconnect 가 취소에 들어가 commit 과 취소가 동시에 일어난다. 더 치명적으로 C++ 에서는
onDisconnect 가 trades_.erase(it) 로 거래 노드를 파괴하는데, commit 은 같은 노드를
TradeSession& t 참조로 들고 자산 이동 중이라 use-after-free 가 된다(반대로 commit 의
마지막 trades_.erase 이후 t 사용도 UAF). (B) commit 의 교환이 "아이템들→골드" 를 비원자
로 적용하고 반환값을 무시해, 중간 실패/세션 정리 시 부분 적용(증발/복제)으로 끝난다.
(A) commit 진입 가드가 단순 플래그라 이중 commit 가능. 정답 한 줄: 거래를 shared_ptr +
상태기계로 관리하고, 거래 락 안에서 commit↔취소를 상호배제하며, 두 플레이어 인벤토리를 고정
순서로 락(std::scoped_lock)해 선검증→all-or-nothing(또는 에스크로)으로 교환하라.
변별: content_gamelogic/problem3(두 거래자 교환 경합/원자성)와 달리, 본 간판은 세션 종료가 commit 과 경합할 때의 조율 + 거래 객체 수명(UAF) + 부분 commit 롤백이다.
문제점
(C) commit vs 취소 비원자 + 거래 객체 UAF (동시성·메모리) ★간판
- 분류 태그: check-then-act / use-after-free.
- 증상:
commit이TradeSession& t = it->second로 노드를 들고 자산 이동 중인데, 다른 스레드의onDisconnect가trades_.erase(it)로 그 노드를 파괴 →t가 댕글링되어 이후 접근이 UAF. 동시에 "교환은 절반 적용됐는데 거래는 취소로 삭제" 되어 롤백 경로도 사라진다.unordered_map::erase는 해당 원소의 참조/반복자만 무효화하지만, 그 무효화가 바로 이 버그. - 재현조건: confirm→commit 진입과 동시에 한쪽 disconnect.
- 근본 원인: 거래 객체 수명이 보호되지 않고(값 노드를 참조로 공유), commit/취소가 단일 임계구역·단일 상태 전이가 아니다.
(B) 비원자 자산 교환 — 부분 적용(복제/증발) (정합) ★간판
- 분류 태그: non-atomic multi-step / ignored return value / missing rollback.
- 증상:
moveItem들 →moveGold들을 순차 적용하고 반환값을 무시. 중간에 한쪽 인벤토리가 정리되거나moveGold가 false 면 A는 아이템을 잃고 B는 골드를 안 줌. - 근본 원인: 교환이 트랜잭션이 아님(선검증·all-or-nothing·롤백 부재).
(A) 이중 commit 가드 부실 (정합/동시성)
- 분류 태그: check-then-act.
- 증상:
!t.committed만 보고 commit. 두 스레드가 동시에 마지막 confirm 을 넣으면 둘 다 통과해 이중 이동. - 근본 원인: commit 전이가 원자 1회로 보장되지 않음.
(참고) 데드락 / 맵 동시성
- 두 플레이어 인벤토리를 각각 락으로 보호한다면, 거래 방향에 따라 (A락→B락)/(B락→A락) 순서가
엇갈려 AB-BA 데드락이 날 수 있다. 또한
trades_/playerToTrade_동시 접근 자체가 레이스.
수정안
핵심: 거래를 shared_ptr 로 수명 보장 + 거래별 mutex/상태기계 + 두 플레이어 락 고정순서.
#include <memory>
#include <mutex>
#include <algorithm>
enum class TradeState { Pending, Committing, Committed, Cancelled };
struct TradeSession {
int64_t tradeId;
TradeOffer a, b;
TradeState state = TradeState::Pending;
std::mutex gate;
};
class TradeService {
public:
explicit TradeService(Inventory& inv) : inv_(inv) {}
void onConfirm(int64_t tradeId, int64_t playerId) {
std::shared_ptr<TradeSession> t;
{ std::lock_guard<std::mutex> mg(mapMu_);
auto it = trades_.find(tradeId); if (it == trades_.end()) return; t = it->second; }
bool doCommit = false;
{
std::lock_guard<std::mutex> g(t->gate);
if (t->state != TradeState::Pending) return; // 취소/완료엔 무시
if (playerId == t->a.playerId) t->a.confirmed = true;
else if (playerId == t->b.playerId) t->b.confirmed = true;
else return;
if (t->a.confirmed && t->b.confirmed) { t->state = TradeState::Committing; doCommit = true; }
}
if (doCommit) commit(t); // shared_ptr 로 수명 보장
}
void onDisconnect(int64_t playerId) {
std::shared_ptr<TradeSession> t;
{ std::lock_guard<std::mutex> mg(mapMu_);
auto pit = playerToTrade_.find(playerId); if (pit == playerToTrade_.end()) return;
auto it = trades_.find(pit->second); if (it == trades_.end()) return; t = it->second; }
{
std::lock_guard<std::mutex> g(t->gate);
if (t->state == TradeState::Committing || t->state == TradeState::Committed) return; // commit 경로 보호
t->state = TradeState::Cancelled; // Pending 만 취소
}
cleanup(t); // 에스크로면 반납; 단계적용이면 아직 아무것도 안 옮겼으므로 정리만
}
private:
void commit(const std::shared_ptr<TradeSession>& t) {
int64_t lo = std::min(t->a.playerId, t->b.playerId);
int64_t hi = std::max(t->a.playerId, t->b.playerId);
{
std::scoped_lock lk(inv_.lockFor(lo), inv_.lockFor(hi)); // 고정순서 동시 락 → 데드락 회피
if (!inv_.canMoveAll(t->a) || !inv_.canMoveAll(t->b)) { cancel(t); return; }
if (!inv_.applyAtomic(t->a, t->b)) { cancel(t); return; } // 실패 시 롤백
}
{ std::lock_guard<std::mutex> g(t->gate); t->state = TradeState::Committed; }
eraseFromMaps(t);
}
// cancel/cleanup/eraseFromMaps: mapMu_ 아래에서 trades_/playerToTrade_ 정리 (shared_ptr 라 사용 중이면 살아있음)
Inventory& inv_;
std::mutex mapMu_;
std::unordered_map<int64_t, std::shared_ptr<TradeSession>> trades_;
std::unordered_map<int64_t, int64_t> playerToTrade_;
};
포인트
shared_ptr<TradeSession>를 맵에 보관하고 작업 전 복사해 쥐면, 다른 스레드가 맵에서 지워도 객체는 살아 있어 UAF 제거.state전이를gate락 안에서 단일화 → commit(Committing)·취소(Cancelled)는 둘 중 하나만.std::scoped_lock(lockFor(lo), lockFor(hi))로 두 인벤토리를 고정순서·동시 락 → AB-BA 데드락 회피, 그 안에서canMoveAll(선검증)→applyAtomic(실패 시 롤백)으로 all-or-nothing.- 맵 접근은
mapMu_로 보호.
더 나은 설계 (+트레이드오프)
- 에스크로 + 2단계: 확정=에스크로 입고, commit=배분, 취소/끊김=반납. 어느 시점에 끊겨도 자산이 정확히 한 곳에 존재. 트레이드오프: 에스크로 저장/정리 잡.
- 영속 트랜잭션/사가: 교환을 단일 DB 트랜잭션 또는 보상 트랜잭션으로 → 크래시·다중 서버 안전. 트레이드오프: 지연/복잡도.
- 접속종료 grace: 순간 끊김에 즉시 취소하지 않고 짧게 freeze. 트레이드오프: 자산 잠금/타임아웃.
- 멱등 commit 키(tradeId): 재시도/중복에도 1회만 적용.
면접 포인트 (예상 질문)
TradeSession&를 들고 자산을 옮기는 중 다른 스레드가 맵에서 erase 하면 왜 UAF 인가?shared_ptr가 이를 어떻게 막는가?- 순차
moveItem/moveGold+ 반환값 무시가 어떤 부분 적용(복제/증발)을 만드는가? 에스크로가 왜 더 안전한가? - 두 플레이어 인벤토리 락에서 AB-BA 데드락을
std::scoped_lock/고정순서로 어떻게 피하는가?