Node.js · 기본
Node.js로 만드는 작은 API
Node.js 배포 전 점검 - SIGTERM 그레이스풀 종료와 프레임워크로 옮길 때 (Node.js API 11단원)
배포 때 요청이 끊기는 문제를 재현하고, 처리 중인 요청과 파일 쓰기를 마친 뒤 끝나는 종료 처리를 만든다. 배포 전 점검표와 Express 같은 프레임워크와의 대응을 정리한다.
개발자 · 원고 갱신
이 단원에서 배우는 것
서버는 켜는 것만큼 끄는 것도 중요하다. 새 버전을 배포할 때마다, 서버 컴퓨터를 점검할 때마다 서버는 꺼졌다 켜진다. 그 순간 처리 중이던 요청이 끊기거나 파일 쓰기가 중간에 멈추면, 사용자는 "가끔 저장이 안 된다"는 재현하기 어려운 문제를 겪는다. 이 단원에서는 메모 API를 운영에 올리기 전 마지막 점검을 한다.
- 프로세스 관리자가 서버를 끌 때 보내는 신호(
SIGTERM)를 받아, 새 연결은 막고 처리 중인 요청은 끝까지 마치는 그레이스풀 종료를 만든다. - keep-alive 연결 때문에 종료가 몇 초씩 늦어지는 현상을 재현하고 고친다.
- 잡지 못한 예외와 처리되지 않은 거부를 로그로 남기고 끝내는 마지막 안전망을 둔다.
- 배포 전 점검표로 정리하고, Express 같은 프레임워크로 옮길 때 무엇이 바뀌고 무엇이 그대로인지 짚는다.
문제 상황
메모 API를 서버 한 대에 올리고 하루에 몇 번씩 새 버전을 배포한다. 배포 도구는 옛 프로세스에 종료 신호를 보내고 새 프로세스를 띄운다. 그런데 배포 직후마다 "저장 버튼을 눌렀는데 오류가 났다"는 문의가 온다. 로그에는 아무것도 없다. 로그를 남길 틈도 없이 프로세스가 사라졌기 때문이다.
이 단원에서 memo-api에 더하는 파일은 연습 서버 slow-server.mjs, 종료 실험 도구 stop-check.mjs, package.json이고, server.mjs를 완성본으로 바꾼다.
완성 코드
응답이 느린 연습 서버: slow-server.mjs
// slow-server.mjs — 응답에 1초 걸리는 연습 서버
// GRACEFUL 없음: 신호 처리 안 함 / GRACEFUL=1: server.close 만 / GRACEFUL=2: 놀게 된 연결도 바로 닫기
import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
const server = createServer(async (req, res) => {
if (req.url === '/slow') await sleep(1000); // 오래 걸리는 작업을 흉내 낸다
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
res.end(`${req.url} 완료\n`);
});
server.listen(Number(process.env.PORT ?? 3701), '127.0.0.1', () => console.log('대기 중'));
const mode = process.env.GRACEFUL;
if (mode === '1' || mode === '2') {
process.once('SIGTERM', () => {
console.log('SIGTERM 받음: 새 연결을 막고 진행 중인 요청을 기다립니다');
let sweeper;
if (mode === '2') {
sweeper = setInterval(() => server.closeIdleConnections(), 100); // 응답이 끝난 연결을 곧바로 정리
}
server.close(() => {
clearInterval(sweeper);
console.log('모든 연결이 끝나 종료합니다');
});
});
}
배포 도구 흉내: stop-check.mjs
// stop-check.mjs — 배포 도구가 하는 일을 흉내 낸다: 서버를 띄우고, 요청 중에 SIGTERM 을 보낸다
// 사용법: node stop-check.mjs <서버파일> <요청경로> [신호까지 기다릴 ms]
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const [script, path = '/health', delay = '200'] = process.argv.slice(2);
const port = process.env.PORT ?? '3701';
const base = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [script], { env: { ...process.env, PORT: port } });
const lines = [];
child.stdout.on('data', (d) => lines.push(...d.toString().trimEnd().split('\n')));
child.stderr.on('data', (d) => lines.push(...d.toString().trimEnd().split('\n')));
const exited = new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal })));
for (let i = 0; i < 50; i++) { // 서버가 뜰 때까지 최대 5초 기다린다
try { await fetch(base + '/health'); break; } catch { await sleep(100); }
}
const started = Date.now();
const pending = fetch(base + path).then(
async (res) => `응답 ${res.status}: ${(await res.text()).trim()}`,
(err) => `요청 실패: ${err.cause?.code ?? err.message}`,
);
await sleep(Number(delay));
child.kill('SIGTERM');
console.log(`${delay}ms 뒤 SIGTERM 보냄`);
console.log(await pending);
const { code, signal } = await exited;
console.log(`서버 종료: code=${code} signal=${signal} (요청 후 약 ${Math.round((Date.now() - started) / 100) * 100}ms)`);
console.log('--- 서버 출력 ---');
for (const line of lines) console.log(line);
완성한 server.mjs
// server.mjs — 완성본. 9단원 서버에 종료 처리를 더했다
import { createServer } from 'node:http';
import { createApp } from './app.mjs';
import { createMemoStore } from './lib/store.mjs';
import { loadConfig, describeConfig } from './lib/config.mjs';
import { withToken } from './lib/auth.mjs';
import { createLogger } from './lib/logger.mjs';
import { withAccessLog } from './lib/access-log.mjs';
const SHUTDOWN_TIMEOUT_MS = 10_000;
let config;
try {
config = loadConfig();
} catch (err) {
console.error(`설정 오류로 시작하지 않습니다:\n${err.message}`);
process.exit(1);
}
const log = createLogger({ level: config.logLevel });
log.info('설정', describeConfig(config));
const store = createMemoStore(config.dataFile);
const app = createApp({
store,
onError: (err, req) => (req.log ?? log).error('처리하지 못한 오류', { err }),
});
const server = createServer(withAccessLog(withToken(app, config.apiToken), log));
server.listen(config.port, config.host, () => {
log.info('서버 시작', { url: `http://${config.host}:${config.port}`, pid: process.pid });
});
let closing = false;
function shutdown(signal) {
if (closing) return; // 신호가 두 번 와도 한 번만 처리
closing = true;
log.info('종료 신호 받음', { signal });
const force = setTimeout(() => {
log.error('제한 시간 안에 끝나지 않아 강제 종료', { timeoutMs: SHUTDOWN_TIMEOUT_MS });
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
force.unref(); // 이 타이머 때문에 프로세스가 붙잡히지 않게
// 응답을 마치고 keep-alive 로 놀고 있는 연결을 바로바로 닫는다
const sweeper = setInterval(() => server.closeIdleConnections(), 100);
server.close(async (err) => { // 새 연결은 거절, 처리 중인 요청은 끝까지
clearInterval(sweeper);
await store.flush(); // 줄 서 있던 파일 쓰기까지 마친다
log.info('정상 종료', { error: err?.message });
clearTimeout(force);
process.exitCode = err ? 1 : 0; // exit() 대신 exitCode: 남은 로그 출력을 끊지 않는다
});
}
process.once('SIGTERM', () => shutdown('SIGTERM')); // 배포 도구·컨테이너가 보내는 종료 요청
process.once('SIGINT', () => shutdown('SIGINT')); // 터미널에서 Ctrl+C
process.on('uncaughtException', (err) => {
log.error('잡지 못한 예외로 종료', { err });
process.exit(1); // 상태를 믿을 수 없으니 살려 두지 않는다
});
process.on('unhandledRejection', (reason) => {
log.error('처리하지 않은 Promise 거부로 종료', { err: reason });
process.exit(1);
});
package.json
$ cat package.json
{
"name": "memo-api",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.11"
},
"scripts": {
"start": "node server.mjs",
"test": "node --test"
}
}
줄별 해설
신호: 운영체제가 프로세스에 거는 말
운영체제는 프로세스에 짧은 신호를 보낼 수 있다. 터미널에서 Ctrl+C를 누르면 SIGINT가, systemd·PM2·Docker·쿠버네티스 같은 도구가 서비스를 멈출 때는 대개 SIGTERM이 간다. Node.js는 이 신호를 받았을 때 따로 할 일을 등록하지 않으면 그 즉시 프로세스를 끝낸다. 처리 중이던 요청, 쓰던 파일, 버퍼에 남은 로그가 모두 그 자리에서 사라진다. 대부분의 도구는 SIGTERM을 보내고 일정 시간(보통 10~30초)을 기다린 뒤에도 살아 있으면 SIGKILL로 강제 종료한다. SIGKILL은 프로그램이 가로챌 수 없다. 그러니 우리에게 주어진 시간은 그 몇 초다.
process.once('SIGTERM', ...)로 할 일을 등록하면 즉시 종료 대신 그 함수가 실행된다. 이때부터 프로세스를 끝내는 책임은 우리에게 있다.
server.close(): 문은 닫고 손님은 마저 받는다
server.close(콜백)은 새 연결을 더 받지 않고, 이미 연결된 요청이 모두 끝나면 콜백을 부른다. 콜백에서 store.flush()로 줄 서 있던 파일 쓰기까지 기다린 다음 종료 로그를 남긴다. 그러면 이벤트 루프에 할 일이 없어지고 프로세스가 스스로 끝난다.
process.exitCode = err ? 1 : 0; // exit() 대신 exitCode: 남은 로그 출력을 끊지 않는다
process.exit()를 부르면 그 자리에서 끝나므로 아직 출력되지 않은 로그가 잘릴 수 있다. 종료 코드만 정해 두고 자연스럽게 끝나게 두는 편이 안전하다.
그림 11-1. 그레이스풀 종료의 순서
강제 종료 타이머와 unref
const force = setTimeout(() => {
log.error('제한 시간 안에 끝나지 않아 강제 종료', { timeoutMs: SHUTDOWN_TIMEOUT_MS });
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
force.unref(); // 이 타이머 때문에 프로세스가 붙잡히지 않게
어떤 요청이 끝나지 않으면 close의 콜백은 영원히 불리지 않는다. 그래서 10초 뒤에는 기록을 남기고 스스로 끝내는 타이머를 건다. 배포 도구가 SIGKILL로 조용히 죽이기 전에 우리가 먼저 이유를 남기고 끝내는 것이다. unref()는 "이 타이머 하나만 남았다면 기다리지 말고 끝내도 된다"는 표시다. 이것이 없으면 모든 요청이 1초 만에 끝나도 프로세스가 이 타이머 때문에 10초를 더 기다린다.
keep-alive가 종료를 붙잡는다
// 응답을 마치고 keep-alive 로 놀고 있는 연결을 바로바로 닫는다
const sweeper = setInterval(() => server.closeIdleConnections(), 100);
HTTP/1.1 클라이언트는 응답을 받은 뒤에도 연결을 바로 끊지 않고 다음 요청에 다시 쓰려고 열어 둔다(keep-alive). Node.js 19부터 server.close()는 부르는 순간 놀고 있던 연결을 닫아 주지만, 그때 처리 중이던 연결은 응답을 마친 뒤 다시 "놀고 있는 연결"이 되어, 클라이언트나 서버 어느 한쪽의 keep-alive 시간이 다 되어 닫힐 때까지 남는다. 실행 결과에서 이 때문에 종료가 약 4초 늦어지는 것을 확인한다. 종료 중에는 0.1초마다 closeIdleConnections()로 놀고 있는 연결을 닫아 이 기다림을 없앤다.
마지막 안전망
2단원에서 본 "처리되지 않은 거부"와, 콜백 안에서 던져져 아무도 잡지 않은 예외(uncaughtException)는 둘 다 프로세스를 끝낸다. 그 전에 로그를 남기도록 처리 함수를 등록했다. 로그를 남긴 뒤에는 반드시 끝낸다. 무엇이 어디까지 실행됐는지 모르는 상태로 서버를 계속 돌리면 데이터가 조용히 망가질 수 있다. 끝나면 프로세스 관리자가 새로 띄운다. "죽지 않는 서버"가 아니라 "죽어도 곧바로 깨끗하게 다시 뜨는 서버"가 목표다.
package.json
외부 패키지를 하나도 쓰지 않았지만 package.json은 둔다. "type": "module"은 나중에 .js 파일을 더해도 ES 모듈로 읽히게 하고(1단원), engines는 이 코드가 import.meta.dirname 때문에 Node.js 20.11 이상을 요구한다는 사실을 기록한다. scripts에 적어 두면 npm start, npm test로 실행할 수 있어서, 처음 보는 사람도 어떻게 띄우고 확인하는지 바로 안다.
실제 실행 결과
stop-check.mjs는 서버를 자식 프로세스로 띄우고, 1초 걸리는 /slow 요청을 보낸 뒤 0.2초 만에 SIGTERM을 보낸다. 배포 도구가 하는 일과 같다. 먼저 신호를 처리하지 않는 서버다.
$ node stop-check.mjs slow-server.mjs /slow 200
200ms 뒤 SIGTERM 보냄
요청 실패: UND_ERR_SOCKET
서버 종료: code=null signal=SIGTERM (요청 후 약 200ms)
--- 서버 출력 ---
대기 중
처리 중이던 요청이 응답 없이 끊겼다(UND_ERR_SOCKET은 fetch가 "연결이 중간에 닫혔다"고 알리는 코드다). 서버는 종료 코드 없이 신호로 죽었다(signal=SIGTERM). 문제 상황의 "배포 직후 저장 오류"가 이것이다.
$ GRACEFUL=1 node stop-check.mjs slow-server.mjs /slow 200
200ms 뒤 SIGTERM 보냄
응답 200: /slow 완료
서버 종료: code=0 signal=null (요청 후 약 4000ms)
--- 서버 출력 ---
대기 중
SIGTERM 받음: 새 연결을 막고 진행 중인 요청을 기다립니다
모든 연결이 끝나 종료합니다
server.close()만 넣은 서버는 요청을 끝까지 처리해 200으로 답했고 종료 코드 0으로 끝났다. 그런데 요청은 약 1초면 끝나는데 종료까지 약 4초가 걸렸다. 응답을 마친 연결이 keep-alive로 남아 있었기 때문이다.
$ GRACEFUL=2 node stop-check.mjs slow-server.mjs /slow 200
200ms 뒤 SIGTERM 보냄
응답 200: /slow 완료
서버 종료: code=0 signal=null (요청 후 약 1000ms)
--- 서버 출력 ---
대기 중
SIGTERM 받음: 새 연결을 막고 진행 중인 요청을 기다립니다
모든 연결이 끝나 종료합니다
놀고 있는 연결을 곧바로 닫자 요청이 끝나는 약 1초에 맞춰 종료했다. 완성한 server.mjs는 이 방식을 쓴다.
그림 11-2. 종료 방식에 따른 결과
$ API_TOKEN=dev-token-0123456789abcd node stop-check.mjs server.mjs /memos 100
100ms 뒤 SIGTERM 보냄
응답 200: {"items":[],"count":0}
서버 종료: code=0 signal=null (요청 후 약 100ms)
--- 서버 출력 ---
{"time":"2026-09-23T15:42:49.221Z","level":"info","msg":"설정","production":false,"host":"127.0.0.1","port":3701,"dataFile":"<프로젝트>/data/memos.json","logLevel":"info","apiToken":"[숨김]"}
{"time":"2026-09-23T15:42:49.227Z","level":"info","msg":"서버 시작","url":"http://127.0.0.1:3701","pid":47229}
{"time":"2026-09-23T15:42:49.296Z","level":"info","msg":"요청 처리","reqId":"4caaca37","method":"GET","path":"/health","status":200,"ms":1.8}
{"time":"2026-09-23T15:42:49.301Z","level":"info","msg":"요청 처리","reqId":"6c48d6f5","method":"GET","path":"/memos","status":200,"ms":0.7}
{"time":"2026-09-23T15:42:49.401Z","level":"info","msg":"종료 신호 받음","signal":"SIGTERM"}
{"time":"2026-09-23T15:42:49.402Z","level":"info","msg":"정상 종료"}
완성 서버도 신호를 받자 종료 신호 받음과 정상 종료를 로그로 남기고 종료 코드 0으로 끝났다. 마지막으로 10단원 테스트를 다시 돌려 이번 변경이 아무것도 망가뜨리지 않았는지 확인했다.
$ node --test --test-reporter=spec
✔ 토큰 없이 POST 하면 401 (20.996833ms)
✔ 만들고 → 읽고 → 고치고 → 지운다 (14.46225ms)
✔ 허용하지 않는 메서드는 405 와 allow 헤더 (3.001666ms)
▶ validateMemo
✔ 제목 앞뒤 공백을 지우고 done 기본값은 false (1.727083ms)
✔ 모르는 필드와 빈 제목을 한꺼번에 알려 준다 (0.301291ms)
✔ PATCH 용 검사는 보낸 필드만 돌려준다 (0.076208ms)
✔ 50자는 통과, 51자는 거절 (0.114375ms)
✔ validateMemo (2.888167ms)
✔ parseId 는 1 이상의 정수 문자열만 받는다 (0.119458ms)
✔ 동시에 20개를 만들어도 id 가 겹치지 않고 모두 저장된다 (16.255834ms)
✔ 없는 메모를 고치거나 지우면 null / false (0.75475ms)
✔ list 가 준 객체를 바꿔도 저장소는 그대로다 (1.135166ms)
ℹ tests 11
ℹ suites 1
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.072292
배포 전 점검표
| 점검 항목 | 이 책에서 만든 것 |
|---|---|
| 잘못된 설정이면 뜨지 않는가 | 8단원 loadConfig, 종료 코드 1 |
| 비밀값이 코드·로그·오류 응답에 없는가 | 8단원 환경변수·describeConfig, 9단원 redact, 6단원 500 응답 |
| 운영에서 쓰기 요청을 아무나 할 수 없는가 | 8단원 API_TOKEN(NODE_ENV=production에서 필수) |
| 살아 있는지 확인할 주소가 있는가 | 4단원 GET /health |
| 요청마다 기록이 남고, 오류를 요청 번호로 찾을 수 있는가 | 9단원 접근 로그, x-request-id |
| 입력 크기와 형식을 제한하는가 | 5단원 10KB 제한, 415·400·422 |
| 끌 때 처리 중인 요청과 파일 쓰기를 마치는가 | 11단원 SIGTERM·close·flush |
| 테스트가 명령 하나로 통과하는가 | 10단원 node --test |
| 프로세스가 몇 개 뜨는가 | 파일 저장소는 하나만(7단원). 여럿이 필요하면 데이터베이스로 |
누가 이 서버를 띄우고 다시 띄우는가
터미널에서 node server.mjs를 치고 창을 닫으면 서버도 끝난다. 운영에서는 프로세스 관리자가 이 일을 맡는다. 서버가 죽으면 다시 띄우고, 컴퓨터가 재부팅되면 자동으로 띄우고, 표준 출력 로그를 모아 파일이나 수집 시스템으로 보낸다. 리눅스 서버라면 운영체제에 들어 있는 systemd가 가장 흔하다. 아래는 서비스 설정의 예시다. 이 책의 검증 환경(macOS)에서는 실행하지 않았다.
[Service]
WorkingDirectory=/srv/memo-api
ExecStart=/usr/bin/node server.mjs
Environment=NODE_ENV=production
EnvironmentFile=/etc/memo-api.env
Restart=on-failure
KillSignal=SIGTERM
TimeoutStopSec=15
TimeoutStopSec을 서버의 강제 종료 시간(10초)보다 길게 잡아, 서버가 스스로 정리할 시간을 준다. 비밀값은 권한을 제한한 별도 파일(EnvironmentFile)에 둔다. 컨테이너로 배포한다면 CMD ["node", "server.mjs"]처럼 node를 직접 실행한다. npm start나 셸 스크립트를 한 겹 거치면, 중간 프로세스가 신호를 서버에 제대로 넘기지 않거나 서버가 끝나기를 기다리지 않는 경우가 있어서 이 단원에서 만든 종료 처리가 소용없어질 수 있다.
프레임워크로 옮긴다면
이 책은 설치 없이 원리를 보이려고 표준 라이브러리만 썼다. 실무 프로젝트는 대개 Express, Fastify, NestJS 같은 프레임워크를 쓴다. 프레임워크는 이 책에서 손으로 만든 것들을 검증된 부품으로 준다.
| 이 책에서 만든 것 | 프레임워크에서 부르는 이름 |
|---|---|
6단원 라우팅 표와 match | 라우터(app.get('/memos/:id', ...), req.params.id) |
5·6단원 readJson과 크기 제한 | 본문 파서(Express의 express.json({ limit })) |
6단원 app의 catch | 오류 처리 미들웨어, 오류 핸들러 |
8·9단원 withToken, withAccessLog | 미들웨어(app.use(...)), 로깅 플러그인 |
5단원 validateMemo | 스키마 검증(JSON Schema, 검증 라이브러리) |
바뀌지 않는 것도 많다. 저장소(7단원), 설정(8단원), 입력 규칙(5단원), 그리고 이 단원의 종료 처리는 프레임워크와 상관없이 그대로 쓴다. Express의 app.listen()도 결국 node:http의 서버를 돌려주므로 server.close()를 똑같이 부른다. 프레임워크를 고를 때는 "이 책에서 손으로 한 일 중 무엇을 대신해 주는가, 기본 설정은 무엇인가(본문 크기 제한, 오류 응답 모양)"를 확인하면 된다. 어떤 프레임워크의 문서를 읽어도 이제 그 밑에서 무슨 일이 일어나는지 보일 것이다.
실무에서 자주 틀리는 것
1. 종료 처리에서 process.exit(0)부터 부른다
SIGTERM을 받자마자 process.exit(0)을 부르면 신호를 처리하지 않은 것과 다를 바 없다. 처리 중인 요청이 끊기고 종료 코드만 0으로 거짓말을 한다.
2. 강제 종료 시간이 없다
끝나지 않는 요청 하나 때문에 종료가 무한정 늦어지면, 결국 배포 도구가 SIGKILL로 죽이고 우리는 이유를 모른다. 스스로 정한 시간 안에 끝나지 않으면 로그를 남기고 끝낸다.
3. uncaughtException에서 살려 둔다
처리 함수를 등록해 로그만 남기고 계속 돌리면 서버는 "살아 있지만 상태를 믿을 수 없는" 좀비가 된다. 기록하고 끝내고, 다시 띄우는 일은 프로세스 관리자에게 맡긴다.
4. 헬스 체크가 거짓말을 한다
/health가 늘 200을 돌려주면 데이터 파일이 깨져 모든 요청이 500이어도 "건강"하다고 나온다. 운영이 커지면 헬스 체크에서 저장소를 한 번 읽어 보는 식으로 "정말 일할 수 있는가"를 확인하게 만든다. 반대로 너무 무거운 확인은 헬스 체크 자체가 부하가 되므로 가볍게 유지한다.
연습 문제
- 완성 서버의 종료 콜백에서
process.exitCode = 0대신process.exit(0)을 쓰면 무엇을 잃을 수 있는가? 반대로 강제 종료 타이머 안에서는 왜process.exit(1)을 쓰는가? stop-graceful실험에서 요청은 약 1초에 끝났는데 종료는 약 4초 걸렸다. 무엇을 기다린 것인가? 요청을 보낸 쪽이fetch가 아니라 매 요청마다 연결을 끊는 클라이언트였다면 결과가 어떻게 달라지는가?- 동료가 Dockerfile의 마지막 줄을
CMD npm start로 적었다. 이 단원의 관점에서 무엇을 확인해야 하는가?
정답과 해설
process.exit(0)은 그 자리에서 프로세스를 끝내므로, 아직 운영체제로 넘어가지 않은 출력(마지막 로그 줄)이 잘릴 수 있다. 특히 표준 출력이 파이프로 연결된 운영 환경에서 그렇다.exitCode는 "끝날 때 쓸 번호"만 정하고, 남은 출력이 모두 나간 뒤 자연스럽게 끝나게 한다. 강제 종료 타이머는 반대로 "무언가가 끝나지 않아서" 불리는 것이므로, 자연스럽게 끝나기를 기다릴 수 없다. 그래서 거기서는process.exit(1)로 즉시 끝낸다.- 응답을 마친 뒤 keep-alive로 열려 있던 연결이, 클라이언트(
fetch)나 서버의 keep-alive 시간이 다 되어 닫히기를 기다렸다. 몇 초가 걸릴지는 양쪽 설정과 버전에 따라 다르다.server.close()의 콜백은 연결이 모두 닫혀야 불리기 때문이다. 매 요청마다 연결을 끊는 클라이언트라면 응답이 끝나는 순간 연결도 닫히므로 약 1초에 종료된다. 하지만 서버는 클라이언트를 고를 수 없으므로closeIdleConnections로 서버 쪽에서 정리한다. npm이SIGTERM을node프로세스에 전달하는지, 그리고node가 정리를 마칠 때까지 기다리는지를 확인해야 한다. 컨테이너에서는CMD로 띄운 프로세스가 첫 번째 프로세스(PID 1)가 되어 신호를 받는다. 중간에npm이나 셸이 끼면 신호 전달과 종료 대기가 버전과 설정에 따라 달라진다. 가장 확실한 방법은CMD ["node", "server.mjs"]처럼node를 직접, 배열 형식으로 실행하는 것이다. 그리고stop-check.mjs처럼 "요청 중에 신호를 보내 보는" 실험을 배포 환경에서 한 번 해 본다.
이것으로 메모 API가 완성됐다. 처음 hello.mjs에서 시작해 모듈, 비동기, 파일, HTTP, 검증, 오류, 저장소, 설정, 로그, 테스트, 종료까지, 서버 하나가 운영에 올라가기까지 거치는 단계를 모두 손으로 만들어 봤다. 다음 걸음으로는 저장소를 데이터베이스로 바꿔 보거나, 같은 API를 프레임워크로 다시 만들어 보며 이 책의 코드와 나란히 놓고 비교해 보기를 권한다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.