32. 커맨드 재전송 시 서버측 중복 실행 방지(dedup) 부재
난이도 상해설 — 커맨드 재전송 시 서버측 중복 실행 방지(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 기반으로 크기를 제한하는 설계까지 함께 고려해야 한다는 점 — "정확성"과 "리소스 유한성"을 동시에 만족시키는 절충안을 제시할 수 있어야 한다.
해설 — 커맨드 재전송 시 서버측 중복 실행 방지(dedup) 부재
난이도: 상
요약
OnUseItemCommand(A)는 수신한 커맨드의 commandId 이력을 전혀 기억하지 않고 매번 새 요청처럼 실행한다. ACK(B)가 네트워크에서 유실되는 흔한 시나리오에서 서버는 이미 아이템을 정상 소모했는데도 클라이언트는 이를 모른 채 같은 commandId 로 재전송하고, 서버가 이를 구분 없이 다시 실행해 효과가 중복 적용된다. 서버가 IOCP/epoll 등으로 여러 워커 스레드가 수신 콜백을 처리하는 구조라면, 같은 commandId 를 가진 재전송이 거의 동시에 서로 다른 스레드에서 처리될 수도 있어 dedup 자료구조 자체의 스레드 안전성도 함께 고려해야 한다.
문제점
- 분류: 멱등성(idempotency) 부재 / 재전송에 대한 dedup 누락
- 증상: 소모성 아이템이 두 번 소모되거나 스킬 쿨다운·즉시 데미지가 중복 적용되는 등 "요청 1회 = 효과 1회" 원칙이 깨진다.
- 재현 조건: 클라이언트가
commandId=N커맨드 전송 → 서버 정상 실행 후 ACK 응답 → ACK 유실 → 클라이언트가 타임아웃 후 같은commandId=N으로 재전송 → 서버가 (A)에서 새 요청으로 재실행. - 근본 원인: 서버가
commandId별 처리 이력을 저장하지 않는다. 재전송과 신규 요청을 구분할 정보(commandId)는 프로토콜에 이미 있는데 실행 로직이 이를 참조하지 않는다. 추가로, 여러 수신 스레드가 존재하는 서버 구조에서는 dedup 여부를 확인하고 등록하는 두 단계 사이에도 경쟁이 생길 수 있어, dedup 자료구조에 대한 접근 자체를 원자적으로 만들어야 한다.
수정안
플레이어별로 최근 처리한 commandId 를 스레드 안전하게 기억해두고, 확인과 등록을 하나의 원자적 연산으로 묶는다. 무한정 커지지 않도록 슬라이딩 윈도우(최근 K개)로 크기를 제한한다.
#include <cstdint>
#include <deque>
#include <mutex>
#include <unordered_map>
#include <unordered_set>
struct UseItemCommand
{
int64_t commandId;
uint64_t playerId;
int itemId;
};
class Inventory
{
public:
bool ConsumeItem(uint64_t playerId, int itemId) { return true; }
};
class AckSender
{
public:
void SendAck(uint64_t playerId, int64_t commandId) { /* 전송 (구현 생략) */ }
};
// 플레이어별로 최근 처리한 commandId 를 제한된 크기로 기억하는 dedup 캐시.
class CommandDedupCache
{
public:
// 이미 처리한 commandId 라면 false 를 반환한다(재실행 금지).
// 처음 보는 commandId 라면 등록하고 true 를 반환한다(실행 허가).
bool TryMarkProcessed(uint64_t playerId, int64_t commandId, size_t windowSize = 256)
{
std::lock_guard<std::mutex> lock(mutex_);
auto& state = perPlayer_[playerId];
if (state.seen.count(commandId) != 0)
return false; // 이미 처리됨 — 재실행 금지
state.order.push_back(commandId);
state.seen.insert(commandId);
// 슬라이딩 윈도우: 오래된 commandId 는 제거해 메모리를 제한한다.
while (state.order.size() > windowSize)
{
state.seen.erase(state.order.front());
state.order.pop_front();
}
return true;
}
private:
struct PerPlayerState
{
std::deque<int64_t> order; // 삽입 순서 유지(만료 판단용)
std::unordered_set<int64_t> seen;
};
std::mutex mutex_;
std::unordered_map<uint64_t, PerPlayerState> perPlayer_;
};
class CommandHandler
{
public:
CommandHandler(Inventory* inventory, AckSender* ackSender, CommandDedupCache* dedup)
: inventory_(inventory), ackSender_(ackSender), dedup_(dedup) {}
// 여러 워커 스레드에서 동시에 호출될 수 있다.
void OnUseItemCommand(const UseItemCommand& cmd)
{
// 확인과 등록을 하나의 원자적 호출로 묶어, 재전송이 동시에 도착해도
// 정확히 하나의 호출만 실행 허가를 받는다.
if (!dedup_->TryMarkProcessed(cmd.playerId, cmd.commandId))
{
ackSender_->SendAck(cmd.playerId, cmd.commandId); // 재전송에도 ACK는 응답
return;
}
bool consumed = inventory_->ConsumeItem(cmd.playerId, cmd.itemId);
if (consumed)
{
ApplyItemEffect(cmd.playerId, cmd.itemId);
}
ackSender_->SendAck(cmd.playerId, cmd.commandId);
}
private:
void ApplyItemEffect(uint64_t playerId, int itemId) { /* 구현 생략 */ }
Inventory* inventory_;
AckSender* ackSender_;
CommandDedupCache* dedup_;
};
더 나은 설계
- 단조 증가 시퀀스 번호 기반 dedup: 클라이언트가 세션 단위로 단조 증가하는 시퀀스 번호를 커맨드 ID 로 쓰도록 프로토콜을 설계하면, 플레이어별로 "마지막으로 처리한 시퀀스 번호" 하나만 저장해도 그 이하의 재전송을 O(1)로 걸러낼 수 있다. 위 예시의
unordered_set기반 윈도우보다 메모리 효율이 훨씬 좋지만, 클라이언트가 커맨드를 병렬로 여러 개 동시 전송(파이프라이닝)하는 구조라면 "마지막 처리 번호"만으로는 순서가 뒤섞인 재전송을 구분하기 어려워질 수 있어 별도의 작은 슬라이딩 윈도우와 병행해야 한다. - 실행 결과 캐싱: 재실행을 막는 것에 그치지 않고 최초 실행 결과(성공/실패)까지 캐싱해두면, 재전송에 대해서도 최초와 동일한 결과를 클라이언트에 돌려줄 수 있다. 캐시 항목당 메모리 비용이 늘어나는 트레이드오프가 있다.
- 락 경합 최소화: 위 구현은 플레이어별 상태를 하나의 전역 뮤텍스로 보호한다. 동시 접속자가 많다면 플레이어 ID 해시 기반 샤딩된 뮤텍스(예: 256개 버킷)로 나눠 경합을 줄일 수 있다. 다만 구현 복잡도가 늘고, 버킷 수를 잘못 잡으면 오히려 캐시 라인 경합(false sharing)이 생길 수 있어 벤치마크로 검증해야 한다.
- 트레이드오프: 시퀀스 번호 방식이 메모리·성능 면에서 가장 우수하지만 프로토콜에 순서 보장이 필요하다. 순서 없는 UDP 기반 커맨드 채널이라면 윈도우 기반 집합 방식이 더 현실적이다.
면접 포인트
- ACK 기반 재전송 프로토콜에서 클라이언트는 "요청 유실"과 "응답 유실"을 구분할 수 없다는 전제를 항상 깔아야 하며, 따라서 서버가 재전송에 대해 멱등하게 동작할 책임을 진다.
- 멱등성 보장의 표준 패턴은 "요청 식별자를 서버가 기억했다가 동일 식별자 재수신 시 재실행 대신 이전 결과를 반환"하는 것이며, 이 dedup 자료구조 자체도 멀티스레드 환경에서는 확인+등록을 원자적으로 묶어야 한다는 점(TOCTOU) 을 짚을 수 있어야 한다.
- dedup 캐시는 무한정 커지면 안 되므로 슬라이딩 윈도우/시퀀스 번호 기반으로 메모리를 제한하는 설계까지 고려해야 하며, "정확성"과 "리소스 유한성"의 절충을 설명할 수 있어야 한다.