← 문제로

32. 커맨드 재전송 시 서버측 중복 실행 방지(dedup) 부재

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

해설 — 커맨드 재전송 시 서버측 중복 실행 방지(dedup) 부재

난이도: 상

요약

클라이언트는 ACK 타임아웃 시 같은 CommandId 로 커맨드를 재전송하도록 설계되어 있는데, 서버의 OnUseItemCommandAsync(A)는 수신한 커맨드를 매번 새 요청처럼 무조건 실행한다. ACK 자체가 네트워크에서 유실되는 흔한 시나리오에서, 서버는 이미 아이템을 정상 소모하고 효과까지 적용했지만 클라이언트는 이를 모른 채 같은 커맨드를 재전송하고, 서버는 이를 구분 없이 다시 실행해 아이템이 두 번 소모되고 효과가 두 번 적용된다. ACK 자체는 재전송에도 항상 응답(B)하도록 되어 있어 클라이언트 관점에서는 정상 동작처럼 보이지만, 서버 상태는 이미 중복 실행으로 오염된 뒤다.

문제점

  • 분류: 멱등성(idempotency) 부재 / 재전송에 대한 dedup 누락
  • 증상: 플레이어가 사용한 소모성 아이템(포션 등)이 서버 로그에는 한 번만 요청된 것처럼 보이는데 실제로는 두 번 소모되거나, 스킬 쿨다운이 한 번의 사용자 조작으로 두 번 걸리거나, 즉시 데미지 스킬이 두 배로 적용되는 등 "요청 1회 = 효과 1회" 원칙이 깨진다. 발생 빈도는 낮지만(ACK 유실이라는 특정 타이밍에서만) 재현되면 밸런스/경제에 직접 영향을 준다.
  • 재현 조건: 클라이언트가 UseItemCommand(CommandId=N) 를 전송 → 서버가 정상 실행 후 ACK 를 응답 → 그 ACK 패킷이 네트워크에서 유실됨 → 클라이언트가 타임아웃 후 같은 CommandId=N 으로 재전송 → 서버가 (A)에서 이를 새 요청으로 처리.
  • 근본 원인: 서버가 CommandId 별 처리 이력(이미 실행했는지, 실행 결과가 무엇이었는지)을 전혀 기억하지 않는다. 재전송과 신규 요청을 서버가 구분할 수 있는 정보(커맨드 ID)는 프로토콜에 이미 존재하는데, 이를 실행 로직이 활용하지 않고 있다.

수정안

플레이어별로 최근 처리한 CommandId 와 그 결과를 캐시에 기억해두고, 이미 처리한 CommandId 가 재수신되면 실행을 건너뛰고 저장해둔 결과로 즉시 ACK 만 재전송한다.

using System.Collections.Concurrent;
using System.Threading.Tasks;

public sealed class CommandHandler
{
    private readonly IInventory _inventory;
    private readonly IAckSender _ackSender;

    // 플레이어별로 "이미 처리한 CommandId" 를 기억한다.
    // 실제 운영에서는 무한 성장을 막기 위해 슬라이딩 윈도우/TTL 로 오래된
    // 항목을 정리해야 한다(아래 "더 나은 설계" 참고).
    private readonly ConcurrentDictionary<(ulong playerId, long commandId), bool> _processed
        = new();

    public CommandHandler(IInventory inventory, IAckSender ackSender)
    {
        _inventory = inventory;
        _ackSender = ackSender;
    }

    public async Task OnUseItemCommandAsync(UseItemCommand cmd)
    {
        var key = (cmd.PlayerId, cmd.CommandId);

        // 이미 처리한 CommandId 라면 재실행하지 않고 ACK 만 재전송한다.
        if (_processed.ContainsKey(key))
        {
            await _ackSender.SendAckAsync(cmd.PlayerId, cmd.CommandId);
            return;
        }

        // 처리 이력을 실행 전에 먼저 등록해, 실행 도중 같은 커맨드가
        // 동시에 재수신되어도(수신 스레드가 여럿인 경우) 중복 실행되지 않게 한다.
        if (!_processed.TryAdd(key, true))
        {
            // 경쟁적으로 다른 스레드가 먼저 등록했다면 여기서도 재실행하지 않는다.
            await _ackSender.SendAckAsync(cmd.PlayerId, cmd.CommandId);
            return;
        }

        bool consumed = await _inventory.ConsumeItemAsync(cmd.PlayerId, cmd.ItemId);
        if (consumed)
        {
            ApplyItemEffect(cmd.PlayerId, cmd.ItemId);
        }

        await _ackSender.SendAckAsync(cmd.PlayerId, cmd.CommandId);
    }

    private void ApplyItemEffect(ulong playerId, int itemId)
    {
        // 아이템 효과 적용 (구현 생략)
    }
}

더 나은 설계

  • 슬라이딩 윈도우 + TTL 기반 dedup 캐시(채택 방향 보완): ConcurrentDictionary 를 그대로 무한정 키우면 메모리가 누수되므로, 실제로는 "최근 N초/최근 M개 CommandId" 만 유지하는 링버퍼나 만료 스윕을 둔다. 커맨드 ID 를 세션 단위 단조 증가 시퀀스로 발급하면, "마지막으로 처리한 시퀀스 번호"만 저장해도 그보다 작거나 같은 재전송을 O(1)로 걸러낼 수 있어 캐시 크기 문제 자체가 사라진다.
  • 실행 결과 캐싱(진정한 멱등 응답): 단순히 재실행을 막는 것을 넘어, 최초 실행 결과(성공/실패, 소모된 아이템 수량 등)까지 캐싱해두면 재전송에 대해 클라이언트가 최초 응답과 동일한 결과를 받을 수 있어 UI 일관성이 좋아진다. 다만 결과 데이터 크기만큼 캐시 메모리 비용이 늘어난다.
  • 트랜잭션 아웃박스와 결합: 커맨드 실행과 "처리 완료" 마킹을 같은 DB 트랜잭션으로 묶으면, 서버 프로세스가 실행 도중 죽어도 "실행은 됐는데 dedup 마킹은 안 된" 어중간한 상태를 방지할 수 있다. 인메모리 캐시만 쓰는 경우 서버 재시작 시 dedup 이력이 사라지는 것도 감안해야 한다(짧은 시간 윈도우 재전송이 대부분이라면 실무적으로 허용 가능한 경우가 많다).
  • 트레이드오프: 시퀀스 번호 기반 dedup 이 메모리 효율과 구현 단순성 면에서 가장 우수하지만, 클라이언트가 커맨드를 병렬로 여러 개 동시 전송하는 구조라면 엄격한 단조 증가를 보장하기 어려울 수 있어 그 경우 별도의 키 기반(딕셔너리) dedup 이 필요하다.

면접 포인트

  • ACK 기반 재전송 프로토콜에서는 "요청 유실"과 "응답(ACK) 유실"을 클라이언트가 구분할 수 없다는 전제를 항상 깔아야 한다 — 따라서 서버는 재전송에 대해 항상 안전(멱등)하게 응답할 수 있어야 하며, 이는 클라이언트 로직이 아니라 서버 로직의 책임이다.
  • 멱등성을 보장하는 표준 패턴은 "요청 식별자(CommandId/Idempotency-Key)를 서버가 기억했다가, 동일 식별자 재수신 시 재실행 대신 이전 결과를 반환"하는 것이다 — 이 패턴을 결제, 보상 지급 등 실제로 되돌리기 어려운 다른 도메인에도 동일하게 적용할 수 있어야 한다.
  • dedup 캐시는 무한정 커지면 안 되므로, 시퀀스 번호나 TTL 기반으로 크기를 제한하는 설계까지 함께 고려해야 한다는 점 — "정확성"과 "리소스 유한성"을 동시에 만족시키는 절충안을 제시할 수 있어야 한다.