← 문제로

15. 오래된 라우팅 캐시와 IP:포트 재사용으로 인한 인스턴스 오인

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

해설 — 오래된 라우팅 캐시와 IP:포트 재사용으로 인한 인스턴스 오인

난이도: 중상

요약

RoutingCache는 TTL 동안 디스커버리 재조회를 생략해(A) 성능을 얻지만, 그 대가로 "TTL이 지나기 전에 대상 인스턴스가 재시작되어 같은 주소를 재사용"하는 상황을 감지할 방법이 없다. 게다가 캐시가 갱신된 뒤에도(B) SendMatchRequest는 응답을 보낸 쪽이 자신이 기대한 인스턴스가 맞는지(인스턴스 식별자) 전혀 확인하지 않는다. 그 결과 재시작으로 새로 뜬, 논리적으로 무관한 인스턴스에게 예전 매치의 요청이 전달되고, 새 인스턴스는 모르는 매치 ID라며 오류를 내거나 최악의 경우 우연히 같은 ID를 다른 용도로 재사용해 조용히 잘못 처리한다.

문제점

1. (TTL 기반 캐시의 신선도 공백) 재시작을 감지 못하는 캐시 — (A)

  • 증상: 인스턴스가 재시작된 직후 최대 TTL(30초) 동안, 매치메이킹 서버는 옛 캐시 항목을 계속 유효하다고 믿고 같은 IP:포트로 요청을 보낸다. 그 주소에는 이제 다른 논리적 인스턴스가 떠 있다.
  • 재현조건: 오케스트레이터가 재시작된 컨테이너/파드에 같은 IP:포트를 재할당(흔한 케이스: 같은 노드의 같은 포트로 재스케줄, 또는 고정 IP 할당). 재시작이 TTL 창 안에서 일어나면 캐시가 갱신되기 전에 요청이 나간다.
  • 근본원인: 캐시가 신선도를 오직 "얼마나 오래 전에 조회했는가(TTL)"로만 판단한다. 대상이 그 사이 재시작됐는지, 즉 주소는 같지만 그 뒤의 논리적 실체가 바뀌었는지는 TTL이 알려주지 않는다 — TTL은 "정보가 오래됐을 가능성"만 낮출 뿐, 오래된 사이 발생한 재시작 이벤트 자체를 감지하는 메커니즘이 아니다.

2. (응답자 신원 미검증) 캐시 갱신 후에도 남는 근본 위험 — (B)

  • 증상: 설령 캐시가 즉시 갱신되더라도, 네트워크 요청이 전송되는 시점과 상대가 실제로 응답하는 시점 사이에 다시 재시작이 끼어들면 여전히 같은 문제가 재발할 수 있다. SendMatchRequest는 "이 주소가 내가 기대한 instanceId가 맞는지"를 확인할 방법 자체가 없다.
  • 근본원인: 라우팅을 "주소"로만 식별하고, 그 주소 뒤에 있는 논리적 인스턴스의 신원(인스턴스 ID, 또는 프로세스 생성 시점을 반영하는 세대/에포크 값)을 요청·응답 어느 쪽에서도 실어 보내거나 검증하지 않는다. 주소는 재사용 가능한 자원이지, 인스턴스의 신원이 아니다.

수정안

  1. 캐시 항목에 논리적 인스턴스 식별자(디스커버리가 등록 시 부여하는, 재시작마다 바뀌는 값 — 예: 프로세스 시작 시각 기반 epoch나 등록 시 발급되는 lease ID)를 함께 저장하고, 요청을 보낼 때 그 식별자를 페이로드에 실어 보낸다. 2) 수신 측은 자신의 현재 인스턴스 식별자와 요청에 실린 식별자가 다르면 즉시 거부하고, 3) 거부 응답을 받은 매치메이킹 서버는 해당 캐시 항목을 즉시 무효화하고 재조회한다.
public sealed class InstanceEndpoint
{
    public string InstanceId;
    public string Address;
    public string Epoch;        // 디스커버리가 등록마다 새로 발급하는 값(재시작 시 반드시 변경됨)
    public DateTime CachedAt;
}

public sealed class RoutingCache
{
    private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);
    private readonly Dictionary<string, InstanceEndpoint> _cache = new Dictionary<string, InstanceEndpoint>();
    private readonly IDiscoveryClient _discovery;

    public RoutingCache(IDiscoveryClient discovery) { _discovery = discovery; }

    public InstanceEndpoint Resolve(string instanceId)
    {
        if (_cache.TryGetValue(instanceId, out var cached) &&
            (DateTime.UtcNow - cached.CachedAt) < Ttl)
        {
            return cached;
        }

        var fresh = _discovery.Lookup(instanceId);
        _cache[instanceId] = fresh;
        return fresh;
    }

    public bool SendMatchRequest(string instanceId, string matchId, byte[] payload)
    {
        var endpoint = Resolve(instanceId);
        // epoch 를 요청에 함께 실어 보낸다
        var response = TransportSend(endpoint.Address, endpoint.Epoch, matchId, payload);

        if (response.RejectedDueToEpochMismatch)
        {
            _cache.Remove(instanceId);         // 즉시 무효화
            var refreshed = Resolve(instanceId);
            response = TransportSend(refreshed.Address, refreshed.Epoch, matchId, payload);
        }
        return response.Success;
    }

    private (bool RejectedDueToEpochMismatch, bool Success) TransportSend(
        string address, string epoch, string matchId, byte[] payload)
    {
        // 실제 네트워크 전송 + 수신측의 epoch 검증 결과 반영 (생략)
        return (false, true);
    }
}
  • 수신 측(인스턴스 서버)도 자기 자신의 epoch를 시작할 때 새로 발급받아, 들어오는 요청의 epoch와 다르면 "다른 세대의 요청"으로 즉시 거부하도록 구현해야 이 수정이 완성된다.

더 나은 설계

  • 서비스 디스커버리 자체가 지원한다면 TTL 폴링 대신 watch/스트리밍 구독으로 등록 해제·재등록 이벤트를 즉시 통지받아 캐시를 능동적으로 무효화한다(폴링 지연을 원천적으로 줄임).
  • 인스턴스 등록에 lease(임대) 개념을 도입해, 인스턴스가 살아있는 동안만 lease를 갱신하고 재시작 시 새 lease를 발급받게 하면 "같은 주소, 다른 lease"를 자연스럽게 구분할 수 있다.
  • 요청-응답 프로토콜 차원에서 상대의 신원을 검증하는 습관(이 저장소 problem6의 split-brain 대응과 같은 맥락에서, "주소가 같다고 신원이 같다고 가정하지 않는다")을 서버-서버 통신 전반에 일관되게 적용한다.
  • 트레이드오프: watch 기반 구독은 폴링보다 디스커버리 서버에 더 많은 연결 상태를 요구하고, epoch 검증은 프로토콜에 필드를 추가해야 하는 비용이 있다 — 다만 오인 라우팅으로 인한 매치 오류/데이터 오염 비용에 비하면 낮은 비용이다.

면접 포인트

  1. TTL 캐시는 "정보가 오래됐을 확률"을 낮출 뿐, 그 사이 발생한 이벤트(재시작, 재할당)를 감지하는 메커니즘이 아니다 — 신선도와 정확성은 다른 문제다.
  2. 네트워크 환경에서 "주소"는 재사용 가능한 자원이지 인스턴스의 신원이 아니다 — 신원 확인이 필요한 통신에는 별도의 식별자(에포크, lease ID, 인스턴스 ID)를 프로토콜에 실어 검증해야 한다.
  3. 폴링 기반 캐시 무효화보다 이벤트 기반(watch/구독) 무효화가 지연을 줄이지만, 두 방식 모두 "상대가 다른 신원임을 스스로 밝힐 수 있는" 검증 계층 없이는 경쟁 조건을 완전히 없애지 못한다 — 방어의 마지막 층은 결국 수신 측의 신원 검증이다.