33. 하트비트와 데이터 메시지의 시퀀스 공유로 인한 유실 오탐
난이도 상해설 — 하트비트와 데이터 메시지의 시퀀스 공유로 인한 유실 오탐
난이도: 상
요약
Sender 는 데이터 메시지와 하트비트 모두 같은 _nextSeq 카운터에서 번호를 받아가므로, 실제 시퀀스 공간에는 두 종류의 메시지가 뒤섞여 존재한다. 그런데 수신측 DataMessageGapDetector 는 "데이터 메시지만" 보고 "이전 데이터 메시지 시퀀스 + 1" 이어야 정상이라고 검사한다. 하트비트가 그 사이에 번호를 하나 이상 소모하면, 수신측 입장에서는 데이터 메시지 시퀀스에 정상적인 갭이 생긴 것처럼 보여 실제로는 아무것도 유실되지 않았는데도 재전송을 요청하는 오탐이 발생한다. 반대로 이 오탐에 대응해 "하트비트만큼 갭을 무조건 눈감아주는" 식으로 어설프게 고치면, 이번엔 진짜 데이터 메시지 유실이 하트비트에 의한 정상 갭과 구별되지 않아 탐지를 놓치게 된다.
문제점
- 분류: 공유 시퀀스 카운터와 부분적 관측(subset observation) 불일치 / 유실 탐지 로직의 암묵적 가정 오류
- 증상: 정상 운영 중에도 하트비트가 오갈 때마다 데이터 메시지 시퀀스에 "갭"이 생긴 것으로 보여 불필요한 재전송 요청이 발생한다(오탐, false positive). 트래픽이 늘어날수록 하트비트 빈도와 무관하게 이런 오탐이 누적되어 대역폭을 낭비하고, 진짜 유실과 구별이 안 되어 로그/모니터링에서 노이즈가 커진다.
- 재현 조건:
Sender.BuildDataMessage로 데이터 메시지를 보낸 뒤, 다음 데이터 메시지를 보내기 전에Sender.BuildHeartbeat가 최소 한 번 이상 호출되어_nextSeq를 소모하는 상황. 이는 하트비트가 주기적으로 도는 정상 운영 환경이라면 사실상 항상 벌어진다. - 근본 원인:
- (A)
BuildHeartbeat가 데이터 메시지와 동일한_nextSeq카운터를 공유해 시퀀스 번호를 소모한다. 시퀀스 공간을 공유하려면 수신측도 그 사실을 알고 대응해야 하는데, 이 설계는 송신측만 카운터를 공유하고 수신측 로직과의 계약을 맞추지 않았다. - (B)
DataMessageGapDetector.OnDataMessageReceived는 하트비트가 시퀀스를 소모한다는 사실을 전혀 모른 채 "데이터 메시지 시퀀스는 항상 1씩 증가해야 한다"고 가정한다. 하트비트 메시지 자체는 이 검사기를 거치지 않으므로(하트비트는 이 메서드를 거치지 않는다라는 주석대로), 하트비트가 소모한 번호만큼의 갭을 검사기가 스스로 메울 방법이 없다. - 근본적으로 "시퀀스 번호는 카운터를 공유하지만, 유실 탐지는 부분집합(데이터 메시지)만 본다"는 두 설계가 서로 모순된다. 카운터를 공유하려면 유실 탐지도 전체 시퀀스 공간(하트비트 포함)을 봐야 하고, 유실 탐지를 데이터 메시지만으로 하고 싶다면애초에 시퀀스 공간을 분리해야 한다.
- (A)
수정안
카운터 공유를 유지하는 대신, 수신측이 하트비트도 포함한 전체 시퀀스 공간을 추적하도록 만든다. 하트비트도 반드시 (내용 없이도) 검사기에 통보되어 시퀀스 진행을 반영해야 한다.
using System.Threading;
public enum MessageKind { Data, Heartbeat }
public sealed class OutgoingMessage
{
public long SequenceNumber;
public MessageKind Kind;
public byte[] Payload;
}
public sealed class Sender
{
private long _nextSeq = 1;
public OutgoingMessage BuildDataMessage(byte[] payload)
{
long seq = Interlocked.Increment(ref _nextSeq) - 1;
return new OutgoingMessage { SequenceNumber = seq, Kind = MessageKind.Data, Payload = payload };
}
public OutgoingMessage BuildHeartbeat()
{
long seq = Interlocked.Increment(ref _nextSeq) - 1;
return new OutgoingMessage { SequenceNumber = seq, Kind = MessageKind.Heartbeat, Payload = null };
}
}
public interface IRetransmitRequester
{
void RequestRetransmit(long fromSeqExclusive, long toSeqInclusive);
}
// 수신측: 종류와 무관하게 "모든" 메시지의 시퀀스 번호로 갭을 검사한다.
// 데이터 메시지의 재전송 요청 범위를 계산할 때는 그 구간에 포함된
// 하트비트 시퀀스를 갭에서 제외한다.
public sealed class GapDetector
{
private long _lastSeenSeq = 0;
private readonly IRetransmitRequester _requester;
public GapDetector(IRetransmitRequester requester)
{
_requester = requester;
}
// 데이터 메시지든 하트비트든, 도착한 모든 메시지가 이 메서드를 거친다.
public void OnMessageReceived(OutgoingMessage msg)
{
long expected = _lastSeenSeq + 1;
if (msg.SequenceNumber > expected)
{
// 진짜 갭: 하트비트를 포함한 전체 시퀀스 공간 기준으로도
// 번호가 비어 있다 — 유실로 판단해 재전송을 요청한다.
_requester.RequestRetransmit(_lastSeenSeq, msg.SequenceNumber);
}
// msg.SequenceNumber < expected (중복/재정렬)는 별도 처리 대상이며
// 여기서는 시퀀스 전진만 다룬다.
_lastSeenSeq = msg.SequenceNumber;
}
}
수신측에서 하트비트를 별도 채널로 처리해야 한다면(예: 페이로드가 없어 상위 로직에 전달할 필요가 없는 경우), 하트비트 수신 시에도 반드시 OnMessageReceived 를 호출해 시퀀스 진행만은 반영하도록 한다 — "하트비트는 상위 로직을 안 타지만 시퀀스 카운터는 통과시킨다"는 계약을 명시적으로 유지하는 것이 핵심이다.
더 나은 설계
- 모든 메시지 종류를 유실 탐지에 포함(채택안): 카운터를 공유하는 한, 수신측 갭 검사도 반드시 전체 시퀀스 공간(하트비트 포함)을 기준으로 해야 한다. 코드 변경이 작고 기존 프로토콜 포맷을 바꾸지 않아도 된다.
- 시퀀스 공간 분리: 하트비트는 별도의 카운터(또는 시퀀스 번호 자체를 두지 않는 무상태 핑퐁)를 쓰고, 데이터 메시지만 유실 탐지 대상 시퀀스를 가진다. 유실 탐지 로직이 단순해지지만 프로토콜에 필드나 채널을 추가해야 해 변경 범위가 크다.
- 명시적 킵얼라이브 프로토콜로 하트비트 자체를 시퀀스 없는 별도 메시지 타입으로 취급(예: 순수 TCP 레벨 keepalive 또는 별도 UDP 핑): 애플리케이션 시퀀스와 완전히 분리되어 개념적으로 가장 깔끔하지만, 기존 전송 계층 위에 새 메커니즘을 얹는 비용이 든다.
- 트레이드오프: 이미 카운터를 공유하도록 설계된 프로토콜이라면 수신측 로직만 고치는 첫 번째 방식이 배포 비용이 가장 낮다. 프로토콜을 처음부터 다시 설계할 수 있다면 시퀀스 공간을 분리하는 편이 장기적으로 더 명확하다.
면접 포인트
- 송신측에서 여러 종류의 메시지가 자원(여기서는 시퀀스 카운터)을 공유한다면, 수신측의 그 자원에 의존하는 로직(유실 탐지)도 반드시 같은 범위를 봐야 한다 — "일부 메시지는 이 카운터를 보지 않는다"는 암묵적 가정이 프로토콜 양단에서 어긋나면 오탐과 미탐이 동시에 발생할 수 있음을 설명할 수 있어야 한다.
- 유실 탐지 로직을 고칠 때 "갭이 있으면 일단 하트비트 탓으로 돌리고 무시"하는 식의 임시방편은 진짜 유실을 가리는 새로운 결함을 만든다 — 오탐 제거와 미탐 방지는 항상 함께 검증해야 하는 한 쌍의 요구사항이다.
- 프로토콜에 하트비트/제어 메시지를 추가할 때는 기존 시퀀스 기반 유실 탐지, 순서 보장, 재전송 로직에 미치는 영향을 처음부터 함께 설계해야 하며, 뒤늦게 추가하면 이런 종류의 은근한 회귀가 생기기 쉽다.
해설 — 하트비트와 데이터 메시지의 시퀀스 공유로 인한 유실 오탐
난이도: 상
요약
Sender 는 데이터 메시지와 하트비트가 같은 nextSeq_ 원자적 카운터에서 번호를 받아가므로, 실제 시퀀스 공간에는 두 종류의 메시지가 뒤섞여 있다. 그러나 수신측 DataMessageGapDetector 는 데이터 메시지만 보고 "직전 데이터 메시지 시퀀스 + 1" 이어야 정상이라 판단한다. 하트비트가 그 사이 번호를 하나 이상 가져가면, 수신측 입장에서는 데이터 메시지 시퀀스에 정상적인 갭이 생긴 것으로 보여 유실이 없었는데도 재전송을 요청하는 오탐이 발생한다. 이 오탐을 "하트비트만큼은 갭으로 치지 않는다"는 식으로 어설프게 덮으면, 이번엔 진짜 데이터 메시지 유실이 하트비트로 인한 정상 갭과 구별되지 않아 탐지를 놓치게 된다.
문제점
- 분류: 공유 시퀀스 카운터와 부분적 관측 불일치 / 유실 탐지 로직의 암묵적 가정 오류
- 증상: 정상 운영 중에도 하트비트가 오갈 때마다 데이터 메시지 시퀀스에 갭이 생긴 것처럼 보여 불필요한 재전송 요청이 발생한다. 하트비트 주기가 짧을수록 오탐 빈도가 늘어나 대역폭을 낭비하고, 진짜 유실 이벤트와 구별이 안 되어 모니터링 신호가 오염된다.
- 재현 조건:
Sender::BuildDataMessage로 데이터 메시지를 보낸 뒤, 다음 데이터 메시지 전에Sender::BuildHeartbeat가 한 번 이상 호출되어nextSeq_를 소모하는 상황 — 하트비트가 주기적으로 도는 정상 운영 환경에서는 사실상 항상 벌어진다. - 근본 원인:
- (A)
BuildHeartbeat이 데이터 메시지와 동일한nextSeq_를 공유해 시퀀스 번호를 소모한다.fetch_add자체는 스레드 안전하지만, 이 카운터를 공유한다는 사실을 수신측 로직과 계약으로 맞추지 않았다. - (B)
DataMessageGapDetector::OnDataMessageReceived는 하트비트가 시퀀스를 소모한다는 사실을 전혀 모른 채 "데이터 메시지 시퀀스는 항상 1씩 증가한다"고 가정한다. 하트비트는 이 검사기를 아예 거치지 않으므로, 하트비트가 소모한 번호만큼의 갭을 검사기 스스로 메울 방법이 없다. - 근본적으로 "시퀀스 번호는 카운터를 공유하지만 유실 탐지는 부분집합만 본다"는 두 설계가 서로 모순되어, 카운터 공유를 유지하려면 유실 탐지도 전체 시퀀스 공간(하트비트 포함)을 봐야 한다.
- (A)
수정안
카운터 공유는 유지하되, 수신측이 하트비트를 포함한 전체 시퀀스 공간을 추적하도록 만든다. 하트비트도 반드시 (상위 로직에 전달하지 않더라도) 시퀀스 진행 확인 경로를 통과시킨다.
#include <cstdint>
#include <atomic>
#include <vector>
enum class MessageKind { Data, Heartbeat };
struct OutgoingMessage
{
int64_t sequenceNumber;
MessageKind kind;
std::vector<uint8_t> payload;
};
class Sender
{
public:
OutgoingMessage BuildDataMessage(std::vector<uint8_t> payload)
{
int64_t seq = nextSeq_.fetch_add(1);
return OutgoingMessage{ seq, MessageKind::Data, std::move(payload) };
}
OutgoingMessage BuildHeartbeat()
{
int64_t seq = nextSeq_.fetch_add(1);
return OutgoingMessage{ seq, MessageKind::Heartbeat, {} };
}
private:
std::atomic<int64_t> nextSeq_{ 1 };
};
class IRetransmitRequester
{
public:
virtual ~IRetransmitRequester() = default;
virtual void RequestRetransmit(int64_t fromSeqExclusive, int64_t toSeqInclusive) = 0;
};
// 수신측: 종류와 무관하게 "모든" 메시지의 시퀀스 번호로 갭을 검사한다.
class GapDetector
{
public:
explicit GapDetector(IRetransmitRequester& requester)
: requester_(requester)
{
}
// 데이터 메시지든 하트비트든, 도착한 모든 메시지가 이 메서드를 거친다.
void OnMessageReceived(const OutgoingMessage& msg)
{
int64_t expected = lastSeenSeq_ + 1;
if (msg.sequenceNumber > expected)
{
// 진짜 갭: 하트비트를 포함한 전체 시퀀스 공간 기준으로도
// 번호가 비어 있다 — 유실로 판단해 재전송을 요청한다.
requester_.RequestRetransmit(lastSeenSeq_, msg.sequenceNumber);
}
// msg.sequenceNumber < expected (중복/재정렬)는 별도 처리 대상.
lastSeenSeq_ = msg.sequenceNumber;
}
private:
int64_t lastSeenSeq_ = 0;
IRetransmitRequester& requester_;
};
하트비트를 상위 게임 로직에는 전달하지 않더라도, OnMessageReceived 처럼 시퀀스 진행만 담당하는 경로는 반드시 통과시켜야 한다 — "하트비트는 상위 로직을 안 타지만 시퀀스 카운터 갱신 경로는 통과한다"는 계약을 코드로 명시하는 것이 핵심이다.
더 나은 설계
- 모든 메시지 종류를 유실 탐지에 포함(채택안): 카운터를 공유하는 한, 수신측 갭 검사도 전체 시퀀스 공간(하트비트 포함)을 기준으로 해야 한다. 프로토콜 포맷 변경 없이 수신측 로직만 고치면 되어 배포 비용이 가장 낮다.
- 시퀀스 공간 분리: 하트비트에는 별도 카운터(또는 시퀀스 번호가 없는 무상태 핑퐁)를 쓰고, 데이터 메시지만 유실 탐지 대상 시퀀스를 가진다. 유실 탐지 로직은 단순해지지만 프로토콜에 필드/채널 추가가 필요해 변경 범위가 크다.
- 하트비트를 전송 계층 keepalive 로 분리: TCP keepalive 나 별도 UDP 핑처럼 애플리케이션 시퀀스와 완전히 분리된 메커니즘으로 하트비트를 대체. 개념적으로 가장 깔끔하지만 기존 전송 계층 위에 새 메커니즘을 얹는 비용이 든다.
- 트레이드오프: 이미 카운터를 공유하도록 배포된 프로토콜이라면 수신측만 고치는 첫 번째 방식이 가장 실용적이다. 프로토콜을 새로 설계할 수 있다면 시퀀스 공간 분리가 장기적으로 더 명확하다.
면접 포인트
- 여러 종류의 메시지가 자원(시퀀스 카운터)을 공유한다면, 그 자원에 의존하는 로직(유실 탐지)도 반드시 같은 범위를 봐야 한다 — 일부 메시지만 관측하는 로직은 프로토콜 양단의 암묵적 가정이 어긋나는 순간 오탐과 미탐을 동시에 만들어낼 수 있다.
- 유실 탐지 로직을 고칠 때 "갭이 있으면 하트비트 탓으로 돌려 무시"하는 임시방편은 진짜 유실을 가리는 새로운 결함을 만든다 — 오탐 제거와 미탐 방지는 항상 함께 검증해야 하는 한 쌍의 요구사항임을 설명할 수 있어야 한다.
std::atomic<int64_t>::fetch_add로 카운터 자체의 스레드 안전성은 보장되지만, 그 카운터의 "의미"를 수신측과 일관되게 맞추는 것은 별개의 프로토콜 설계 문제라는 점을 구분해서 말할 수 있어야 한다.