끄기 전에 기다려야 하는 시간 — 드레인과 헬스 체크 주기
Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 마지막 글입니다. 이번 요구는 서버를 새 버전으로 바꿔 띄우는 동안 들어온 요청이 하나도 실패하지 않아야 한다는 것입니다.
종료 신호를 받으면 하던 일을 마치고 닫는 방법, 곧 그레이스풀 셧다운의 기본은 예전 글에 적었습니다. 이번 글은 그 뒤에 남은 질문 하나를 실험으로 확인한 기록입니다. 닫기 전에 얼마나 기다려야 하는가입니다.
서버를 끄는 순간에도 요청은 계속 들어옵니다
서버가 여러 대일 때 그 앞에는 요청을 나눠 주는 문지기가 있습니다. 로드 밸런서나 리버스 프록시라고 부르는 것들입니다. 문지기는 서버가 꺼졌다는 사실을 스스로 알지 못합니다. 일정한 간격으로 서버에 "살아 있니?"라고 묻는 헬스 체크로 알아냅니다.
그래서 서버가 꺼진 순간과 문지기가 그것을 알아챈 순간 사이에 틈이 생깁니다. 그 틈에 문지기는 여전히 꺼진 서버로 요청을 보내고, 그 요청들은 실패합니다.
시간 →
헬스 체크 ✓ ─────────────── ✓ ─────────────── ✗ (여기서야 알아챔)
서버 B1 ────────── ✕ 꺼짐
B1 로 간 요청 ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ ← 이 틈의 요청이 실패한다
드레인은 "곧 닫는다"고 먼저 알리고 기다리는 것입니다
틈을 없애는 방법은 순서를 바꾸는 것입니다. 종료 신호를 받으면 바로 닫지 않고, 먼저 헬스 체크에만 "나 곧 닫는다(503)"라고 답하기 시작합니다. 요청은 계속 처리합니다. 문지기가 그 답을 보고 이 서버를 목록에서 빼면, 그때 닫습니다. 이 기다림을 드레인(drain), 물을 빼듯 들어오는 요청을 먼저 줄이는 과정이라고 부릅니다.
종료 신호 → 헬스 체크를 503 으로 바꾼다 → (요청은 계속 처리) → 문지기가 뺄 때까지 기다린다 → 닫는다
그렇다면 얼마나 기다려야 할까요. 직접 재 봤습니다.
헬스 체크 주기보다 짧게 기다리면 요청이 실패했습니다
서버 두 대(B1, B2)와, 그 앞에서 요청을 번갈아 나눠 주는 작은 문지기를 Node.js 로 만들었습니다. 문지기는 1초마다 헬스 체크를 하고, 실패하거나 503 을 받은 서버를 목록에서 뺍니다. 요청은 0.01초마다 한 건씩 3.5초 동안 보냈고, 요청 하나를 처리하는 데는 0.03초가 걸립니다. 그리고 헬스 체크 바로 뒤, 문지기가 가장 늦게 알아채는 시점에 B1 을 내렸습니다.
요청마다 새 연결(keep-alive 끔)
즉시 종료 | 요청 330건 중 실패 42건 (ECONNREFUSED)
드레인 0.3초 뒤 종료 | 요청 303건 중 실패 27건 (ECONNREFUSED)
드레인 1.2초 뒤 종료 | 요청 325건 중 실패 0건
즉시 닫으면 다음 헬스 체크까지 B1 으로 간 요청이 모두 연결을 거절당했습니다. 0.3초를 기다려도 헬스 체크 주기(1초)보다 짧으니, 실패가 조금 줄었을 뿐 사라지지 않았습니다. 주기보다 길게 1.2초를 기다리자 실패가 0 이 됐습니다. 드레인 시간은 문지기가 알아채는 데 걸리는 시간보다 길어야 합니다.
솔직히 적자면 처음 실험은 헬스 체크를 0.2초마다 하게 두었는데, 즉시 종료해도 실패가 0~1건밖에 나오지 않았습니다. 나중에 들여다보니 종료 시점이 우연히 헬스 체크 직전에 걸려 있었습니다. 운 좋은 시점에서 잰 0건은 안전하다는 뜻이 아니었습니다. 그래서 가장 불리한 시점에 내리도록 실험을 다시 짰습니다.
다만 이 결과는 직접 만든 작은 문지기로 잰 것이라, 실제 로드 밸런서의 숫자는 아닙니다. 실제 제품은 "몇 번 연속 실패해야 뺀다" 같은 조건이 붙는 경우가 많아서, 알아채는 시간이 주기보다 더 길 수 있습니다.
연결을 재사용하면 실패는 줄지만, 닫히는 데 오래 걸립니다
같은 실험을 연결 재사용을 켜고 다시 돌렸습니다. keep-alive 는 요청마다 연결을 새로 맺지 않고, 한 번 맺은 연결을 여러 요청에 다시 쓰는 방식입니다. 서버 쪽은 쉬는 연결을 5초 뒤에 닫도록(keepAliveTimeout) 두었습니다.
연결 재사용(keep-alive 켬)
즉시 종료 | 요청 336건 중 실패 22건 | B1 이 완전히 닫히기까지 6.9초
드레인 0.3초 뒤 종료 | 요청 337건 중 실패 0건 | B1 이 완전히 닫히기까지 6.6초
드레인 1.2초 뒤 종료 | 요청 334건 중 실패 0건 | B1 이 완전히 닫히기까지 0초
이미 맺어 둔 연결로 가는 요청은 서버가 닫기 시작한 뒤에도 처리됐습니다. 그래서 실패가 줄었습니다. 대신 서버가 완전히 닫히기까지 6초가 넘게 걸렸습니다.
이유는 Node.js 의 server.close() 에 있습니다. 공식 문서에 따르면 이 함수는 새 연결을 더 받지 않고, 부르는 그 순간 쉬고 있는 연결만 닫습니다. 그 순간 요청을 처리하던 연결은 남았다가, 나중에 쉬게 되면 keepAliveTimeout 이 지나서야 닫혔습니다. 드레인을 충분히 기다린 경우에는 닫는 순간 모든 연결이 쉬고 있었으므로 곧바로 닫혔습니다.
그래서 기다림의 예산은 한 줄의 부등식이 됩니다
서버를 관리하는 쪽은 무한정 기다려 주지 않습니다. 종료 신호를 보낸 뒤 정해진 시간이 지나면 강제로 끕니다. Docker 는 리눅스 컨테이너에 기본 10초를 주고, Compose 의 stop_grace_period 기본값도 10초입니다. Kubernetes 는 기본 30초를 주며, 종료 전에 실행하는 준비 단계(preStop)의 시간도 이 안에 포함됩니다. 강제 종료가 되면 처리 중이던 요청은 그대로 잘립니다.
드레인 대기 + 처리 중인 요청 마무리 + keep-alive 연결이 닫히는 꼬리 ≤ 강제 종료까지의 유예 시간
(문지기가 알아채는 시간보다 길게)
이 실험의 숫자로는 1.2초 + 0.03초 + 최대 5초 남짓이라 10초 안에 들어옵니다. 하지만 keepAliveTimeout 을 길게 잡거나 문지기의 알아채는 시간이 길다면, 같은 코드도 유예 시간을 넘길 수 있습니다. 숫자 하나만 보고 정할 수 없고, 세 항목의 합으로 봐야 한다는 생각이 들었습니다.
NestJS 에서는 기다림을 종료 훅 안이나 신호 처리에 넣습니다
NestJS 는 enableShutdownHooks() 를 불러 두면 종료 신호를 받았을 때 종료 훅들(beforeApplicationShutdown 등)을 부르며 정리를 시작합니다. 공식 문서의 설명입니다. 신호를 받자마자 정리가 시작되므로, 드레인 대기가 필요하다면 그 대기를 훅 안에 넣거나 신호 처리를 직접 짜야 합니다. 저는 헬스 체크를 먼저 503 으로 바꾸고, 정해 둔 시간을 기다린 뒤 앱을 닫는 순서를 직접 짰습니다.
let draining = false;
// 헬스 체크: 드레인 중이면 503
app.getHttpAdapter().get('/health', (_req, res) => {
res.status(draining ? 503 : 200).send(draining ? 'draining' : 'ok');
});
// 이렇게 직접 짤 때는 enableShutdownHooks() 를 함께 켜지 않는다(신호를 받자마자 정리가 시작된다)
process.once('SIGTERM', async () => {
draining = true; // 1) 문지기에게 먼저 알린다
await new Promise((r) => setTimeout(r, DRAIN_MS)); // 2) 알아챌 시간만큼 기다린다
await app.close(); // 3) 그다음에 닫는다
});
여기서 DRAIN_MS 는 앞의 부등식을 만족하도록 문지기의 헬스 체크 설정을 보고 정합니다. 개발 환경처럼 문지기가 없는 곳에서는 기다릴 이유가 없으므로 0 으로 둘 수 있습니다.
정리하며
- 닫기 전에 먼저 알립니다. 종료 신호를 받으면 헬스 체크를 503 으로 바꾸고, 요청은 계속 처리합니다.
- 드레인 시간은 문지기가 알아채는 시간보다 길게 잡습니다. 헬스 체크 주기보다 짧으면 요청이 실패했습니다.
- 세 시간의 합이 유예 시간 안에 들어와야 합니다. 드레인, 처리 중인 요청, keep-alive 꼬리를 함께 셉니다.
Go 에 약해서 시작한 재작성이었지만, 연재를 마치고 보니 다시 배운 것들은 대부분 언어와 상관없는 약속이었습니다. 빈 값을 어떻게 읽을지, 같은 요청을 몇 번 처리할지, 시각을 어느 시계로 잴지, 언제 닫을지 같은 것들입니다. 언어를 바꾼 덕분에 그 약속들을 처음부터 다시 적어 볼 기회를 얻었다는 생각이 들었습니다.
실험 환경: Node.js v26.5.0(node:http) · 2026-09-29. 로드 밸런서는 직접 만든 단순한 라우터로 흉내 냈고, 헬스 체크 주기·처리 시간·keepAliveTimeout 은 설명을 위해 정한 값입니다.
관련 글
Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점
Docker나 Kubernetes 환경에서 헬스체크를 구현할 때 Liveness와 Readiness의 차이를 이해하고, NestJS Backend와 Next.js Web에서 실제로 구현한 경험을 정리했습니다.
Ctrl+C를 누르면 서버에 무슨 일이 일어나는가 — Signal과 Graceful Shutdown
Ctrl+C를 누르면 서버 내부에서 무슨 일이 일어나는지 추적합니다. OS 시그널의 기초부터 Node.js와 NestJS에서의 Graceful Shutdown 구현, Docker 환경에서의 PID 1 문제까지 하나씩 따라가봅니다.
MySQL 커넥션 풀, 설정했다가 뺀 이유 — TCP Keep-Alive와 네트워크 토폴로지
MySQL 커넥션 풀에 TCP Keep-Alive를 설정했다가 제거한 과정을 통해, 네트워크 토폴로지에 따라 설정이 달라져야 하는 이유를 정리했습니다.