1. 골드 이체 서비스
난이도 중해설 — 골드 이체 서비스
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
플레이어 간 골드 이체. 보내는 쪽 차감(C)과 받는 쪽 가산(D)을 별개의 락 구간으로 쪼개고, 멱등성 체크(B)/기록(E)도 락 밖에서 처리한다. 그 결과 총합 비보존(골드 증발/복사), 멱등성 무력화로 인한 중복 이체, 공유 자료구조 동시성 버그가 동시에 존재한다. 핵심 통찰은 "이체는 차감+가산+멱등성기록이 하나의 직렬화된 트랜잭션이어야 한다" 는 것이다.
난이도가 '중'인 이유: 락 자체는 걸려 있어 "락만 추가하면 끝" 으로 보이기 쉽지만, 진짜 어려움은 (1) 임계 구역의 경계를 어디로 잡아야 원자성이 성립하는지, (2) 두 락을 동시에 잡을 때 생기는 락 순서 데드락을 피하는 것, (3) 멱등성이 왜 "직렬화" 로만 보장되는지를 설명하는 데 있다. 단순 "락 빠짐" 문제가 아니다.
문제점
(C)+(D) 차감과 가산이 한 트랜잭션이 아님 — 원자성 위반 (정확성/동시성)
- 증상: 차감은 됐는데 가산 직전 예외가 나거나 서버가 죽으면 골드가 증발한다. 중간 상태(차감만 반영된 상태)가 다른 스레드/감사에 관측된다.
- 재현 조건:
from.Gold -= amount직후lock (to.Lock)진입 전에_players[toId]접근이 던지는 경우(받는 사람 탈퇴/잘못된 ID로KeyNotFoundException은 (D) 이전_players[toId]에서 이미 나지만, 일반화하면 두 락 사이의 어떤 예외/크래시든) 차감만 반영되고 가산은 누락 → 총합 감소. 두 lock 구간이 분리돼 있어 그 사이가 관측 가능한 중간 상태다. - 근본 원인: 이체는 "차감+가산"이 하나의 원자 단위여야 하는데 두 개의 독립 락 구간으로 쪼개졌다. 실패 시 롤백 경로도 없다.
(B)+(E) 멱등성 체크/기록이 락 밖 — 중복 이체 (정확성/동시성/보안)
- 증상: 같은
requestId두 요청이 동시에 들어오면 골드가 두 번 이동한다. 어뷰저가 의도적으로 같은 요청을 동시에 쏘면 재화 복사로 이어진다. - 재현 조건: 동일
requestId두 요청이 거의 동시에 도착. 둘 다 (B)에서 "아직 없음" 을 통과(이 시점엔 누구도 (E)에서 Add 하지 않았다) → 둘 다 이체 수행 → 둘 다 (E)에서 Add. 체크와 기록 사이가 비어 있어 두 스레드가 같은 틈으로 빠져나간다. 게다가HashSet은 스레드 안전하지 않아 동시Add/Contains가 내부 구조를 손상 시켜 무한 루프/오동작까지 유발할 수 있다. - 근본 원인 (★멱등성이 왜 직렬화로만 성립하는가):
멱등성은 "이 requestId 가 이미 처리됐는가" 라는 질문에 대한 답이 처리 도중에
바뀌면 안 된다. 즉
Contains(check) → 이체(process) → Add(record)세 단계가 불가분(원자적) 이어야 한다. 어떤 락도 이 셋을 함께 감싸지 않으면, 두 스레드가 동시에Contains==false를 관측하는 TOCTOU 창이 항상 존재한다. 이 창을 없애는 유일한 방법은 "같은 requestId 에 대한 체크-처리-기록 전체를 한 번에 한 스레드만 실행하도록 직렬화" 하는 것이다.- 수정안에서 멱등성 검사/기록을 이체와 같은 임계 구역 안에 넣으면, 락을 먼저
잡은 스레드가 처리+기록을 끝낸 뒤에야 두 번째 스레드가 임계 구역에 들어온다.
그때는 이미
_processed에 requestId 가 있으므로 두 번째는Contains==true로 조기 반환한다. "먼저 들어온 자가 기록까지 마친다" 는 직렬 순서가 보장되기 때문에 중복이 원천적으로 불가능해진다. 락이 보장하는 것은 상호 배제이고, 그 상호 배제가 곧 "체크-처리-기록의 불가분 실행 = 직렬화" 다.
- 수정안에서 멱등성 검사/기록을 이체와 같은 임계 구역 안에 넣으면, 락을 먼저
잡은 스레드가 처리+기록을 끝낸 뒤에야 두 번째 스레드가 임계 구역에 들어온다.
그때는 이미
(G) TotalGold 가 락 없이 순회 (동시성)
- 증상: 총합 감사(audit) 값이 틀린다.
Dictionary순회 중 다른 스레드가_players를 수정하면InvalidOperationException(컬렉션 변경됨). - 근본 원인: 공유 컬렉션 비보호 순회 +
long필드를 락 없이 읽음(64비트 정렬 읽기는 보통 찢기지 않으나 메모리 가시성/이체 도중 스냅샷 정합성은 별개 문제).
(A)+(F) Gold 가 public 가변 필드 (유지보수/보안)
- 어디서든
player.Gold = ...로 직접 수정 가능 → 락/멱등성/감사 경로를 우회하는 코드가 언제든 생긴다. 캡슐화 부재가 위 모든 보장을 무력화한다.
락 순서: 잠재적 데드락 (동시성)
- 현재는 lock 구간이 겹치지 않아 데드락은 없다. 그러나 원자성을 위해 두 락을 동시에
잡도록 고치는 순간,
Transfer(A→B)와Transfer(B→A)가 동시에 일어나면 락 순서 역전 데드락이 생긴다. 수정안에서 반드시 전역 락 순서로 회피해야 한다.
수정안
핵심: ① 두 플레이어 락을 항상 같은 순서(Id 오름차순) 로 잡아 차감+가산을 하나의 원자 트랜잭션으로 만들고, ② 멱등성 체크/기록을 같은 임계 구역 안에서 처리해 체크-처리-기록을 직렬화한다.
public class GoldService
{
private readonly Dictionary<long, Player> _players;
private readonly object _ledgerLock = new object(); // _processed 보호 전용
private readonly HashSet<Guid> _processed = new HashSet<Guid>();
public GoldService(Dictionary<long, Player> players) => _players = players;
public bool Transfer(Guid requestId, long fromId, long toId, long amount)
{
if (amount <= 0 || fromId == toId) return false;
if (!_players.TryGetValue(fromId, out var from)) return false;
if (!_players.TryGetValue(toId, out var to)) return false;
// 데드락 회피: 항상 Id 오름차순으로 락 획득(전역 락 순서 규칙)
Player first = fromId < toId ? from : to;
Player second = fromId < toId ? to : from;
lock (first.Lock)
lock (second.Lock)
{
// 멱등성: 체크-처리-기록을 같은 임계 구역에서 → 직렬화로 중복 차단.
// 두 플레이어 락을 잡은 상태이므로, 같은 (from,to) 쌍에 대한 동시 요청은
// 여기서 한 번에 하나만 통과한다. 먼저 들어온 스레드가 _processed 에
// 기록까지 마치고 락을 놓아야 다음 스레드가 들어오고, 그땐 이미 처리됨.
lock (_ledgerLock)
{
if (_processed.Contains(requestId)) return true; // 이미 처리 → 멱등
}
if (from.Gold < amount) return false;
// 차감+가산이 같은 임계 구역 → 중간 상태 관측 불가, 총합 보존
from.Gold -= amount;
to.Gold += amount;
lock (_ledgerLock) { _processed.Add(requestId); }
}
return true;
}
public long TotalGold()
{
// 감사 정합성: 모든 플레이어 락을 일관된 순서로 잡거나(아래 더 나은 설계),
// 최소한 각 플레이어 락 안에서 읽는다.
long sum = 0;
foreach (var p in _players.Values) { lock (p.Lock) sum += p.Gold; }
return sum;
}
}
주의: 두
_ledgerLock구간(체크/기록)은 모두 두 플레이어 락 안쪽에 있으므로, 같은 requestId 의 동시 요청은 같은 (from,to) 락을 두고 직렬화된다. 만약 서로 다른 (from,to) 쌍이 우연히 같은 requestId 를 보낸다면(정상 도메인에선 없음),_ledgerLock자체가_processed접근을 직렬화하므로 여전히 한 번만 처리된다. 락 획득 순서는 항상first.Lock → second.Lock → _ledgerLock로 고정되어 데드락이 없다.
Gold 는 private 으로 바꾸고 Deposit/Withdraw 메서드로만 접근하게 캡슐화한다.
더 나은 설계
1) 진짜 영속성이 필요하면 DB 트랜잭션이 정답
인메모리 락은 프로세스 크래시에 무력하다. 차감 후 크래시 = 골드 증발. 실서비스 재화는 DB 트랜잭션 + 멱등성 키로 처리한다.
BEGIN;
-- UNIQUE(request_id): 중복이면 INSERT 가 실패 → "이미 처리"로 멱등 응답
INSERT INTO transfers(request_id, from_id, to_id, amount) VALUES(?,?,?,?);
UPDATE players SET gold = gold - ? WHERE id = ? AND gold >= ?; -- affected=0 → 잔액부족 → ROLLBACK
UPDATE players SET gold = gold + ? WHERE id = ?;
COMMIT;
- 트레이드오프: DB 왕복 지연. 대신 원자성/내구성/멱등성이 한 번에 보장된다.
여기서
UNIQUE(request_id)가 인메모리의 "같은 임계 구역" 역할을 DB 차원의 직렬화로 대체한다 — 멱등성을 락이 아니라 제약(constraint) 으로 강제.
2) 락-프리/싱글스레드 액터 모델
재화 계정을 샤딩해 각 샤드를 단일 스레드(액터)가 소유하면 락 자체가 사라진다.
- 같은 샤드 내 이체는 락 없이 안전(직렬 처리). 샤드 간 이체는 2-페이즈/사가 패턴.
- 트레이드오프: 크로스 샤드 트랜잭션 복잡도 증가.
3) 멱등성 키 만료
_processed 가 무한히 커진다. requestId 에 TTL(예: 24h)을 두거나 DB UNIQUE +
주기적 파티션 정리로 메모리 누수를 막는다.
면접 포인트
- 면접관이 듣고 싶은 핵심: "이체는 단일 트랜잭션이고, 멱등성은 체크-처리-기록의 직렬화로만 성립한다" 는 통찰. 원자성·총합 보존·멱등성·데드락 회피(락 순서)를 하나로 엮어 설명하고, 특히 "왜 멱등성 체크가 락 안에 있어야 하는가(TOCTOU 창 제거)" 를 메커니즘으로 짚으면 강하다.
- 예상 질문:
- "멱등성 체크를 락 밖에서 하면 정확히 무슨 일이 생기나?"
→ 두 동시 요청이 모두
Contains==false를 관측하는 TOCTOU 창이 열려 둘 다 이체. 체크-처리-기록을 한 임계 구역에 넣어 직렬화해야 그 창이 닫힌다. - "두 플레이어 락을 동시에 잡으면 데드락은?" → 전역 락 순서(Id 오름차순) 고정. A→B 와 B→A 가 같은 순서로 잡혀 역전이 없다.
- "차감 후 서버가 죽으면 인메모리로 막을 수 있나?" → 못 막는다. 영속 트랜잭션/원장 로그가 본질적 해법. DB UNIQUE(request_id)가 멱등성까지 함께 보장한다.
- "멱등성 체크를 락 밖에서 하면 정확히 무슨 일이 생기나?"
→ 두 동시 요청이 모두
해설 — 골드 이체 서비스
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
플레이어 간 골드 이체. 보내는 쪽 차감(C)과 받는 쪽 가산(D)을 별개의 락 구간으로
쪼개고, 멱등성 체크(B)/기록(E)도 락 밖에서, 그것도 스레드 안전하지 않은
std::unordered_set 위에서 처리한다. 그 결과 총합 비보존(골드 증발/복사), 멱등성
무력화로 인한 중복 이체, 컨테이너 동시 변경으로 인한 UB가 동시에 존재한다. 핵심
통찰은 "이체는 차감+가산+멱등성기록이 하나의 직렬화된 트랜잭션이어야 한다" 는 것이다.
난이도가 '중'인 이유: 락 자체는 걸려 있어 "락만 추가하면 끝" 으로 보이기 쉽지만, 진짜 어려움은 (1) 임계 구역의 경계를 어디로 잡아야 원자성이 성립하는지, (2) 두
std::mutex를 동시에 잡을 때 생기는 락 순서 데드락을 피하는 것, (3) 멱등성이 왜 "직렬화" 로만 보장되는지를 설명하는 데 있다. 단순 "락 빠짐" 이 아니다.
문제점
(C)+(D) 차감과 가산이 한 트랜잭션이 아님 — 원자성 위반 (정확성/동시성)
- 증상: 차감은 됐는데 가산 직전 예외가 나거나 서버가 죽으면 골드가 증발한다. 중간 상태(차감만 반영된 상태)가 다른 스레드/감사에 관측된다.
- 재현 조건:
from->gold -= amount직후 두lock_guard구간 사이에서 어떤 예외/크래시가 나면 차감만 반영되고 가산은 누락 → 총합 감소. 두 lock 구간이 분리돼 있어 그 사이가 관측 가능한 중간 상태다. 또한 동시에 다른 이체가 끼면 한쪽 계좌만 본 부분 상태가 노출된다. - 근본 원인: 이체는 "차감+가산"이 하나의 원자 단위여야 하는데 두 개의 독립 락
구간(서로 다른
mutex)으로 쪼개졌다. RAIIlock_guard가 스코프 끝에서 락을 풀어 두 구간이 절대 겹치지 않으므로 원자성이 깨진다. 실패 시 롤백 경로도 없다.
(B)+(E) 멱등성 체크/기록이 락 밖 — 중복 이체 + 컨테이너 UB (정확성/동시성/보안)
- 증상: 같은
requestId두 요청이 동시에 들어오면 골드가 두 번 이동한다. 어뷰저가 의도적으로 같은 요청을 동시에 쏘면 재화 복사로 이어진다. - 재현 조건: 동일
requestId두 요청이 거의 동시에 도착. 둘 다 (B)에서 "아직 없음" 을 통과(이 시점엔 누구도 (E)에서insert하지 않았다) → 둘 다 이체 수행 → 둘 다 (E)에서insert. 체크와 기록 사이가 비어 있어 두 스레드가 같은 틈으로 빠져나간다. 더 나아가std::unordered_set은 스레드 안전하지 않으므로 동시count()/insert()가 rehash 중 버킷 리스트를 손상시켜 자료구조 손상 = 미정의 동작(UB)(크래시/무한 루프)까지 유발한다. - 근본 원인 (★멱등성이 왜 직렬화로만 성립하는가):
멱등성은 "이 requestId 가 이미 처리됐는가" 라는 질문의 답이 처리 도중에 바뀌면
안 된다. 즉
count(check) → 이체(process) → insert(record)세 단계가 불가분(원자적) 이어야 한다. 어떤 락도 이 셋을 함께 감싸지 않으면 두 스레드가 동시에count==0을 관측하는 TOCTOU 창이 항상 존재한다. 이 창을 없애는 유일한 방법은 "같은 requestId 에 대한 체크-처리-기록 전체를 한 번에 한 스레드만 실행하도록 직렬화" 하는 것이다. 더불어unordered_set접근 자체도 전용 락으로 직렬화해야 한다 (C++ 표준 컨테이너는 같은 객체에 대한 동시 쓰기를 보장하지 않는다).
(G) TotalGold 가 락 없이 순회 (동시성)
- 증상: 총합 감사(audit) 값이 틀린다. 다른 스레드가 이체로 한쪽
gold만 바꾼 중간 상태를 읽으면 합이 어긋난다. 비원자int64_t읽기의 메모리 가시성/데이터 레이스(엄밀히는 동시 비동기화 읽기/쓰기는 UB). - 근본 원인: 각 플레이어
gold를 그 플레이어 락 없이 읽는다. 이체 도중 스냅샷 정합성이 보장되지 않는다.
(A)+(F) gold 가 public 가변 필드 (유지보수/보안)
- 어디서든
player->gold = ...로 직접 수정 가능 → 락/멱등성/감사 경로를 우회하는 코드가 언제든 생긴다. 캡슐화 부재가 위 모든 보장을 무력화한다.
락 순서: 잠재적 데드락 (동시성)
- 현재는 lock 구간이 겹치지 않아 데드락은 없다. 그러나 원자성을 위해 두
mutex를 동시에 잡도록 고치는 순간,Transfer(A→B)와Transfer(B→A)가 동시에 일어나면 락 순서 역전 데드락이 생긴다. 수정안에서 반드시 회피해야 한다.
수정안
핵심: ① 두 플레이어 락을 데드락 없이 동시에 잡아(std::scoped_lock 또는
std::lock) 차감+가산을 하나의 원자 트랜잭션으로 만들고, ② 멱등성 체크/기록을
같은 임계 구역 안에서 처리해 체크-처리-기록을 직렬화한다.
class GoldService {
public:
explicit GoldService(std::unordered_map<int64_t, Player*>& players)
: players_(players) {}
bool Transfer(const std::string& requestId, int64_t fromId, int64_t toId, int64_t amount) {
if (amount <= 0 || fromId == toId) return false;
auto fit = players_.find(fromId);
auto tit = players_.find(toId);
if (fit == players_.end() || tit == players_.end()) return false;
Player* from = fit->second;
Player* to = tit->second;
// 데드락 회피: 두 mutex 를 한 번에 잠근다(획득 순서 무관, 교착 없음).
std::scoped_lock lk(from->lock, to->lock);
// 멱등성: 체크-처리-기록을 같은 임계 구역에서 → 직렬화로 중복 차단.
{
std::lock_guard<std::mutex> llk(ledgerMtx_);
if (processed_.count(requestId) > 0) return true; // 이미 처리 → 멱등
}
if (from->gold < amount) return false;
// 차감+가산이 같은 임계 구역 → 중간 상태 관측 불가, 총합 보존
from->gold -= amount;
to->gold += amount;
{
std::lock_guard<std::mutex> llk(ledgerMtx_);
processed_.insert(requestId);
}
return true;
}
int64_t TotalGold() {
int64_t sum = 0;
for (auto& kv : players_) {
std::lock_guard<std::mutex> lk(kv.second->lock);
sum += kv.second->gold;
}
return sum;
}
private:
std::unordered_map<int64_t, Player*>& players_;
std::mutex ledgerMtx_; // processed_ 보호 전용
std::unordered_set<std::string> processed_;
};
std::scoped_lock은 내부적으로std::lock의 데드락 회피 알고리즘을 써서 두 락을 안전하게 동시에 잡으므로, A→B 와 B→A 가 동시에 와도 교착이 없다(전역 락 순서를 직접 정렬할 필요도 없다).ledgerMtx_는processed_접근을 직렬화해 컨테이너 UB 를 막고, 두 플레이어 락 안쪽에 있어 같은 (from,to) 의 동시 요청을 직렬화한다.
gold 는 private 으로 바꾸고 Deposit/Withdraw 메서드로만 접근하게 캡슐화한다.
더 나은 설계
1) 진짜 영속성이 필요하면 DB 트랜잭션이 정답
인메모리 락은 프로세스 크래시에 무력하다. 차감 후 크래시 = 골드 증발. 실서비스 재화는 DB 트랜잭션 + 멱등성 키로 처리한다.
BEGIN;
-- UNIQUE(request_id): 중복이면 INSERT 가 실패 → "이미 처리"로 멱등 응답
INSERT INTO transfers(request_id, from_id, to_id, amount) VALUES(?,?,?,?);
UPDATE players SET gold = gold - ? WHERE id = ? AND gold >= ?; -- affected=0 → 잔액부족 → ROLLBACK
UPDATE players SET gold = gold + ? WHERE id = ?;
COMMIT;
- 트레이드오프: DB 왕복 지연. 대신 원자성/내구성/멱등성이 한 번에 보장된다.
여기서
UNIQUE(request_id)가 인메모리의 "같은 임계 구역" 역할을 DB 차원의 직렬화로 대체한다 — 멱등성을 락이 아니라 제약(constraint) 으로 강제.
2) 락-프리/싱글스레드 액터 모델
재화 계정을 샤딩해 각 샤드를 단일 스레드(액터)가 소유하면 락 자체가 사라진다.
- 같은 샤드 내 이체는 락 없이 안전(직렬 처리). 샤드 간 이체는 2-페이즈/사가 패턴.
- 트레이드오프: 크로스 샤드 트랜잭션 복잡도 증가.
3) 멱등성 키 만료
processed_ 가 무한히 커진다. requestId 에 TTL(예: 24h)을 두거나 DB UNIQUE +
주기적 파티션 정리로 메모리 누수를 막는다.
면접 포인트
- 면접관이 듣고 싶은 핵심: "이체는 단일 트랜잭션이고, 멱등성은 체크-처리-기록의 직렬화로만 성립한다" 는 통찰. 원자성·총합 보존·멱등성·데드락 회피를 하나로 엮어 설명하고, 특히 "왜 멱등성 체크가 락 안에 있어야 하는가(TOCTOU 창 제거)" 를 메커니즘으로 짚으면 강하다.
- 예상 질문:
- "멱등성 체크를 락 밖에서 하면 정확히 무슨 일이 생기나?"
→ 두 동시 요청이 모두
count==0을 관측하는 TOCTOU 창이 열려 둘 다 이체. 게다가unordered_set동시 변경은 UB. 체크-처리-기록을 한 임계 구역에 넣어 직렬화해야. - "두 플레이어 락을 동시에 잡으면 데드락은?
std::lock/scoped_lock은 왜 안전한가?" → 두 락을 동시에 잡되 한쪽이 막히면 잡은 락을 풀고 재시도하는 데드락 회피 알고리즘을 쓰기 때문에 A→B 와 B→A 가 교착되지 않는다. - "차감 후 서버가 죽으면 인메모리로 막을 수 있나?" → 못 막는다. 영속 트랜잭션/원장 로그가 본질적 해법. DB UNIQUE(request_id)가 멱등성까지 함께 보장한다.
- "멱등성 체크를 락 밖에서 하면 정확히 무슨 일이 생기나?"
→ 두 동시 요청이 모두