28. 가방 정렬 중에 다른 경로로 같은 아이템이 변경되는 상황
난이도 하해설 — 가방 정렬 중에 다른 경로로 같은 아이템이 변경되는 상황
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
SortBag 이 인벤토리를 느리게 재배치/압축하는 동안 ConsumeItem(사용/판매/버리기)이
같은 리스트와 같은 슬롯 객체를 동시에 만진다.
(A) working = inv.Slots 는 복사가 아니라 같은 리스트를 가리키는 별칭이다. List<T>.Sort/
순회는 스레드 안전하지 않아, 정렬 도중 ConsumeItem 이 같은 리스트를 Remove 하면
순회·정렬 중 컬렉션 변경으로 InvalidOperationException 이 나거나 정렬이 깨진다.
(B) 압축은 working(정렬 시작 시점에 본 슬롯들)을 기준으로 compacted 를 만든 뒤
inv.Slots = compacted 로 통째로 덮어쓴다. 그 사이 ConsumeItem 이 어떤 슬롯을 0으로
만들어 Remove 했어도, 그 슬롯 객체는 아직 working 에 남아 있어 되살아난다(복제) —
혹은 그 사이 추가된 슬롯이 compacted 에 없어 사라진다. 정답 한 줄: 플레이어 인벤토리
변경은 플레이어 단위로 직렬화하고, 정렬은 "스냅샷 복사본"이 아니라 "잠금 아래 원자적
재배치"로 하라.
변별: problem2(스택/수량 관리), problem18(착용/해제 중 강화·분해)와 달리, 본 문제는 느린 재배치(정렬·압축)와 짧은 차감(사용/판매/버리기)의 경합 — 별칭·읽고-쓰는 사이 끼어드는 변경이 핵심.
문제점
(A) working = inv.Slots — 별칭 + 비동기화 동시 접근 (동시성/aliasing) ★간판
- 분류 태그: data race / non-thread-safe collection access / aliasing.
- 증상: 정렬·순회 중 다른 스레드가 같은 리스트를
Remove/Add하면InvalidOperationException: Collection was modified또는 정렬 비교자가 보는 원소가 중간에 바뀌어ArgumentException(IComparer 일관성 위반)으로 죽는다. - 재현 조건: 슬롯이 많아
Sort/압축이 오래 걸리는 동안ConsumeItem이 들어옴. - 근본 원인:
List<T>는 동시 변경에 안전하지 않고,working은 복사본이 아니라 별칭.
(B) 정렬 시작 시점 스냅샷으로 덮어쓰기 — 되살아남/사라짐 (정합/lost update) ★간판
- 분류 태그: read-modify-write race / lost update.
- 증상:
ConsumeItem이 어떤 슬롯을 0으로 만들고 리스트에서 빼도,compacted는 정렬 시작 때 본working을 기준으로 만들어져 이미 판매/사용한 아이템이 다시 나타난다(복제). 반대로 정렬 도중 들어온 신규 슬롯은compacted에 없어 유실된다. - 재현 조건: 정렬과 차감이 시간상 겹침(정렬 시작 → 그 사이 차감 → 정렬이 통째로 덮어씀).
- 근본 원인: "읽고(스냅샷) → 한참 뒤 통째로 쓰기" 사이의 변경을 무시. 마지막 쓰기가 이김.
(C) Find + Remove 의 check-then-act + 객체 공유
- 분류 태그: check-then-act / shared mutable object.
- 증상:
Find로 찾은 슬롯 참조를 정렬 쪽도 들고 있어, 한쪽이Count를 바꾸는 사이 다른 쪽이 같은 객체를 읽는다. 동시ConsumeItem두 개면 둘 다 통과해 음수 수량도 가능. - 근본 원인: 슬롯 객체와 리스트 모두 보호 없이 공유.
수정안 (정확한 코드)
플레이어 단위 락으로 모든 인벤토리 변경을 직렬화한다. 정렬은 잠금 아래에서 원자적으로.
public class InventoryService
{
private readonly Dictionary<long, Inventory> _inv = new();
private readonly object _gate = new(); // 데모용 전역 락(실서비스는 플레이어 단위 락)
public void SortBag(long playerId)
{
lock (_gate)
{
if (!_inv.TryGetValue(playerId, out var inv)) return;
// 잠금 아래에서 한 번에: 정렬 → 압축 → 교체 (그 사이 변경 불가)
var compacted = new List<InventorySlot>(inv.Slots.Count);
foreach (var s in inv.Slots)
if (s.Count > 0) compacted.Add(s);
compacted.Sort((a, b) => a.ItemId.CompareTo(b.ItemId));
inv.Slots = compacted;
}
}
public bool ConsumeItem(long playerId, long itemId, int amount)
{
if (amount <= 0) return false;
lock (_gate)
{
if (!_inv.TryGetValue(playerId, out var inv)) return false;
var slot = inv.Slots.Find(s => s.ItemId == itemId);
if (slot == null || slot.Count < amount) return false;
slot.Count -= amount;
if (slot.Count == 0) inv.Slots.Remove(slot);
return true;
}
}
}
핵심은 정렬과 차감이 같은 락을 공유해 서로 끼어들 수 없게 하는 것. 정렬 도중 차감은 정렬이 끝난 뒤(또는 전에) 적용되며, 어느 쪽도 상대의 부분 결과를 보지 않는다.
더 나은 설계 (+ 트레이드오프)
- 플레이어 단위 락/액터: 전역 락은 정렬이 길면 모든 플레이어를 막는다. 플레이어별 락 (또는 플레이어를 한 스레드/액터에 고정해 그 플레이어의 모든 명령을 순차 처리)이 정석. 트레이드오프: 락 객체 관리 비용 vs 처리량.
- 정렬은 순수 함수로:
inv.Slots를 입력으로 받아 "정렬된 새 리스트"를 만들고, 교체만 락 안에서. 단, 입력을 읽는 동안에도 변경이 없어야 하므로 결국 읽기까지 락이 필요 (또는 불변 스냅샷 + 버전/CAS 로 교체 시 충돌 감지 후 재시도). - 슬롯을 값/불변으로: 슬롯 객체를 공유 가변 객체로 두지 말고, 수량 변경 시 새 슬롯으로 교체하면 (C)의 객체 공유가 사라진다.
- 정렬은 표현(view) 계층으로: 서버는 정렬 순서를 저장하지 않고 클라가 정렬 키만 보내 표시용으로 정렬하면, 서버 인벤토리 모델 자체를 건드리지 않아 경합이 원천 차단(단, 영구 정렬 저장이 요구라면 불가).
면접 포인트
- "복사한 줄 알았는데 별칭" —
var x = list;는 참조 복사다. 진짜 스냅샷은new List<>(list)이고, 그조차 슬롯 객체는 공유되니 깊은 복사/불변이 필요할 수 있다. - "읽고 한참 뒤 통째로 덮어쓰기"가 위험한 이유 — 그 사이 변경이 사라진다(lost update). 정렬처럼 느린 read-modify-write 는 락이나 버전 검증으로 감싸야 한다.
- 정렬을 굳이 서버 상태로 가질 필요가 있나? 표시용이면 클라/뷰로 밀어 경합을 없앤다.
해설 — 가방 정렬 중에 다른 경로로 같은 아이템이 변경되는 상황 (C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
SortBag 이 std::vector<InventorySlot> 를 정렬·압축(원소 이동/삭제)하는 동안
ConsumeItem 이 같은 벡터를 차감·삭제한다. 보호가 전혀 없다.
(A) slots 는 같은 벡터에 대한 참조다. (B) std::sort 와 erase 는 원소를 이동/재배치하고
기존 포인터·반복자를 무효화한다. (C)(D) ConsumeItem 은 벡터 원소를 가리키는
생짜 포인터 target 을 들고 있다가 나중에 그 포인터로 위치를 역산해 erase 한다 —
그 사이 다른 스레드의 SortBag(또는 동시 ConsumeItem)이 정렬/삭제로 벡터를 재배치하면
target 은 엉뚱한 원소를 가리키거나 무효화되어, 잘못된 슬롯이 지워지거나 해제된 메모리를
건드린다. 정답 한 줄: 인벤토리 변경을 플레이어 단위로 직렬화하고, 원소를 가리키는 생짜
포인터를 보관하지 말고 (락 아래) 인덱스/키로 즉시 처리하라.
변별: C# 트윈이 "별칭·lost update"를 강조한다면, C++ 트윈은 반복자/포인터 무효화와 use-after-free 가 핵심이다(컨테이너가 원소를 물리적으로 이동/삭제하기 때문).
문제점
(A)(B) 보호 없는 동시 정렬/삭제 — 반복자·포인터 무효화 (동시성/메모리) ★간판
- 분류 태그: data race (UB) / iterator & pointer invalidation.
- 증상:
SortBag의std::sort·erase가 원소를 이동/삭제하는 동안 다른 스레드가 같은 벡터를 읽거나 쓰면 정의되지 않은 동작. 단일 스레드에서도, 정렬이 슬롯을 옮긴 뒤 보관해 둔 포인터를 쓰면 깨진다. - 재현 조건: 슬롯이 많아 정렬/압축이 길고, 그 사이
ConsumeItem진입. - 근본 원인:
std::vector의 삽입/삭제/정렬은 반복자·참조·포인터를 무효화한다(재할당 시 전 원소,erase시 삭제 지점 이후 원소). 동기화도 없다.
(C)(D) 벡터 원소를 가리키는 생짜 포인터 보관 → 무효화 후 사용 (메모리) ★간판
- 분류 태그: use-after-invalidation / dangling pointer.
- 증상:
target으로target - slots.data()위치를 계산해erase하는데, 그 사이 벡터가 재할당/이동됐다면target은 더 이상 현재 저장소를 가리키지 않는다 → 잘못된 인덱스 계산(또는 UB), 엉뚱한 슬롯 삭제. - 근본 원인: "찾을 때의 위치"와 "지울 때의 위치"가 같다는 보장이 없다.
수정안 (정확한 코드)
플레이어 단위 락으로 모든 변경을 직렬화하고, 포인터 대신 인덱스를 락 안에서 즉시 사용.
#include <mutex>
class InventoryService
{
std::unordered_map<int64_t, Inventory> inv_;
std::mutex mtx_; // 데모용 전역 락(실서비스는 플레이어 단위 락/샤딩)
public:
void SortBag(int64_t playerId)
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = inv_.find(playerId);
if (it == inv_.end()) return;
auto& slots = it->second.slots;
slots.erase(std::remove_if(slots.begin(), slots.end(),
[](const InventorySlot& s){ return s.count <= 0; }),
slots.end());
std::sort(slots.begin(), slots.end(),
[](const InventorySlot& a, const InventorySlot& b){
return a.itemId < b.itemId;
});
}
bool ConsumeItem(int64_t playerId, int64_t itemId, int amount)
{
if (amount <= 0) return false;
std::lock_guard<std::mutex> lk(mtx_);
auto it = inv_.find(playerId);
if (it == inv_.end()) return false;
auto& slots = it->second.slots;
for (size_t i = 0; i < slots.size(); ++i) // 포인터 보관 없이 인덱스로 즉시 처리
{
if (slots[i].itemId == itemId)
{
if (slots[i].count < amount) return false;
slots[i].count -= amount;
if (slots[i].count == 0)
slots.erase(slots.begin() + i);
return true;
}
}
return false;
}
};
erase-remove 관용구로 압축을 한 번에 처리하고, 차감/삭제는 포인터를 보관하지 않고
락 안에서 인덱스로 즉시 끝낸다. 락이 정렬과 차감의 동시 진입을 막는다.
더 나은 설계 (+ 트레이드오프)
- 플레이어 단위 락/샤딩 또는 단일 스레드 소유: 전역 락은 정렬이 길면 모두를 막는다. 플레이어를 한 워커에 고정해 명령을 순차 처리하면 락 자체가 사라진다(트레이드오프: 라우팅).
- 안정적 핸들 사용: 위치가 바뀌는 벡터 대신, 슬롯에 안정적 id/키를 두고
unordered_map<slotId, Slot>또는 인덱스 안정성이 있는 컨테이너(std::deque/풀)에서 관리하면 "찾기→지우기" 사이 위치 변동 위험이 준다(트레이드오프: 순회 지역성↓). - 정렬은 표시(view)용으로: 서버 모델은 안정적으로 두고, 정렬 순서는 클라/표시 계층에서 처리하면 모델 재배치 자체가 사라진다(영구 정렬 저장이 요구라면 불가).
면접 포인트
std::vector의 무효화 규칙 — 재할당 시 모든 반복자/포인터/참조 무효,erase시 삭제 지점 이후 무효. "원소를 가리키는 포인터를 오래 들고 있지 마라."erase-remove관용구로 조건부 일괄 삭제를 O(n) 한 번에. 루프 안erase반복은 O(n²).- 컨테이너 동시 접근은 const 읽기끼리만 안전. 쓰기가 끼면 외부 동기화 필수(표준 컨테이너는 스레드 안전하지 않다).