11. 계정 정지/강제 종료(킥) vs 진행 중인 정상 처리
난이도 중해설 — 계정 정지/강제 종료(킥) vs 진행 중인 정상 처리
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
어드민 스레드의 "정지+킥" 과 워커 스레드의 "정상 처리" 가 같은 세션/플레이어 상태를
락 없이 동시에 만진다. 결함이 세 갈래다. (1) TOCTOU: 워커는 진입 시 Banned 를
한 번만 보고, 그 뒤 보상 지급(D)→저장(E)을 진행한다. 어드민이 그 사이에 정지시켜도
워커는 멈추지 않아 정지된 플레이어가 행동을 완료한다. (2) 가시성: Banned/Closed
가 비동기화 bool 이라 워커 스레드가 어드민의 쓰기를 영영 못 볼 수 있다. (3) 소켓
이중/동시 정리 & UAF: 어드민이 Socket.Close()(F) 한 뒤 워커가 같은 소켓에 쓰면
ObjectDisposedException, 또한 Dictionary 를 동시 Read/Remove 하면 손상. 핵심:
세션 종료는 상태 머신으로 직렬화하고, 정상 작업은 커밋 직전 정지를 재확인해야 한다.
문제점
(C)+(D)+(E) 정지 검사-적용 비원자 — TOCTOU (정확성/보안) ★간판
- 증상: 정지된 플레이어가 정지 직후의 보상 지급/거래 체결을 그대로 완료한다.
- 재현 조건: 워커가 (C)에서
Banned==false를 보고 긴 작업을 시작. 그 사이 어드민이 (G)에서Banned=true. 워커는 재확인 없이 (D)(E)를 진행 → 부정 이득. - 근본 원인: "정지 여부" 검사와 "효과 적용/커밋" 사이가 원자적이지 않다. 정지는 커밋 직전(또는 커밋과 같은 트랜잭션)에서 재확인해야 한다.
(A)/(B) bool 의 가시성 — 메모리 모델 (동시성)
- 증상: 워커가 어드민의
Banned=true/Closed=true를 관측하지 못해 계속 진행. - 근본 원인: 일반
bool필드는 스레드 간 가시성/순서 보장이 없다.volatile/Interlocked/락 또는 상태를 단일 소유 스레드로 모아야 한다.
(F)+(D) 소켓 Close 와 워커의 사용 경합 — UAF/예외 (동시성/메모리)
- 증상: 어드민이 (F)에서
Socket.Close()한 직후 워커가 그 소켓으로 송신/수신하면ObjectDisposedException, in-flight 비동기 콜백과 겹치면 더 지저분한 경합. - 근본 원인: 소켓 수명과 진행 중 작업이 조율되지 않는다. 종료는 "진행 중 작업 드레인 → 소켓 정리" 순서로 가야 한다(session_network/problem4 의 종료 순서 문제와 연결).
(H)+(I) 종료/제거 비원자 + 이중 정리 (동시성/정확성)
- 증상: 어드민 킥과 정상 로그아웃(또는 두 번의 어드민 킥)이 겹치면
Close()가 두 번 호출돼 이중 Dispose,_sessions를 동시 Read(Find)/Remove 해 자료구조 손상. - 근본 원인:
Closed플래그를 검사·설정하는 곳이 비원자(이중 정리 방어 부재)이고, 레지스트리 접근에 락이 없다. 종료는 정확히 한 번만 일어나야 한다(멱등).
(보조) 절반 적용 위험 — 정확성
- (D) 보상 지급과 (E) 저장이 분리돼 있고, 그 사이 킥/크래시가 나면 "메모리엔 지급됐는데 저장 안 됨" 또는 그 반대의 절반 상태가 남는다. 한 트랜잭션이어야 한다.
수정안
핵심: ① 세션에 종료 상태(0=Active,1=Closing,2=Closed)를 Interlocked 로 1회 전이, ② 정상 작업은 세션 락 안에서 정지/종료를 재확인하고 지급+저장을 한 트랜잭션으로, ③ 레지스트리 접근/제거에 락, ④ 소켓 정리는 진행 중 작업을 막은 뒤 1회만.
public class GameSession
{
public long Id;
public Player Player;
public Socket Socket;
private int _state; // 0 Active / 1 Closing / 2 Closed
private readonly object _lock = new object();
public volatile bool Banned; // 가시성 보장(또는 _lock 안에서만 접근)
public void HandleRewardPacket(int amount)
{
lock (_lock)
{
// 커밋 직전 재확인: 정지/종료되었으면 아무것도 적용하지 않음
if (Banned || Volatile.Read(ref _state) != 0) return;
// 지급 + 저장을 한 트랜잭션으로(절반 적용 금지)
using var tx = Db.BeginTransaction();
Player.ApplyReward(amount, tx);
Player.SaveProgress(tx);
tx.Commit();
}
}
// 정확히 한 번만 종료(멱등)
public bool BeginClose()
{
// Active(0) -> Closing(1) 전이에 성공한 스레드만 정리 책임을 진다
return Interlocked.CompareExchange(ref _state, 1, 0) == 0;
}
public void FinishClose()
{
lock (_lock) { /* 진행 중 작업과 직렬화 */ }
try { Socket.Close(); } catch { /* 이미 닫힘 무시 */ }
Volatile.Write(ref _state, 2);
}
}
public class SessionManager
{
private readonly Dictionary<long, GameSession> _sessions = new();
private readonly object _regLock = new object();
public void BanAndKick(long sessionId)
{
GameSession s;
lock (_regLock) { _sessions.TryGetValue(sessionId, out s); }
if (s == null) return;
s.Banned = true; // 가시성 보장 필드
if (s.BeginClose()) // 종료 책임을 한 스레드만 갖는다
{
s.FinishClose();
lock (_regLock) { _sessions.Remove(sessionId); }
}
}
}
Banned를 세운 뒤BeginClose로 단 한 번만 정리한다. 워커는 커밋 직전Banned/_state를 재확인하므로 정지 이후 작업은 커밋되지 않는다. 지급+저장은 한 트랜잭션이라 절반 적용도 없다.
더 나은 설계
1) 협조적 취소(cooperative cancellation) + 드레이닝
- 즉시 소켓을 끊는 대신 세션에
CancellationToken을 두고, 정지 시 토큰을 취소. 워커는 단계마다 토큰을 확인해 안전 지점에서 롤백/중단하고, 진행 중 작업이 빠진 뒤 소켓을 정리한다. graceful shutdown(problem10) 과 같은 드레이닝 모델.
2) 단일 액터로 세션 소유
- 한 세션의 모든 이벤트(게임 패킷, 어드민 명령, 종료)를 단일 큐로 직렬 처리하면 TOCTOU/가시성/이중정리가 구조적으로 사라진다. 어드민 킥도 그 큐에 메시지로 넣는다. 트레이드오프: 세션당 큐 비용 vs 동시성 버그 제거. MMO 에서 널리 쓰는 패턴.
3) 권위 있는 정지 상태는 DB/중앙에
- ban 은 게임서버 메모리 플래그만으로 부족하다(멀티 인스턴스/재접속). 계정 상태를 중앙(DB/인증서버)에 두고, 경제적 행위는 "정지 아님" 을 같은 트랜잭션에서 확인한다.
4) 종료를 명시적 상태 머신으로
- Active→Closing→Closed 단방향 전이 + CAS 로 "정리 책임 1회" 를 보장. in-flight 비동기 완료를 카운트(또는 Drain)한 뒤 소켓 Dispose(problem4 의 종료 순서 원칙).
면접 포인트
- 핵심: "정지/킥은 언제 효력을 가져야 하는가?" — 진입 시점이 아니라 커밋 시점. 그리고 종료는 멱등(정확히 1회), 소켓 수명은 진행 중 작업과 조율.
- 예상 질문:
- "정지됐는데도 보상이 들어가는 경로를 설명하라." → 진입 검사 통과 후 정지 → 재확인 없이 커밋(TOCTOU). 커밋 직전 재확인 필요.
- "어드민 킥이 두 번 와도 안전하려면?" → CAS 로 Active→Closing 전이에 성공한 스레드만 정리(멱등 종료).
- "소켓을 바로 Close 하면 왜 위험한가?" → in-flight 송수신과 겹쳐 ObjectDisposedException/경합. 드레인 후 정리.
해설 — 계정 정지/강제 종료(킥) vs 진행 중인 정상 처리 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 트윈과 동일한 본질에 C++ 의 수명/fd 문제가 더해진다. (1) TOCTOU: 워커는 진입
시 banned 를 한 번만 보고 보상(D)→저장(E)을 진행 → 정지된 플레이어가 행동을 완료.
(2) 가시성: banned/closed 가 일반 bool 이라 워커가 어드민 쓰기를 못 볼 수 있다
(C++ 메모리 모델상 동기화 없는 동시 접근은 data race = UB). (3) use-after-free:
어드민이 (J) delete s 로 세션을 해제했는데 워커가 같은 GameSession*/player 를
계속 쓰면 UAF. (4) 이중 close / fd 재사용: (F) ::close(fd) 가 두 번 호출되면, 그
사이 다른 스레드가 새 소켓에 같은 fd 번호를 받았다면 엉뚱한 연결을 닫는 심각한 버그.
핵심: 수명은 shared_ptr 로, 종료는 멱등 상태전이로, 정지는 커밋 직전 재확인.
문제점
(C)+(D)+(E) 정지 검사-적용 비원자 — TOCTOU (정확성/보안) ★간판
- 진입 검사 통과 후 어드민이 (G)
banned=true→ 워커는 재확인 없이 (D)(E) 커밋 → 정지 이후 부정 이득. 정지는 커밋 직전(또는 같은 트랜잭션)에서 재확인해야 한다.
data race — banned/closed 무동기 (동시성/UB) ★C++ 특화
bool banned/closed를 한 스레드가 쓰고 다른 스레드가 읽는데 동기화가 없다. C++ 메모리 모델상 이것은 정의되지 않은 동작(컴파일러가 읽기를 호이스팅/캐싱 가능).std::atomic<bool>또는 락 안에서만 접근해야 한다.
(J) delete 와 워커의 사용 — use-after-free (메모리) ★치명
- 증상: 어드민이 세션을
delete한 뒤 워커가player->ApplyReward를 호출하면 해제된 메모리 접근(UAF) → 크래시 또는 조용한 손상. - 근본 원인: raw 포인터 소유권이 불명확하고, 진행 중 작업이 끝나기 전에 해제한다.
shared_ptr<GameSession>으로 소유를 공유하고, 마지막 참조가 사라질 때만 파괴해야 한다.
(F) 이중 close / fd 재사용 — 자원 정리 (정확성) ★C++ 특화
- 증상:
close(fd)가 두 번 불리면 두 번째 close 는 EBADF 이거나, 그 사이 새 연결이 같은 fd 번호를 재사용했다면 무관한 연결을 닫아버린다(매우 찾기 어려운 버그). - 근본 원인: 종료가 멱등이 아니고, fd 수명이 진행 중 I/O 와 조율되지 않는다.
(H)+(I)+(J) 레지스트리 동시 접근 (동시성)
sessions_(unordered_map)를 워커의Find와 어드민의erase가 락 없이 동시 접근 → rehash 중 UB. 종료/제거가 비원자.
(보조) 절반 적용 — 정확성
- (D) 지급과 (E) 저장 분리. 그 사이 킥/크래시 시 절반 상태. 한 트랜잭션이어야 한다.
수정안
#include <atomic>
#include <memory>
#include <mutex>
struct GameSession
{
int64_t id;
Player* player;
int fd;
std::atomic<int> state{0}; // 0 Active / 1 Closing / 2 Closed
std::atomic<bool> banned{false};
std::mutex mtx;
void HandleRewardPacket(int amount)
{
std::lock_guard<std::mutex> g(mtx);
// 커밋 직전 재확인
if (banned.load() || state.load() != 0) return;
auto tx = Db::Begin();
player->ApplyReward(amount, tx);
player->SaveProgress(tx);
tx.Commit(); // 지급+저장 한 트랜잭션
}
// 정확히 한 번만 종료(멱등). 성공한 스레드만 정리.
bool BeginClose()
{
int expected = 0;
return state.compare_exchange_strong(expected, 1);
}
void FinishClose()
{
{ std::lock_guard<std::mutex> g(mtx); } // 진행 중 작업과 직렬화
if (fd >= 0) { ::close(fd); fd = -1; } // 1회만, 닫은 뒤 -1
state.store(2);
}
};
class SessionManager
{
public:
void BanAndKick(int64_t sessionId)
{
std::shared_ptr<GameSession> s;
{ std::lock_guard<std::mutex> g(regMtx_);
auto it = sessions_.find(sessionId);
if (it != sessions_.end()) s = it->second; }
if (!s) return;
s->banned.store(true);
if (s->BeginClose()) {
s->FinishClose();
std::lock_guard<std::mutex> g(regMtx_);
sessions_.erase(sessionId); // shared_ptr ref 감소; 워커가 끝나면 파괴
}
}
private:
std::unordered_map<int64_t, std::shared_ptr<GameSession>> sessions_;
std::mutex regMtx_;
};
소유권을
shared_ptr로: 워커도shared_ptr사본을 들고 작업하므로 어드민의 erase 가 즉시 파괴하지 않는다(UAF 제거). 종료는 CAS 로 1회, fd 는 닫은 뒤 -1 로 이중 close 차단.
더 나은 설계
1) RAII + shared_ptr 로 수명 일원화
- 세션은 항상
shared_ptr로만 전달. fd 는 RAII 래퍼(소멸자에서 1회 close)로 감싸 이중 close/누수를 타입 수준에서 차단. 트레이드오프: 약간의 refcount 비용 vs UAF/누수 제거.
2) 협조적 취소 + 드레이닝
- 즉시 close 대신 취소 플래그(atomic) + 진행 중 I/O 카운트. 워커가 안전 지점에서 롤백/중단 하고 in-flight 가 0이 되면 fd 정리(session_network/problem4 종료 순서 원칙).
3) 단일 액터로 세션 소유
- 세션의 모든 이벤트(게임 패킷/어드민 명령/종료)를 단일 스레드 큐로 직렬 처리하면 data race/TOCTOU/이중정리가 구조적으로 사라진다. 어드민 킥도 큐 메시지로.
4) 권위 있는 정지 상태는 중앙에
- 메모리 플래그만으로는 멀티 인스턴스/재접속에 부족. 계정 상태를 DB/인증서버에 두고 경제 행위 트랜잭션에서 "정지 아님" 을 함께 확인.
면접 포인트
- 핵심: TOCTOU(커밋 시 재확인) + C++ 의 소유권/UAF + 이중 close/fd 재사용 + data race(atomic).
- 예상 질문:
- "raw 포인터를 shared_ptr 로 바꾸면 무엇이 해결되나?" → 어드민 erase 가 즉시 파괴하지 않아 워커의 UAF 가 사라진다.
- "fd 를 두 번 close 하면 왜 위험한가?" → 그 사이 새 소켓이 같은 fd 번호를 재사용했다면 무관한 연결을 닫는다.
- "banned 를 atomic 으로 해야 하는 이유는?" → 무동기 동시 접근은 C++ 에서 data race = UB. atomic/락 필요.