22. 서비스 디스커버리 등록과 헬스체크의 준비 상태·해제 순서 (C#)
난이도 중해설 — 서비스 디스커버리 등록과 헬스체크의 준비 상태·해제 순서 (C#)
난이도: 중상
요약
이 코드는 "프로세스가 살아 있다(liveness)"와 "요청을 처리할 준비가 됐다(readiness)"를 구분하지 않는다. 리스너를 열고 워밍업 전에 디스커버리에 등록해 버리므로, 최소 연결 수 기반 LB 를 쓰는 게이트웨이가 새 인스턴스를 "가장 한가한 서버"로 보고 신규 접속을 전량 몰아준다.
종료 경로는 반대로 순서가 뒤집혀 있다. 리스너를 먼저 닫고 나중에 디스커버리에서 지우기 때문에 그 사이 구간에서 게이트웨이는 연결을 받지 못하는 인스턴스로 계속 라우팅한다. 게이트웨이 쪽 캐시도 TTL 만 볼 뿐 갱신 실패를 구분하지 않아서, 디스커버리 조회가 한 번 실패하면 빈 목록으로 캐시를 통째로 갈아엎어 클러스터 전체를 라우팅 대상에서 지운다.
문제점
(A) 준비 상태를 반영하지 않는 등록과 헬스체크
- 분류: readiness/liveness 미분리 · 콜드 스타트 · 연쇄 장애(cascading failure)
- 증상:
- 배포 직후 새 인스턴스에 접속이 몰리고 로그인/맵 진입이 대량 타임아웃된다.
- 오케스트레이터가 그 인스턴스를 unhealthy 로 보고 죽였다가 다시 띄우고, 다시 뜨자마자 또 트래픽이 몰려 또 죽는 재시작 루프가 된다. 그 사이 살아 있던 인스턴스의 부하가 올라가 같은 일이 옆 인스턴스로 번진다.
- 하트비트 루프에서 예외가 나면 아무 로그 없이 조용히 멈춘다. 하트비트가 끊기면 인스턴스는 "죽은 것"으로 간주되어 정상 서비스 중인 서버가 라우팅 대상에서 빠진다.
- 재현 조건: 워밍업(맵 데이터 + 스폰 테이블 + DB 풀 예열)이 수십 초 걸리는 인스턴스를
트래픽이 있는 시간대에 롤링 배포.
ActiveConnections == 0이므로OrderBy가 항상 새 인스턴스를 최우선으로 고른다. - 근본 원인:
StartAsync가RegisterAsync를 워밍업 3종보다 먼저 호출한다. 등록되는 순간 게이트웨이 목록에 들어가고, 실제 준비는 그 뒤에 이뤄진다.HealthCheck()가 무조건 200 을 반환한다. 맵 데이터가 없든 DB 풀이 비었든 항상 통과하므로 헬스체크가 아무 정보도 주지 못한다. 이 엔드포인트는 liveness 로도, readiness 로도 쓸 수 없다._listener.Start()가 등록보다 먼저 호출되는 것 자체는 문제가 아니지만, 리스너가 열려 있으니 TCP 레벨 헬스체크(포트 열림 확인)도 통과해 버린다. "포트가 열렸다 = 준비됐다" 라는 가정이 깨진다.Task.Run(() => HeartbeatLoop(...))는 반환된Task를 버린다.HeartbeatAsync안에서HttpRequestException이나TaskCanceledException이 나면 그 예외는 아무도 관측하지 않는Task에 담긴 채 사라지고 루프는 끝난다.await도ContinueWith도 없다.DiscoveryClient는 호출마다new HttpClient()를 만들고using으로 버린다. 소켓이TIME_WAIT로 쌓여 포트 고갈(SocketException: Only one usage of each socket address)로 이어지고,Timeout을 지정하지 않아 기본 100 초 동안 하트비트 한 번이 매달린다. 5 초 주기 하트비트가 100 초 블로킹되면 사실상 하트비트가 죽는다.
(B) 해제 순서 역전과 라우팅 캐시의 통째 교체
- 분류: 그레이스풀 셧다운 순서 위반 · 빈 결과 방어 부재 · 캐시 일관성
- 증상:
- 롤링 배포 중 "서버가 응답하지 않습니다" 접속 실패가 인스턴스당 최대 TTL(20 초) + 갱신 주기(10 초) 만큼 발생한다. 이미 리스너를 닫은 인스턴스로 게이트웨이가 계속 신규 접속을 보낸다.
- 디스커버리가 잠깐 500 을 뱉거나 네트워크가 끊기면 전체 서비스가 라우팅 불가가 된다
(
PickForNewConnection이null반환). - 라우팅 캐시가 갱신되는 순간순간 게이트웨이가 인스턴스를 못 찾는 접속 실패가 산발한다.
- 재현 조건:
SIGTERM→ApplicationStopping→ShutdownAsync._listener.Stop()직후부터DeregisterAsync완료 + 게이트웨이 캐시 갱신까지의 구간에 신규 접속을 시도.- 디스커버리 앞단 로드밸런서를 재시작해
ListAsync가 한 번 실패하게 만든다.
- 근본 원인:
- 순서 역전. 그레이스풀 셧다운의 올바른 순서는 ① 디스커버리에서 먼저 빠지고 ② 게이트웨이가 그 사실을 반영할 만큼 기다린 다음 ③ 리스너를 닫고 ④ 세션을 정리하는 것이다. 현재 코드는 ③ → ④ → ① 이라 ①이 끝나기 전까지 "받을 수 없는데 목록에는 있는" 상태가 유지된다.
ListAsync는 실패 시new List<InstanceInfo>()를 반환하고,RefreshLoop는 그 빈 목록을 정상 결과와 똑같이 취급해 캐시에 반영한다. "조회 실패"와 "인스턴스가 하나도 없음"이 구분되지 않는다. 디스커버리는 가용성보다 일관성을 택하는 시스템이라 일시적 조회 실패가 정상이며, 클라이언트는 마지막으로 성공한 목록을 유지(stale-if-error)해야 한다._routeCache.Clear()후foreach로 다시 채우는 동안 캐시는 중간의 빈/부분 상태를 노출한다.ConcurrentDictionary는 개별 연산만 원자적이고 "Clear + N 개 삽입"은 원자적이지 않다. 이 사이에PickForNewConnection이 호출되면null또는 일부 인스턴스만 보인다.- 등록/해제 이벤트에 세대(generation) 정보가 없다. 하트비트나 재등록이 네트워크 지연으로
늦게 도착하면, 방금 지운 인스턴스가 오래된 등록으로 되살아난다.
InstanceId만으로는 "이 등록이 언제 것인가"를 판단할 수 없다. _cachedAtUtc는 TTL 판정에 쓰이지만 로그만 찍고 아무 조치도 하지 않는다. stale 캐시로 계속 라우팅한다. 또DateTime필드를 락 없이 다른 스레드에서 읽는다 (DateTime은 8 바이트라 x64 에서 tearing 은 실질적으로 없지만 가시성 보장은 없다).ApplicationStopping.Register안에서GetAwaiter().GetResult()로 비동기를 동기 대기한다.StopAsync도 같은ShutdownAsync를 부르므로 셧다운 경로가 두 번 실행될 수 있고,DeregisterAsync가 두 번 나가거나_cts.Cancel()이 중복 호출된다.
수정안
핵심은 세 가지다. ① 등록을 워밍업 이후로 미루고 헬스체크를 실제 의존성 상태에 연결한다. ② 종료는 디스커버리에서 먼저 빠지고 전파 지연만큼 기다린 뒤 리스너를 닫는다. ③ 라우팅 캐시는 원자 교체 + 빈 결과 방어로 갱신한다.
(A) readiness 게이트와 상태 기반 헬스체크
public enum ReadyState { Starting, Ready, Draining }
public sealed class ZoneServerHost : IHostedService
{
private int _state = (int)ReadyState.Starting;
private Task _heartbeatTask;
public ReadyState State => (ReadyState)Volatile.Read(ref _state);
public async Task StartAsync(CancellationToken ct)
{
// 1) 워밍업을 먼저 끝낸다
await MapDataLoader.LoadAsync(_self.InstanceId);
await SpawnTableCache.WarmUpAsync();
await DbPool.PrewarmAsync(minConnections: 32);
// 2) 그 다음 리스너를 열고
_listener.Start(_self.Port);
Volatile.Write(ref _state, (int)ReadyState.Ready);
// 3) 마지막에 디스커버리에 등록한다
await _discovery.RegisterAsync(_self, ct);
_heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_cts.Token));
}
// liveness: 프로세스가 데드락 없이 돌고 있는가 (의존성은 보지 않는다)
public int LivenessCheck() => _lastLoopTickUtc > DateTime.UtcNow.AddSeconds(-30) ? 200 : 503;
// readiness: 지금 트래픽을 받아도 되는가
public int ReadinessCheck()
{
if (State != ReadyState.Ready) return 503;
if (!DbPool.IsHealthy) return 503;
if (!SpawnTableCache.IsLoaded) return 503;
return 200;
}
}
하트비트는 예외를 삼키지 말고 잡아서 로그를 남기고 루프를 유지해야 한다.
private async Task HeartbeatLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
if (ReadinessCheck() == 200)
await _discovery.HeartbeatAsync(_self.InstanceId, ct);
_lastLoopTickUtc = DateTime.UtcNow;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "heartbeat failed for {Id}", _self.InstanceId);
}
try { await Task.Delay(TimeSpan.FromSeconds(5), ct); }
catch (OperationCanceledException) { break; }
}
}
HttpClient 는 IHttpClientFactory 로 받거나 최소한 정적 단일 인스턴스로 재사용하고,
반드시 타임아웃을 준다. 하트비트 주기(5 초)보다 짧아야 요청이 겹치지 않는다.
services.AddHttpClient<DiscoveryClient>(c =>
{
c.BaseAddress = new Uri(discoveryUrl);
c.Timeout = TimeSpan.FromSeconds(2);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // DNS 변경 반영
(B) 종료 순서 정상화와 캐시 원자 교체
private int _shutdownOnce;
private async Task ShutdownAsync()
{
if (Interlocked.Exchange(ref _shutdownOnce, 1) != 0) return; // 멱등
// 1) 먼저 "받을 수 없음"을 알린다 — readiness 를 내리고 디스커버리에서 뺀다
Volatile.Write(ref _state, (int)ReadyState.Draining);
try { await _discovery.DeregisterAsync(_self.InstanceId); }
catch (Exception ex) { _logger.LogWarning(ex, "deregister failed"); }
// 2) 게이트웨이 캐시가 갱신될 시간을 준다 (갱신주기 + 여유)
await Task.Delay(_gatewayPropagationDelay); // 예: 15초
// 3) 그제야 리스너를 닫고 기존 세션을 정리한다
_listener.Stop();
await _sessions.DrainAsync(TimeSpan.FromSeconds(30));
// 4) 마지막에 하트비트를 멈추고 회수한다
_cts.Cancel();
if (_heartbeatTask != null)
await Task.WhenAny(_heartbeatTask, Task.Delay(TimeSpan.FromSeconds(5)));
}
라우팅 캐시는 Clear + Add 대신 새 스냅샷을 만들어 참조 하나만 교체한다.
읽기 쪽은 락 없이 항상 일관된 목록을 본다.
public sealed class GatewayRouter
{
private IReadOnlyList<InstanceInfo> _snapshot = Array.Empty<InstanceInfo>();
private DateTime _lastSuccessUtc = DateTime.MinValue;
private static readonly TimeSpan MaxStale = TimeSpan.FromMinutes(2);
public async Task RefreshLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var list = await _discovery.ListAsync("zone-server", ct);
// 빈 결과 방어: 조회는 성공했지만 목록이 비었다면 즉시 반영하지 않는다
if (list.Count == 0 && DateTime.UtcNow - _lastSuccessUtc < MaxStale)
{
_logger.LogWarning("discovery returned empty list; keeping previous snapshot");
}
else
{
Volatile.Write(ref _snapshot, list.ToArray()); // 원자 교체
_lastSuccessUtc = DateTime.UtcNow;
}
}
catch (Exception ex)
{
// 조회 실패는 "인스턴스 없음"이 아니다 — 이전 스냅샷을 유지한다
_logger.LogError(ex, "discovery list failed; serving stale snapshot");
}
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
public InstanceInfo PickForNewConnection()
{
var snap = Volatile.Read(ref _snapshot);
if (snap.Count == 0) throw new NoHealthyInstanceException();
// 최소 연결 수만 보면 새 인스턴스에 몰린다 — 후보 2개 중 적은 쪽(P2C)
return PowerOfTwoChoices(snap);
}
}
ListAsync 는 실패를 삼키지 말고 던져야 호출자가 "실패"와 "빈 목록"을 구분할 수 있다.
public async Task<IReadOnlyList<InstanceInfo>> ListAsync(string svc, CancellationToken ct)
{
var res = await _http.GetAsync($"/v1/health/service/{svc}", ct);
res.EnsureSuccessStatusCode(); // 실패는 예외로
return Parse(await res.Content.ReadAsStringAsync(ct));
}
세대(generation) 문제는 등록 페이로드에 모노토닉 증가값을 넣어 해결한다.
프로세스 시작 시각(또는 etcd lease ID / Consul 세션)을 함께 저장하고,
캐시 반영 시 같은 InstanceId 의 더 낮은 세대는 무시한다.
public sealed class InstanceInfo
{
public string InstanceId;
public long Generation; // 프로세스 시작 시각(Ticks) 또는 lease ID
}
// 반영 시
if (existing != null && incoming.Generation < existing.Generation) continue; // 늦게 온 옛 등록 무시
더 나은 설계
(1) 디스커버리 등록을 애플리케이션 코드에서 빼고 플랫폼에 위임한다.
Kubernetes 의 readinessProbe / livenessProbe + Service Endpoints, 또는 Consul 의
sidecar 등록(consul-template, Nomad)에 맡기면 애플리케이션은 /ready, /live 두 엔드포인트만
정확히 구현하면 된다. 등록/해제 순서와 전파 지연은 플랫폼이 검증된 방식으로 처리한다.
- 트레이드오프: 플랫폼 종속이 생기고, 게임 서버 특유의 "이 존은 만석" 같은 도메인 신호를
표준 probe 로 표현하기 어렵다.
readinessProbe를 만석 시 503 으로 쓰면 기존 세션까지 Endpoints 에서 빠져 끊긴다(readiness 는 신규 라우팅만 막는 게 아니라 Endpoints 에서 제거한다). 그래서 실무에서는 probe 는 순수 기술 상태만 보고, 만석 여부는 별도 라우팅 메타데이터로 뺀다.
(2) 푸시 기반 감시(watch)로 바꾼다. Consul blocking query / etcd watch 를 쓰면 폴링 TTL 이 사라지고 변경이 수백 ms 안에 전파된다. 게이트웨이는 초기 목록을 한 번 받고 이후 델타만 적용한다.
- 트레이드오프: 연결 유지 비용과 재연결 시 재동기화 로직이 필요하다. watch 가 끊긴 동안의 이벤트 유실을 revision/index 기반으로 따라잡아야 하며, 이 재동기화를 잘못 짜면 지금 코드와 같은 "빈 목록 반영" 사고가 다시 난다. 실무에서는 watch + 저빈도 전체 재동기화(예: 60 초)를 함께 쓴다.
(3) 트래픽 램프업(slow start)을 LB 에 넣는다.
새 인스턴스가 목록에 들어와도 처음 30~60 초는 가중치를 선형으로 올린다(Envoy slow_start_config,
NGINX slow_start). readiness 를 통과했더라도 JIT 컴파일, CPU 캐시, 커넥션 풀이 실제 부하로
데워지는 데는 시간이 더 걸린다.
- 트레이드오프: LB 설정 복잡도가 오르고, 램프업 중에는 기존 인스턴스 부하가 잠깐 더 높다. 급증 트래픽 상황에서는 램프업이 오히려 확장을 늦춘다. 그래도 재시작 루프로 클러스터가 통째로 무너지는 것보다는 훨씬 낫다.
(4) 최소 연결 수 대신 P2C(power of two choices) 를 쓴다. 후보 2 개를 무작위로 뽑아 그중 연결 수가 적은 쪽을 고르면, 새 인스턴스로 전량 쏠리는 "herd" 현상이 사라지면서도 최소 연결 수와 거의 같은 균형을 얻는다.
- 트레이드오프: 완벽한 최적 분배는 아니다. 다만 분산 환경에서 각 게이트웨이가 보는
ActiveConnections는 어차피 stale 이라 "정확한 최소"라는 전제 자체가 성립하지 않는다.
면접 포인트
- "liveness 와 readiness 를 왜 나누는가?" — liveness 실패는 재시작, readiness 실패는 트래픽 차단이다. 의존성(DB, 캐시) 상태를 liveness 에 넣으면 DB 가 흔들릴 때 모든 인스턴스가 동시에 재시작되어 장애를 증폭시킨다. 의존성은 readiness 에만 넣는다.
- "그레이스풀 셧다운의 올바른 순서는?" — ① readiness 내리기/디스커버리 해제 → ② 전파 지연 대기 → ③ 리스너 닫기 → ④ 기존 요청 드레인 → ⑤ 종료. ②를 빼면 반드시 접속 실패 구간이 생긴다.
- "디스커버리 조회가 실패했을 때 인스턴스 목록을 비워야 하는가?" — 절대 아니다. 마지막 성공 스냅샷을 유지한다(stale-if-error). "조회 실패"와 "인스턴스 0 개"를 타입 수준에서 구분해야 하며, 실패를 빈 컬렉션으로 뭉개는 API 시그니처 자체가 사고의 원인이다.
- "
Task.Run의 반환값을 버리면?" — 예외가 관측되지 않은 채 사라진다. .NET Core 부터는TaskScheduler.UnobservedTaskException이 떠도 프로세스를 죽이지 않으므로 더 조용하다. 백그라운드 루프는BackgroundService로 감싸거나 반드시Task를 보관해 회수한다. - "
new HttpClient()를 매번 만들면?" —TIME_WAIT소켓 누적으로 포트가 고갈된다. 반대로 정적 싱글턴만 쓰면 DNS 변경이 반영되지 않으므로IHttpClientFactory나SocketsHttpHandler.PooledConnectionLifetime으로 핸들러 수명을 제한한다. - "늦게 도착한 등록 이벤트가 죽은 인스턴스를 되살리는 문제는?" — 등록에 세대/lease 를 붙이고 더 낮은 세대는 무시한다. 이것이 분산 시스템의 fencing token 과 같은 원리다.
해설 — 서비스 디스커버리 등록과 헬스체크의 준비 상태·해제 순서 (C++)
난이도: 상
요약
이 코드는 "프로세스가 살아 있다(liveness)"와 "요청을 처리할 준비가 됐다(readiness)"를 구분하지 않는다. 리스너를 열고 워밍업 전에 디스커버리에 등록하므로, 최소 연결 수 기반 LB 를 쓰는 게이트웨이가 새 인스턴스를 "가장 한가한 서버"로 보고 신규 접속을 전량 몰아준다.
종료 경로는 순서가 뒤집혀 있다. 리스너를 먼저 닫고 나중에 디스커버리에서 지우므로
그 사이 게이트웨이는 연결을 받지 못하는 인스턴스로 계속 라우팅한다.
C++ 쪽은 여기에 수명(lifetime) 문제가 겹친다. 하트비트 스레드를 detach() 했기 때문에
소멸자는 스레드가 끝날 때까지 기다리지 않고, 스레드는 이미 파괴된 this 를 계속 만진다.
게이트웨이의 라우팅 캐시도 std::vector 를 뮤텍스 없이 읽고 쓰며 clear() 후 재삽입하기 때문에
순회 중 반복자와 포인터가 통째로 무효화된다.
문제점
(A) 준비 상태를 반영하지 않는 등록과 헬스체크
- 분류: readiness/liveness 미분리 · 콜드 스타트 · 연쇄 장애(cascading failure)
- 증상:
- 배포 직후 새 인스턴스로 접속이 몰려 로그인/맵 진입이 대량 타임아웃된다.
- 오케스트레이터가 unhealthy 로 보고 죽였다가 다시 띄우고, 다시 뜨자마자 또 트래픽이 몰려 또 죽는 재시작 루프가 된다. 그 사이 살아 있던 인스턴스 부하가 올라가 옆으로 번진다.
Heartbeat가 예외를 던지면HeartbeatLoop를 뚫고 나간다. 이 스레드는detach()되어 있으므로 예외가 스레드 최상위까지 올라가면std::terminate로 프로세스 전체가 죽는다.
- 재현 조건: 워밍업(맵 데이터 + 스폰 테이블 + DB 풀 예열)이 수십 초 걸리는 인스턴스를
트래픽 있는 시간대에 롤링 배포.
activeConnections == 0이라PickForNewConnection의 최소값 탐색이 항상 새 인스턴스를 고른다. - 근본 원인:
Start()가Register()를 워밍업 3종보다 먼저 호출한다. 등록되는 순간 게이트웨이 목록에 들어가고, 실제 준비는 그 뒤에 이뤄진다.HealthCheck()가 무조건 200 을 반환한다. 맵 데이터가 없든 DB 풀이 비었든 통과하므로 이 엔드포인트는 liveness 로도 readiness 로도 쓸 수 없다.listener_.Start()가 등록보다 먼저이므로 TCP 레벨 헬스체크(포트 열림 확인)도 통과한다. "포트가 열렸다 = 준비됐다"는 가정이 깨진다.std::thread(...).detach()는 스레드 핸들을 버린다. 예외 안전성도, 회수 수단도 없다.Heartbeat안에서 던져진 예외를 잡는 곳이 아무 데도 없다.
(B) 해제 순서 역전, 스레드 수명, 캐시 자료구조 무효화
- 분류: 그레이스풀 셧다운 순서 위반 · 객체 수명(dangling this) · 데이터 경쟁 · 빈 결과 방어 부재
- 증상:
- 롤링 배포 중 "서버가 응답하지 않습니다" 접속 실패가 인스턴스당 최대 TTL(20 초) + 갱신 주기(10 초) 만큼 발생한다. 이미 리스너를 닫은 인스턴스로 게이트웨이가 계속 신규 접속을 보낸다.
- 프로세스 종료 시 간헐적으로 세그폴트가 난다. 스택에
HeartbeatLoop가 찍히고,discovery_나self_.instanceId가 쓰레기 값이다. ASan 은heap-use-after-free를 낸다. - 게이트웨이가 접속 처리 중 무작위로 죽거나, 존재하지 않는 IP 로 접속을 보낸다.
- 디스커버리가 잠깐 실패하면
PickForNewConnection이nullptr을 반환해 전체 접속이 막힌다.
- 재현 조건:
SIGTERM→Shutdown().listener_.Stop()직후부터Deregister완료 + 게이트웨이 캐시 갱신까지의 구간에 신규 접속을 시도.ZoneServerHost를 스택/unique_ptr로 잡고 소멸시킨 뒤 5 초 안에 프로세스가 살아 있게 두면 detach 된 스레드가 파괴된 객체를 만진다.- 게이트웨이를
-fsanitize=thread로 빌드하고RefreshLoop와 접속 워커를 동시에 돌린다.
- 근본 원인:
- 순서 역전. 올바른 순서는 ① 디스커버리에서 먼저 빠지고 ② 게이트웨이가 반영할 만큼 기다린 뒤 ③ 리스너를 닫고 ④ 세션을 정리하는 것이다. 현재는 ③ → ④ → ① 이라 ①이 끝나기 전까지 "받을 수 없는데 목록에는 있는" 상태가 유지된다.
- 소멸자가 스레드를 join 하지 않는다.
~ZoneServerHost는running_ = false만 쓰고 끝난다. 하지만 하트비트 스레드는 최대 5 초짜리sleep_for안에 있을 수 있고, 깨어나면 이미 파괴된this를 통해running_,discovery_,self_를 읽는다.detach()했으므로 join 할 방법 자체가 없다.std::atomic<bool>을 썼다는 사실은 자료의 원자성만 보장할 뿐 객체가 살아 있음은 보장하지 않는다. Shutdown()과 소멸자가 둘 다running_을 건드리지만 멱등하지 않다.Shutdown()을 호출한 뒤 소멸자가 또 돌면Deregister는 한 번만 나가지만,Shutdown()을 두 번 부르면Deregister도 두 번 나간다.routeCache_는 뮤텍스 없이 쓰기(RefreshLoop)와 읽기(PickForNewConnection)가 동시에 일어난다. 이는 명백한 데이터 경쟁이며,std::vector::clear()+push_back()은 버퍼 재할당으로 기존 원소의 주소를 전부 무효화한다.PickForNewConnection은const InstanceInfo*를 그 벡터 내부로 반환하므로, 반환된 포인터는 다음 갱신에서 즉시 dangling 이 된다. 범위 기반 for 순회 중 재할당이 일어나면 순회 자체가 무너진다.List()가 실패와 "인스턴스 0 개"를 똑같이 빈 벡터로 표현한다.RefreshLoop는 이를 정상 결과처럼 캐시에 반영해 클러스터 전체를 지운다. API 시그니처가 사고의 원인이다.- 등록/해제 이벤트에 세대(generation) 정보가 없다. 늦게 도착한 오래된 등록이
방금 지운 인스턴스를 되살린다.
instanceId만으로는 "이 등록이 언제 것인가"를 알 수 없다. cachedAtMs_의 TTL 판정은 로그만 찍고 아무 조치도 하지 않는다. stale 캐시로 계속 라우팅한다.PickForNewConnection()이const인데 내부적으로 공유 상태를 읽는다. C++ 의const는 "여러 스레드에서 동시에 호출해도 안전하다"는 표준 라이브러리의 관례를 암시하는데, 이 구현은 그 계약을 지키지 않는다.
수정안
핵심은 네 가지다. ① 등록을 워밍업 이후로 미루고 헬스체크를 실제 의존성에 연결한다. ② 하트비트 스레드를 멤버로 보관해 소멸자에서 join 한다. ③ 종료는 디스커버리에서 먼저 빠지고 전파 지연만큼 기다린 뒤 리스너를 닫는다. ④ 라우팅 캐시는 불변 스냅샷 + 원자 포인터 교체로 바꾼다.
(A)(B) 호스트: 준비 상태 게이트 + 스레드 소유
enum class ReadyState { Starting, Ready, Draining };
class ZoneServerHost {
public:
ZoneServerHost(DiscoveryClient* d, InstanceInfo self)
: discovery_(d), self_(std::move(self)) {}
~ZoneServerHost() { Shutdown(); } // 반드시 스레드를 회수하고 나간다
ZoneServerHost(const ZoneServerHost&) = delete;
ZoneServerHost& operator=(const ZoneServerHost&) = delete;
void Start() {
// 1) 워밍업을 먼저 끝낸다
LoadMapData(self_.instanceId);
WarmUpSpawnTable();
PrewarmDbPool(32);
// 2) 그 다음 리스너를 열고 준비 완료로 표시한다
listener_.Start(self_.port);
state_.store(ReadyState::Ready);
// 3) 마지막에 등록하고, 스레드를 detach 하지 않고 멤버로 보관한다
discovery_->Register(self_);
running_.store(true);
heartbeat_ = std::thread([this] { HeartbeatLoop(); });
}
// liveness: 프로세스가 돌고 있는가 (의존성은 보지 않는다)
int LivenessCheck() const { return running_.load() ? 200 : 503; }
// readiness: 지금 트래픽을 받아도 되는가
int ReadinessCheck() const {
if (state_.load() != ReadyState::Ready) return 503;
if (!DbPoolHealthy()) return 503;
if (!SpawnTableLoaded()) return 503;
return 200;
}
void Shutdown() {
bool expected = false;
if (!shutdownOnce_.compare_exchange_strong(expected, true)) return; // 멱등
// 1) 먼저 "받을 수 없음"을 알린다
state_.store(ReadyState::Draining);
discovery_->Deregister(self_.instanceId);
// 2) 게이트웨이 캐시가 갱신될 시간을 준다 (갱신주기 + 여유)
std::this_thread::sleep_for(propagationDelay_);
// 3) 그제야 리스너를 닫고 기존 세션을 정리한다
listener_.Stop();
sessions_.Drain(std::chrono::seconds(30));
// 4) 마지막에 하트비트 스레드를 깨워서 회수한다
running_.store(false);
wake_.notify_all();
if (heartbeat_.joinable()) heartbeat_.join();
}
private:
void HeartbeatLoop() {
std::unique_lock<std::mutex> lk(wakeMu_);
while (running_.load()) {
lk.unlock();
try {
if (ReadinessCheck() == 200) discovery_->Heartbeat(self_.instanceId);
} catch (const std::exception& e) {
// 예외가 스레드를 뚫고 나가면 std::terminate 다 — 반드시 여기서 막는다
std::cerr << "[zone] heartbeat failed: " << e.what() << "\n";
}
lk.lock();
// sleep_for 가 아니라 조건변수 — 종료 신호에 즉시 깨어난다
wake_.wait_for(lk, std::chrono::seconds(5), [this] { return !running_.load(); });
}
}
DiscoveryClient* discovery_;
InstanceInfo self_;
Listener listener_;
SessionManager sessions_;
std::atomic<bool> running_{false};
std::atomic<bool> shutdownOnce_{false};
std::atomic<ReadyState> state_{ReadyState::Starting};
std::thread heartbeat_;
std::mutex wakeMu_;
std::condition_variable wake_;
std::chrono::seconds propagationDelay_{15};
};
sleep_for 를 조건변수로 바꾼 것이 중요하다. 원래 코드는 종료를 지시해도 최대 5 초를 그대로 잔다.
join() 을 넣더라도 그 5 초만큼 종료가 늦어지고, 오케스트레이터의 terminationGracePeriod 를
넘기면 SIGKILL 로 강제 종료되어 결국 세션 정리가 날아간다.
(B) 실패와 빈 목록을 타입으로 구분
struct DiscoveryResult {
bool ok = false; // 조회 자체가 성공했는가
std::vector<InstanceInfo> instances; // ok == true 일 때만 의미가 있다
};
struct DiscoveryClient {
virtual ~DiscoveryClient() = default;
virtual bool Register(const InstanceInfo&) = 0;
virtual bool Deregister(const std::string&) = 0;
virtual void Heartbeat(const std::string&) = 0;
virtual DiscoveryResult List(const std::string&) = 0; // 빈 벡터로 실패를 표현하지 않는다
};
(B) 라우팅 캐시: 불변 스냅샷 + 원자 포인터 교체
using Snapshot = std::vector<InstanceInfo>;
class GatewayRouter {
public:
explicit GatewayRouter(DiscoveryClient* d)
: discovery_(d), snapshot_(std::make_shared<Snapshot>()) {}
void RefreshLoop(const std::atomic<bool>& stop) {
while (!stop.load()) {
DiscoveryResult r = discovery_->List("zone-server");
if (!r.ok) {
// 조회 실패는 "인스턴스 없음"이 아니다 — 이전 스냅샷을 유지한다
std::cerr << "[gw] discovery list failed; serving stale snapshot\n";
} else if (r.instances.empty() && NowMs() - lastSuccessMs_ < kMaxStaleMs) {
// 빈 결과 방어: 성공했지만 목록이 비었다면 곧바로 반영하지 않는다
std::cerr << "[gw] empty list ignored; keeping previous snapshot\n";
} else {
auto merged = std::make_shared<Snapshot>(MergeByGeneration(r.instances));
std::atomic_store(&snapshot_, merged); // 참조 하나만 원자 교체
lastSuccessMs_ = NowMs();
}
std::this_thread::sleep_for(std::chrono::seconds(10));
}
}
// 포인터가 아니라 값을 반환한다 — 호출자가 dangling 을 만들 수 없다
InstanceInfo PickForNewConnection() {
std::shared_ptr<const Snapshot> snap = std::atomic_load(&snapshot_);
if (!snap || snap->empty()) throw std::runtime_error("no healthy instance");
// 최소 연결 수만 보면 새 인스턴스로 몰린다 — 후보 2개 중 적은 쪽(P2C)
std::uniform_int_distribution<size_t> dist(0, snap->size() - 1);
const InstanceInfo& a = (*snap)[dist(rng_)];
const InstanceInfo& b = (*snap)[dist(rng_)];
return a.activeConnections <= b.activeConnections ? a : b;
}
private:
// 늦게 도착한 오래된 등록이 최신 상태를 덮어쓰지 못하게 한다
Snapshot MergeByGeneration(const std::vector<InstanceInfo>& incoming) const {
auto prev = std::atomic_load(&snapshot_);
Snapshot out;
out.reserve(incoming.size());
for (const auto& in : incoming) {
auto it = std::find_if(prev->begin(), prev->end(), [&](const InstanceInfo& p) {
return p.instanceId == in.instanceId;
});
if (it != prev->end() && in.generation < it->generation) continue;
out.push_back(in);
}
return out;
}
static std::int64_t NowMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static constexpr std::int64_t kMaxStaleMs = 120000;
DiscoveryClient* discovery_;
std::shared_ptr<Snapshot> snapshot_;
std::int64_t lastSuccessMs_ = 0;
std::mt19937 rng_{std::random_device{}()};
};
InstanceInfo::generation 은 프로세스 시작 시각(steady 아닌 wall clock 또는 etcd lease ID)을
넣는다. 같은 instanceId 라도 세대가 낮은 등록은 무시하면 "죽은 인스턴스 부활"이 사라진다.
std::atomic_load(std::shared_ptr*)는 C++20 에서 deprecated 다. C++20 이상이면std::atomic<std::shared_ptr<Snapshot>>를 쓰는 편이 낫다. 다만 대부분의 구현이 락 기반이므로, 초당 수만 회 읽는다면 RCU 나 seqlock 을 고려한다.
더 나은 설계
(1) 디스커버리 등록을 애플리케이션에서 빼고 플랫폼에 위임한다.
Kubernetes 의 readinessProbe/livenessProbe + Service Endpoints, 또는 Consul sidecar 등록에
맡기면 애플리케이션은 /ready, /live 두 엔드포인트만 정확히 구현하면 된다.
등록/해제 순서와 전파 지연은 플랫폼이 검증된 방식으로 처리하고, preStop 훅으로 드레인 지연을 준다.
- 트레이드오프: 플랫폼 종속이 생기고, "이 존은 만석" 같은 도메인 신호를 표준 probe 로 표현하기 어렵다. readiness 를 만석 시 503 으로 쓰면 Endpoints 에서 아예 빠져 기존 세션까지 끊긴다. 실무에서는 probe 는 기술 상태만 보고, 만석 여부는 별도 라우팅 메타데이터로 뺀다.
(2) 하트비트 스레드를 없애고 단일 타이머 스레드/이벤트 루프에 합친다. 인스턴스마다 스레드를 하나씩 띄우는 대신 하나의 타임휠에 하트비트 작업을 등록하면 스레드 수명 문제 자체가 사라지고, 종료 시 회수도 타임휠 하나만 정리하면 된다.
- 트레이드오프: 타임휠 안의 한 작업이 블로킹되면 다른 작업의 스케줄이 밀린다. 하트비트 HTTP 호출을 반드시 비동기(또는 짧은 타임아웃)로 만들어야 하고, 이는 결국 비동기 런타임(asio, libuv) 도입으로 이어진다.
(3) 푸시 기반 감시(watch)로 바꾼다. Consul blocking query / etcd watch 를 쓰면 폴링 TTL 이 사라지고 변경이 수백 ms 안에 전파된다.
- 트레이드오프: 연결 유지 비용과 재연결 시 재동기화 로직이 필요하다. watch 가 끊긴 동안의 이벤트 유실을 revision/index 로 따라잡아야 하며, 이 재동기화를 잘못 짜면 지금과 같은 "빈 목록 반영" 사고가 다시 난다. watch + 저빈도 전체 재동기화를 함께 쓴다.
(4) 트래픽 램프업(slow start)을 LB 에 넣는다. readiness 를 통과했어도 CPU 캐시, 커넥션 풀, 페이지 캐시가 실제 부하로 데워지는 데는 시간이 걸린다. 처음 30~60 초는 가중치를 선형으로 올린다.
- 트레이드오프: LB 설정 복잡도가 오르고 램프업 중 기존 인스턴스 부하가 잠깐 더 높다. 급증 트래픽에서는 확장을 늦춘다. 그래도 재시작 루프로 클러스터가 무너지는 것보다는 낫다.
면접 포인트
- "liveness 와 readiness 를 왜 나누는가?" — liveness 실패는 재시작, readiness 실패는 트래픽 차단이다. DB 상태를 liveness 에 넣으면 DB 가 흔들릴 때 전 인스턴스가 동시에 재시작되어 장애를 증폭시킨다. 의존성은 readiness 에만 넣는다.
- "그레이스풀 셧다운의 올바른 순서는?" — ① readiness 내리기/디스커버리 해제 → ② 전파 지연 대기 → ③ 리스너 닫기 → ④ 기존 요청 드레인 → ⑤ 종료. ②를 빼면 반드시 접속 실패 구간이 생긴다.
- "
std::thread::detach()는 언제 써도 되는가?" — 사실상 프로세스와 수명이 같은 스레드뿐이다. 객체 멤버를 캡처한 스레드를 detach 하면 그 객체 소멸 시점에 dangling 이 된다.std::atomic<bool> running_은 자료의 원자성만 보장하지 객체가 살아 있음은 보장하지 않는다. 스레드는 소유(멤버std::thread+ 소멸자 join)하거나jthread(C++20)로 감싼다. - "소멸자에서 join 하면 예외 안전한가?" —
join()이 던지면 소멸자에서 예외가 나가므로noexcept소멸자에서는std::terminate다. 실무에서는 소멸자 안의 정리 로직 전체를try/catch(...)로 감싸고 로그만 남긴다. - "스레드 함수 밖으로 예외가 나가면?" —
std::terminate로 프로세스가 죽는다. 스레드 진입점은 항상try/catch(...)로 감싸야 한다. - "
std::vector를 캐시로 쓰고 내부 원소 포인터를 반환하면?" — 재할당 시 모든 포인터/반복자가 무효화된다. 공유 캐시는 불변 스냅샷 +shared_ptr교체로 만들고, 반환은 값 또는 스냅샷을 함께 잡는 핸들로 한다. 이것이 read-mostly 자료구조의 표준 패턴이다. - "디스커버리 조회가 실패했을 때 목록을 비워야 하는가?" — 절대 아니다.
마지막 성공 스냅샷을 유지한다(stale-if-error). 실패를 빈 컬렉션으로 뭉개는 API 시그니처
자체가 원인이므로
DiscoveryResult{ok, instances}처럼 타입으로 구분한다. - "늦게 도착한 등록이 죽은 인스턴스를 되살리는 문제는?" — 등록에 세대/lease 를 붙이고 더 낮은 세대는 무시한다. 분산 시스템의 fencing token 과 같은 원리다.