2. 인벤토리 스택/수량 관리
난이도 중해설 — 인벤토리 스택/수량 관리
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
간판 결함은 좁은 정수 타입(ushort)의 산술 오버플로/언더플로 다. 그리고 이건
"치트로 비정상 값이 들어왔을 때" 가 아니라 정상 도메인 입력만으로 실제 코드 경로에서
재현된다. 근본 원인은 신규 슬롯 생성 시 상한(MAX_STACK)을 강제하지 않아(D) 한
슬롯이 999 를 한참 넘는 값을 합법적으로 갖게 되고, 그 값으로 (C) 누적 / (F) 합산이
ushort 안에서 랩어라운드하는 것이다. 여기에 C# struct(값 타입) 복사 의미론(E)이
얽혀, 슬롯을 변수에 받아 수정하는 코드가 미묘한 함정이 된다. 락은 걸려 있으나 산술
결함은 단일 스레드에서도 터진다 — "락이 있으니 안전" 은 착각이다.
문제점
(D) 신규 슬롯 생성 시 상한 미적용 — 모든 산술 붕괴의 발화점 (정확성) ★근본
- 증상: 같은 itemId 가 인벤토리에 없을 때
_slots[i].Count = amount로 그대로 대입한다.amount가 999 를 넘어도(요구사항상 한 스택은 1..999 여야 함) 검증·클램프 없이 들어간다. - 재현 조건 (정상 경로): 레이드 보상 일괄 지급
AddItem(soulShard, 5000). 해당 소재가 인벤토리에 처음 들어오는 경우, 두 번째 루프의 빈 슬롯에Count = 5000으로 저장된다. 이 시점에 이미 "한 스택 ≤ 999" 불변식이 깨진다(5000 은ushort범위 0..65535 안이라 대입 자체는 멀쩡, 그래서 조용히 통과). - 근본 원인: 도메인 불변식(1..999)이 입력 경계에서 강제되지 않는다. 한번 999 초과 값을 슬롯에 허용하는 순간, 이후 (C)/(F)/(G)의 모든 "상한 가정" 이 무너지고 오버플로의 연료가 된다.
(C) 누적 합산 오버플로 — 정상 경로 랩어라운드 (정확성/보안)
- 증상: 같은 아이템을 반복 획득하면
Count가 어느 순간 확 줄어든다(랩어라운드). - 재현 조건 (정상 경로): (D)로 이미 한 슬롯에 5000 이 들어있는 소재를 레이드마다
또 5000 씩 지급.
_slots[i].Count += amount는ushort += ushort라 연산은int로 승격되지만 복합 대입(+=)이 결과를 다시ushort로 잘라 저장한다. 누적이 65535 를 넘는 순간 랩. 예: 5000 을 14회 누적하면 70000 → 4464 로 잘린다. 플레이어가 14만큼 받았는데 보유량이 4464 로 보임 → 아이템 대량 증발. - 근본 원인: 합산을 좁은 타입(
ushort)으로 수행하고 상한 클램프도 없다. C# 의 복합 대입 연산자(+=)는 좌변 타입으로 암시적 캐스트백을 수행해 조용히 자른다. 기본은 unchecked 컨텍스트라OverflowException도 안 난다.
(F) 스택 합산 오버플로 — 합치기에서 랩어라운드 (정확성/보안)
- 증상: 두 슬롯을 합치면 결과가 엉뚱한 값(작은 수)이 된다.
- 재현 조건 (정상 경로): (D)/(C)로 두 슬롯이 각각 40000 을 가진 상태에서
MergeStacks(src, dst).(ushort)(a.Count + b.Count)는 덧셈 자체는int에서 80000 이 되지만 명시적(ushort)캐스트가 14464 로 잘라낸다. 그러면total <= MAX_STACK분기를 타고 두 스택이 14464 로 "합쳐졌다고" 처리되며 a 슬롯이 비워진다 → 8만 개가 1.4만으로 증발. - 근본 원인: 결과를 담는 변수 타입(과 명시적 캐스트)이 좁다. 저장 시점에 잘린다 — 결과 변수 타입이 곧 산술 안전성을 결정한다.
(G) 초과분 처리의 오버플로 전파 (정확성)
a.Count = (ushort)(total - MAX_STACK)는total이 정상일 때만 맞다. (F)에서total이 이미 랩어라운드됐다면 분기 선택 자체가 틀려 이 줄로 오지도 않거나, 와도 잘못된 잔량을 남긴다. 오버플로가 하류로 전파된다.
(H) 차감 언더플로 — 음수 수량이 거대 양수로 (정확성/보안)
- 증상: 보유보다 많이 차감하면
Count가 음수가 되어야 하는데ushort라 거대 양수로 랩한다 → 순식간에 6만 개 보유. 재화/아이템 복사 치트의 단골. - 재현 조건 (정상 경로): 5개 가진 스택에서 묶음 판매
RemoveCount(slot, 10).5 - 10이ushort복합 대입에서 65531.if (Count == 0)도 false 라 슬롯은 그대로 살아 있고 보유량이 폭증한다. 차감 전amount <= Count검증이 전혀 없다. - 근본 원인: 부호 없는 타입의 언더플로는 (unchecked 기본 컨텍스트에서) 예외 없이 조용히 거대값이 된다. 사전 검증으로만 막을 수 있다.
(E)+(A) struct 값 타입 복사 의미론 (정확성/유지보수) ★C# 특유
- 증상:
Slot a = _slots[src];는 값 복사다.a.Count를 바꿔도 배열 원소는 변하지 않으며, 반드시_slots[src] = a;로 되써야 반영된다. 현재 코드는 되쓰기를 하므로 동작은 하지만, 이 패턴은 "복사본을 고치고 되쓰기를 빠뜨리는" 버그의 온상 이다. 누군가 리팩터링 중_slots[src] = a;한 줄을 지우면 변경이 통째로 유실되며 컴파일러는 경고조차 안 한다(가변 struct 의 고전적 함정).GetSlot(B)도 값 복사를 반환하므로 호출부가 그 복사본을 수정해도 인벤토리엔 반영되지 않는다. - 근본 원인: 가변(mutable)
struct는 "이게 참조인가 복사인가" 가 문맥마다 달라 추론이 깨진다. C# 가이드라인이 struct 를 불변(immutable)으로 권하는 이유다.
인덱스 / 슬롯 일치 미검증 (정확성/보안)
_slots[src]등은 배열 범위 검사(IndexOutOfRangeException)는 있으나,src == dst면 자기 자신을 합치며 수량이 꼬인다. 두 슬롯ItemId가 같은지도 확인하지 않아 다른 아이템을 합쳐 한쪽이 복제/소멸한다.
수정안
원칙: ① 합산은 넓은 타입(uint)으로, ② 도메인 불변식(1..999)을 입력 경계에서
강제, ③ 언더플로는 사전 검증으로 차단, ④ 넘침은 정책으로 명시(조용한 삭제 금지),
⑤ 인덱스/아이템 일치 검증, ⑥ struct 는 불변으로 또는 배열 원소를 직접 다룬다.
public bool AddItem(uint itemId, ushort amount)
{
if (itemId == 0 || amount == 0) return false; // 입력 검증
lock (_lock)
{
uint remaining = amount; // 넓은 타입으로 누적
// 같은 아이템 슬롯들에 분배(여러 스택 허용), 각 스택은 999 로 캡
for (int i = 0; i < _slots.Length && remaining > 0; i++)
{
if (_slots[i].ItemId == itemId && _slots[i].Count < MAX_STACK)
{
uint space = (uint)(MAX_STACK - _slots[i].Count);
uint add = Math.Min(space, remaining);
_slots[i].Count = (ushort)(_slots[i].Count + add);
remaining -= add;
}
}
// 남으면 빈 슬롯에 999 단위로(신규 슬롯도 상한 강제 → (D) 버그 차단)
for (int i = 0; i < _slots.Length && remaining > 0; i++)
{
if (_slots[i].ItemId == 0)
{
uint add = Math.Min((uint)MAX_STACK, remaining);
_slots[i].ItemId = itemId;
_slots[i].Count = (ushort)add;
remaining -= add;
}
}
return remaining == 0; // 다 못 담으면 실패(또는 leftover 반환 정책)
}
}
public bool MergeStacks(int src, int dst)
{
lock (_lock)
{
if (src == dst || (uint)src >= _slots.Length || (uint)dst >= _slots.Length)
return false;
if (_slots[src].ItemId == 0 || _slots[src].ItemId != _slots[dst].ItemId)
return false; // 같은 아이템만
uint total = (uint)_slots[src].Count + _slots[dst].Count; // 넓은 타입 합산
if (total <= MAX_STACK)
{
_slots[dst].Count = (ushort)total;
_slots[src] = default; // 빈 슬롯
}
else
{
_slots[dst].Count = MAX_STACK;
_slots[src].Count = (ushort)(total - MAX_STACK); // 안전(total 정상)
}
return true;
}
}
public bool RemoveCount(int slot, ushort amount)
{
lock (_lock)
{
if ((uint)slot >= _slots.Length) return false;
if (amount == 0 || amount > _slots[slot].Count) return false; // 언더플로 차단
_slots[slot].Count = (ushort)(_slots[slot].Count - amount);
if (_slots[slot].Count == 0) _slots[slot].ItemId = 0;
return true;
}
}
핵심은 (D) 차단이다. 신규 슬롯도 999 로 캡하면
Count가 절대 999 를 넘지 않으므로 (C)/(F)/(G)의 오버플로 연료가 사라지고, 합산을uint로 하면 999+999=1998 도 안전하다. 즉 상한 강제 + 넓은 타입 합산이 한 쌍으로 모든 오버플로를 닫는다. 또한 배열 원소를_slots[i].Count로 직접 다뤄(복사본 경유 X) struct 복사-되쓰기 누락 위험을 없앤다.
더 나은 설계
1) 불변식을 타입에 가둔다
Count를 검증 생성자를 가진StackCount값 타입(생성 시 1..999 보장, 불변 struct)으로 감싸면 "음수/초과가 애초에 표현 불가능" 해진다. 산술은 넓은 타입에서 하고 경계에서만 좁힌다. C# 에선readonly struct로 만들어 복사-수정 함정을 없앤다.- 트레이드오프: 약간의 래핑 비용. 정합성·디버깅 비용에 비하면 싸다.
2) 넘침/잔량 정책을 명시적으로 반환
AddItem이ushort leftover를 반환하면 호출부가 "우편으로 보냄/거절/새 스택" 을 결정할 수 있다. "조용히 잘라버리기/랩어라운드" 는 운영 클레임과 복사 치트의 원천.
3) checked 컨텍스트 / 락 입도
- 의심스러운 산술은
checked { ... }로 감싸면 오버플로 시OverflowException이 나서 조용한 랩 대신 시끄럽게 실패한다(개발/검증 단계에서 특히 유용). 인벤토리당 단일 락은 인벤토리 단위 작업엔 적절하다. 다만 거래/우편처럼 두 인벤토리 간 이동이 생기면 전역 락 순서 규칙이 필요하다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 부호 없는 정수의 오버/언더플로는 (unchecked 기본) 예외 없이 조용히 발생하며, 결과를 담는 변수 타입과 복합 대입의 캐스트백이 곧 산술 안전성이라는 점. 그 출발점이 "신규 슬롯에 상한을 강제하지 않은 것" 이라는 인과 사슬과, C# 가변 struct 복사 의미론의 함정을 짚는 것. "락이 있으니 안전" 이라는 착각을 깨는 것(산술 버그는 동시성과 무관).
- 예상 질문:
- "
ushort total = (ushort)(a.Count + b.Count)의 연산은 어느 타입에서 일어나나?" → 덧셈은int로 승격돼 일어나지만 명시적 캐스트(또는+=의 암시적 캐스트백)가 대입 시ushort로 자른다. 결과 변수 타입이 안전성을 결정한다. - "
Slot a = _slots[src];를 고쳐도 배열이 안 바뀌는 이유는?" →Slot이struct(값 타입)라a는 복사본._slots[src] = a;로 되써야 반영. 가변 struct 의 고전적 함정 — 불변 struct 또는 배열 직접 접근으로 피한다. - "999 상한이 있는데 왜 65535 까지 갈 수 있나?" → 신규 슬롯 생성/누적에서 상한을 강제하지 않아 Count 가 999 를 넘게 되고, 그 값이 합산에서 65535 를 넘겨 랩한다. 상한을 입력 경계에서 막아야 한다.
- "
해설 — 인벤토리 스택/수량 관리
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
간판 결함은 좁은 정수 타입(uint16_t)의 산술 오버플로/언더플로 다. 그리고 이건
"치트로 비정상 값이 들어왔을 때" 가 아니라 정상 도메인 입력만으로 실제 코드 경로에서
재현된다. 근본 원인은 신규 슬롯 생성 시 상한(MAX_STACK)을 강제하지 않아(C) 한
슬롯이 999 를 한참 넘는 값을 합법적으로 갖게 되고, 그 값으로 (B) 누적 / (E) 합산이
uint16_t 안에서 랩어라운드하는 것이다. 락은 걸려 있으나 산술 결함은 단일 스레드에서도
터진다 — "락이 있으니 안전" 은 착각이다.
문제점
(C) 신규 슬롯 생성 시 상한 미적용 — 모든 산술 붕괴의 발화점 (정확성) ★근본
- 증상: 같은 itemId 가 인벤토리에 없을 때
s.count = amount로 그대로 대입한다.amount가 999 를 넘어도(요구사항상 한 스택은 1..999 여야 함) 검증·클램프 없이 들어간다. - 재현 조건 (정상 경로): 레이드 보상 일괄 지급
AddItem(soulShard, 5000). 해당 소재가 인벤토리에 처음 들어오는 경우, 두 번째 루프의 빈 슬롯에s.count = 5000으로 저장된다. 이 시점에 이미 "한 스택 ≤ 999" 불변식이 깨진다(5000 은uint16_t범위 0..65535 안이라 대입 자체는 멀쩡, 그래서 조용히 통과). - 근본 원인: 도메인 불변식(1..999)이 입력 경계에서 강제되지 않는다. 한번 999 초과 값을 슬롯에 허용하는 순간, 이후 (B)/(E)/(F)의 모든 "상한 가정" 이 무너지고 오버플로의 연료가 된다.
(B) 누적 합산 오버플로 — 정상 경로 랩어라운드 (정확성/보안)
- 증상: 같은 아이템을 반복 획득하면
s.count가 어느 순간 확 줄어든다(랩어라운드). - 재현 조건 (정상 경로): (C)로 이미 한 슬롯에 5000 이 들어있는 소재를 레이드마다
또 5000 씩 지급.
s.count += amount가uint16_t에서 일어나 누적이 65535 를 넘는 순간 랩. 예: 5000 을 14회 누적하면 70000 → 4464 로 잘린다(검증함). 플레이어가 14만큼 받았는데 보유량이 4464 로 보임 → 아이템 대량 증발. - 근본 원인: 합산을 좁은 타입(
uint16_t)으로 수행하고 상한 클램프도 없다. (C)가 999 초과를 허용했기에 정상 입력만으로도 65535 에 도달한다.
(E) 스택 합산 오버플로 — 합치기에서 랩어라운드 (정확성/보안)
- 증상: 두 슬롯을 합치면 결과가 엉뚱한 값(작은 수)이 된다.
- 재현 조건 (정상 경로): (C)/(B)로 두 슬롯이 각각 40000 을 가진 상태에서
MergeStacks(src, dst).uint16_t total = a.count + b.count는 정수 승격으로int에서 80000 이 계산되지만total변수가uint16_t라 대입 시 14464 로 잘린다. 그러면total <= MAX_STACK분기를 타고 두 스택이 14464 로 "합쳐졌다고" 처리되며 a 슬롯이 비워진다 → 8만 개가 1.4만으로 증발. - 근본 원인: 결과를 담는 변수 타입이 좁다. 연산은
int에서 되더라도 저장 시점에 잘린다 — 결과 변수 타입이 곧 산술 안전성을 결정한다.
(F) 초과분 처리의 오버플로 전파 (정확성)
a.count = total - MAX_STACK는total이 정상일 때만 맞다. (E)에서total이 이미 랩어라운드됐다면 분기 선택 자체가 틀려 이 줄로 오지도 않거나, 와도 잘못된 잔량을 남긴다. 오버플로가 하류로 전파된다.
(G) 차감 언더플로 — 음수 수량이 거대 양수로 (정확성/보안)
- 증상: 보유보다 많이 차감하면
s.count가 음수가 되어야 하는데uint16_t라 거대 양수로 랩한다 → 순식간에 6만 개 보유. 재화/아이템 복사 치트의 단골. - 재현 조건 (정상 경로): 5개 가진 스택에서 묶음 판매
RemoveCount(slot, 10).5 - 10이uint16_t에서 65531.if (s.count == 0)도 false 라 슬롯은 그대로 살아 있고 보유량이 폭증한다. 차감 전amount <= s.count검증이 전혀 없다. - 근본 원인: 부호 없는 타입의 언더플로는 예외 없이 조용히 거대값이 된다. 사전 검증으로만 막을 수 있다.
(D) 슬롯 인덱스 / src==dst / 아이템 불일치 미검증 (정확성/보안)
slots_[src]는 범위 검사 없는operator[]→ 위조/버그성 인덱스로 out-of-bounds (UB/크래시).src == dst면 자기 자신을 합치며 수량이 꼬인다. 두 슬롯itemId가 같은지도 확인하지 않아 다른 아이템을 합쳐 한쪽이 복제/소멸한다.
동시성 측면 (정합성)
- 락은 인벤토리당 단일 mutex 로 잘 걸려 있어 위 산술 버그는 동시성과 무관하게 단일 스레드에서도 터진다. 다만 (G) 류는 같은 슬롯에 대한 두 차감 요청이 직렬화돼 합계가 보유량을 넘으면 두 번째에서 언더플로 → 동시성이 트리거를 앞당긴다.
수정안
원칙: ① 합산은 넓은 타입(uint32_t)으로, ② 도메인 불변식(1..999)을 입력
경계에서 강제, ③ 언더플로는 사전 검증으로 차단, ④ 넘침은 정책으로 명시
(조용한 삭제 금지), ⑤ 인덱스/아이템 일치 검증.
bool AddItem(uint32_t itemId, uint16_t amount) {
if (itemId == 0 || amount == 0) return false; // 입력 검증
std::lock_guard<std::mutex> lk(mtx_);
uint32_t remaining = amount; // 넓은 타입으로 누적
// 같은 아이템 슬롯들에 분배(여러 스택 허용), 각 스택은 999 로 캡
for (auto& s : slots_) {
if (remaining == 0) break;
if (s.itemId == itemId && s.count < MAX_STACK) {
uint32_t space = MAX_STACK - s.count;
uint32_t add = std::min(space, remaining);
s.count = static_cast<uint16_t>(s.count + add);
remaining -= add;
}
}
// 남으면 빈 슬롯에 999 단위로(신규 슬롯도 상한 강제 → (C) 버그 차단)
for (auto& s : slots_) {
if (remaining == 0) break;
if (s.itemId == 0) {
uint32_t add = std::min<uint32_t>(MAX_STACK, remaining);
s.itemId = itemId;
s.count = static_cast<uint16_t>(add);
remaining -= add;
}
}
return remaining == 0; // 다 못 담으면 실패(또는 leftover 반환 정책)
}
bool MergeStacks(size_t src, size_t dst) {
std::lock_guard<std::mutex> lk(mtx_);
if (src == dst || src >= slots_.size() || dst >= slots_.size()) return false;
Slot& a = slots_[src];
Slot& b = slots_[dst];
if (a.itemId == 0 || a.itemId != b.itemId) return false; // 같은 아이템만
uint32_t total = static_cast<uint32_t>(a.count) + b.count; // 넓은 타입 합산
if (total <= MAX_STACK) {
b.count = static_cast<uint16_t>(total);
a.itemId = 0; a.count = 0;
} else {
b.count = MAX_STACK;
a.count = static_cast<uint16_t>(total - MAX_STACK); // 안전(total 정상)
}
return true;
}
bool RemoveCount(size_t slot, uint16_t amount) {
std::lock_guard<std::mutex> lk(mtx_);
if (slot >= slots_.size()) return false;
Slot& s = slots_[slot];
if (amount == 0 || amount > s.count) return false; // 언더플로 차단
s.count = static_cast<uint16_t>(s.count - amount);
if (s.count == 0) s.itemId = 0;
return true;
}
핵심은 (C) 차단이다. 신규 슬롯도 999 로 캡하면
count가 절대 999 를 넘지 않으므로 (B)/(E)/(F)의 오버플로 연료가 사라지고, 합산을uint32_t로 하면 999+999=1998 도 안전하다. 즉 상한 강제 + 넓은 타입 합산이 한 쌍으로 모든 오버플로를 닫는다.
더 나은 설계
1) 불변식을 타입에 가둔다
count를 검증 생성자를 가진StackCount값 타입(생성 시 1..999 보장)으로 감싸면 "음수/초과가 애초에 표현 불가능" 해진다. 산술은 넓은 타입에서 하고 경계에서만 좁힌다.- 트레이드오프: 약간의 래핑 비용/메모리. 정합성·디버깅 비용에 비하면 싸다.
2) 넘침/잔량 정책을 명시적으로 반환
AddItem이uint16_t leftover를 반환하면 호출부가 "우편으로 보냄/거절/새 스택" 을 결정할 수 있다. "조용히 잘라버리기/랩어라운드" 는 운영 클레임과 복사 치트의 원천.
3) 락 입도(粒度)
- 인벤토리당 단일 mutex 는 인벤토리 단위 작업엔 적절. 다만 거래/우편처럼 두 인벤토리 간 이동이 생기면(problem3 처럼) 전역 락 순서 규칙이 필요하다. lock-free 까지는 보통 불필요 — 인벤토리 작업은 짧고 빈도가 낮다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 부호 없는 정수의 오버/언더플로는 예외 없이 조용히 발생하며, 결과를 담는 변수 타입이 곧 산술 안전성이라는 점. 그리고 그 출발점이 "신규 슬롯에 상한을 강제하지 않은 것"이라는 인과 사슬을 짚는 것. "락이 있으니 안전" 이라는 착각을 깨는 것(산술 버그는 동시성과 무관).
- 예상 질문:
- "
uint16_t total = a.count + b.count의 연산은 어느 타입에서 일어나나?" → 정수 승격으로int에서 계산되지만 대입 시uint16_t로 잘린다. 그래서 결과 변수 타입이 안전성을 결정한다. - "999 상한이 있는데 왜 65535 까지 갈 수 있나?" → 신규 슬롯 생성/누적에서 상한을 강제하지 않아 count 가 999 를 넘게 되고, 그 값이 합산에서 65535 를 넘겨 랩한다. 상한을 입력 경계에서 막아야 한다.
- "풀스택에 더 주웠을 때 어떻게 처리하는 게 맞나?" → 정책 결정 사항: 잔량 반환 → 새 슬롯/우편/거절. 조용한 삭제·랩은 금지.
- "