31. 여러 이벤트 경로에서 트리거되는 연결 정리의 중복 실행 (이중 정리)
난이도 중해설 — 여러 이벤트 경로에서 트리거되는 연결 정리의 중복 실행 (이중 정리)
난이도: 중상
요약
Cleanup 은 (A) 에서 s.IsClosed 를 확인하고 정리 작업 후 (B) 에서 IsClosed = true 를 설정하지만, 검사와 표시 사이가 원자적이지 않다. HandleError, HandleTimeout, HandleLogout 이 서로 다른 스레드(수신/타이머/워커)에서 같은 세션에 대해 거의 동시에 호출되면, 둘 이상이 (A) 를 동시에 통과해 SaveToDb, NotifyOtherSystems, ReleaseResources 가 중복 실행된다. 게다가 이 세 경로에 대한 조회(_sessions.TryGetValue)와 Cleanup 내부의 _sessions.Remove 도 동기화 없이 같은 딕셔너리에 동시 접근해 그 자체로 안전하지 않다.
문제점
(A)→(B) 검사-표시 비원자성 — 여러 이벤트 경로의 동시 중복 정리 (TOCTOU)
- 증상: DB 저장이 두 번 일어나 마지막에 실행된 쪽의 (아마도 더 오래된) 상태로 덮어써질 수 있고,
NotifyOtherSystems가 두 번 호출되어 길드/파티에 퇴장 통지가 중복 발송되며,ReleaseResources가 두 번 호출되면 이미 반납된 리소스를 다시 반납하려다 오류가 나거나 다른 세션에 재할당된 자원을 잘못 건드릴 수 있다. - 재현 조건: 같은 세션에 대해 에러 콜백과 타임아웃 콜백(또는 로그아웃)이 거의 동시에 도착.
- T1(에러 콜백), T2(타임아웃 콜백) 모두 (A) 에서
IsClosed == false를 읽고 통과. - T1 이
SaveToDb~ReleaseResources실행 후 (B) 에서IsClosed = true. - T2 도 이미 (A) 를 통과했으므로 자신의
SaveToDb~ReleaseResources를 그대로 실행 — 중복.
- T1(에러 콜백), T2(타임아웃 콜백) 모두 (A) 에서
- 근본 원인: "정리 완료 여부 확인"과 "정리 완료 표시"가 하나의 락으로 보호되지 않은 check-then-act.
_sessions 딕셔너리 동시 접근 — 조회/삭제 경합
- 증상:
HandleError/HandleTimeout/HandleLogout의TryGetValue와Cleanup내부의Remove가 서로 다른 스레드에서 동기화 없이 겹치면Dictionary내부 상태가 손상되거나 예외가 발생할 수 있다. - 재현 조건: 정리 진행 중(
Remove호출 시점)에 다른 경로가 같은 세션을 조회. - 근본 원인:
Dictionary<int, PlayerSession>는 스레드 안전하지 않은데 외부 동기화가 없다.
수정안 (정확한 코드)
세션별 락으로 "검사+정리+표시"를 하나의 임계 구역으로 묶고, _sessions 컬렉션 접근도 별도로 보호한다. 원자적 CAS 스타일의 Interlocked.CompareExchange 로 "정리 시작 권한"을 정확히 한 스레드에만 부여하는 방식이 락보다 더 간단하고 안전하다.
using System;
using System.Collections.Generic;
using System.Threading;
class PlayerSession
{
public int SessionId;
// 0 = 열림, 1 = 정리 중/완료. CompareExchange 로 "정리 시작 권한"을 단 한 번만 부여.
private int _closedFlag;
public bool TryMarkClosing() => Interlocked.CompareExchange(ref _closedFlag, 1, 0) == 0;
}
class SessionManager
{
private readonly object _mapLock = new();
private readonly Dictionary<int, PlayerSession> _sessions = new();
private void SaveToDb(PlayerSession s) { /* 최종 상태 DB 저장 */ }
private void NotifyOtherSystems(PlayerSession s) { /* 길드/파티 등에 퇴장 통지 */ }
private void ReleaseResources(PlayerSession s) { /* 버퍼/소켓 핸들 반납 */ }
private PlayerSession? FindSession(int sessionId)
{
lock (_mapLock)
{
return _sessions.TryGetValue(sessionId, out var s) ? s : null;
}
}
public void HandleError(int sessionId) => TryCleanup(sessionId);
public void HandleTimeout(int sessionId) => TryCleanup(sessionId);
public void HandleLogout(int sessionId) => TryCleanup(sessionId);
private void TryCleanup(int sessionId)
{
var s = FindSession(sessionId);
if (s == null) return;
// 원자적 CAS: 오직 이 호출만 "정리 시작"에 성공한다.
// 다른 경로가 동시에 들어와도 CompareExchange 는 단 한 스레드에게만 true 를 반환한다.
if (!s.TryMarkClosing())
return; // 이미 다른 경로가 정리를 시작/완료함 — 여기서 즉시 반환
SaveToDb(s);
NotifyOtherSystems(s);
ReleaseResources(s);
lock (_mapLock)
{
_sessions.Remove(sessionId);
}
}
}
더 나은 설계 (+ 트레이드오프)
- 세션 소유 큐/액터로 직렬화: 세 이벤트 경로가 세션에 대한 작업을 직접 실행하지 않고 세션 전용 큐에 "정리 요청"만 넣으면, 큐 소비자가 단일 스레드로 처리하므로 애초에 경합이 없다. 대신 이벤트→큐→처리의 지연이 생기고 큐 인프라가 필요하다.
- 상태 머신 + 명시적 상태 전이 로그:
Open → Closing → Closed상태를 두고 어떤 경로가 종료를 트리거했는지 로그로 남기면 디버깅이 쉬워진다(예: "타임아웃이 먼저 트리거됐는데 로그아웃 패킷이 뒤늦게 도착" 같은 레이스를 사후 분석 가능). - 참조 카운트/약한 참조로 세션 수명 관리: 정리 이후에도 진행 중이던 다른 콜백이 세션 객체에 안전하게 접근(정리됐음을 확인만 하고 조용히 무시)하도록 하면, 정리 타이밍과 콜백 도착 타이밍이 어긋나도 안전하다.
- 트레이드오프:
Interlocked.CompareExchange방식은 락보다 가볍고 데드락 위험이 없지만, "정리 권한을 놓친 스레드"가 정리 작업의 완료를 기다려야 하는 경우(예: 정리 완료 후 응답을 보내야 함)라면 완료 신호(이벤트/Task)를 별도로 제공해야 한다.
면접 포인트
- 하나의 자원(세션)을 여러 독립 이벤트 경로가 종료시킬 수 있는 구조에서는 "정리 완료 여부 확인+표시"를 원자적 연산(CAS) 또는 락으로 묶어야 한다 — 그렇지 않으면 검사-표시 사이 TOCTOU 로 중복 실행된다.
Interlocked.CompareExchange를 이용한 "단 한 번만 성공하는 플래그"패턴은 락보다 가볍게 정확히 한 번(exactly-once) 진입을 보장하는 관용구다.- 정리 작업(DB 저장, 알림, 리소스 반납) 각각이 멱등하지 않다면 중복 실행이 데이터 오염·중복 통지로 직결된다 — 가능하면 멱등하게 만들되, 근본적으로는 진입 자체를 한 번으로 제한하는 것이 우선이다.
해설 — 여러 이벤트 경로에서 트리거되는 연결 정리의 중복 실행 (이중 정리, C++)
난이도: 중상
요약
cleanup 은 (A) 에서 s->isClosed 를 확인하고 정리 작업 후 (B) 에서 isClosed = true 를 설정한 뒤 delete s 로 세션 객체를 해제하지만, 검사와 표시/해제 사이가 원자적이지 않다. handleError, handleTimeout, handleLogout 이 서로 다른 스레드에서 같은 세션에 대해 거의 동시에 호출되면 둘 이상이 (A) 를 동시에 통과할 수 있고, 그 결과는 C# 버전보다 훨씬 심각하다 — 한쪽이 먼저 delete s 를 마치면 다른 쪽은 **이미 해제된 포인터를 계속 참조(use-after-free)**하다가 결국 같은 포인터를 다시 delete(double-free) 하게 되어 힙 손상·크래시로 이어진다.
문제점
(A)→(B) 검사-표시 비원자성 — 여러 이벤트 경로의 동시 중복 정리
- 증상:
SaveToDb~releaseResources가 두 번 실행되어 DB 상태 덮어쓰기, 중복 알림, 리소스 이중 반납이 일어난다. - 재현 조건: 같은 세션에 대해 에러 콜백과 타임아웃 콜백이 거의 동시에 도착.
- T1, T2 모두 (A) 에서
isClosed == false를 읽고 통과. - T1 이 정리 작업을 마치고 (B) 에서
isClosed = true,sessions_.erase,delete s. - T2 는 이미 (A) 를 통과했으므로 자신의 정리 작업을 실행하려 하는데, 이 시점
s는 이미 해제된 포인터다.
- T1, T2 모두 (A) 에서
- 근본 원인: "정리 완료 여부 확인"과 "정리 완료 표시+객체 해제"가 하나의 동기화 단위로 묶이지 않은 check-then-act.
(B) delete 이후 다른 스레드의 use-after-free / double-free
- 증상: T2 가 이미
delete된s에 대해s->isClosed를 읽거나 쓰면 use-after-free이고, T2 가 자신도 정리 작업을 마쳤다고 믿고delete s를 다시 호출하면 double-free 로 힙이 손상되어 크래시하거나(더 나쁘게는) 이후 무관한 할당이 오작동한다. - 재현 조건: 위 재현 조건과 동일 — T1 이
delete를 마친 직후 T2 가 같은 포인터에 접근. - 근본 원인: 세션 객체의 수명을 "정리 완료 시 즉시
delete" 로 단정해, 그 시점에 다른 스레드가 같은 포인터를 들고 있을 가능성을 전혀 방어하지 않는다.
수정안 (정확한 코드)
std::atomic<bool> 로 "정리 시작 권한"을 CAS 로 정확히 한 스레드에만 부여하고, 세션 수명을 shared_ptr 로 관리해 진행 중인 다른 스레드가 안전하게 마저 끝낼 수 있게 한다.
#include <unordered_map>
#include <memory>
#include <mutex>
#include <atomic>
struct PlayerSession {
int sessionId;
std::atomic<bool> closing{false}; // CAS 로 "정리 시작 권한"을 단 한 번만 부여
// 오직 이 호출만 true 를 반환한다 — 다른 스레드가 동시에 호출해도 하나만 성공.
bool tryMarkClosing() {
bool expected = false;
return closing.compare_exchange_strong(expected, true);
}
};
class SessionManager {
std::mutex mapMx_;
std::unordered_map<int, std::shared_ptr<PlayerSession>> sessions_;
void saveToDb(const std::shared_ptr<PlayerSession>&) { /* 최종 상태 DB 저장 */ }
void notifyOtherSystems(const std::shared_ptr<PlayerSession>&) { /* 퇴장 통지 */ }
void releaseResources(const std::shared_ptr<PlayerSession>&) { /* 리소스 반납 */ }
std::shared_ptr<PlayerSession> findSession(int sessionId) {
std::lock_guard<std::mutex> lk(mapMx_);
auto it = sessions_.find(sessionId);
return it != sessions_.end() ? it->second : nullptr;
}
void tryCleanup(int sessionId) {
auto s = findSession(sessionId); // shared_ptr 복사로 수명 연장 — UAF 불가
if (!s) return;
if (!s->tryMarkClosing())
return; // 이미 다른 경로가 정리를 시작/완료함
saveToDb(s);
notifyOtherSystems(s);
releaseResources(s);
std::lock_guard<std::mutex> lk(mapMx_);
sessions_.erase(sessionId);
// s(shared_ptr) 는 함수 종료 시 참조 카운트 감소 — 마지막 참조가 사라질 때만 실제 해제
}
public:
void handleError(int sessionId) { tryCleanup(sessionId); }
void handleTimeout(int sessionId) { tryCleanup(sessionId); }
void handleLogout(int sessionId) { tryCleanup(sessionId); }
};
핵심: ① compare_exchange_strong 으로 "정리 시작"을 정확히 한 스레드에만 허용해 중복 실행을 차단. ② shared_ptr 로 세션 수명을 관리해, findSession 이 반환한 복사본을 들고 있는 동안은 다른 스레드가 erase 해도 실제 메모리는 살아있어 use-after-free/double-free 가 발생하지 않는다.
더 나은 설계 (+ 트레이드오프)
- 세션 소유 스레드/큐로 직렬화: 세 이벤트 경로가 세션에 직접 접근하지 않고 세션 전용 처리 큐에 이벤트만 넣으면 단일 소비자가 순차 처리해 경합 자체가 없다. 대신 큐 인프라와 지연이 추가된다.
- 핸들(index+generation) 기반 접근: raw 포인터 대신 slot-map 핸들을 사용하면 세대 검증으로 "이미 정리된 세션"에 대한 접근을 안전하게 거부할 수 있다(concurrency_memory/problem31 의 엔티티 핸들 패턴과 동일 원리).
- 정리 완료 대기가 필요한 경우: CAS 에서 진 스레드가 "정리가 끝났다"는 사실을 알아야 한다면(예: 로그아웃 응답을 보내야 함)
std::future/조건변수로 완료 신호를 별도로 제공해야 한다. - 트레이드오프:
shared_ptr+atomic<bool>조합은 구현이 비교적 단순하지만, 참조 카운트 원자 연산 비용이 세션이 매우 많고 이벤트가 빈번한 서버에서는 누적될 수 있다 — 세션 소유 스레드 모델이 근본적으로 더 확장성이 좋다.
면접 포인트
- C++ 에서 "정리 완료 시 즉시 delete" 패턴은 다른 스레드가 같은 포인터를 참조 중일 가능성을 반드시 고려해야 한다 —
shared_ptr로 수명을 연장하거나 정리를 지연시켜야 UAF/double-free 를 막는다. std::atomic<bool>::compare_exchange_strong을 이용한 "단 한 번만 성공하는 진입 플래그"는 락 없이도 exactly-once 진입을 보장하는 관용구다.- 여러 독립 이벤트 경로가 같은 자원을 종료시킬 수 있는 구조에서는 "확인+표시+해제"를 하나의 원자적 단위로 묶어야 하며, 그렇지 않으면 이중 정리(double cleanup)가 C++ 에서는 특히 메모리 손상급 결함으로 이어진다.