← 문제로

33. 하트비트와 데이터 메시지의 시퀀스 공유로 인한 유실 오탐

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

해설 — 하트비트와 데이터 메시지의 시퀀스 공유로 인한 유실 오탐

난이도: 상

요약

Sender 는 데이터 메시지와 하트비트 모두 같은 _nextSeq 카운터에서 번호를 받아가므로, 실제 시퀀스 공간에는 두 종류의 메시지가 뒤섞여 존재한다. 그런데 수신측 DataMessageGapDetector 는 "데이터 메시지만" 보고 "이전 데이터 메시지 시퀀스 + 1" 이어야 정상이라고 검사한다. 하트비트가 그 사이에 번호를 하나 이상 소모하면, 수신측 입장에서는 데이터 메시지 시퀀스에 정상적인 갭이 생긴 것처럼 보여 실제로는 아무것도 유실되지 않았는데도 재전송을 요청하는 오탐이 발생한다. 반대로 이 오탐에 대응해 "하트비트만큼 갭을 무조건 눈감아주는" 식으로 어설프게 고치면, 이번엔 진짜 데이터 메시지 유실이 하트비트에 의한 정상 갭과 구별되지 않아 탐지를 놓치게 된다.

문제점

  • 분류: 공유 시퀀스 카운터와 부분적 관측(subset observation) 불일치 / 유실 탐지 로직의 암묵적 가정 오류
  • 증상: 정상 운영 중에도 하트비트가 오갈 때마다 데이터 메시지 시퀀스에 "갭"이 생긴 것으로 보여 불필요한 재전송 요청이 발생한다(오탐, false positive). 트래픽이 늘어날수록 하트비트 빈도와 무관하게 이런 오탐이 누적되어 대역폭을 낭비하고, 진짜 유실과 구별이 안 되어 로그/모니터링에서 노이즈가 커진다.
  • 재현 조건: Sender.BuildDataMessage 로 데이터 메시지를 보낸 뒤, 다음 데이터 메시지를 보내기 전에 Sender.BuildHeartbeat 가 최소 한 번 이상 호출되어 _nextSeq 를 소모하는 상황. 이는 하트비트가 주기적으로 도는 정상 운영 환경이라면 사실상 항상 벌어진다.
  • 근본 원인:
    • (A) BuildHeartbeat 가 데이터 메시지와 동일한 _nextSeq 카운터를 공유해 시퀀스 번호를 소모한다. 시퀀스 공간을 공유하려면 수신측도 그 사실을 알고 대응해야 하는데, 이 설계는 송신측만 카운터를 공유하고 수신측 로직과의 계약을 맞추지 않았다.
    • (B) DataMessageGapDetector.OnDataMessageReceived 는 하트비트가 시퀀스를 소모한다는 사실을 전혀 모른 채 "데이터 메시지 시퀀스는 항상 1씩 증가해야 한다"고 가정한다. 하트비트 메시지 자체는 이 검사기를 거치지 않으므로(하트비트는 이 메서드를 거치지 않는다라는 주석대로), 하트비트가 소모한 번호만큼의 갭을 검사기가 스스로 메울 방법이 없다.
    • 근본적으로 "시퀀스 번호는 카운터를 공유하지만, 유실 탐지는 부분집합(데이터 메시지)만 본다"는 두 설계가 서로 모순된다. 카운터를 공유하려면 유실 탐지도 전체 시퀀스 공간(하트비트 포함)을 봐야 하고, 유실 탐지를 데이터 메시지만으로 하고 싶다면애초에 시퀀스 공간을 분리해야 한다.

수정안

카운터 공유를 유지하는 대신, 수신측이 하트비트도 포함한 전체 시퀀스 공간을 추적하도록 만든다. 하트비트도 반드시 (내용 없이도) 검사기에 통보되어 시퀀스 진행을 반영해야 한다.

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 핑): 애플리케이션 시퀀스와 완전히 분리되어 개념적으로 가장 깔끔하지만, 기존 전송 계층 위에 새 메커니즘을 얹는 비용이 든다.
  • 트레이드오프: 이미 카운터를 공유하도록 설계된 프로토콜이라면 수신측 로직만 고치는 첫 번째 방식이 배포 비용이 가장 낮다. 프로토콜을 처음부터 다시 설계할 수 있다면 시퀀스 공간을 분리하는 편이 장기적으로 더 명확하다.

면접 포인트

  • 송신측에서 여러 종류의 메시지가 자원(여기서는 시퀀스 카운터)을 공유한다면, 수신측의 그 자원에 의존하는 로직(유실 탐지)도 반드시 같은 범위를 봐야 한다 — "일부 메시지는 이 카운터를 보지 않는다"는 암묵적 가정이 프로토콜 양단에서 어긋나면 오탐과 미탐이 동시에 발생할 수 있음을 설명할 수 있어야 한다.
  • 유실 탐지 로직을 고칠 때 "갭이 있으면 일단 하트비트 탓으로 돌리고 무시"하는 식의 임시방편은 진짜 유실을 가리는 새로운 결함을 만든다 — 오탐 제거와 미탐 방지는 항상 함께 검증해야 하는 한 쌍의 요구사항이다.
  • 프로토콜에 하트비트/제어 메시지를 추가할 때는 기존 시퀀스 기반 유실 탐지, 순서 보장, 재전송 로직에 미치는 영향을 처음부터 함께 설계해야 하며, 뒤늦게 추가하면 이런 종류의 은근한 회귀가 생기기 쉽다.