본문 바로가기

Node.js와 Express

Express 5 프로젝트에 타입스크립트를 설정하고, 요청과 응답, 라우터, 미들웨어에 타입을 붙인 API 서버를 만든 뒤, 정적 타입이 요청 본문을 검증하지 않는다는 점을 확인하고 서버의 입구에서 값을 검사하는 방법을 다룹니다.

웹 애플리케이션은 사용자가 보는 화면인 프론트엔드(frontend)와, 데이터를 저장하고 처리하는 서버 쪽의 백엔드(backend)로 나뉩니다. 앞 장까지 React로 화면을 만들었다면, 이 장부터는 그 화면이 요청을 보내는 서버를 타입스크립트로 만듭니다.

Node.js는 브라우저 밖에서 자바스크립트를 실행하는 런타임이고, Express는 그 위에서 HTTP 요청을 받아 응답하는 서버를 간단히 만들게 해 주는 웹 프레임워크입니다. 이 글은 2026년 10월 현재 최신인 Express 5로 API 서버를 만들고, 요청과 응답, 라우터, 미들웨어에 타입을 붙인 뒤, 바깥에서 들어온 값을 서버의 입구에서 검사하는 방법을 봅니다.

Node.js와 Express

Node.js는 Chrome의 V8 엔진으로 자바스크립트를 실행하며, 파일이나 네트워크 같은 입출력을 기다리는 동안 다른 요청을 처리하는 이벤트 루프(event loop) 모델을 씁니다. 그래서 한 스레드로도 많은 연결을 동시에 다룰 수 있고, 프론트엔드와 같은 언어로 서버를 작성할 수 있습니다. Node.js 공식 사이트에서 LTS 버전을 설치한 뒤 node -v와 npm -v로 버전을 확인합니다.

Express는 특정 구조를 강제하지 않는 작은 프레임워크입니다. 어떤 경로와 메서드의 요청을 어느 함수가 처리할지 정하는 라우팅(routing)과, 요청이 처리 함수에 닿기 전후에 공통 작업을 끼워 넣는 미들웨어(middleware)가 핵심 기능입니다. 로깅, 요청 본문의 해석, 인증 같은 일은 모두 미들웨어로 더합니다.

프로젝트 준비

새 폴더를 만들고 Express와 타입스크립트 도구를 설치합니다. 실행할 때 쓰는 express는 일반 의존성으로, 타입 검사와 개발에만 쓰는 나머지 도구는 --save-dev로 나눠 설치합니다.

mkdir my-express-ts-app
cd my-express-ts-app
npm init -y
npm pkg set type=module
npm install express
npm install --save-dev typescript @types/node @types/express tsx

npm pkg set type=module은 package.json에 "type": "module"을 적어, 이 프로젝트의 .js 파일을 ES 모듈로 실행하게 합니다. @types/node와 @types/express는 Node.js와 Express의 타입 정의이고, tsx는 개발 중에 .ts 파일을 바로 실행해 주는 도구입니다. 다음으로 프로젝트 루트에 tsconfig.json을 만듭니다.

tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"],
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

module의 nodenext는 Node.js가 실제로 모듈을 찾고 불러오는 규칙대로 검사하고 출력하는 설정입니다. 이 규칙에서 ES 모듈의 상대 경로 import는 확장자를 생략할 수 없으므로, 소스에서도 출력될 파일 이름에 맞춰 "./routes/userRoutes.js"처럼 .js를 적습니다. TypeScript 6.0부터 types의 기본값이 빈 배열이어서, process나 Buffer 같은 Node.js의 전역 타입을 쓰려면 "types": ["node"]를 직접 적어야 합니다.

package.json의 scripts는 다음처럼 바꿉니다.

package.json
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

npm run dev는 tsx로 서버를 실행하고 파일이 바뀌면 다시 시작합니다. tsx는 타입 표기를 지우고 실행할 뿐 타입을 검사하지 않으므로, 타입 검사는 npm run typecheck로 따로 합니다. 배포할 때는 npm run build로 dist에 자바스크립트를 만들고 npm run start로 실행합니다.

첫 서버

src/index.ts에 사용자 목록을 돌려주고 새 사용자를 받는 서버를 만듭니다. 사용자 목록은 데이터베이스 없이 메모리의 배열 users에 두므로, 서버를 다시 시작하면 추가한 사용자는 사라집니다. 코드를 읽을 때는 User와 NewUser 두 타입이 요청과 응답의 어디에 붙는지를 보면 됩니다.

src/index.ts
import express from "express";
import type { NextFunction, Request, Response } from "express";

interface User {
  id: number;
  name: string;
  email: string;
}

type NewUser = Omit<User, "id">;

const users: User[] = [
  { id: 1, name: "Alice", email: "[email protected]" },
  { id: 2, name: "Bob", email: "[email protected]" },
];

const app = express();
const port = 3000;

app.use(express.json());

app.get("/", (_req: Request, res: Response) => {
  res.send("Hello from Express with TypeScript!");
});

app.get("/api/users", (_req: Request, res: Response<User[]>) => {
  res.json(users);
});

app.post("/api/users", (req: Request<{}, User, NewUser>, res: Response<User>) => {
  const user: User = { id: users.length + 1, ...req.body };
  users.push(user);
  res.status(201).json(user);
});

app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err);
  res.status(500).json({ message: "Internal Server Error" });
});

app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

express.json()은 Content-Type: application/json인 요청의 본문을 해석해 req.body에 넣는 미들웨어입니다. Request의 타입 인수는 차례로 경로 매개변수, 응답 본문, 요청 본문, 쿼리 문자열의 타입이어서, Request<{}, User, NewUser>는 req.body를 NewUser로 다루겠다는 뜻이고 Response<User>는 res.json에 User만 넘기게 합니다. 오류 처리 미들웨어는 매개변수가 네 개여야 Express가 알아보므로, 쓰지 않는 _next도 지우지 않습니다.

서버를 띄우고 다른 터미널에서 요청을 보내 봅니다.

curl http://localhost:3000/api/users
curl -X POST -H "Content-Type: application/json" -d '{"name":"Carol","email":"[email protected]"}' http://localhost:3000/api/users
[{"id":1,"name":"Alice","email":"[email protected]"},{"id":2,"name":"Bob","email":"[email protected]"}]
{"id":3,"name":"Carol","email":"[email protected]"}

res.json은 객체를 공백 없는 JSON 문자열로 보내므로 출력이 한 줄로 나옵니다. 두 번째 요청은 상태 코드 201과 함께 새 사용자를 돌려주며, 사용자는 메모리의 배열에만 들어가므로 서버를 다시 시작하면 사라집니다.

여기서 중요한 점은 NewUser라는 타입이 요청 본문을 검사하지 않는다는 것입니다. -d '{"name":12}'를 보내도 서버는 201과 {"id":4,"name":12}를 돌려주는데, req.body의 실제 값은 클라이언트가 보낸 JSON이고 타입 인수는 그 값을 어떻게 다룰지 컴파일러에게 알려 줄 뿐이기 때문입니다. 바깥에서 들어온 값을 검사하는 방법은 이 글의 마지막 절에서 다룹니다.

라우터와 경로 매개변수

경로가 늘어나면 기능별로 Router를 만들어 파일을 나눕니다. 다음 라우터는 경로 매개변수 :id로 사용자 한 명을 찾아 돌려주고, 없으면 404와 안내 메시지를 돌려줍니다. 라우터 안의 경로에는 등록할 때 붙일 앞부분을 뺀 나머지만 적습니다.

src/routes/userRoutes.ts
import express from "express";
import type { Request, Response } from "express";

interface User {
  id: number;
  name: string;
  email: string;
}

const users: User[] = [
  { id: 1, name: "Alice", email: "[email protected]" },
  { id: 2, name: "Bob", email: "[email protected]" },
];

const router = express.Router();

router.get("/:id", (req: Request<{ id: string }>, res: Response<User | { message: string }>) => {
  const userId = Number.parseInt(req.params.id, 10);
  const user = users.find((u) => u.id === userId);
  if (user) {
    res.json(user);
  } else {
    res.status(404).json({ message: "User not found" });
  }
});

export default router;

메인 파일에서는 import userRoutes from "./routes/userRoutes.js";로 라우터를 가져와 app.use("/api/users", userRoutes);로 등록합니다. 그러면 /api/users/1 요청이 이 라우터의 /:id로 들어오고, req.params.id는 문자열 "1"입니다. 경로 매개변수는 URL의 일부이므로 언제나 문자열이며, Request<{ id: string }>는 그 사실을 타입으로 적은 것입니다.

Number.parseInt는 문자열의 앞에서부터 숫자로 읽을 수 있는 데까지만 읽습니다. 그래서 /api/users/1abc도 1로 해석되어 Alice를 돌려주고, /api/users/abc는 NaN이 되어 404가 됩니다. 형식이 잘못된 ID와 없는 ID를 구별하려면 문자열 전체를 검사해야 하며, 그 방법도 마지막 절에서 봅니다.

미들웨어와 Request 보강

미들웨어는 (req, res, next)를 받는 함수로, 일을 마치면 next()를 불러 다음 미들웨어나 처리 함수로 넘깁니다. 다음 미들웨어는 요청의 메서드와 URL을 콘솔에 찍고 바로 next()를 부르는 가장 단순한 형태입니다. 응답 객체는 쓰지 않으므로 매개변수 이름을 _res로 적어 그 사실을 드러냈습니다.

src/middleware/loggerMiddleware.ts
import type { NextFunction, Request, Response } from "express";

export default function loggerMiddleware(req: Request, _res: Response, next: NextFunction) {
  console.log(`${req.method} ${req.url}`);
  next();
}

app.use(loggerMiddleware)로 등록하면 모든 요청마다 GET /api/users/1 같은 줄이 서버의 콘솔에 찍힙니다. next()를 부르지도 응답을 보내지도 않으면 요청은 응답을 받지 못한 채 멈추므로, 미들웨어는 둘 가운데 하나를 반드시 해야 합니다.

인증 미들웨어가 req.user처럼 요청에 새 속성을 붙인다면, 그 속성의 타입은 모든 처리 함수가 함께 알아야 합니다. 이때는 Express가 열어 둔 전역 Express.Request 인터페이스를 보강합니다.

src/types/express.d.ts
export {};

declare global {
  namespace Express {
    interface Request {
      user?: { id: number; username: string };
    }
  }
}

이 파일이 include의 src 안에 있으면, 모든 처리 함수에서 req.user가 { id: number; username: string } | undefined 타입이 됩니다. 그래서 req.user?.username ?? "guest"처럼 값이 없는 경우를 함께 처리하게 됩니다. 보강은 속성의 타입만 더하므로, 실제 req.user에 값을 넣는 일은 토큰을 검증한 인증 미들웨어가 해야 하며 그런 미들웨어가 없으면 이 식은 언제나 guest입니다.

서버의 입구에서 값 검사하기

지금까지의 서버는 JSON 해석과 정적 타입만 갖추었습니다. 아래 그림은 여기에 인증과 입력 검증, 서비스 분리를 더한 설계로, 요청의 모든 값을 unknown으로 보고 검사를 통과한 값만 도메인 코드에 넘깁니다.

HTTP INPUT · RUNTIME TYPE BOUNDARY

Express 경계에서 unknown을 검증된 도메인 입력으로 바꾼다

확장 설계: 원문 Express 예제는 JSON 파싱과 정적 타입만 구현합니다. 아래 인증·Zod 검증·service 분리는 추가 구성입니다. 인증이 principal을 확인하고 Zod는 입력 DTO를 검증하며, 둘을 framework 객체 없이 service에 전달합니다.

확장 설계의 입력 검증과 응답 분기원시 입력이 parser, 인증, Zod를 거쳐 service에 들어갑니다. service의 성공은 공개 DTO, 실패는 중앙 오류 처리로 구분합니다. parser와 인증 및 검증의 오류도 중앙 오류 처리 계약에 포함합니다. 입력요청문맥검증 DTO + principal성공공개 응답실패 / next(error)오류EXTERNAL원시 입력외부 HTTP 값PARSERHTTP 해석크기 · 형식AUTHprincipal 확인인증된 사용자ZOD입력 검증DTO 구조 검증SERVICE도메인 작업framework 분리SUCCESS공개 DTO허용한 필드만 반환FAILURE오류 처리상태 · 안전한 bodyHTTP응답성공 · 실패
  1. 신뢰하지 않는 입력

    params·query·body는 모두 unknown으로 시작하며 타입 단언으로 검증을 건너뛰지 않습니다.

  2. parser와 인증

    HTTP 형식·크기·content type을 제한하고 인증이 principal identity를 요청 문맥에 붙입니다.

  3. runtime validation

    Zod가 입력의 구조와 제약을 검사해 DTO를 만듭니다. principal은 앞선 인증 결과에서 가져옵니다.

  4. 도메인 service

    service는 Express의 Request·Response를 모르고 DTO와 context만 받아 도메인 결과를 반환합니다.

  5. 응답 경계

    성공은 response DTO로, 실패는 중앙 error middleware에서 안전한 상태와 오류 body로 바뀝니다.

입력 단계의 실패도 중앙 오류 계약으로 전달합니다. Express 5의 async handler가 반환한 Promise rejection은 자동 전달되지만, callback·별도 비동기 작업은 next(error) 등으로 연결해야 합니다. 정적 타입과 JSON 파싱은 구조 검증이나 인증을 대신하지 않습니다.

그림은 Zod 같은 검증 라이브러리를 쓰는 구성이지만, 여기서는 생각을 보이기 위해 검증 함수를 직접 작성하고 인증과 서비스 분리는 다루지 않습니다. 먼저 경로의 ID는 문자열 전체가 양의 정수인지 확인합니다.

src/validation.ts
export interface NewUser {
  name: string;
  email: string;
}

export function parsePositiveId(raw: string): number | null {
  if (!/^[1-9]\d*$/.test(raw)) return null;
  const id = Number(raw);
  return Number.isSafeInteger(id) ? id : null;
}

export function parseNewUser(body: unknown): NewUser | null {
  if (typeof body !== "object" || body === null) return null;
  const { name, email } = body as Record<string, unknown>;
  if (typeof name !== "string" || name.trim() === "") return null;
  if (typeof email !== "string" || !email.includes("@")) return null;
  return { name: name.trim(), email };
}

parsePositiveId는 "1"을 1로 바꾸고, "1abc", "0", "01", 안전한 정수의 범위를 넘는 값은 null로 돌려줍니다. parseNewUser는 본문이 객체이고 name과 email이 조건에 맞는 문자열일 때만 NewUser를 돌려주며, 이메일은 @가 있는지만 보는 간단한 검사입니다. 처리 함수는 null을 받으면 400을 보내고, 검증된 값으로만 다음 일을 합니다.

router.get("/:id", (req: Request<{ id: string }>, res: Response<User | { message: string }>) => {
  const id = parsePositiveId(req.params.id);
  if (id === null) {
    res.status(400).json({ message: "Invalid id" });
    return;
  }
  const user = users.find((u) => u.id === id);
  if (user) {
    res.json(user);
  } else {
    res.status(404).json({ message: "User not found" });
  }
});

이제 /api/users/1abc는 400과 {"message":"Invalid id"}를, /api/users/9는 404와 {"message":"User not found"}를 돌려주어 형식 오류와 없는 사용자가 구별됩니다. 응답을 보낸 뒤 return을 적은 것은, 400을 보낸 요청이 아래의 사용자 찾기와 두 번째 응답으로 넘어가지 않게 하기 위해서입니다. 지금의 @types/express 5는 처리 함수의 반환 타입을 unknown으로 적고 있어 return res.status(400).json(...)처럼 한 줄로 써도 진단되지 않지만, 이 글은 응답을 보내는 줄과 함수를 끝내는 줄을 나누어 적습니다. POST의 처리 함수도 같은 방식으로 parseNewUser(req.body)가 null이면 400을 보내게 바꿉니다.

오류 처리 미들웨어도 오류의 종류를 살펴야 합니다. express.json()은 해석할 수 없는 JSON을 받으면 status가 400인 오류를 다음으로 넘기는데, 앞의 오류 처리 미들웨어는 언제나 500을 보내므로 클라이언트의 잘못이 서버의 잘못처럼 보입니다.

function statusOf(err: unknown): number {
  if (typeof err === "object" && err !== null && "status" in err && typeof err.status === "number") {
    return err.status;
  }
  return 500;
}

app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  const status = statusOf(err);
  if (status >= 500) console.error(err);
  res.status(status).json({ message: status >= 500 ? "Internal Server Error" : "Bad Request" });
});

"status" in err는 err가 status 속성을 가진 객체라고 좁혀 주므로, 그 뒤에 err.status의 타입을 검사할 수 있습니다. 서버의 잘못일 때만 오류를 기록하고, 응답의 본문에는 내부의 오류 메시지 대신 정해 둔 문구만 담아 서버의 내부 정보가 새지 않게 합니다. Express 5는 async 처리 함수가 거부된 Promise를 돌려주면 그 오류를 자동으로 이 미들웨어에 넘기지만, 콜백이나 따로 시작한 비동기 작업의 오류는 next(error)로 직접 넘겨야 합니다.

Express의 요청 객체 문서도 req.body는 사용자가 보낸 값이므로 믿기 전에 검증하라고 안내합니다. 실무에서는 Zod 같은 라이브러리로 스키마를 한 번 적고, 그 스키마에서 검증 함수와 타입을 함께 얻는 방식을 많이 씁니다.

더 깊이: Express 5에서 달라진 동작

Express 5로 옮겨 가는 안내에 따르면, Express 5는 미들웨어와 처리 함수가 돌려준 Promise가 거부되면 그 값을 오류로 보고 오류 처리 미들웨어로 넘깁니다. async 처리 함수 안에서 예외를 던지거나 거부된 Promise를 await해도 next(err)를 부른 것처럼 처리되므로, Express 4처럼 .catch(next)를 일일이 붙이지 않아도 됩니다. @types/express 5도 처리 함수의 반환 타입을 unknown으로 적고 있어, Promise를 돌려주는 async 함수를 처리 함수로 그대로 넘길 수 있습니다.

몇 가지 기본 동작도 바뀌었습니다. 본문을 해석하는 미들웨어를 거치지 않은 요청에서 req.body는 Express 4의 {}가 아니라 undefined이고, express.urlencoded의 extended 옵션은 기본값이 false가 되었습니다. 경로 문자열의 문법도 달라져서 와일드카드 *에는 /*splat처럼 이름을 붙여야 하고, 생략할 수 있는 부분은 ? 대신 /:file{.:ext}처럼 중괄호로 적습니다. 그래서 Express 4용으로 쓴 예제나 미들웨어를 옮겨 올 때는 경로 문법과 req.body를 읽는 코드를 먼저 확인합니다.

정리

  • Node.js 프로젝트는 "type": "module"과 module: "nodenext"로 설정하고, 상대 경로 import에는 출력 파일의 확장자 .js를 적습니다.
  • TypeScript 6.0부터 types의 기본값이 비어 있으므로 Node.js의 전역 타입을 쓰려면 "types": ["node"]를 적고, tsx는 타입을 검사하지 않으므로 tsc --noEmit으로 따로 검사합니다.
  • Request의 타입 인수는 경로 매개변수, 응답 본문, 요청 본문, 쿼리의 타입을 정하지만, 실제 값을 검사하지는 않습니다.
  • 미들웨어는 next()를 부르거나 응답을 보내야 하며, 요청에 붙이는 속성은 전역 Express.Request를 보강해 모든 처리 함수가 알게 합니다.
  • 바깥에서 들어온 값은 unknown으로 보고 검사한 뒤에 쓰며, 형식 오류는 400, 없는 자원은 404, 서버의 잘못은 500으로 구별합니다.

연습 문제

1. 타입과 다른 본문

첫 서버에 {"name":12}를 POST로 보내면 무엇이 돌아오나요? 검증을 넣은 뒤에는 어떻게 바뀌나요?

해설 보기

첫 서버는 본문을 검사하지 않으므로 201과 {"id":4,"name":12} 같은 응답을 돌려주며, 숫자 이름과 빠진 이메일이 그대로 저장됩니다. parseNewUser를 넣으면 name이 문자열이 아니어서 null이 되고, 처리 함수가 400을 보내므로 아무것도 저장되지 않습니다.

2. ID 문자열의 해석

"1abc", "0", "abc"를 ID로 보냈을 때, Number.parseInt를 쓰는 처음 라우터와 parsePositiveId를 쓰는 라우터는 각각 무엇을 돌려주나요?

해설 보기

처음 라우터에서 "1abc"는 1로 읽혀 Alice를 돌려주고, "0"은 해당 사용자가 없어 404, "abc"는 NaN이 되어 404입니다. parsePositiveId를 쓰는 라우터는 세 값을 모두 null로 판정해 400을 돌려주므로, 형식이 틀린 요청과 없는 사용자가 다른 상태 코드로 구별됩니다.

3. 잘못된 JSON의 상태 코드

-d '{"name":'처럼 끝나지 않은 JSON을 보냈습니다. 처음의 오류 처리 미들웨어와 statusOf를 쓰는 미들웨어는 각각 어떤 상태 코드를 보내나요?

해설 보기

express.json()이 해석에 실패해 status가 400인 오류를 넘깁니다. 처음 미들웨어는 오류를 살피지 않고 언제나 500을 보내지만, statusOf를 쓰는 미들웨어는 그 status를 읽어 400과 {"message":"Bad Request"}를 보냅니다. 클라이언트가 고쳐야 할 요청과 서버가 고쳐야 할 오류가 상태 코드로 구별됩니다.

다음 글 NestJS 프레임워크에서는 모듈과 의존성 주입으로 구조를 잡아 주는 NestJS로 같은 종류의 API를 만들고, 요청이 처리되는 순서를 따라갑니다.