소켓을 연 서버의 기본값은 운영값이 아니다
한 줄 요약
http.createServer 의 기본값은 「로컬에서 잘 뜬다」 에 맞춰져 있다. 앞단(로드밸런서) 뒤에서, 느린 클라이언트와 큰 본문과
배포 신호를 견뎌야 하는 운영 서버는 그 기본값 가운데 다섯 곳을 직접 정해야 한다 — 본문 크기, 헤더·요청 시간 제한,
keep-alive 시간, 오류 응답, 종료 순서.
왜 이게 필요했나
앞 모듈의 핸들러는 {status, body} 를 돌려주는 함수였다. 계약은 거기서 정해진다. 그런데 그 함수를 소켓에 붙이는 순간
계약 밖의 일이 생긴다. 클라이언트가 헤더를 1초에 한 줄씩 보낸다. 본문이 10MB 라고 적고 들어온다. 앞단이 1분 동안 놀린
연결로 요청을 보낸다. 배포 도구가 SIGTERM 을 보낸다. 이 넷은 핸들러 시험에서 한 번도 나오지 않고, 운영에서는 매일 나온다.
Node 22 의 기본값을 재 보면 이렇다(2026-11-05, Node 22.11 실측). keepAliveTimeout 5초, headersTimeout 60초,
requestTimeout 300초, 본문 크기 제한은 없다. 그리고 시간 제한 검사는 connectionsCheckingInterval(기본 30초)마다
한 번 돈다 — headersTimeout 을 3초로 줘도 검사 주기를 줄이지 않은 서버는 10초가 지나도 느린 연결을 끊지 않았다.
값만 바꾸고 재 보지 않으면 고친 줄 안다.
어떻게 동작하나
| 장치 | 무엇을 막나 | 어디서 정하나 |
|---|---|---|
| 본문 상한 | 큰 본문으로 메모리·시간 소진. 머리의 Content-Length 로 먼저 거절하고, 크기를 밝히지 않는 chunked 는 세면서 받는다 | 핸들러(직접) |
| headersTimeout | 헤더를 질질 끄는 연결(slowloris) | createServer 옵션 |
| requestTimeout | 본문을 질질 끄는 연결. 응답을 만드는 시간은 포함하지 않는다 | createServer 옵션 |
| keepAliveTimeout | 앞단보다 먼저 닫아서 생기는 「닫히는 연결에 보내기」 경합 | server 속성 |
| 오류 응답 | 프로세스 전체가 죽거나(던진 오류를 아무도 안 받음), 응답에 스택·비밀이 실려 나감 | 요청 처리기의 catch |
keep-alive 는 방향이 중요하다. 앞단은 서버로 가는 연결을 모아 두고 다시 쓴다. 서버가 5초 만에 연결을 닫는데 앞단은 60초 동안 쓸 수 있다고 믿으면, 서버가 닫는 바로 그 순간 앞단이 보낸 요청이 끊기고 사용자는 502 를 본다. 그래서 닫는 쪽은 앞단이어야 하고, 서버의 keepAliveTimeout 은 앞단의 유휴 시간보다 길어야 한다.
종료는 순서다. SIGTERM 을 받으면 (1) 새 연결을 받지 않고(server.close()), (2) 놀고 있는 연결을 닫고, (3) 진행 중인
요청은 끝까지 답하되 그 연결은 응답 뒤 닫고(Connection: close), (4) 모두 끝나면 스스로 0 으로 끝난다. (5) 마감을 넘기면
남은 연결을 끊는다 — 오케스트레이터의 SIGKILL 이 오기 전에, 스스로. 여기에 함정이 하나 있다. server.close() 는
그 순간 놀던 연결만 닫는다. 그때 일하던 keep-alive 연결은 응답을 마친 뒤에도 열려 있어서, close 콜백은 영영 오지 않고
앞단은 그 연결로 계속 요청을 보낸다. 실측에서 그런 프로세스는 SIGKILL 될 때까지 살아 있었다.
현장에서 만나는 모습
「배포할 때만 502 가 몇 개 난다」 는 흔한 보고는 대개 셋 중 하나다. SIGTERM 에 바로 process.exit 하는 코드(진행 중 요청이
끊긴다), 마감을 너무 짧게 잡은 우아한 종료(가장 긴 요청보다 짧다), 그리고 위의 열린 keep-alive 연결(끝나지 않다가 SIGKILL).
셋 다 로그에는 「502」 한 줄로 같게 보이고, 가르는 단서는 옛 프로세스가 SIGTERM 뒤 언제, 어떻게 끝났는가 다.
쿠버네티스에서는 엔드포인트 제거와 SIGTERM 이 동시에 시작되므로 preStop 으로 몇 초 버티는 장치를 더하는데, 그것은 이
실습 밖의 이야기다.
본문과 시간 제한은 공격보다 사고로 먼저 만난다. 모바일 앱의 재시도 버그가 같은 10MB 업로드를 계속 다시 보내 서버 메모리가
출렁이거나, 회선이 나쁜 지역의 클라이언트가 연결을 반쯤 연 채 수백 개 남겨 소켓이 마른다. 이때 「프레임워크가 알아서 막아 주겠지」
는 대개 틀린다 — Express 의 body-parser 는 기본 100KB 상한이 있지만 표준 http 에는 아무것도 없고, 시간 제한은 둘 다 Node 의
기본값을 그대로 물려받는다.
오류 응답도 같다. throw 한 번에 프로세스가 통째로 죽는 서버, 그걸 막으려고 unhandledRejection 을 로그만 남기게 바꿨더니
이번엔 요청이 영영 대답을 못 받는 서버(앞 모듈의 사건), 그리고 500 응답에 err.stack 을 그대로 실어 DB 비밀번호를 보여 준
서버가 모두 실제로 있다.
다음 실습에서 할 것
/root/work/srv/server.mjs 하나를 여섯 단계에 걸쳐 키운다. 채점기는 그 파일을 빈 포트로 직접 띄워 진짜 소켓으로 찌른다 —
헤더를 0.5초마다 한 줄씩 보내고, 10MB 라고 적고 1KB 만 보내고, 연결을 7초 놀린 뒤 다시 쓰고, 일하는 중에 SIGTERM 을 보낸다.
마지막 단계는 사건이다. 세 서비스가 배포 때마다 502 를 내고, 원인은 서비스마다 다르다.