41. 계정 공용 창고, 세션 단위 락으로 보호된 공유 자원 (C#)
난이도 중해설 — 계정 공용 창고, 세션 단위 락으로 보호된 공유 자원 (C#)
난이도: 중
요약
Withdraw/Deposit 은 계정 창고(AccountWarehouse.Slots)라는 계정 전체가 공유하는 자원을 다루지만, (A) 에서 잠그는 락은 session.SessionLock — 즉 캐릭터(세션)마다 서로 다른 락 객체다. 같은 계정의 캐릭터 A, B가 각자 자기 세션 락만 쥔 채 (B) 에서 같은 슬롯을 동시에 차감하면, 두 인출이 서로를 배제하지 못하고 검사-차감 구간이 교차 실행되어 슬롯 수량이 음수가 되거나 실제 보유량을 초과해 인출된다.
문제점
(A) 락 범위가 공유 자원보다 좁음 (세션 단위 락 vs 계정 단위 자원)
- 증상: 같은 계정, 같은 슬롯에 대해 두 캐릭터가 동시에 인출을 요청하면 둘 다 "잔량 충분" 판정을 받고 둘 다 차감을 수행해, 합산 인출량이 슬롯 보유량을 초과한다(
qty가 음수로 내려갈 수 있음). - 재현 조건:
- 계정 X의 캐릭터 A(세션 SA), 캐릭터 B(세션 SB)가 동시 접속.
- 슬롯 5에 아이템 1개 보유.
- A가 스레드 T1에서
Withdraw(sessionA, 5, 1), B가 스레드 T2에서Withdraw(sessionB, 5, 1)을 거의 동시에 호출. - T1은
session.SessionLock(SA의 락)을, T2는session.SessionLock(SB의 락)을 잡는다 — 서로 다른 객체이므로 상호 배제가 전혀 일어나지 않는다. - 둘 다 (A)의
qty(=1) >= amount(=1)검사를 통과하고, 둘 다 (B)에서 차감 — 최종qty는1 - 1 - 1 = -1.
- 근본 원인: 보호 대상(계정 창고, 계정 단위)과 락의 범위(캐릭터 세션 단위)가 불일치한다. "세션이 다르면 별개로 안전하다"는 잘못된 전제로 락을 설계했다.
(B) 검사-차감 자체도 원자적으로 보이지만 락이 무의미해 TOCTOU로 귀결
- 증상: (A)의 락이 실질적으로 아무것도 배제하지 못하므로, 검사와 차감 사이에 다른 세션의 차감이 끼어들 수 있는 것과 동일한 결과가 발생한다(Lost Update).
- 재현 조건: 위와 동일. 락이 계정 단위로 걸리지 않는 한 몇 번을 다시 구현해도 재현된다.
- 근본 원인:
Dictionary<int,int>자체도 동시 쓰기에 안전하지 않아, 최악의 경우 내부 구조 손상까지 겹칠 수 있다(락이 무력화된 상태이므로).
수정안 (정확한 코드)
락을 세션이 아니라 계정 창고(자원) 단위로 소유하게 바꾼다.
using System.Collections.Generic;
public sealed class CharacterSession
{
public long AccountId;
public long CharacterId;
}
public sealed class AccountWarehouse
{
public long AccountId;
public readonly object Lock = new object(); // 창고(자원) 소유 락
public Dictionary<int, int> Slots = new Dictionary<int, int>();
}
public sealed class WarehouseService
{
private readonly Dictionary<long, AccountWarehouse> _warehouses = new Dictionary<long, AccountWarehouse>();
private AccountWarehouse GetWarehouse(long accountId)
{
lock (_warehouses)
{
if (!_warehouses.TryGetValue(accountId, out var wh))
{
wh = new AccountWarehouse { AccountId = accountId };
_warehouses[accountId] = wh;
}
return wh;
}
}
public bool Withdraw(CharacterSession session, int slotId, int amount)
{
var wh = GetWarehouse(session.AccountId);
lock (wh.Lock) // 계정(창고) 단위 락 — 같은 계정의 모든 캐릭터가 이 락을 공유
{
if (!wh.Slots.TryGetValue(slotId, out var qty) || qty < amount)
return false;
wh.Slots[slotId] = qty - amount;
}
return true;
}
public void Deposit(CharacterSession session, int slotId, int amount)
{
var wh = GetWarehouse(session.AccountId);
lock (wh.Lock)
{
wh.Slots.TryGetValue(slotId, out var qty);
wh.Slots[slotId] = qty + amount;
}
}
}
더 나은 설계 (+ 트레이드오프)
- 계정 소유 액터/큐로 직렬화: 계정 창고에 대한 모든 요청을 계정별 단일 처리 큐(액터 모델)로 라우팅하면 락 경합 없이 순서가 보장된다. 대신 캐릭터 세션 스레드에서 계정 액터로 메시지를 보내는 추가 홉·지연이 생긴다.
- 슬롯별 원자 연산 + 낙관적 재시도:
Interlocked/CAS 로 슬롯 수량을 갱신하되 실패 시 재시도하는 방식도 가능하지만, "동시에 여러 슬롯"을 다루는 작업(교환, 세트 예치 등)으로 확장되면 원자성 보장이 복잡해진다. - 영속 저장소(DB) 기준이라면 트랜잭션 + 행 잠금:
SELECT ... FOR UPDATE로 슬롯 행을 잠근 뒤 갱신하면 서버 프로세스가 여러 대여도 안전하다. - 트레이드오프: 계정 단위 락은 구현이 단순하지만, 같은 계정의 캐릭터들이 서로 다른 슬롯을 동시에 건드릴 때도 하나의 락을 공유해 불필요한 대기가 생길 수 있다. 슬롯 단위로 락을 세분화하면 동시성은 늘지만 "여러 슬롯을 함께 검사·차감"하는 트랜잭션적 요구(교환 등)에서 데드락 방지를 위한 락 순서 규칙이 추가로 필요하다.
면접 포인트
- 락은 "무엇을 보호하는가(자원)"를 기준으로 범위를 잡아야 한다. "요청이 어느 세션에서 왔는가"와 "그 요청이 만지는 자원이 누구 소유인가"는 다른 개념이며, 후자가 락의 단위가 되어야 한다.
- 계정처럼 여러 세션(캐릭터)이 공유하는 자원은 세션 수명과 무관한 자체 동기화 primitive를 가져야 한다.
- 이런 버그는 부하가 낮을 때는 거의 재현되지 않다가, 같은 계정 멀티클라이언트(PC방, 부계정 매크로) 사용 패턴에서만 드러나므로 테스트 설계 시 "같은 소유자, 다른 세션" 시나리오를 반드시 포함해야 한다.
해설 — 계정 공용 창고, 세션 단위 락과 세션 수명 문제 (C++)
난이도: 중상
요약
withdraw/deposit 은 계정 창고(AccountWarehouse::slots)라는 계정 전체가 공유하는 자원을 다루지만, (A) 에서 잠그는 락은 session->sessionLock — 캐릭터(세션)마다 서로 다른 락 객체다. 같은 계정의 두 캐릭터가 각자 자기 세션 락만 쥔 채 (B) 에서 같은 슬롯을 동시에 차감하면 상호 배제가 전혀 일어나지 않아 슬롯 수량이 음수가 될 수 있다. C++ 에서는 여기에 더해, 락으로 삼은 sessionLock이 세션 객체 소유라는 점이 두 번째 문제를 낳는다 — 세션 스레드가 (A)에서 락을 잡고 대기/작업 중인 사이 그 세션의 접속이 끊겨 다른 스레드가 CharacterSession을 delete하면, 이미 잡고 있던(혹은 잡으려던) 뮤텍스가 해제된 메모리를 가리키게 되어 use-after-free가 발생한다.
문제점
(A) 락 범위가 공유 자원보다 좁음 (세션 단위 락 vs 계정 단위 자원)
- 증상: 같은 계정, 같은 슬롯에 대해 두 캐릭터가 동시에 인출을 요청하면 둘 다 "잔량 충분" 판정을 받고 둘 다 차감을 수행해, 합산 인출량이 슬롯 보유량을 초과한다.
- 재현 조건:
- 계정 X의 캐릭터 A(세션 SA), 캐릭터 B(세션 SB)가 동시 접속, 슬롯 5에 아이템 1개.
- A가 스레드 T1에서
withdraw(SA, 5, 1), B가 스레드 T2에서withdraw(SB, 5, 1)을 거의 동시에 호출. - T1은
SA->sessionLock, T2는SB->sessionLock을 잡는다 — 서로 다른 뮤텍스이므로 상호 배제가 없다. - 둘 다
it->second(=1) >= amount(=1)검사를 통과하고 둘 다 차감 — 최종 값은-1.
- 근본 원인: 보호 대상(계정 창고)과 락의 소유자(캐릭터 세션)가 불일치한다.
(A)/(B) sessionLock을 쥔 채 세션이 파괴될 수 있는 use-after-free
- 증상: 세션 접속 종료 처리 스레드가
CharacterSession을 해제하는 동안, 다른 스레드가 이미 그 세션의sessionLock을 잠그려 하거나 잠근 채 슬롯을 갱신 중이면 뮤텍스/세션 메모리에 대한 UAF가 발생해 크래시하거나 메모리가 손상된다. - 재현 조건:
- 캐릭터 A가
withdraw호출 중 (A)에서session->sessionLock을 잠그고 슬롯 갱신을 진행. - 거의 동시에 A의 클라이언트 연결이 끊겨, 별도 스레드가 세션 정리 로직에서
delete session을 수행. withdraw가 락을 해제(lock_guard소멸)하려는 시점엔 이미sessionLock이 포함된 메모리가 해제되어 있어 UB(락 해제가 손상된 메모리에 대한 연산이 됨).
- 캐릭터 A가
- 근본 원인: 동기화 primitive의 수명이 "요청을 보낸 세션"에 묶여 있는데, 세션은 언제든 다른 스레드에 의해 파괴될 수 있는 객체다. 보호해야 할 자원(창고)과 무관한, 게다가 수명이 짧은 객체를 락으로 쓰고 있다.
수정안 (정확한 코드)
락을 세션이 아니라 계정 창고(자원) 자체가 소유하게 바꾼다. 창고는 WarehouseService가 소유·관리하므로 세션의 접속/해제 수명과 무관하게 안정적으로 존재한다.
#include <string>
#include <unordered_map>
#include <mutex>
#include <memory>
struct CharacterSession {
long long accountId;
long long characterId;
};
struct AccountWarehouse {
long long accountId;
std::mutex mtx; // 창고(자원) 소유 락
std::unordered_map<int, int> slots;
};
class WarehouseService {
std::mutex mapMx_;
// 재해시 시 참조가 무효화되지 않도록 포인터를 저장
std::unordered_map<long long, std::unique_ptr<AccountWarehouse>> warehouses_;
AccountWarehouse& getWarehouse(long long accountId) {
std::lock_guard<std::mutex> lk(mapMx_);
auto it = warehouses_.find(accountId);
if (it == warehouses_.end()) {
auto wh = std::make_unique<AccountWarehouse>();
wh->accountId = accountId;
it = warehouses_.emplace(accountId, std::move(wh)).first;
}
return *it->second;
}
public:
bool withdraw(const CharacterSession& session, int slotId, int amount) {
AccountWarehouse& wh = getWarehouse(session.accountId);
std::lock_guard<std::mutex> lk(wh.mtx); // 계정(창고) 단위 락
auto it = wh.slots.find(slotId);
if (it == wh.slots.end() || it->second < amount)
return false;
it->second -= amount;
return true;
}
void deposit(const CharacterSession& session, int slotId, int amount) {
AccountWarehouse& wh = getWarehouse(session.accountId);
std::lock_guard<std::mutex> lk(wh.mtx);
wh.slots[slotId] += amount;
}
};
핵심 변경:
- 락(
mtx)을AccountWarehouse가 소유하게 해 자원과 동기화 범위를 일치시켰다. warehouses_를unordered_map<..., unique_ptr<AccountWarehouse>>로 바꿔, 맵 재해시가 일어나도 이미 발급된AccountWarehouse&참조가 무효화되지 않도록 했다(값 타입으로 두면 재해시 시 버킷 이동으로 참조가 깨질 수 있다).withdraw/deposit은 세션을const&로만 받아 식별 정보(accountId)만 사용하고, 세션의 수명이나 락에 의존하지 않는다 — 세션이 함수 호출 도중 해제돼도 안전하다.
더 나은 설계 (+ 트레이드오프)
- 계정 소유 액터/큐로 직렬화: 계정 창고에 대한 모든 요청을 계정별 단일 처리 큐로 라우팅하면 락 경합 없이 순서가 보장된다. 대신 세션 스레드에서 계정 액터로의 메시지 홉·지연이 추가된다.
shared_ptr<AccountWarehouse>로 발급:getWarehouse가 참조 대신shared_ptr을 반환하면, 창고 자체가 서비스에서 제거되는 시나리오(계정 정리 등)가 있어도 진행 중인 작업이 안전하게 끝날 때까지 창고가 살아있음을 보장할 수 있다. 다만 참조 카운팅 오버헤드가 추가된다.- 세션과 도메인 객체의 락을 절대 공유하지 않는 원칙: 세션은 "누가 요청했는가"의 표현일 뿐, 요청이 다루는 도메인 자원(창고, 슬롯)과 수명이 다르다. 동기화는 항상 도메인 자원 쪽에 둔다.
- 트레이드오프: 계정 단위 락은 구현이 단순하지만, 같은 계정의 캐릭터들이 서로 다른 슬롯을 동시에 건드릴 때도 하나의 락을 공유해 불필요한 대기가 생길 수 있다. 슬롯 단위로 세분화하면 동시성은 늘지만 여러 슬롯을 함께 다루는 트랜잭션(교환 등)에서 락 순서 규칙으로 데드락을 막아야 한다.
면접 포인트
- 락은 "보호해야 할 자원이 누구 소유인가"를 기준으로 잡아야 한다 — 요청을 보낸 세션과 자원의 소유자를 혼동하면 상호 배제가 무력화된다.
- C++에서 동기화 primitive의 수명은 보호 대상 자원과 같거나 그보다 길어야 한다. 다른 스레드에 의해 임의 시점에 파괴될 수 있는 객체(세션)를 락으로 쓰면 use-after-free로 직결된다.
unordered_map에 값 타입으로 객체를 저장하면 재해시 시 참조/포인터가 무효화될 수 있다 — 장기 보관할 참조를 발급해야 한다면unique_ptr/shared_ptr간접 저장이 필요하다.