TT Lab
블로그 시작하기
배우기 러닝패스 코스

Node.js 백엔드 — 프레임워크가 감춘 것

소켓을 연 서버의 기본값은 운영값이 아니다

TT Lab 에서 이어서 보기

한 줄 요약

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 를 내고, 원인은 서비스마다 다르다.