38. 크래프팅 다중 재료 슬롯 동시 소모 (검사-차감 비원자)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 크래프팅 다중 재료 슬롯 동시 소모 (검사-차감 비원자)
난이도: 중
요약
OnCraft 는 "모든 슬롯 검사 → 모두 통과하면 슬롯별 순서대로 차감" 구조지만, 검사 구간과 차감 구간이 하나의 임계 구역으로 묶여 있지 않다. 같은 플레이어의 동시 제작 요청 두 개가 각각 (A) 검사를 통과한 뒤 (B) 에서 번갈아 차감하면, 두 요청 모두 "재료가 충분하다"고 판단한 상태로 차감을 진행해 슬롯 수량이 음수가 되거나(초과 소모), 완성품만 중복 지급(재료 대비 초과 산출)되는 결과가 나온다. 또한 레시피가 요구하는 슬롯이 여러 개일 때, 차감 도중 다른 요청이 끼어들면 "일부 슬롯만 차감된" 중간 상태가 다른 스레드에 노출될 수 있다.
문제점
(A) 검사와 (B) 차감 사이의 비원자성 (TOCTOU / lost update)
- 증상: 슬롯 보유량이 요청 1회분(예: 5개)만 있는데 동시 요청 2건이 모두 통과해 완성품이 2개 지급되고, 슬롯 수량은 5 - 5 - 5 = -5 로 내려간다.
- 재현 조건: 같은 플레이어가 같은 레시피를 요청하는 T1, T2 가 동시에
OnCraft진입.- T1 이 (A) 에서 모든 슬롯 통과.
- T2 도 T1 이 아직 차감하기 전에 (A) 에서 모든 슬롯 통과(같은 값을 읽음).
- T1 이 (B) 에서 차감, T2 도 (B) 에서 차감 — 검사 시점 값 기준으로 각자 차감하므로 총 소모량이 실제 보유량을 초과.
- 근본 원인: "검사"와 "차감"이 같은 락/트랜잭션으로 보호되지 않는 check-then-act. 슬롯 딕셔너리 자체도 동시 접근에 안전하지 않다(별도 동기화 없음).
(B) 여러 슬롯 차감이 레시피 단위로 원자적이지 않음 (부분 적용)
- 증상: 레시피가 슬롯 3, 7 을 요구할 때, 슬롯 3 차감 직후 다른 스레드가 같은 인벤토리를 읽으면 "슬롯 3은 이미 줄었는데 슬롯 7은 아직" 인 중간 상태를 관찰할 수 있고, 예외/오류로 차감 도중 중단되면 일부만 소모된 채 완성품은 지급되지 않는 상태가 남는다.
- 재현 조건: 다중 재료 레시피 + 동시 조회/차감 경합, 또는 차감 루프 중 예외.
- 근본 원인: 여러 슬롯에 대한 다중 문(multi-statement) 갱신을 트랜잭션 경계 없이 순차 실행.
수정안 (정확한 코드)
플레이어 단위 락으로 "검사+차감"을 하나의 임계 구역으로 묶고, 차감 전 재검증을 락 내부에서 수행한다.
using System.Collections.Generic;
using System.Threading;
class CraftingHandler
{
private readonly Dictionary<int, Dictionary<int, InventorySlot>> _inventories = new();
// 플레이어별 락 (플레이어 단위로 제작 요청을 직렬화)
private readonly Dictionary<int, object> _locks = new();
private object GetLock(int playerId)
{
lock (_locks)
{
if (!_locks.TryGetValue(playerId, out var lk))
{
lk = new object();
_locks[playerId] = lk;
}
return lk;
}
}
private Dictionary<int, InventorySlot> GetInventory(int playerId) => _inventories[playerId];
private void GrantResult(int playerId, string itemId) { /* 완성품 지급 */ }
public bool OnCraft(int playerId, Recipe recipe)
{
var playerLock = GetLock(playerId);
lock (playerLock) // 검사~차감 전체를 하나의 임계 구역으로
{
var inv = GetInventory(playerId);
// 검사: 락 안에서 최신 값 기준으로 확인
foreach (var (slotId, needQty) in recipe.Ingredients)
{
if (!inv.TryGetValue(slotId, out var slot) || slot.Quantity < needQty)
return false;
}
// 차감: 검사와 같은 락 구간이므로 다른 요청이 끼어들 수 없다
foreach (var (slotId, needQty) in recipe.Ingredients)
{
inv[slotId].Quantity -= needQty;
}
GrantResult(playerId, recipe.ResultItemId);
return true;
}
}
}
더 나은 설계 (+ 트레이드오프)
- 인벤토리 단위 액터/직렬 큐: 플레이어별 인벤토리 변경을 단일 처리 큐(또는 액터)로 직렬화하면 락 경합 없이 순서가 보장된다. 대신 큐 지연·처리량 한계.
- 낙관적 버전 검사: 슬롯마다 버전(또는 CAS)을 두고 "검사 시점 버전 == 차감 시점 버전"이 아니면 재시도. 락 없이도 정합성을 보장하지만 재시도 로직 복잡도가 늘어난다.
- DB 트랜잭션(영속 인벤토리인 경우): 슬롯이 DB 행이라면
SELECT ... FOR UPDATE로 관련 슬롯을 모두 잠근 뒤 한 트랜잭션에서 검사+차감+지급을 커밋. 배포 환경이 단일 프로세스가 아니어도 정합성 유지 가능. - 트레이드오프: 플레이어 단위 락은 구현이 단순하지만 같은 플레이어의 다른 요청(예: 인벤토리 정렬)과도 경합할 수 있어 락 범위를 신중히 설계해야 한다.
면접 포인트
- 여러 자원(슬롯)에 대한 "검사 후 실행"은 자원이 하나일 때보다 원자성 확보가 어렵다 — 락 범위가 전체 자원 집합을 포괄해야 한다.
- "검사 통과 = 실행 시점에도 유효"라는 가정이 동시성 환경에서 깨지는 대표적 TOCTOU 패턴, 재검증 또는 임계 구역 확대로 해결.
- 부분 적용(일부 슬롯만 차감) 방지를 위해 다중 갱신을 원자적 단위(락/트랜잭션)로 묶는 설계 원칙.
해설 · C++
해설 — 크래프팅 다중 재료 슬롯 동시 소모 (검사-차감 비원자, C++)
난이도: 중
요약
onCraft 는 (A) 에서 모든 재료 슬롯을 검사하고 (B) 에서 순서대로 차감하지만, 두 구간이 하나의 임계 구역으로 묶여 있지 않고 inventories_/슬롯 맵 자체에도 동기화가 없다. 같은 플레이어의 동시 제작 요청이 각자 (A) 를 통과한 뒤 (B) 를 교차 실행하면, 실제 보유량보다 많은 양이 차감되어 quantity 가 음수가 되거나 완성품이 재료 대비 초과 지급된다. C++ 에서는 여기에 더해 unordered_map 을 여러 스레드가 동기화 없이 동시에 읽고 쓰는 것 자체가 데이터 레이스(UB)다.
문제점
(A) 검사와 (B) 차감 사이의 비원자성 (TOCTOU / lost update)
- 증상: 슬롯 수량이 요청 1회분만 있어도 동시 요청 2건이 모두 검사를 통과해 둘 다 차감을 진행,
quantity가 음수로 내려가고 완성품이 초과 지급된다. - 재현 조건: 같은
playerId로 같은 레시피를 요청하는 스레드 T1, T2 가 동시에onCraft진입.- T1, T2 모두 (A) 에서 같은 값을 읽고 통과.
- T1 이 (B) 에서 차감, 이어 T2 도 (B) 에서 차감 — 각자 검사 시점 값을 근거로 판단했으므로 총 소모량이 실제 보유량을 초과.
- 근본 원인: 검사·차감이 락으로 보호되지 않은 채 서로 다른 스레드에서 인터리빙될 수 있다.
unordered_map 동시 접근 자체의 데이터 레이스
- 증상: 동시
find/삽입/원소 변경이 겹치면 버킷 재해시·포인터 무효화로 크래시나 손상된 값을 읽을 수 있다. - 재현 조건: 여러 워커가 같은
playerId의 인벤토리 맵에 동기화 없이 동시 접근. - 근본 원인: 표준 컨테이너는 동시 읽기만 안전하고, 하나라도 쓰기가 섞이면 외부 동기화가 필요한데 이 코드엔 없다.
수정안 (정확한 코드)
플레이어별 뮤텍스로 검사~차감 전체를 하나의 임계 구역으로 묶는다.
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <memory>
struct InventorySlot {
int slotId;
std::string itemId;
int quantity;
};
struct Recipe {
std::string resultItemId;
std::vector<std::pair<int,int>> ingredients; // (slotId, needQty)
};
class CraftingHandler {
std::mutex mapMx_; // _playerLocks_/_inventories_ 구조 보호
std::unordered_map<int, std::unordered_map<int, InventorySlot>> inventories_;
std::unordered_map<int, std::unique_ptr<std::mutex>> playerLocks_;
std::mutex& getLock(int playerId) {
std::lock_guard<std::mutex> lk(mapMx_);
auto it = playerLocks_.find(playerId);
if (it == playerLocks_.end()) {
it = playerLocks_.emplace(playerId, std::make_unique<std::mutex>()).first;
}
return *it->second;
}
void grantResult(int /*playerId*/, const std::string& /*itemId*/) { /* 완성품 지급 */ }
public:
bool onCraft(int playerId, const Recipe& recipe) {
std::mutex& playerLock = getLock(playerId);
std::lock_guard<std::mutex> lk(playerLock); // 검사~차감 전체를 하나의 임계 구역으로
std::unordered_map<int, InventorySlot>* inv;
{
std::lock_guard<std::mutex> mlk(mapMx_);
inv = &inventories_[playerId];
}
// 검사: 락 안에서 최신 값 기준
for (const auto& [slotId, needQty] : recipe.ingredients) {
auto it = inv->find(slotId);
if (it == inv->end() || it->second.quantity < needQty)
return false;
}
// 차감: 검사와 같은 락 구간
for (const auto& [slotId, needQty] : recipe.ingredients) {
(*inv)[slotId].quantity -= needQty;
}
grantResult(playerId, recipe.resultItemId);
return true;
}
};
더 나은 설계 (+ 트레이드오프)
- 플레이어 소유 틱/워커 스레드로 직렬화: 인벤토리 변경 요청을 플레이어 소유 큐로 라우팅하면 락 경합 없이 순서가 보장된다. 대신 메시지 홉·큐 지연이 추가된다.
- 슬롯별 원자 카운터 + 예약/롤백:
std::atomic<int> quantity로fetch_sub후 결과가 음수면 되돌리는 방식도 가능하지만, 레시피가 여러 슬롯을 요구하면 "전부 성공 또는 전부 롤백"을 보장하기 위한 추가 조정(2단계 커밋류)이 필요해 오히려 복잡해진다. - DB/영속 인벤토리라면 트랜잭션: 관련 슬롯 행을 모두 잠근(
SELECT ... FOR UPDATE) 뒤 한 트랜잭션에서 검사+차감+지급. - 트레이드오프: per-player mutex 맵은 구현이 단순하지만 플레이어 수가 매우 많을 때 락 객체 자체의 메모리·생성 동시성(
playerLocks_보호용mapMx_)이 추가 비용이 된다.
면접 포인트
- 여러 자원(슬롯)에 걸친 "검사 후 실행"은 락 범위가 관련 자원 전체를 포괄해야 안전하다.
- 표준 컨테이너(
unordered_map)의 동시 접근 규칙 — 동시 읽기는 안전, 쓰기가 섞이면 외부 동기화 필수. - 다중 자원 갱신의 원자성(all-or-nothing)을 보장하려면 임계 구역 확대 또는 트랜잭션/2단계 커밋이 필요하다.