Quick Reference
신뢰성 event는 "한 번만 도착"한다고 가정하지 않습니다. sender는 고유 eventId를 붙여 승인(acknowledgement)을 받을 때까지 재전송하고, receiver는 최근 처리한 ID를 기억해 같은 event를 다시 적용하지 않습니다. 결과를 바꾸는 handler 자체도 같은 ID를 두 번 받아도 결과가 한 번만 생기게 만들어야 합니다.
| 필요한 성질 | 맡는 위치 | 빠지면 생기는 일 |
|---|---|---|
| event 식별 | protocol envelope의 eventId | retry와 새 요청을 구분하지 못함 |
| 수신 확인 | receiver의 acknowledgement | sender가 언제 멈출지 모름 |
| 유실 대응 | sender의 timeout과 retry | UDP 유실 뒤 행동이 사라짐 |
| 중복 제거 | receiver의 dedupe window | 구매·보상이 두 번 적용됨 |
| 최종 멱등성 | authoritative game handler 또는 DB | race와 재접속에서 중복 결과 발생 |
state snapshot은 다음 snapshot이 이전 값을 덮어쓸 수 있지만, UseItem, AcceptMatch, ClaimReward 같은 이산 event는 덮어쓸 다음 값이 없습니다. 이런 event에만 신뢰성 경로를 쓰고, 매 tick의 이동 입력이나 위치 전체를 같은 queue에 넣지 않습니다.
승인과 재전송 흐름
eventId는 connection sequence만으로 만들지 않습니다. reconnect 뒤에도 충돌하지 않도록 session ID와 monotonic sequence를 합치거나 UUID 같은 요청 식별자를 사용합니다. receiver는 형식과 권한을 먼저 검사한 뒤 authoritative 처리 결과를 기록하고, 그 결과에 대응하는 ack를 보냅니다.
type ReliableEvent = {
eventId: string;
kind: "UseItem";
payload: { slot: number };
};
function receive(event: ReliableEvent, session: Session) {
const prior = session.recentEvents.get(event.eventId);
if (prior) return sendAck(event.eventId, prior.result);
const result = applyUseItemOnce(session.playerId, event.payload);
session.recentEvents.set(event.eventId, { result, expiresAt: now() + 30_000 });
sendAck(event.eventId, result);
}sender는 retry 간격과 최대 시도를 둡니다. timeout이 났다고 event가 실패했다고 단정하면 안 됩니다. server가 이미 처리했고 ack만 잃었을 수 있으므로, 같은 eventId로 다시 보내거나 결과 조회를 합니다. 최대 retry 뒤에는 client에 "결과 확인 중" 상태를 보이고 session 복구 정책으로 넘기는 편이 안전합니다.
TCP는 byte 순서와 신뢰성 전달을 제공하지만, application 처리와 응답 결과가 client에 보였는지는 보장하지 않습니다. TCP connection이 응답 직전에 끊기면 client는 결과를 모릅니다. 중요 action에 event ID와 결과 조회를 두는 이유입니다.
중복 제거의 범위
dedupe cache는 무한히 보관하지 않습니다. retry 가능한 시간, reconnect grace period, session 수명을 기준으로 TTL을 잡고 memory 상한을 둡니다. 짧게 지우면 늦은 retry가 새 action으로 처리되고, 너무 길면 match가 길어질수록 memory가 늘어납니다. match 종료 뒤에도 보상처럼 영속 결과를 바꾸는 action은 session cache가 아니라 DB unique key 또는 transaction으로 한 번만 적용합니다.
순서가 중요한 event는 eventId만으로 충분하지 않을 수 있습니다. 예를 들어 장비 변경은 server가 기대하는 revision을 함께 보내고, 현재 revision과 맞을 때만 처리합니다. 일련번호가 하나 건너뛰었다고 무조건 기다리면 UDP 유실로 queue가 멈출 수 있으므로, 순서 보장이 필요한지와 최신 상태로 복구 가능한지를 event 종류별로 분리합니다.
자주 틀리는 부분
ack를 socket write 성공으로 취급하면 안 됩니다. ack는 receiver가 어떤 단계까지 처리했는지를 뜻해야 합니다. "수신 buffer에 넣음"과 "게임 규칙을 적용함"을 섞으면 client가 성공으로 본 뒤 server tick에서 action을 거절하는 상태가 됩니다.
또한 동일한 payload를 비교해 중복을 찾으면 서로 다른 두 번의 구매를 하나로 지울 수 있습니다. 중복 판단의 기준은 payload가 아니라 client가 action 하나에 부여한 고유 ID입니다. 재전송, 권한 검사, authoritative 결과, 영속 저장은 서로 대체하지 않는 네 층입니다.
참고 링크
2 sources