← 문제로

9. 아이템 강화(인챈트) 원자성과 실패 처리

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

해설 — 아이템 강화(인챈트) 원자성과 실패 처리

난이도: 상

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

요약

간판 결함은 강화 성공 여부를 클라이언트가 결정한다는 것(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) 정수 언더플로 / 좁은 타입 (정확성/보안)

  • EnchantStonesint 라 음수는 표현되지만, 위 (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순위 결함으로 지목하고, 검증-차감 순서(부족하면 무변경)롤백 없는 부분 반영 을 함께 잡는 것. "연출 동기화는 서버가 결과+시드를 내려주는 방식" 까지 말하면 시니어 수준.
  • 예상 질문:
    1. "연출을 위해 클라가 결과를 먼저 알아야 한다는데 어떻게 치팅을 막나?" → 서버가 결과를 정하고 시드만 내려 연출 재생. 공정성 증명은 commit-reveal.
    2. "왜 먼저 차감하면 안 되나? 어차피 시도 비용은 소모인데?" → 부족할 때도 차감되어 음수 잔액·부분 반영이 생긴다. 검증을 차감 전에 두면 실패 시 무변경이라 롤백이 불필요.
    3. "Equips[uid] 인덱서가 위험한 이유는?" → 없는 키면 KeyNotFoundException(예외 기반 흐름) — 관대하게 고치면 유령 장비 생성 위험. TryGetValue 로 존재·소유를 명시적으로 확인해야 한다.