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

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

운영 기본값과 우아한 종료 — 진짜 소켓으로 잰다

TT Lab 에서 이어서 보기

목표

/root/work/srv/server.mjs 하나를 키워 가며, 앞단 뒤에서 일하는 서버가 스스로 정해야 하는 다섯 가지 — 입력 범위, 오류 응답, 본문 크기, 시간 제한, keep-alive — 와 SIGTERM 에 끝나는 순서를 진짜 소켓으로 재 봅니다. 마지막 단계는 사건입니다.

왜 중요한가

핸들러 시험은 소켓이 없으니 느린 클라이언트도, 큰 본문도, 앞단의 연결 재사용도, 배포 신호도 만나지 않습니다. 그런데 운영의 502·메모리 폭증·「배포할 때만 나는 오류」 는 전부 그 틈에서 나옵니다. 그리고 이 설정들은 값을 적는 것과 실제로 먹는 것이 다릅니다 — 검사 주기 때문에 늦게 끊기는 시간 제한, 응답을 마친 연결을 남겨 두는 close() 를 이 실습에서 직접 재 봅니다.

채점기는 여러분의 server.mjs 를 빈 포트(PORT 환경 변수)로 직접 띄워 시험하고 끝나면 거둡니다. 띄워 둔 서버가 옛 코드여도 채점에는 영향이 없습니다. 직접 해 볼 때는 PORT=8081 node /root/work/srv/server.mjs & 로 띄우고 kill %1 로 거두세요.

단계

  1. PORT 에서 듣고 /healthz·/work(0~5000 정수만)·404 를 답한다
  2. /boom·/boom-async 가 던져도 500 JSON, 본문에 비밀·스택 없음, 프로세스는 산다
  3. POST /echo 본문 64KiB 상한 — 머리로 먼저, chunked 는 세면서 413
  4. 헤더 끌기 5초·본문 끌기 10초에 끊되, 3초짜리 정상 요청은 끊지 않는다
  5. keepAliveTimeout 을 앞단 유휴 시간(60초)보다 길게
  6. SIGTERM — 듣기 멈춤, 놀던 연결 닫기, 진행 중은 끝까지, 0 으로 끝, 마감 4초
  7. 사건 nd-deploy — 배포할 때마다 502, 서비스마다 원인이 다르다

참고

PORT 에서 듣고 입력을 거르는 서버

/root/work/srv/server.mjs 를 만듭니다. 포트는 환경 변수 PORT 에서 읽어 127.0.0.1 에서 듣고(채점기가 빈 포트를 골라 직접 띄웁니다), GET /healthz 는 200 과 {"status":"ok"}, GET /work?ms=<n> 은 n 밀리초를 기다린 뒤 200 과 {"done": n} 을 답합니다. ms 가 0~5000 의 정수가 아니면(음수·소수·글자·빈 값) 400, 없는 경로는 JSON 404 입니다. 모든 응답은 JSON 입니다.

방향: 서버가 받는 값은 모두 밖에서 온 것입니다 — ms=99999999 를 그대로 기다리면 요청 하나가 서버를 붙잡습니다. 도구: node:http 의 createServer·listen(port, host), new URL(req.url, 'http://local').searchParams, process.env.PORT. 구체: 응답은 writeHead(status, {content-type, content-length}) 와 end(body) 를 한 함수로 묶어 두면 뒤 단계에서 머리 하나 더하기가 쉽습니다. 직접 띄워 보려면 PORT=8081 node server.mjs & 뒤 curl -s 127.0.0.1:8081/work?ms=300.

던진 오류가 프로세스도 비밀도 넘기지 않게

일부러 터지는 경로 둘을 더합니다. GET /boom 은 핸들러에서 throw new Error('db password=hunter2 failed at /srv/app/db.js') 하고, GET /boom-async 는 await 한 번 뒤에 같은 꼴의 오류를 던집니다. 두 요청 모두 500 과 {"error": "..."} JSON 으로 답해야 하고, 응답 본문에는 오류 메시지·스택·파일 경로가 없어야 합니다. 대신 표준 오류(console.error)에는 남기고, 프로세스는 계속 일해야 합니다.

방향: 요청 처리기가 돌려준 약속(promise)이 거부되면 받는 사람이 있어야 합니다 — 아무도 안 받으면 Node 22 는 프로세스를 끝냅니다. 도구: route(req, res).catch(...), res.headersSent, console.error(err.stack). 구체: catch 에서 이미 머리를 보냈다면 응답을 고칠 수 없으니 연결을 끊고(res.destroy()), 아니면 고정된 문구의 500 을 보냅니다. err.message 를 응답에 넣는 순간 이 단계는 떨어집니다 — 메시지에 비밀번호가 들어 있습니다.

본문 크기를 받기 전에, 받으면서 제한한다

POST /echo 를 더합니다. JSON 본문을 받아 200 과 {"echo": <received-json>} 으로 돌려주되, 본문 상한은 64KiB(65536바이트)입니다. 머리의 Content-Length 가 상한을 넘으면 본문을 기다리지 않고 413, 크기를 밝히지 않는 Transfer-Encoding: chunked 본문은 받은 바이트를 세다가 상한을 넘는 순간 413, 깨진 JSON 은 400 입니다. 60KB 본문은 받아야 합니다.

방향: 거절은 일찍 할수록 쌉니다. 머리만 보고 거절할 수 있는 것을 본문을 다 받고 나서 거절하면 메모리와 시간을 이미 내준 뒤입니다. 도구: req.headers['content-length'], for await (const chunk of req), 상태 코드를 품은 오류 클래스(예: HttpError)를 던져 02 단계의 catch 에서 가르기. 구체: 413 을 보낼 때는 Connection: close 를 붙이세요 — 남은 본문이 같은 연결로 계속 들어오는데 그 연결을 다음 요청에 쓸 수는 없습니다. 시험: curl -s -H 'Content-Length: 10485760' -d '{}' ... 는 curl 이 머리를 바로잡으므로, 채점기처럼 python3 의 socket 으로 머리를 직접 써 보세요.

느린 클라이언트가 연결을 붙잡지 못하게

헤더를 다 보내지 않고 질질 끄는 연결은 5초(headersTimeout), 헤더는 보냈지만 본문을 질질 끄는 연결은 요청 전체 10초(requestTimeout) 안팎에 서버가 끊어야 합니다. 채점기는 0.5초마다 헤더 한 줄·본문 한 바이트를 보내며 언제 끊기는지 재고, 동시에 GET /work?ms=3000 이 200 으로 끝나는지도 봅니다(응답을 만드는 시간은 클라이언트 탓이 아닙니다). 헤더 끌기는 3–7.5초, 본문 끌기는 8–13초에 끊겨야 합니다.

방향: 값을 넣었는데도 제때 안 끊긴다면 값이 아니라 「언제 검사하느냐」 를 의심하세요. 도구: http.createServer({ headersTimeout, requestTimeout, connectionsCheckingInterval }, handler), python3 의 socket 으로 헤더를 한 줄씩 보내며 time.time() 재기. 구체: Node 는 이 두 시간을 connectionsCheckingInterval(기본 30000ms)마다 한 번 봅니다. 5초 제한에 30초 주기면 최대 35초까지 버팁니다 — 주기를 1초쯤으로.

keep-alive 는 앞단보다 길게

이 서버 앞에는 로드밸런서가 있고, 그 유휴 연결 시간은 60초입니다. 서버의 keepAliveTimeout 을 그보다 길게(65000ms 이상) 둡니다. 채점기는 요청을 실제로 받은 서버 객체의 값을 읽고, 연결을 7초 놀린 뒤 같은 연결로 두 번째 요청을 보내 봅니다(기본값 5초라면 이미 닫혔습니다). 앞 단계의 시간 제한(headersTimeout 5초·requestTimeout 10초)은 그대로여야 하고, 평소 응답에 Connection: close 를 붙이면 안 됩니다.

방향: 연결을 닫는 쪽이 누구여야 하는지부터 정하세요 — 앞단은 서버가 닫은 연결을 그 순간에는 모릅니다. 도구: server.keepAliveTimeout, 응답 머리의 Keep-Alive: timeout=, curl -v 로 같은 연결 재사용(Re-using existing connection) 보기. 구체: keepAliveTimeout 과 headersTimeout 은 다른 시계입니다 — 앞의 것은 응답 뒤 놀고 있는 시간, 뒤의 것은 새 요청의 머리를 받는 시간입니다. 하나를 늘린다고 다른 하나를 풀지 마세요.

SIGTERM 에 우아하게 끝난다

SIGTERM 을 받으면 새 연결을 받지 않고, 놀고 있는 keep-alive 연결은 바로 닫고, 진행 중인 요청은 끝까지 답한 뒤 그 연결도 닫고, 모두 끝나면 스스로 종료 코드 0 으로 끝납니다. 4초 안에 끝나지 않는 요청이 있으면 남은 연결을 끊고 끝냅니다(3.5~5.2초). 채점기는 놀던 연결 하나와 1.5초짜리 요청 하나를 걸어 둔 채 SIGTERM 을 보내고, 본문을 질질 끄는(스스로 끝나지 않는) 업로드로 마감도 봅니다.

방향: server.close() 가 무엇을 닫고 무엇을 남기는지 직접 재 보세요 — 콜백이 언제 오는지가 관건입니다. 도구: process.on('SIGTERM', ...), server.close(cb), server.closeIdleConnections(), server.closeAllConnections(), 응답 머리 Connection: close, setTimeout(...).unref(). 구체: close() 는 그 순간 놀던 연결만 닫습니다. 그때 일하던 keep-alive 연결은 응답 뒤에도 열려 있어 콜백이 오지 않습니다 — 종료 중에는 응답에 Connection: close 를 붙여 응답과 함께 닫히게 하세요. 마감 타이머는 unref 해야 그것 하나 때문에 프로세스가 남지 않습니다.

사건 — 배포할 때마다 502

incident nd-deploy 를 실행하면 사건이 납니다. 앞단(ndproxy, 127.0.0.1:8080) 뒤의 세 서비스 catalog·checkout·profile 은 각각 두 인스턴스(-a·-b)로 떠 있고, 배포(ndctl deploy <service>)할 때마다 앞단이 502 를 냅니다. 서비스마다 /srv/nd-deploy/<service>/server.mjs 의 SIGTERM 처리를 고쳐 다시 띄우고(ndctl restart <instance>), 세 서비스 모두 배포 중에도 502 가 나지 않게 합니다. 앞단의 설정과 프로세스는 건드리지 않고, keep-alive 는 유지합니다. /root/incident/nd-deploy/report.txt 에 catalog: <code>, checkout: <code>, profile: <code> 세 줄과, 서비스마다 그 원인을 보여 주는 기록(/var/log/nd/deploy.log·proxy.log)의 한 줄을 evidence-catalog: <line>, evidence-checkout: <line>, evidence-profile: <line> 로 적습니다. 원인은 서비스마다 따로 정해져 있어 같은 원인이 둘일 수도 있습니다. 서로 다른 원인 코드로 세 번 틀리면 사건이 거둬져 다시 내야 합니다. 코드는 다음 중 하나입니다: drain-too-short(마감이 진행 중 요청보다 짧음), exit-on-sigterm(SIGTERM 에 바로 끝남), idle-left-open(응답 마친 연결을 열어 둠), keepalive-shorter(keep-alive 가 앞단보다 짧음).

방향: 502 줄은 셋 다 같아 보입니다. 가르는 것은 옛 프로세스가 SIGTERM 뒤 언제, 어떻게 끝났는가입니다 — 바로, 조금 뒤, 끝내 안 끝나 강제로. 도구: /var/log/nd/deploy.log 의 인스턴스 줄(exit= · after · SIGKILL), proxy.log 의 reused=, 인스턴스 로그, 서비스 코드의 process.on('SIGTERM' 부분, 고친 뒤 ndctl deploy <service> 로 다시 재기. 구체: 06 단계에서 세운 종료 순서와 서비스마다 견주어 빠진 것을 찾으세요. 배포 도구의 느린 요청은 800ms(채점 때는 인스턴스마다 0.5초·1.3초로 바꿔 「정해 둔 시간 기다렸다 끝내기」 를 가립니다), SIGTERM 뒤 3초에 SIGKILL 합니다. keep-alive 를 끄거나 매 응답에 Connection: close 를 붙여 피하는 것은 고침이 아닙니다(채점기가 앞단의 연결 재사용을 봅니다).