9. 아이템 강화(인챈트) 원자성과 실패 처리
난이도 상해설 — 아이템 강화(인챈트) 원자성과 실패 처리
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
간판 결함은 강화 성공 여부를 클라이언트가 결정한다는 것(A/B/E)이다. 어뷰저가
ClientSuccess=true 를 보내면 항상 성공 — 확률 시스템 자체가 무력화되는 치팅의
정문이다. 여기에 검증을 뒤로 미룬 비원자적 차감(D→G) 으로 잔액이 음수가 되고
롤백도 없으며, Dictionary 인덱서 접근(C)이 없는 키에 대해 KeyNotFoundException 을
던져 차감 후 예외로 부분 반영을 만든다. 보안·정확성이 얽힌 상급 문제.
문제점
(A)(B)(E) 클라가 강화 결과를 결정 — 확률 조작 치트 (보안·치팅) ★최우선
- 증상:
req.ClientSuccess를 그대로 믿어 단계 상승 여부를 정한다. 어뷰저가 항상true를 보내면 100% 성공.ClientRoll도 신뢰 경계 밖. - 재현 조건: 패킷 변조로
ClientSuccess=true고정. 클라는 적이므로 무조건 가능. - 근본 원인: 강화 성공/실패는 서버 권위 영역인데 클라 입력에 위임했다. "연출을 위해 클라가 결과를 먼저 안다" 는 요구는 정당하지만, 해법은 서버가 결과를 정해 클라에 통보→연출 이지 "클라가 정해 서버가 받아쓰기" 가 아니다. 서버 RNG로 성공 확률을 굴려 결정하고, 필요하면 시드를 내려 클라가 같은 연출을 재생한다.
(D)→(G) 검증을 사후로 미룸 — 음수 잔액 + 롤백 부재 (정확성)
- 증상: 소재/골드가 부족해도 먼저 빼고 나서(D) 마지막에 음수인지 확인(G)한다.
그 사이 (E)/(F)에서 이미 단계가 올라간 뒤라
return false해도 소재·골드는 음수로 차감되고 단계 상승은 남는다. 부분 반영 + 롤백 없음. - 재현 조건:
EnchantStones=0인데 강화 시도. (D)에서0 - stoneCost로 음수가 되고,ClientSuccess=true면 (F)에서 Level 이 올라간다. (G)에서 false 를 반환하지만 이미 변경된 상태(음수 소재, 오른 단계)는 되돌리지 않는다 → "공짜 강화" 또는 음수 재화로 인한 후속 버그. - 근본 원인: "충분한가" 검증은 차감 전에 끝나야 하고, 실패면 아무 상태도 안 변해야 한다. 여기선 순서가 뒤집혀 있고 실패 경로의 롤백이 없다. (성공/실패 정책상 소재는 어차피 소모되더라도, "부족하면 시도 자체가 거부" 되어야 한다.)
(C) Dictionary 인덱서 — KeyNotFoundException 으로 부분 반영 (정확성/보안)
- 증상:
p.Equips[req.EquipUid]는 키가 없으면KeyNotFoundException을 던진다. 위조된 uid 로 강화하면 (C)에서 즉시 예외 — 이 경우엔 차감 전이라 부분 반영은 없지만, 존재/소유 검증을 인덱서의 예외에 떠넘기는 설계는 위험하다. - 추가 위험: 만약 누군가 (C)를
Equips.TryGetValue없이GetValueOrDefault류로 바꾸거나 빈 Equip 을 만들어 넣는 식으로 "관대하게" 고치면, C++ 의operator[]자동 삽입과 똑같이 유령 장비 생성 으로 이어진다. 어느 쪽이든 명시적 존재·소유 확인 (TryGetValue+ 소유권 검증)이 정답이다. - 근본 원인: 존재/소유 검증 없이 인덱서로 접근. 예외 기반 흐름 제어는 의도/오류를 구분하지 못한다.
(D) 정수 언더플로 / 좁은 타입 (정확성/보안)
EnchantStones가int라 음수는 표현되지만, 위 (D)로 음수가 된 채 방치되면 이후 비교/소모 로직이 꼬인다. 또e.Level + 1비용이 매우 높은 단계에서int곱셈은 오버플로 가능(stoneCost는 int,goldCost는 long 으로 받지만 단계 비용 자체의 상한 검증이 없다).
동시성/자료구조 (동시성)
p.Lock으로 플레이어 단위 직렬화는 되어 있으나,Equips(Dictionary)를 다른 서비스 (인벤토리/거래)가 다른 락 또는 무락으로 동시 접근하면 깨진다(Dictionary는 동시 쓰기에 스레드 안전하지 않음). 한 자료구조에 대한 락 규약이 시스템 전체에서 일관돼야 한다.- 멱등성 키가 없어 재전송 시 강화가 두 번 시도된다(소재 이중 소모).
수정안
원칙: ① 결과는 서버 RNG로만 결정, ② 검증을 차감 전에 끝내고 통과한 경우에만
상태 변경(롤백 불필요), ③ TryGetValue 로 장비 존재·소유 확인, ④ 멱등성 키.
public readonly struct EnchantResult
{
public bool Ok { get; init; }
public bool Success { get; init; }
public int NewLevel { get; init; }
public ulong ServerSeed { get; init; }
}
public EnchantResult Enchant(Player p, long equipUid, Guid requestId)
{
lock (p.Lock)
{
// 멱등성: 이미 처리된 요청이면 기록된 결과 반환(영수증 맵 사용)
if (_receipts.TryGetValue(requestId, out var prev)) return prev;
// 존재/소유 확인(인덱서 예외/자동 삽입 회피)
if (!p.Equips.TryGetValue(equipUid, out var e))
return new EnchantResult { Ok = false, NewLevel = 0 };
int stoneCost = e.Level + 1;
long goldCost = (long)(e.Level + 1) * 1000;
// 1) 검증 먼저: 부족하면 아무것도 안 바꾸고 거부
if (p.EnchantStones < stoneCost || p.Gold < goldCost)
return new EnchantResult { Ok = false, NewLevel = e.Level };
// 2) 서버 권위 RNG로 성공 결정(클라 입력은 무시/연출용)
ulong seed = NextServerSeed(); // 서버가 시드 생성·기록
bool success = RollChance(seed) < SuccessRate(e.Level);
// 3) 통과 → 커밋(같은 임계 구역, 부분 반영 없음)
p.EnchantStones -= stoneCost; // 시도 비용은 항상 소모
p.Gold -= goldCost;
if (success) e.Level += 1;
// 실패 정책(유지/하락)은 여기서 명시적으로 적용
var result = new EnchantResult { Ok = true, Success = success,
NewLevel = e.Level, ServerSeed = seed };
_receipts[requestId] = result; // 멱등성 기록
return result;
}
}
private readonly Dictionary<Guid, EnchantResult> _receipts = new();
검증을 차감 전에 두므로 실패 시 상태가 전혀 변하지 않아 롤백이 필요 없다. 성공 여부는 서버
seed로 결정하고, 클라 연출이 필요하면ServerSeed를 응답에 실어 클라가 같은 시드로 연출만 재생한다(결과는 서버가 이미 확정).TryGetValue로 인덱서 예외/유령 장비를 모두 피한다.
더 나은 설계
1) 신뢰 경계: 클라는 연출만, 결과는 서버
- 클라가 보내는 값(
ClientSuccess,ClientRoll)은 결과에 영향 0. 서버 RNG가 유일 진실. 공정성 증명이 필요하면 commit-reveal(서버가 결과를 커밋 후 연출 끝에 공개). - 트레이드오프: 결과를 미리 내려주면 클라가 결과를 안다(연출 스킵/스포일러). 민감하면 commit-reveal 또는 결과를 연출 종료 시점에만 노출.
2) 강화를 영속 트랜잭션으로(과금/감사 대상)
- 강화는 과금(소재/유료 보호권)과 직결되고 클레임이 잦다. 소재·골드 차감과 단계 변경, 결과 로그를 DB 트랜잭션 + requestId UNIQUE 멱등성으로 묶어 부분 반영/이중 소모를 원천 차단. 트레이드오프: DB 왕복. 정합성·감사가 우선이라 정당.
3) 실패 정책을 데이터로
- "실패 시 유지/하락/파괴", 단계별 확률, 보호권 효과를 코드 상수가 아닌 검증된 설정으로 외부화. 확률은 일부 국가에서 고지 대상이므로 감사 로그 필수.
4) RNG 안전성
- 멀티스레드 RNG는
Random.Shared(.NET6+)/ThreadLocal<Random>, 예측 불가성이 필요하면RandomNumberGenerator(CSPRNG). 시드를 서버가 생성·기록해 재현·감사 가능하게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 신뢰 경계(클라는 적이다) 를 즉시 짚어 "성공 여부를 클라가 보낸다" 를 1순위 결함으로 지목하고, 검증-차감 순서(부족하면 무변경) 와 롤백 없는 부분 반영 을 함께 잡는 것. "연출 동기화는 서버가 결과+시드를 내려주는 방식" 까지 말하면 시니어 수준.
- 예상 질문:
- "연출을 위해 클라가 결과를 먼저 알아야 한다는데 어떻게 치팅을 막나?" → 서버가 결과를 정하고 시드만 내려 연출 재생. 공정성 증명은 commit-reveal.
- "왜 먼저 차감하면 안 되나? 어차피 시도 비용은 소모인데?" → 부족할 때도 차감되어 음수 잔액·부분 반영이 생긴다. 검증을 차감 전에 두면 실패 시 무변경이라 롤백이 불필요.
- "
Equips[uid]인덱서가 위험한 이유는?" → 없는 키면KeyNotFoundException(예외 기반 흐름) — 관대하게 고치면 유령 장비 생성 위험.TryGetValue로 존재·소유를 명시적으로 확인해야 한다.
해설 — 아이템 강화(인챈트) 원자성과 실패 처리
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
간판 결함은 강화 성공 여부를 클라이언트가 결정한다는 것(A/B/E)이다. 어뷰저가
clientSuccess=true 를 보내면 항상 성공 — 확률 시스템 자체가 무력화되는 치팅의
정문이다. 여기에 검증을 뒤로 미룬 비원자적 차감(D→G) 으로 잔액이 음수가 되고
롤백도 없으며, operator[] 자동 삽입(C)으로 존재하지 않는 장비가 만들어지고,
unordered_map/언더플로 등이 겹친다. 보안·정확성·동시성이 얽힌 상급 문제.
문제점
(A)(B)(E) 클라가 강화 결과를 결정 — 확률 조작 치트 (보안·치팅) ★최우선
- 증상:
req.clientSuccess를 그대로 믿어 단계 상승 여부를 정한다. 어뷰저가 항상true를 보내면 100% 성공.clientRoll도 신뢰 경계 밖. - 재현 조건: 패킷 변조로
clientSuccess=true고정. 클라는 적이므로 무조건 가능. - 근본 원인: 강화 성공/실패는 서버 권위 영역인데 클라 입력에 위임했다. "연출을 위해 클라가 결과를 먼저 안다" 는 요구는 정당하지만, 해법은 서버가 결과를 정해 클라에 통보→연출 이지 "클라가 정해 서버가 받아쓰기" 가 아니다. 서버 RNG로 성공 확률을 굴려 결정하고, 필요하면 시드를 내려 클라가 같은 연출을 재생한다.
(D)→(G) 검증을 사후로 미룸 — 음수 잔액 + 롤백 부재 (정확성)
- 증상: 소재/골드가 부족해도 먼저 빼고 나서(D) 마지막에 음수인지 확인(G)한다.
그 사이 (E)/(F)에서 이미 단계가 올라간 뒤라
return false해도 소재·골드는 음수로 차감되고 단계 상승은 남는다. 부분 반영 + 롤백 없음. - 재현 조건:
enchantStones=0인데 강화 시도. (D)에서0 - stoneCost로 음수가 되고,clientSuccess=true면 (F)에서 level 이 올라간다. (G)에서 false 를 반환하지만 이미 변경된 상태(음수 소재, 오른 단계)는 되돌리지 않는다 → "공짜 강화" 또는 음수 재화로 인한 후속 버그. - 근본 원인: "충분한가" 검증은 차감 전에 끝나야 하고, 실패면 아무 상태도 안 변해야 한다. 여기선 순서가 뒤집혀 있고 실패 경로의 롤백이 없다. (성공/실패 정책상 소재는 어차피 소모되더라도, "부족하면 시도 자체가 거부" 되어야 한다.)
(C) operator[] 자동 삽입 — 유령 장비 생성 (정확성/보안)
- 증상:
p.equips[req.equipUid]는 키가 없으면 기본값 Equip{uid=0,level=0} 을 삽입한다. 존재하지 않는 장비를 강화 대상으로 만들어, 위조된 uid로 강화하면 level 0 장비가 생기고 (F)로 level 1 이 된다(허위 장비/소유권 우회). - 근본 원인: 존재/소유 검증 없이
operator[]로 접근.find로 존재를 확인해야 한다.
(D) 정수 언더플로 / 좁은 타입 (정확성/보안)
enchantStones가int32_t라 음수는 표현되지만, 위 (D)로 음수가 된 채 방치되면 이후 비교/소모 로직이 꼬인다. 또e.level + 1비용이 매우 높은 단계에서int32_t곱(* 1000)은int64_t로 받지만stoneCost(int32)는 오버플로 가능.
동시성/자료구조 (동시성)
p.lock으로 플레이어 단위 직렬화는 되어 있으나,equips(unordered_map)를 (C)에서 삽입하면 같은 플레이어의 다른 경로가 같은 맵을 동시 접근할 때 위험(여기선 같은 락이라 안전하지만, 인벤토리/거래 등 다른 서비스가 같은 맵을 다른 락으로 만지면 깨진다).- 멱등성 키가 없어 재전송 시 강화가 두 번 시도된다(소재 이중 소모).
수정안
원칙: ① 결과는 서버 RNG로만 결정, ② 검증을 차감 전에 끝내고 통과한 경우에만
상태 변경(롤백 불필요), ③ find 로 장비 존재·소유 확인, ④ 멱등성 키.
struct EnchantResult { bool ok; bool success; int32_t newLevel; uint64_t serverSeed; };
EnchantResult Enchant(Player& p, int64_t equipUid, uint64_t requestId) {
std::lock_guard<std::mutex> lk(p.lock);
// 멱등성: 이미 처리된 요청이면 기록된 결과 반환(여기선 생략, 영수증 맵 사용)
// if (auto r = receipts_.find(requestId); r != receipts_.end()) return r->second;
auto it = p.equips.find(equipUid); // 존재/소유 확인(자동 삽입 금지)
if (it == p.equips.end()) return {false,false,0,0};
Equip& e = it->second;
int32_t stoneCost = e.level + 1;
int64_t goldCost = (int64_t)(e.level + 1) * 1000;
// 1) 검증 먼저: 부족하면 아무것도 안 바꾸고 거부
if (p.enchantStones < stoneCost || p.gold < goldCost)
return {false,false,e.level,0};
// 2) 서버 권위 RNG로 성공 결정(클라 입력은 무시/연출용)
uint64_t seed = NextServerSeed(); // 서버가 시드 생성·기록
bool success = (RollChance(seed) < SuccessRate(e.level));
// 3) 통과 → 커밋(같은 임계 구역, 부분 반영 없음)
p.enchantStones -= stoneCost; // 시도 비용은 항상 소모
p.gold -= goldCost;
if (success) e.level += 1;
// 실패 정책(유지/하락)은 여기서 명시적으로 적용
// receipts_[requestId] = {...}; // 멱등성 기록
return {true, success, e.level, seed};
}
검증을 차감 전에 두므로 실패 시 상태가 전혀 변하지 않아 롤백이 필요 없다. 성공 여부는 서버
seed로 결정하고, 클라 연출이 필요하면serverSeed를 응답에 실어 클라가 같은 시드로 연출만 재생한다(결과는 서버가 이미 확정).
더 나은 설계
1) 신뢰 경계: 클라는 연출만, 결과는 서버
- 클라가 보내는 값(
clientSuccess,clientRoll)은 결과에 영향 0. 서버 RNG가 유일 진실. 공정성 증명이 필요하면 commit-reveal(서버가 결과를 커밋 후 연출 끝에 공개). - 트레이드오프: 결과를 미리 내려주면 클라가 결과를 안다(연출 스킵/스포일러). 민감하면 commit-reveal 또는 결과를 연출 종료 시점에만 노출.
2) 강화를 영속 트랜잭션으로(과금/감사 대상)
- 강화는 과금(소재/유료 보호권)과 직결되고 클레임이 잦다. 소재·골드 차감과 단계 변경, 결과 로그를 DB 트랜잭션 + requestId UNIQUE 멱등성으로 묶어 부분 반영/이중 소모를 원천 차단. 트레이드오프: DB 왕복. 정합성·감사가 우선이라 정당.
3) 실패 정책을 데이터로
- "실패 시 유지/하락/파괴", 단계별 확률, 보호권 효과를 코드 상수가 아닌 검증된 설정으로 외부화. 확률은 일부 국가에서 고지 대상이므로 감사 로그 필수.
4) RollChance/RNG 안전성
- 멀티스레드 RNG는 스레드별 인스턴스 또는 CSPRNG. 시드를 서버가 생성·기록해 재현·감사 가능하게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 신뢰 경계(클라는 적이다) 를 즉시 짚어 "성공 여부를 클라가 보낸다" 를 1순위 결함으로 지목하고, 검증-차감 순서(부족하면 무변경) 와 롤백 없는 부분 반영 을 함께 잡는 것. "연출 동기화는 서버가 결과+시드를 내려주는 방식" 까지 말하면 시니어 수준.
- 예상 질문:
- "연출을 위해 클라가 결과를 먼저 알아야 한다는데 어떻게 치팅을 막나?" → 서버가 결과를 정하고 시드만 내려 연출 재생. 공정성 증명은 commit-reveal.
- "왜 먼저 차감하면 안 되나? 어차피 시도 비용은 소모인데?" → 부족할 때도 차감되어 음수 잔액·부분 반영이 생긴다. 검증을 차감 전에 두면 실패 시 무변경이라 롤백이 불필요.
- "
equips[uid]가 위험한 이유는?" → 없는 키를 자동 삽입해 유령 장비 생성.find로 존재·소유를 먼저 확인해야 한다.