21. 가방이 가득 찬 상태에서 동시 다중 획득
난이도 하해설 — 가방이 가득 찬 상태에서 동시 다중 획득
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
AddItem 이 "빈 칸 찾기(FindFreeSlot) → 그 칸에 쓰기" 를 락 없이 두 단계로 한다.
이것은 전형적인 check-then-act(TOCTOU) 다. 두 스레드가 거의 동시에 들어오면 둘 다
같은 빈 칸 인덱스(예: 3)를 받아, 나중 스레드가 먼저 넣은 아이템을 덮어써 유실시키고
둘 다 true(성공)를 반환해 빈 칸 수보다 많은 "성공" 이 발생한다. 가방이 거의 찼을
때(빈 칸 1개에 동시 2개 도착) 특히 잘 터진다. 정답 한 줄: "빈 칸 점유 → 쓰기" 를 단일
임계구역으로 원자화(또는 인벤토리를 단일 스레드/액터로 직렬화)한다.
문제점
(A)+(B) FindFreeSlot 과 쓰기 사이의 TOCTOU — 같은 칸 덮어쓰기/아이템 유실 (동시성/버그) ★간판
- 증상: 빈 칸이 인덱스 3 하나뿐인 상태에서 두 보상이 동시 도착:
- T1
FindFreeSlot()→ 3 ... (쓰기 전 선점) ... T2FindFreeSlot()→ 3 →_slots[3]=B; return true→ T1 재개_slots[3]=A; return true. - 결과: 칸 3 에는 A 만 남고 B 는 사라짐(유실). 그런데 둘 다
true를 반환 → 호출부는 "두 개 다 지급 성공"으로 집계 → 가짜 성공/초과 지급.
- T1
- 재현조건: 같은 플레이어에게 퀘스트 보상·드랍·우편이 서로 다른 워커에서 동시 도착, 가방이 가득 차기 직전.
- 근본 원인: 빈 칸 조회와 점유가 원자적이지 않다. 조회 결과(인덱스)가 쓰기 시점까지 유효하다는 보장이 없다.
(공통) 동기화 부재 — 비원자 가시성
_slots에 락이 전혀 없다. 고정 배열이라 버킷 손상 같은 구조 붕괴는 없지만, 쓰기 가시성/순서 보장이 없어 위 유실이 그대로 노출된다.AddMany도 호출별로 비원자라 부분 적용·중간 끼어듦이 가능.
(정확성) 성공 개수의 의미 붕괴
AddMany의granted가 실제 보관 개수와 어긋난다(둘 다 true 였지만 1개만 남음). 보상 로그/재화 차감과 불일치 → 운영 클레임·이중 보상 시비.
수정안
핵심: "빈 칸 찾기 + 점유" 를 한 락 안에서 원자적으로.
public class Inventory
{
private readonly object _gate = new();
private readonly Item[] _slots;
public int Capacity => _slots.Length;
public Inventory(int capacity) { _slots = new Item[capacity]; }
public bool AddItem(Item item)
{
lock (_gate)
{
int free = FindFreeSlot_locked();
if (free < 0) return false; // 진짜 가득 참
_slots[free] = item; // 찾기와 쓰기가 같은 임계구역
return true;
}
}
private int FindFreeSlot_locked()
{
for (int i = 0; i < _slots.Length; i++)
if (_slots[i] == null) return i;
return -1;
}
// 여러 개를 한 번에: 부분 성공을 명확히 하려면 트랜잭션 경계를 호출자가 결정
public int AddMany(IReadOnlyList<Item> items)
{
lock (_gate)
{
int granted = 0;
foreach (var it in items)
{
int free = FindFreeSlot_locked();
if (free < 0) break; // 더 못 넣음
_slots[free] = it;
granted++;
}
return granted; // 실제 보관 개수 == 반환값
}
}
}
포인트
- 조회+점유가 원자적 → 같은 칸 동시 점유/덮어쓰기 불가,
true개수 == 실제 보관 개수. AddMany를 한 락으로 묶어 "여러 보상" 을 일관되게 처리(부분 성공 정책은 호출자가 결정).
더 나은 설계 (+트레이드오프)
- 인벤토리 단일 액터/싱글스레드 메일박스: 모든 가방 명령을 한 큐로 직렬 처리. 락 제거, 추론 단순. 트레이드오프: 핫 플레이어 큐 지연.
- 선점(reserve)→커밋 2단계: 빈 칸을 먼저 예약(원자적 카운터/슬롯 점유)하고 나중에 채운다. 보상 묶음의 부분 성공/실패를 트랜잭션으로 처리. 트레이드오프: 예약 누수 시 회수 로직 필요.
- 가방 가득참 정책 명시: 초과분은 우편으로 반송 / 거절 / 자동 정리. "성공" 의 의미를
Added / Mailed / Rejected로 분리해 호출자·로그가 정확히 집계. - 멱등 지급 키: 보상에 grantId 를 붙여 재시도·중복 패킷에도 한 번만 지급.
면접 포인트 (예상 질문)
- 빈 칸이 하나뿐인데 두 보상이 동시에 오면 무슨 일이 생기는가? 왜 둘 다 성공으로 보고되는가?
- 고정 배열이라 자료구조는 안 깨지는데도 왜 여전히 락이 필요한가?
- 보상 묶음(AddMany)에서 가방이 중간에 차면 "부분 성공"을 어떻게 일관되게 처리할까? reserve→commit 방식의 장단점은?
해설 — 가방이 가득 찬 상태에서 동시 다중 획득 (C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
addItem 이 "빈 칸 찾기(findFreeSlot) → 그 칸에 쓰기" 를 락 없이 두 단계로 한다.
전형적인 check-then-act(TOCTOU). 두 스레드가 동시에 같은 빈 인덱스를 받아 한쪽이 다른
쪽을 덮어써 아이템을 유실시키고 둘 다 true 를 반환해 빈 칸보다 많은 성공이 난다.
C++ 에서는 더해 std::vector<shared_ptr> 를 동시에 읽고 쓰는 것이 데이터 레이스(UB)
이고, shared_ptr 의 대입(이전 객체 해제 + 레퍼런스 카운트 갱신)이 비원자로 겹치면
이중 해제/카운트 손상까지 갈 수 있다. 정답 한 줄: "빈 칸 점유 → 쓰기" 를 단일
std::mutex 임계구역으로 원자화한다.
문제점
(A)+(B) findFreeSlot 과 쓰기 사이의 TOCTOU — 같은 칸 덮어쓰기/유실 (동시성/버그) ★간판
- 증상: 빈 칸이 인덱스 3 하나뿐일 때 두 보상 동시 도착:
- T1
findFreeSlot()→3 ... (쓰기 전 선점) ... T2findFreeSlot()→3 →slots_[3]=B; return true;→ T1slots_[3]=A; return true;. - 결과: 칸 3 엔 A 만, B 는 유실. 둘 다
true→ 호출부는 둘 다 성공으로 집계.
- T1
- 근본 원인: 조회와 점유가 원자적이지 않아 조회 결과 인덱스의 유효성이 보장되지 않음.
(공통, C++ 고유) vector / shared_ptr 동시 접근 — UB / 카운트 손상
- 증상: 락 없이
slots_원소를 여러 스레드가 동시 대입/읽기 → 데이터 레이스(UB).slots_[free] = std::move(item)은 기존shared_ptr해제 + 새 포인터 저장을 수반하는데, 같은 칸을 두 스레드가 동시 대입하면 control block 갱신이 겹쳐 이중 해제/레퍼런스 카운트 손상이 가능하다. (벡터 크기는 고정이라 재할당은 없지만 원소 동시 쓰기가 문제.) - 근본 원인: 공유 가변 컨테이너에 동기화가 전혀 없다.
(정확성) 성공 개수 의미 붕괴
addMany의granted가 실제 보관 개수와 어긋남 → 보상 로그/재화 정산과 불일치.
수정안
#include <mutex>
class Inventory {
public:
explicit Inventory(int capacity) : slots_(capacity) {}
bool addItem(std::shared_ptr<Item> item) {
std::lock_guard<std::mutex> lk(m_);
int free = findFreeSlot_locked();
if (free < 0) return false; // 진짜 가득 참
slots_[free] = std::move(item); // 찾기+쓰기 같은 임계구역
return true;
}
int addMany(const std::vector<std::shared_ptr<Item>>& items) {
std::lock_guard<std::mutex> lk(m_);
int granted = 0;
for (const auto& it : items) {
int free = findFreeSlot_locked();
if (free < 0) break;
slots_[free] = it;
++granted; // 반환값 == 실제 보관 개수
}
return granted;
}
private:
int findFreeSlot_locked() const {
for (int i = 0; i < static_cast<int>(slots_.size()); ++i)
if (!slots_[i]) return i;
return -1;
}
mutable std::mutex m_;
std::vector<std::shared_ptr<Item>> slots_;
};
포인트
- 조회+점유 원자화 → 같은 칸 동시 점유 불가,
true개수 == 실제 보관 개수. std::mutex로 vector/shared_ptr 동시 접근 UB·카운트 손상 제거.
더 나은 설계 (+트레이드오프)
- 인벤토리 단일 액터(싱글스레드 큐): 락 제거·추론 단순. 트레이드오프: 큐 지연.
- reserve→commit 2단계 점유: 빈 칸을 원자적으로 예약 후 채움. 묶음 보상의 부분 성공/실패를 트랜잭션으로. 트레이드오프: 예약 누수 회수 필요.
- 가득참 정책 명시(우편 반송/거절/자동정리) + 결과 타입
Added/Mailed/Rejected. - 멱등 지급 키(grantId) 로 재시도·중복 패킷 1회 지급.
면접 포인트 (예상 질문)
- 빈 칸 하나에 두 보상이 동시에 오면 왜 한 개가 사라지고도 둘 다 성공으로 보고되는가?
- 고정 크기
vector라 재할당이 없는데도 동시 원소 대입이 왜 UB 이고,shared_ptr대입이 왜 이중 해제로 이어질 수 있는가? - reserve→commit 2단계 점유가 묶음 보상의 부분 실패를 어떻게 깔끔히 처리하는가?