15. 오래된 라우팅 캐시와 IP:포트 재사용으로 인한 인스턴스 오인
난이도 중해설 — 오래된 라우팅 캐시와 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, 또는 프로세스 생성 시점을 반영하는 세대/에포크 값)을 요청·응답 어느 쪽에서도 실어 보내거나 검증하지 않는다. 주소는 재사용 가능한 자원이지, 인스턴스의 신원이 아니다.
수정안
- 캐시 항목에 논리적 인스턴스 식별자(디스커버리가 등록 시 부여하는, 재시작마다 바뀌는 값 — 예: 프로세스 시작 시각 기반 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 검증은 프로토콜에 필드를 추가해야 하는 비용이 있다 — 다만 오인 라우팅으로 인한 매치 오류/데이터 오염 비용에 비하면 낮은 비용이다.
면접 포인트
- TTL 캐시는 "정보가 오래됐을 확률"을 낮출 뿐, 그 사이 발생한 이벤트(재시작, 재할당)를 감지하는 메커니즘이 아니다 — 신선도와 정확성은 다른 문제다.
- 네트워크 환경에서 "주소"는 재사용 가능한 자원이지 인스턴스의 신원이 아니다 — 신원 확인이 필요한 통신에는 별도의 식별자(에포크, lease ID, 인스턴스 ID)를 프로토콜에 실어 검증해야 한다.
- 폴링 기반 캐시 무효화보다 이벤트 기반(watch/구독) 무효화가 지연을 줄이지만, 두 방식 모두 "상대가 다른 신원임을 스스로 밝힐 수 있는" 검증 계층 없이는 경쟁 조건을 완전히 없애지 못한다 — 방어의 마지막 층은 결국 수신 측의 신원 검증이다.
해설 — 오래된 라우팅 캐시와 재사용된 커넥션 핸들로 인한 인스턴스 오인
난이도: 상
요약
RoutingCache::resolve는 TTL 이내면(A) 디스커버리 재조회 없이 캐시된 Connection 핸들을 그대로 돌려준다. 문제는 이 Connection이 IP:포트라는 주소만 감싸고 있을 뿐, 그 주소 뒤에 있는 논리적 인스턴스가 누구인지는 전혀 알지 못한다는 점이다. 대상 인스턴스가 재시작되어 같은 IP:포트를 재사용하면, 로컬에 캐시된 Connection 핸들은 (재연결 실패를 겪기 전까지는) 계속 "유효해 보이는" 상태로 남아, 이전 인스턴스를 향해 만들어졌던 요청을 전혀 다른 논리적 실체를 가진 새 인스턴스로 그대로 보내버린다. 캐시가 갱신된 뒤(B)에도 send가 상대의 신원을 검증하지 않는 한 이 위험은 원천적으로 남는다.
문제점
1. (TTL 기반 캐시의 신선도 공백) 재시작을 감지 못하는 캐시 — (A)
- 증상: 인스턴스가 재시작된 직후 최대 TTL(30초) 동안, 매치메이킹 서버는 옛
Connection핸들을 유효하다고 믿고 같은 주소로 요청을 보낸다. 그 주소에는 이제 다른 논리적 인스턴스가 떠 있다. - 재현조건: 오케스트레이터가 재시작된 컨테이너/파드에 같은 IP:포트를 재할당. 재시작이 TTL 창 안에서 일어나면 캐시가 갱신되기 전에 요청이 나간다.
- 근본원인: 캐시가 신선도를 오직 "얼마나 오래 전에 조회했는가(TTL)"로만 판단한다. 주소는 같지만 그 뒤의 논리적 실체가 바뀌었는지는 TTL이 알려주지 않는다.
2. (연결 핸들의 신원 부재) 재사용되는 Connection 객체 — (B)
- 증상: 캐시 미스로
resolve가 새Connection을 만들더라도(B), 그Connection은 주소 문자열만 들고 있을 뿐 "이 연결이 어느 논리적 인스턴스와 맺어진 것인지"를 기록하지 않는다.send는 상대가 응답으로 자신의 현재 인스턴스 식별자를 돌려줘도 이를 검증할 방법이 없다. - 근본원인: 라우팅을 "주소"로만 식별하고, 인스턴스의 신원(재시작마다 바뀌는 식별자)을 연결 객체나 프로토콜 어디에도 담지 않았다. C++ 관점에서 보면 이는 "핸들이 가리키는 대상이 조용히 교체된 채로 계속 유효해 보이는" 상황과 본질적으로 같은 부류의 문제다 — 메모리의 use-after-free가 아니라 네트워크 자원 차원의 use-after-restart라고 할 수 있다. 로컬 메모리는 멀쩡하지만 원격 실체가 이미 바뀌었다는 것을 감지할 수단이 없다.
수정안
InstanceEndpoint와 Connection에 재시작마다 바뀌는 epoch(세대) 식별자를 추가하고, 요청에 실어 보내 수신 측이 검증하도록 한다. 검증 실패 응답을 받으면 캐시 항목을 즉시 무효화하고 재조회한다.
struct InstanceEndpoint {
std::string instanceId;
std::string address;
std::string epoch; // 디스커버리가 등록마다 새로 발급하는 값(재시작 시 반드시 변경됨)
Clock::time_point cachedAt;
};
class Connection {
public:
Connection(std::string address, std::string epoch)
: address_(std::move(address)), epoch_(std::move(epoch)) {}
// 반환값: 상대가 epoch 불일치로 거부했으면 false
bool send(const std::string& matchId, const std::vector<uint8_t>& payload) {
// 실제 네트워크 전송 시 epoch_ 을 헤더에 실어 보내고,
// 상대가 "다른 세대"라며 거부하면 false 를 반환 (생략)
return true;
}
private:
std::string address_;
std::string epoch_;
};
class RoutingCache {
public:
explicit RoutingCache(std::shared_ptr<IDiscoveryClient> discovery)
: discovery_(std::move(discovery)) {}
std::shared_ptr<Connection> resolve(const std::string& instanceId, bool forceRefresh = false) {
auto it = cache_.find(instanceId);
if (!forceRefresh && it != cache_.end()) {
auto age = Clock::now() - it->second.endpoint.cachedAt;
if (age < kTtl) return it->second.conn;
}
InstanceEndpoint fresh = discovery_->lookup(instanceId);
auto conn = std::make_shared<Connection>(fresh.address, fresh.epoch);
cache_[instanceId] = Entry{fresh, conn};
return conn;
}
void sendMatchRequest(const std::string& instanceId, const std::string& matchId,
const std::vector<uint8_t>& payload) {
auto conn = resolve(instanceId);
if (!conn->send(matchId, payload)) {
cache_.erase(instanceId); // epoch 불일치 → 즉시 무효화
auto refreshed = resolve(instanceId, /*forceRefresh=*/true);
refreshed->send(matchId, payload);
}
}
private:
static constexpr std::chrono::seconds kTtl{30};
struct Entry { InstanceEndpoint endpoint; std::shared_ptr<Connection> conn; };
std::shared_ptr<IDiscoveryClient> discovery_;
std::unordered_map<std::string, Entry> cache_;
};
- 수신 측(인스턴스 서버)도 자기 자신의 epoch를 프로세스 시작 시 새로 발급받아, 들어오는 요청의 epoch와 다르면 즉시 거부하도록 구현해야 이 수정이 완성된다.
더 나은 설계
- 서비스 디스커버리가 지원한다면 TTL 폴링 대신 watch/스트리밍 구독으로 등록 해제·재등록 이벤트를 즉시 통지받아 캐시를 능동적으로 무효화한다.
- 인스턴스 등록에 lease(임대) 개념을 도입해, 재시작 시 새 lease(=epoch)를 발급받게 하면 "같은 주소, 다른 세대"를 자연스럽게 구분할 수 있다.
- TCP 연결 자체가 끊어지면(재시작 시 흔함) 재연결을 시도하되, 재연결된 새 소켓에도 반드시 새로 조회한 epoch를 실어야 한다 — "연결이 됐다"는 사실이 "같은 상대"라는 보장은 아니다.
- 트레이드오프: epoch 검증은 프로토콜에 필드를 추가하고 양측 구현을 요구하는 비용이 있지만, 오인 라우팅으로 인한 매치 오류/데이터 오염을 막는 편이 훨씬 저렴하다.
면접 포인트
- TTL 캐시는 "정보가 오래됐을 확률"을 낮출 뿐, 그 사이 발생한 이벤트(재시작, 재할당)를 감지하는 메커니즘이 아니다.
- 네트워크 상의 "주소"나 로컬에 유지된 "연결 핸들"은 재사용 가능한 자원일 뿐 인스턴스의 신원이 아니다 — 핸들이 유효해 보이는 것과 그 핸들이 가리키는 논리적 실체가 같은 것인지는 별개의 질문이다.
- 분산 시스템에서 신원 검증은 로컬 캐시 무효화 타이밍에만 의존하지 말고, 통신 프로토콜 자체에 세대/lease 식별자를 실어 수신 측이 마지막 방어선으로 검증하게 만드는 것이 안전하다.