← 문제로

28. 가방 정렬 중에 다른 경로로 같은 아이템이 변경되는 상황

난이도 하
내 리뷰 · C#
해설 · C#

해설 — 가방 정렬 중에 다른 경로로 같은 아이템이 변경되는 상황

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

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) 계층으로: 서버는 정렬 순서를 저장하지 않고 클라가 정렬 키만 보내 표시용으로 정렬하면, 서버 인벤토리 모델 자체를 건드리지 않아 경합이 원천 차단(단, 영구 정렬 저장이 요구라면 불가).

면접 포인트

  1. "복사한 줄 알았는데 별칭" — var x = list; 는 참조 복사다. 진짜 스냅샷은 new List<>(list) 이고, 그조차 슬롯 객체는 공유되니 깊은 복사/불변이 필요할 수 있다.
  2. "읽고 한참 뒤 통째로 덮어쓰기"가 위험한 이유 — 그 사이 변경이 사라진다(lost update). 정렬처럼 느린 read-modify-write 는 락이나 버전 검증으로 감싸야 한다.
  3. 정렬을 굳이 서버 상태로 가질 필요가 있나? 표시용이면 클라/뷰로 밀어 경합을 없앤다.