Node.js와 Express.js 입문
Node.js 런타임과 Express 프레임워크의 역할을 이해하고 라우트와 미들웨어를 가진 첫 HTTP 서버를 실행합니다.
앞 글 서버와 클라이언트 관계 이해에서는 클라이언트-서버 모델과 HTTP로 요청과 응답이 오가는 흐름을 살펴봤습니다. 프런트엔드가 사용자에게 보이는 부분을 맡는다면, 백엔드는 보이지 않는 곳에서 데이터를 처리하고 업무 로직을 실행해 클라이언트에게 필요한 정보를 제공합니다.
이 글부터는 그런 백엔드 서버를 직접 만들어 봅니다. 프런트엔드에서 쓰던 자바스크립트를 서버에서도 쓰게 해 주는 Node.js와, Node.js 위에서 웹 애플리케이션을 만드는 프레임워크인 Express를 중심으로 서버 개발의 기초를 다집니다. 자바스크립트 하나로 프런트엔드와 백엔드를 모두 만드는 풀스택(full-stack) 개발의 첫걸음입니다. 이 글은 Express 5.3을 기준으로 합니다.
Node.js
지금까지 자바스크립트는 웹 브라우저 안에서 실행되는 언어로 썼습니다. Node.js는 Chrome의 V8 자바스크립트 엔진을 바탕으로 만든 자바스크립트 런타임(runtime)으로, 브라우저가 아닌 서버 환경에서도 자바스크립트를 실행하게 해 줍니다.
Node.js 런타임과 비동기 I/O 경계 — 상태 추적
JS 호출·I/O 위임·완료 통지·콜백 실행의 주체와 대기 상태가 달라진다. JS 스택이 I/O 완료를 계속 붙들고 있다는 오해를 줄이며 실제 libuv phase 전체는 생략한다.
RUNTIME BOUNDARY
I/O를 맡기고 완료되면 JS 콜백으로
Node API가 I/O를 OS나 libuv worker pool에 맡깁니다. 이벤트 루프는 완료 알림에 따라 JS 콜백을 실행하며, 긴 동기 코드는 이 처리를 지연시킬 수 있습니다.
긴 CPU 연산은 별도로 분리
worker_threads는 별도 JS 스레드에서 CPU 작업을 실행하고 메시지로 결과를 전달합니다. libuv I/O pool과는 역할이 다릅니다.
JS 호출 스택을 오래 점유하면 콜백 처리가 지연됩니다. “JS 콜백을 한 번에 하나 실행한다”는 “Node 프로세스에 스레드가 하나뿐이다”라는 뜻이 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | worker_threads는 별도 JS 스레드에서 CPU 작업을 실행하고 메시지로 결과를 전달합니다. libuv I/O pool과는 역할이 다릅니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 동기 실행 | JS stack: 현재 함수 / IO: 호스트에 위임 | Node JavaScript가 IO를 등록한다 | IO 등록은 callback 완료가 아니다 |
| 호스트 작업 | JS stack: 다른 작업 가능 / IO: 진행/완료 준비 | JavaScript는 반환하고 IO 완료를 기다린다 | 긴 IO를 기다리는 동안 현재 함수가 계속 실행되는 것은 아니다 |
| callback 실행 | JS stack: callback / IO: 결과 사용 | 실행 가능해진 callback이 JS stack에서 이어진다 | callback 계산은 다시 JavaScript 실행이며 phase 전체 모델은 생략한다 |
Node.js 런타임과 비동기 I/O 경계 — 상태 추적
JS 호출·I/O 위임·완료 통지·콜백 실행의 주체와 대기 상태가 달라진다. JS 스택이 I/O 완료를 계속 붙들고 있다는 오해를 줄이며 실제 libuv phase 전체는 생략한다.
RUNTIME BOUNDARY
I/O를 맡기고 완료되면 JS 콜백으로
Node API가 I/O를 OS나 libuv worker pool에 맡깁니다. 이벤트 루프는 완료 알림에 따라 JS 콜백을 실행하며, 긴 동기 코드는 이 처리를 지연시킬 수 있습니다.
긴 CPU 연산은 별도로 분리
worker_threads는 별도 JS 스레드에서 CPU 작업을 실행하고 메시지로 결과를 전달합니다. libuv I/O pool과는 역할이 다릅니다.
JS 호출 스택을 오래 점유하면 콜백 처리가 지연됩니다. “JS 콜백을 한 번에 하나 실행한다”는 “Node 프로세스에 스레드가 하나뿐이다”라는 뜻이 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | worker_threads는 별도 JS 스레드에서 CPU 작업을 실행하고 메시지로 결과를 전달합니다. libuv I/O pool과는 역할이 다릅니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 동기 실행 | JS stack: 현재 함수 / IO: 호스트에 위임 | Node JavaScript가 IO를 등록한다 | IO 등록은 callback 완료가 아니다 |
| 호스트 작업 | JS stack: 다른 작업 가능 / IO: 진행/완료 준비 | JavaScript는 반환하고 IO 완료를 기다린다 | 긴 IO를 기다리는 동안 현재 함수가 계속 실행되는 것은 아니다 |
| callback 실행 | JS stack: callback / IO: 결과 사용 | 실행 가능해진 callback이 JS stack에서 이어진다 | callback 계산은 다시 JavaScript 실행이며 phase 전체 모델은 생략한다 |
위 그림은 Node.js가 파일이나 네트워크 같은 입출력(I/O)을 처리하는 방식입니다. 자바스크립트의 호출 스택에서 동기 코드를 실행하다가 Node API로 비동기 I/O를 요청하면, 그 작업은 운영체제의 비동기 I/O나 libuv라는 라이브러리의 작업자 풀(worker pool)로 넘어갑니다. 네트워크 소켓처럼 운영체제가 준비와 완료를 알려 주는 작업은 운영체제가, 파일 읽기나 일부 DNS 조회, 일부 암호화처럼 그렇게 할 수 없는 작업은 작업자 풀이 맡습니다. 그동안 자바스크립트는 다른 코드를 계속 실행할 수 있습니다.
작업이 끝나면 완료 알림이 이벤트 루프(event loop)로 전달되고, 이벤트 루프가 그 결과를 받아 자바스크립트 콜백을 실행합니다. 그림은 이벤트 루프의 실제 단계별 대기열을 생략한 개념도입니다. 그림 아래의 설명처럼 자바스크립트가 호출 스택을 오래 붙잡고 있으면 다른 콜백의 실행이 모두 늦어집니다. 오래 걸리는 CPU 계산은 worker_threads로 별도의 자바스크립트 스레드에서 실행하고 메시지로 결과를 받을 수 있으며, 이것은 libuv의 I/O 작업자 풀과는 역할이 다릅니다. 자바스크립트 콜백을 한 번에 하나씩 실행한다는 것이, Node.js 프로세스에 스레드가 하나뿐이라는 뜻은 아닙니다.
Node.js의 특징
Node.js의 가장 큰 특징은 비동기 논블로킹(non-blocking) I/O입니다. 자바스크립트 콜백은 하나의 이벤트 루프에서 실행되고, I/O는 운영체제와 libuv의 도움을 받아 기다리는 동안 다른 요청을 처리할 수 있습니다. 그래서 I/O가 많은 서버에 잘 맞지만, 오래 걸리는 동기 자바스크립트 작업은 이벤트 루프를 막아 모든 요청을 늦춥니다.
프런트엔드와 백엔드를 모두 자바스크립트로 만들 수 있어 언어를 오가는 부담이 적습니다. npm이라는 큰 패키지 생태계가 있어 수많은 오픈 소스 라이브러리를 쉽게 설치해 쓸 수 있고, 가벼운 프레임워크와 빠른 시작 덕분에 시제품이나 최소 기능 제품(minimum viable product, MVP)을 빨리 만들기 좋습니다.
Node.js 설치 확인
Node.js가 설치되어 있는지 먼저 확인합니다. 다음 두 명령은 각각 설치된 Node.js와 npm의 버전을 출력하며, 명령을 찾을 수 없다는 오류가 나오면 아직 설치되지 않은 것입니다. npm은 Node.js를 설치할 때 함께 설치됩니다.
node -v # Node.js 버전 확인
npm -v # npm 버전 확인설치되어 있지 않다면 Node.js 공식 웹사이트에서 장기 지원(LTS) 버전을 내려받아 설치합니다. Express 5는 Node.js 18 이상이 필요합니다. 이 글의 app.js는 require()를 쓰는 CommonJS 형식이므로, 새로 만든 프로젝트에서 실습하세요. 이미 package.json에 "type": "module"이 있는 프로젝트라면 모듈 시스템에서 본 것처럼 모듈 형식부터 맞춰야 합니다.
Express
Node.js만으로도 웹 서버를 만들 수 있지만, HTTP 요청을 해석하고, 경로마다 처리 함수를 나누고, 공통 처리를 연결하는 일을 모두 직접 만들기는 번거롭습니다. Express는 Node.js를 위한 빠르고 가벼운 웹 애플리케이션 프레임워크로, 이런 기능을 미리 갖춰 백엔드 서버를 쉽고 빠르게 만들게 해 줍니다.
Express의 핵심 기능은 라우팅과 미들웨어입니다. 라우팅(routing)은 URL 경로와 HTTP 메서드에 따라 알맞은 함수를 실행해 요청을 처리하는 기능입니다. 미들웨어(middleware)는 요청이 들어와 응답이 나가기까지 사이에 요청 로깅, 인증, 본문 해석 같은 일을 하는 함수들을 차례로 연결하는 구조입니다. 이 밖에도 Pug, EJS, Handlebars 같은 템플릿 엔진으로 HTML을 만들 수 있고, 요청과 응답 객체에 편리한 메서드를 더해 줍니다. 꼭 필요한 기능만 갖춘 작은 프레임워크라서 프로젝트에 따라 필요한 기능을 더해 확장할 수 있습니다.
Express 미들웨어 요청 처리 파이프라인 — 상태 추적
본문 POST 요청이 parser→guards→route→handler를 지나며 req 상태와 응답 여부가 바뀐다. next와 응답 종료/오류 전달을 실제 요청 한 건으로 추적하면 중복 응답을 예방한다.
FLOWCHART · B99 WEB FUNDAMENTALS
Express 미들웨어 요청 처리 파이프라인
미들웨어는 등록 순서대로 실행됩니다. next()는 다음 처리로, next(err)는 오류 처리기로 넘어갑니다. 응답을 보냈다면 후속 처리를 멈춥니다. 인증·검증은 본문 예제에 보강할 설계 단계입니다.
404는 라우트 끝까지 응답하지 않은 요청을 처리하는 일반 미들웨어입니다. 오류 처리기는 (err, req, res, next)를 받으며 라우트 뒤에 등록합니다. Express 5는 반환된 Promise 거부도 전달하지만, 별도 비동기 콜백의 오류는 직접 next(err)로 전달해야 합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 미들웨어는 등록 순서대로 실행됩니다. next()는 다음 처리로, next(err)는 오류 처리기로 넘어갑니다. 응답을 보냈다면 후속 처리를 멈춥니다. 인증·검증은 본문 예제에 보강할 설계 단계입니다. |
| 전체 관계·조건 설명 | HTTP 요청은 parser와 guards를 거쳐 method와 path를 비교한다. 일치하면 업무 처리와 성공 응답으로, NO MATCH면 404로 향한다. guards의 NEXT(ERROR)는 별도 오류 처리기로 향한다. 하나의 응답을 보내고 요청을 종료한다. |
| 원도표 설명 | 404는 라우트 끝까지 응답하지 않은 요청을 처리하는 일반 미들웨어입니다. 오류 처리기는 (err, req, res, next)를 받으며 라우트 뒤에 등록합니다. Express 5는 반환된 Promise 거부도 전달하지만, 별도 비동기 콜백의 오류는 직접 next(err)로 전달해야 합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 본문 파싱 | req.body: 파싱 결과 / 응답: 미전송 | Express parser가 요청 body를 준비한다 | 형식 오류는 다음 handler로 성공 값처럼 넘기지 않는다 |
| guard와 route | 실행: middleware/guard/route / 응답: 미전송 또는 거절 | 조건을 만족한 요청만 handler로 전달한다 | next와 error 경로가 후속 실행을 결정한다 |
| 응답 종료 | 응답: 전송 완료 / handler: 종료 또는 지정 next | 한 응답을 보내면 후속 전송을 반복하지 않는다 | 처리 완료와 다음 middleware 호출은 자동으로 같은 뜻이 아니다 |
Express 미들웨어 요청 처리 파이프라인 — 상태 추적
본문 POST 요청이 parser→guards→route→handler를 지나며 req 상태와 응답 여부가 바뀐다. next와 응답 종료/오류 전달을 실제 요청 한 건으로 추적하면 중복 응답을 예방한다.
FLOWCHART · B99 WEB FUNDAMENTALS
Express 미들웨어 요청 처리 파이프라인
미들웨어는 등록 순서대로 실행됩니다. next()는 다음 처리로, next(err)는 오류 처리기로 넘어갑니다. 응답을 보냈다면 후속 처리를 멈춥니다. 인증·검증은 본문 예제에 보강할 설계 단계입니다.
404는 라우트 끝까지 응답하지 않은 요청을 처리하는 일반 미들웨어입니다. 오류 처리기는 (err, req, res, next)를 받으며 라우트 뒤에 등록합니다. Express 5는 반환된 Promise 거부도 전달하지만, 별도 비동기 콜백의 오류는 직접 next(err)로 전달해야 합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 미들웨어는 등록 순서대로 실행됩니다. next()는 다음 처리로, next(err)는 오류 처리기로 넘어갑니다. 응답을 보냈다면 후속 처리를 멈춥니다. 인증·검증은 본문 예제에 보강할 설계 단계입니다. |
| 전체 관계·조건 설명 | HTTP 요청은 parser와 guards를 거쳐 method와 path를 비교한다. 일치하면 업무 처리와 성공 응답으로, NO MATCH면 404로 향한다. guards의 NEXT(ERROR)는 별도 오류 처리기로 향한다. 하나의 응답을 보내고 요청을 종료한다. |
| 원도표 설명 | 404는 라우트 끝까지 응답하지 않은 요청을 처리하는 일반 미들웨어입니다. 오류 처리기는 (err, req, res, next)를 받으며 라우트 뒤에 등록합니다. Express 5는 반환된 Promise 거부도 전달하지만, 별도 비동기 콜백의 오류는 직접 next(err)로 전달해야 합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 본문 파싱 | req.body: 파싱 결과 / 응답: 미전송 | Express parser가 요청 body를 준비한다 | 형식 오류는 다음 handler로 성공 값처럼 넘기지 않는다 |
| guard와 route | 실행: middleware/guard/route / 응답: 미전송 또는 거절 | 조건을 만족한 요청만 handler로 전달한다 | next와 error 경로가 후속 실행을 결정한다 |
| 응답 종료 | 응답: 전송 완료 / handler: 종료 또는 지정 next | 한 응답을 보내면 후속 전송을 반복하지 않는다 | 처리 완료와 다음 middleware 호출은 자동으로 같은 뜻이 아니다 |
위 그림은 요청 하나가 Express 앱 안에서 지나가는 길입니다. 미들웨어는 등록한 순서대로 실행되며, next()를 호출하면 다음 처리로, next(err)를 호출하면 오류 처리기로 넘어갑니다. 먼저 본문, 쿠키, 로깅 같은 미들웨어가 등록 순서대로 요청을 해석하고 정보를 더합니다. 그다음 인증, 권한, 입력 검사 같은 미들웨어가 요청을 걸러, 실패하면 그 자리에서 4xx 응답으로 끝낼 수 있습니다.
통과한 요청은 메서드와 경로가 맞는 라우트를 찾습니다. 맞는 라우트가 있으면 핸들러(handler)가 업무를 처리하고 res.status(...).json(...)처럼 성공 응답을 보내며 끝납니다. 맞는 라우트가 없으면 라우트 목록의 끝에 둔 404 처리 미들웨어가 응답합니다. 처리 중에 next(err)로 넘어온 오류는 (err, req, res, next) 네 인수를 받는 오류 처리 미들웨어가 받아, 오류를 알맞은 응답으로 바꾸고 기록한 뒤 응답 하나로 끝냅니다. 응답을 이미 보냈다면 뒤의 처리는 이어지지 않습니다.
그림 아래의 설명처럼 404 처리는 라우트 끝까지 아무도 응답하지 않은 요청을 받는 일반 미들웨어이고, 오류 처리기는 라우트 뒤에 등록합니다. Express 5는 핸들러가 돌려준 Promise가 거부되면 그 오류를 오류 처리기로 넘겨 주지만, setTimeout() 같은 별도의 비동기 콜백 안에서 생긴 오류는 직접 next(err)로 넘겨야 합니다. 그림의 인증과 검증 단계는 앞으로 더할 설계이며, 이 글의 예제에는 본문 해석, 핸들러, 응답 단계만 있습니다.
더 깊이: Express 4의 경로 문법이 5에서 오류가 나는 이유
인터넷의 Express 예제 가운데 상당수는 Express 4를 기준으로 쓰였는데, Express 5는 라우트 경로를 해석하는 규칙을 바꿨습니다. 가장 자주 부딪히는 것은 와일드카드입니다. Express 4에서 모든 경로를 받던 app.get('/*', ...)은 5에서 쓸 수 없고, 와일드카드에 이름을 붙여 app.get('/*splat', ...)처럼 써야 합니다. /*splat은 루트 경로 / 자체와는 맞지 않으므로, 루트까지 포함하려면 중괄호로 감싸 /{*splat}으로 씁니다.
선택적인 부분을 나타내던 ?도 중괄호로 바뀌었습니다. Express 4의 '/:file.:ext?'는 5에서 '/:file{.:ext}'처럼 씁니다. 또 경로 문자열 안에 정규 표현식의 문법을 섞어 쓰는 것도 지원하지 않으므로, 여러 경로를 한 핸들러로 받으려면 ['/discussion/:slug', '/page/:slug']처럼 경로의 배열을 넘깁니다. 그래서 Express 4의 예제를 그대로 옮기면 라우트를 등록하는 단계에서 경로를 해석하지 못해 오류가 날 수 있습니다.
본문 처리도 달라졌습니다. 본문을 해석하는 미들웨어를 거치지 않은 요청에서 req.body는 Express 4에서는 빈 객체 {}였지만, Express 5에서는 undefined입니다. 그래서 req.body.name처럼 바로 읽는 코드는 Express 5에서 TypeError를 낼 수 있습니다. 바뀐 점의 전체 목록은 Express 5 이전 안내에 있습니다.
첫 번째 Express 서버
이제 Node.js와 Express로 간단한 웹 서버를 만들어 봅니다. 먼저 새 프로젝트 폴더를 만들고 초기화합니다. npm init -y는 기본값으로 package.json을 만듭니다.
mkdir my-express-server
cd my-express-server
npm init -y이후의 명령은 모두 이 my-express-server 폴더 안에서 실행합니다.
Express를 설치합니다. 설치하면 package.json의 dependencies에 express가 기록되고, 실제 파일은 node_modules 폴더에 들어갑니다. 버전을 적지 않았으므로 설치하는 시점의 최신 버전이 설치됩니다.
npm install express프로젝트 루트에 app.js를 만들고 다음 코드를 작성합니다. 번호를 붙인 주석이 모듈 불러오기, 앱 만들기, 포트 정하기, 미들웨어 등록, 라우트 정의, 서버 시작의 순서를 나타냅니다. 라우트는 /, /api/greeting, /api/data 세 개이며, 코드 아래에서 하나씩 요청을 보내 봅니다.
// 1. Express 모듈을 임포트합니다.
const express = require('express');
// 2. Express 애플리케이션 인스턴스를 생성합니다.
const app = express();
// 3. 서버가 들을 포트 번호를 정의합니다.
// process.env.PORT는 환경 변수가 설정되어 있을 경우 해당 포트를 사용하고,
// 그렇지 않으면 3000번 포트를 기본값으로 사용합니다.
const port = process.env.PORT || 3000;
// 4. 미들웨어 설정: JSON 요청 본문 파싱 (클라이언트가 JSON 데이터를 보낼 때 필요)
app.use(express.json());
// 5. 라우트(Route) 정의: GET 요청 처리
// '/' 경로는 웹사이트의 루트(홈)를 의미합니다.
app.get('/', (req, res) => {
// 클라이언트에게 "Hello, World! Welcome to my Express server!" 메시지를 응답합니다.
res.send('Hello, World! Welcome to my Express server!');
});
// 6. 새로운 라우트 정의: /api/greeting 경로로 GET 요청 처리
// 쿼리 파라미터 'name'을 받아 사용합니다.
app.get('/api/greeting', (req, res) => {
const queryName = req.query.name; // req.query로 쿼리 파라미터 접근
const name = typeof queryName === 'string'
? queryName || '방문자'
: '방문자';
res.type('text/plain').send(`안녕하세요, ${name}님! Express API에 오신 것을 환영합니다.`);
});
// 7. 새로운 라우트 정의: POST 요청 처리 (데이터 받기)
app.post('/api/data', (req, res) => {
const receivedData = req.body; // req.body로 요청 본문 (JSON) 접근
console.log('클라이언트로부터 받은 데이터:', receivedData);
res.status(200).json({
message: '데이터가 성공적으로 수신되었습니다!',
yourData: receivedData
});
});
// 8. 서버 시작: 정의된 포트에서 요청을 대기합니다.
app.listen(port, () => {
console.log(`서버가 http://localhost:${port} 에서 실행 중입니다.`);
console.log(`http://localhost:${port}/api/greeting?name=김철수 로 접속해보세요.`);
console.log(`POST 요청 테스트: http://localhost:${port}/api/data`);
});express()로 앱을 만들고, app.use(express.json())으로 JSON 본문을 해석하는 미들웨어를 등록합니다. app.get()과 app.post()는 각각 GET과 POST 요청을 처리할 라우트를 정합니다. /api/greeting의 인사말은 HTML이 아닌 일반 텍스트 응답이므로 res.type('text/plain')으로 형식을 밝힙니다. 쿼리 문자열의 name은 문자열일 때만 쓰고, 없거나 빈 문자열이면 방문자로 응답합니다. 마지막의 app.listen()이 정한 포트에서 요청을 기다리기 시작합니다.
터미널에서 서버를 실행합니다.
node app.js서버가 시작되면 터미널에 서버가 http://localhost:3000 에서 실행 중입니다. 같은 메시지가 나옵니다. 브라우저에서 http://localhost:3000에 접속하면 Hello, World! Welcome to my Express server!가 보이고, http://localhost:3000/api/greeting?name=김철수에 접속하면 안녕하세요, 김철수님! Express API에 오신 것을 환영합니다.가 보입니다.
POST 요청 하나를 끝까지 따라가기
주소창에 URL을 입력하는 것은 GET 요청이므로, POST 핸들러는 다른 방법으로 시험해야 합니다. 서버를 실행한 터미널은 그대로 두고, Postman 같은 요청 도구에서 메서드는 POST, URL은 http://localhost:3000/api/data, 헤더는 Content-Type: application/json, 본문은 raw JSON으로 정한 뒤 다음 값을 넣습니다.
{
"name": "Alice",
"age": 20
}"age"의 값 20은 따옴표가 없으므로 문자열이 아니라 숫자로 전달됩니다.
curl을 쓸 수 있다면 다른 터미널에서 같은 요청을 보낼 수도 있습니다. -i는 응답 헤더까지 출력하고, -X POST는 메서드를, -H는 헤더를, -d는 본문을 정합니다. 줄 끝의 \는 한 명령을 여러 줄로 나눠 쓰는 bash 문법이므로, Windows의 PowerShell이나 명령 프롬프트에서는 줄 나눔과 따옴표 규칙이 달라 그대로 붙여 넣으면 동작하지 않을 수 있습니다.
curl -i -X POST http://localhost:3000/api/data \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","age":20}'이 요청이 express.json()을 지나면 req.body는 name과 age를 가진 객체가 됩니다. /api/data의 POST 핸들러가 그 객체를 receivedData로 읽고, 서버 터미널에는 클라이언트로부터 받은 데이터:와 두 필드가 출력됩니다. 로그의 공백이나 따옴표 모양은 HTTP 응답의 본문과 다를 수 있습니다.
클라이언트에서는 상태 200과 JSON 응답, 그리고 다음 필드를 확인합니다. 들여쓰기는 읽기 좋게 표시한 것이며, 실제로 전송되는 JSON에 이 공백이 들어 있어야 하는 것은 아닙니다.
{
"message": "데이터가 성공적으로 수신되었습니다!",
"yourData": {
"name": "Alice",
"age": 20
}
}요청의 Content-Type은 서버가 받은 본문을 어떻게 해석할지 알려 주고, 응답의 Content-Type은 클라이언트가 받은 본문을 어떻게 해석할지 알려 줍니다. 이 예제에서는 res.json()이 응답의 Content-Type을 정합니다. Express의 express.json()은 요청 헤더가 JSON인 본문만 해석하고, res.json()은 넘겨받은 값을 JSON으로 바꿔 보냅니다.
이 예제에 실제로 있는 단계는 그림의 본문 해석, 메서드와 경로에 맞는 핸들러, 응답뿐입니다. 그림의 인증, 권한, 입력 검사를 이 코드가 구현한 것은 아니며, JSON 문법을 읽었다고 age가 양의 정수인지 같은 업무 조건까지 검증되지는 않습니다. 또 /api/data는 받은 값을 돌려줄 뿐 배열이나 데이터베이스에 저장하지 않습니다.
Content-Type: text/plain으로 같은 JSON 문자열을 보내면 express.json()이 그 요청을 건너뜁니다. Express 5에서는 해석된 본문이 없으면 req.body가 undefined이므로, 응답의 yourData도 undefined가 되어 JSON에서 빠지고 message만 돌아옵니다. 이 핸들러는 본문이 있는지 검사하지 않고 여전히 200을 보내므로, 상태 코드만 보지 말고 yourData가 있는지도 확인해야 합니다. 반대로 application/json에 {"name":}처럼 깨진 JSON을 보내면 핸들러에 닿기 전에 해석 오류가 납니다. 이때 상태는 400이지만(body-parser의 오류 규칙), 앞의 성공 응답과 같은 모양의 오류 응답이 오는 것은 아닙니다.
코드를 바꿀 때마다 자동으로 다시 시작하기
서버 코드를 고칠 때마다 서버를 껐다가 다시 켜는 것은 번거롭습니다. Nodemon은 코드가 바뀐 것을 알아채 Node.js 서버를 자동으로 다시 시작해 주는 도구입니다. 개발할 때만 쓰므로 개발 의존성으로 설치합니다.
npm install --save-dev nodemonpackage.json의 scripts에 start:dev를 추가합니다. 이미 있는 다른 스크립트는 그대로 두고 이 항목만 더합니다.
{
"scripts": {
"start": "node app.js",
"start:dev": "nodemon app.js"
}
}start는 일반 실행용, start:dev는 개발용입니다. npm start처럼 run 없이 줄여 쓸 수 있는 것은 start나 test 같은 몇몇 이름뿐이므로, 다음 명령처럼 start:dev는 npm run을 붙여 실행합니다.
npm run start:dev이제 app.js를 고치고 저장하면 Nodemon이 서버를 다시 시작해 변경 사항이 바로 반영됩니다. 패키지를 설치하지 않고 node --watch app.js로도 파일이 바뀔 때 자동으로 다시 시작할 수 있는데, 이 옵션은 Node.js 18.11에 실험 기능으로 들어와 20.13과 22.0에서 안정 기능이 되었습니다.
Express 서버는 작게 시작하더라도, 요청이 어떤 미들웨어와 라우트를 거치는지 그림처럼 먼저 그려 두면 진입점, 공통 미들웨어, 라우트 핸들러를 파일로 나눌 기준이 분명해집니다.
정리
- Node.js는 V8 엔진으로 브라우저 밖에서 자바스크립트를 실행하는 런타임이며, I/O는 운영체제와 libuv에 맡기고 완료되면 이벤트 루프가 콜백을 실행합니다.
- 자바스크립트 콜백은 하나의 이벤트 루프에서 하나씩 실행되므로, 오래 걸리는 동기 계산은 모든 요청을 늦추며
worker_threads로 분리할 수 있습니다. - Express는 라우팅과 미들웨어를 제공하는 Node.js 웹 프레임워크로, 미들웨어는 등록한 순서대로 실행됩니다.
- 요청은 본문 해석, 인증과 검사, 라우트 판정, 핸들러를 거쳐 응답 하나로 끝나며, 맞는 라우트가 없으면 404, 오류는 오류 처리 미들웨어로 갑니다.
express.json()은 요청의Content-Type이 JSON일 때만 본문을 해석하며, 해석했다고 업무 조건까지 검증된 것은 아닙니다.- 개발 중에는 Nodemon이나
node --watch로 코드가 바뀔 때 서버를 자동으로 다시 시작합니다.
연습 문제
1. 본문을 바꿔 결과 예측하기
메서드는 POST, 헤더는 application/json을 그대로 두고 본문만 {"name":"Bob","age":21}로 바꿨습니다. 서버 로그의 두 필드, 응답 상태, yourData를 먼저 적어 보세요.
해설 보기
응답 상태는 200이고, yourData는 {"name":"Bob","age":21}입니다. 서버 로그에도 Bob과 21이 나타납니다.
이것은 서버가 받은 값을 그대로 돌려준 것일 뿐이며, 어디에도 저장되지 않았습니다. 같은 요청을 다시 보내도 서버에는 이전 요청의 흔적이 남지 않습니다.
2. 200인데 받은 것이 없다
같은 본문을 헤더만 Content-Type: text/plain으로 바꿔 보냈습니다. 응답의 상태와 본문은 어떻게 될까요? 상태 코드만으로 데이터를 잘 받았다고 판단할 수 있을까요?
해설 보기
상태는 200이지만, 응답 본문에는 message만 있고 yourData가 없습니다. express.json()은 text/plain 요청을 해석하지 않으므로 Express 5에서 req.body가 undefined가 되고, undefined인 속성은 JSON으로 바꿀 때 빠집니다.
이 핸들러는 본문을 검사하지 않고 늘 200을 보내므로, 상태 코드만으로는 데이터를 받았는지 알 수 없습니다. 본문이 반드시 필요하다면 핸들러에서 req.body를 검사해 없으면 400을 돌려주도록 고쳐야 합니다.
3. 이벤트 루프를 막는 라우트
app.js에 다음 라우트를 더하고, 브라우저 탭 하나에서 /slow를 요청한 직후 다른 탭에서 /를 요청했습니다. /의 응답은 언제 올까요?
app.get('/slow', (req, res) => {
const start = Date.now();
while (Date.now() - start < 5000) {} // 5초 동안 계산하는 척 대기
res.send('느린 응답');
});해설 보기
/slow의 반복문이 끝날 때까지, 즉 약 5초 동안 /의 응답도 오지 않습니다. 이 반복문은 비동기 I/O가 아니라 동기 자바스크립트 코드라서 이벤트 루프를 붙잡고 있고, 그동안 다른 요청의 콜백을 실행할 수 없습니다.
I/O를 기다리는 일이라면 비동기 API를 써야 하고, 실제로 오래 걸리는 CPU 계산이라면 worker_threads나 별도의 작업 프로세스로 옮겨 이벤트 루프를 비워 둬야 합니다.
다음 글 RESTful API 설계와 구현에서는 Express로 자원을 만들고, 읽고, 고치고, 지우는 API를 REST의 원칙에 맞게 설계하고 구현해 봅니다.