Node.js와 Express
Express 프로젝트에 TypeScript를 설정하고 요청·응답·라우터·미들웨어 타입을 적용한 기본 API 서버를 구축합니다.
웹 애플리케이션은 사용자에게 보이는 부분인 프론트엔드(Frontend)와, 데이터 처리, 비즈니스 로직, 데이터베이스 관리 등을 담당하는 백엔드(Backend)로 구성됩니다.
프론트엔드가 사용자 경험(UX)을 결정한다면, 백엔드는 애플리케이션의 핵심 기능과 데이터의 안정성을 책임집니다.
이 절에서는 백엔드 개발에서 널리 쓰이는 Node.js와 Express를 다룹니다.
Node.js는 자바스크립트를 서버 환경에서 실행하게 해주고, Express는 그 위에서 웹 서버와 API를 빠르게 구축하도록 돕습니다.
여기에 타입스크립트를 결합해 더 견고하고 유지보수하기 쉬운 백엔드 코드를 작성하는 방법을 살펴보겠습니다.
Node.js 소개
Node.js는 Google Chrome의 V8 JavaScript 엔진으로 빌드된 자바스크립트 런타임입니다.
즉, 웹 브라우저 밖에서도 자바스크립트 코드를 실행할 수 있게 해줍니다.
Node.js의 가장 큰 특징은 다음과 같습니다.
- 비동기 논블로킹 I/O: Node.js는 단일 스레드 기반의 이벤트 루프(Event Loop) 모델을 사용하여 I/O(입출력) 작업을 비동기적으로 처리합니다. 이는 많은 동시 연결을 효율적으로 처리할 수 있게 하여 높은 처리량과 확장성을 제공합니다.
- 자바스크립트 사용: 프론트엔드와 백엔드 모두 자바스크립트(또는 타입스크립트)를 사용하여 풀 스택(Full-stack) 개발이 가능해집니다. 이는 개발 생산성을 높이고, 개발팀의 기술 스택 통일을 용이하게 합니다.
- 방대한 생태계 (NPM): Node.js는 세계에서 가장 큰 패키지 생태계인 NPM(Node Package Manager)을 통해 수많은 라이브러리와 도구를 제공합니다.
Node.js 설치
Node.js는 공식 웹사이트에서 다운로드하여 설치할 수 있습니다.
설치 후 터미널에서 다음 명령어로 버전을 확인할 수 있습니다.
node -v
npm -vExpress 소개
Express는 Node.js를 위한 빠르고 개방적이며 최소한의 웹 프레임워크입니다.
웹 애플리케이션과 API를 구축하는 데 필요한 강력한 기능 세트를 제공합니다.
- 간결한 API: 라우팅, 미들웨어, 템플릿 엔진 통합 등 웹 애플리케이션 개발에 필요한 기본적인 기능들을 쉽고 직관적인 API로 제공합니다.
- 유연성: 특정 아키텍처나 패턴을 강제하지 않고, 개발자가 원하는 방식으로 애플리케이션을 구성할 수 있도록 높은 자유도를 제공합니다.
- 미들웨어 시스템: 요청(request)과 응답(response) 사이에서 다양한 작업을 수행할 수 있는 미들웨어 개념을 통해 기능을 확장하기 용이합니다. (예: 로깅, 인증, 데이터 파싱)
Node.js 및 Express 프로젝트에 타입스크립트 적용하기
타입스크립트를 Node.js 및 Express 프로젝트에 적용하면, 런타임 이전에 타입 오류를 감지하고, 코드의 가독성과 유지보수성을 높일 수 있습니다.
Express + TypeScript 초기 설정
새로운 프로젝트를 시작하고 타입스크립트 환경을 설정하는 단계입니다.
mkdir my-express-ts-app
cd my-express-ts-app
npm init -y필수 의존성 설치 Node.js, Express, 그리고 타입스크립트 관련 패키지를 설치합니다.
npm install express
npm install --save-dev typescript @types/node @types/express ts-node nodemontypescript: 타입스크립트 컴파일러@types/node: Node.js 코어 모듈에 대한 타입 정의@types/express: Express 프레임워크에 대한 타입 정의ts-node: 타입스크립트 파일을 직접 실행할 수 있게 해주는 도구 (개발용)nodemon: 파일 변경 시 자동으로 서버를 재시작해주는 도구 (개발용)
tsconfig.json 설정:
npx tsc --init 명령어를 실행하여 기본 tsconfig.json 파일을 생성합니다.
npx tsc --init이 학습용 Node.js 프로젝트에서는 생성된 tsconfig.json의 전체 내용을 아래 설정으로 교체합니다.
{
"compilerOptions": {
"target": "es2018", // Node.js 버전과 호환되는 ES 버전
"module": "commonjs", // 이 예제는 CommonJS 출력을 선택
"lib": ["es2018"], // 사용 가능한 런타임 라이브러리
"outDir": "./dist", // 컴파일된 JS 파일이 저장될 디렉토리
"rootDir": "./src", // 소스 코드의 루트 디렉토리
"strict": true, // 엄격한 타입 검사 활성화
"esModuleInterop": true, // CommonJS/ES Modules 간의 상호 운용성 지원
"skipLibCheck": true, // 선언 파일(*.d.ts)에 대한 타입 검사 건너뛰기
"forceConsistentCasingInFileNames": true, // 파일 이름 대소문자 일관성 강제
"noImplicitAny": true, // 'any' 타입으로 추론되는 경우 오류 발생
"resolveJsonModule": true // JSON 파일을 모듈로 가져올 수 있게 허용
},
"include": ["src/**/*.ts"], // 컴파일할 파일 지정
"exclude": ["node_modules"] // 컴파일에서 제외할 파일 지정
}package.json 스크립트 추가:
npm install이 기록한 의존성 버전은 그대로 두고, 기존 package.json의 scripts 객체를 다음 내용으로 바꿉니다.
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts"
}
}Express 서버 구축 예시
이제 타입스크립트 환경에서 간단한 Express 서버를 구축해봅시다.
src/index.ts)import express, { Request, Response, NextFunction } from 'express';
const app = express();
const port = 3000;
// JSON 요청 본문을 파싱하기 위한 미들웨어
app.use(express.json());
// 루트 경로 핸들러
app.get('/', (req: Request, res: Response) => {
res.send('Hello from Express with TypeScript!');
});
// GET 요청 예시
app.get('/api/users', (req: Request, res: Response) => {
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
res.json(users);
});
// POST 요청 예시: 새로운 사용자 추가
interface NewUserRequest extends Request {
body: { name: string; email: string };
}
app.post('/api/users', (req: NewUserRequest, res: Response) => {
const newUser = req.body; // req.body는 타입스크립트에서 기본적으로 any로 추론됩니다.
// 인터페이스로 명시하여 타입 안정성을 확보할 수 있습니다.
console.log('Received new user:', newUser);
// 실제 애플리케이션에서는 데이터베이스에 저장하는 로직이 들어갑니다.
res.status(201).json({ message: 'User created successfully', user: newUser });
});
// 에러 핸들링 미들웨어 (가장 마지막에 위치)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
// 서버 시작
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
console.log('Press Ctrl+C to stop the server.');
});터미널에서 다음 명령어를 실행하여 개발 서버를 시작합니다.
npm run dev파일을 수정하면 nodemon이 자동으로 서버를 재시작해줍니다.
프로덕션 배포를 위해서는 먼저 타입스크립트 코드를 자바스크립트로 컴파일해야 합니다.
npm run build
npm run startbuild 명령어가 dist 디렉터리에 .js 파일을 생성하고, start 명령어가 이 컴파일된 자바스크립트 파일을 실행합니다.
Express 라우팅 및 미들웨어에 타입 적용
Express는 라우팅과 미들웨어를 통해 요청 처리를 구성합니다.
타입스크립트와 함께 사용하면 이 과정도 타입 안전하게 만들 수 있습니다.
라우터 분리 및 타입 적용
대규모 애플리케이션에서는 모든 라우트를 하나의 파일에 넣는 대신, 기능별로 라우터를 분리하여 관리하는 것이 일반적입니다.
import { Router, Request, Response } from 'express';
const router = Router();
// 사용자 관련 타입 정의
interface User {
id: number;
name: string;
email: string;
}
// GET /api/users/:id
router.get('/:id', (req: Request<{ id: string }>, res: Response<User | { message: string }>) => {
const userId = parseInt(req.params.id);
// 간단한 더미 데이터
const users: User[] = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
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 express, { Request, Response, NextFunction } from 'express';
import userRoutes from './routes/userRoutes'; // 라우터 임포트
const app = express();
const port = 3000;
app.use(express.json());
app.get('/', (req: Request, res: Response) => {
res.send('Hello from Express with TypeScript!');
});
// /api/users 경로로 들어오는 모든 요청은 userRoutes가 처리
app.use('/api/users', userRoutes); // 라우터 미들웨어 등록
// ... (기존 에러 핸들링 미들웨어 및 서버 시작 코드)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});Express의 Request, Response 객체는 제네릭 타입을 지원하여 req.params, req.query, req.body, res.json 등의 타입을 명시적으로 지정할 수 있습니다.
예를 들어, Request<{ id: string }>는 req.params.id가 string 타입임을 나타냅니다.
커스텀 미들웨어에 타입 적용
미들웨어도 타입스크립트와 함께 작성할 수 있습니다.
import { Request, Response, NextFunction } from 'express';
const loggerMiddleware = (req: Request, res: Response, next: NextFunction) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
next(); // 다음 미들웨어 또는 라우트 핸들러로 제어권 넘기기
};
export default loggerMiddleware;미들웨어가 req.user처럼 앱 전체에서 쓰는 요청 속성을 추가한다면 한 함수의 매개변수만 확장해서는 다음 핸들러의 정적 타입이 바뀌지 않습니다.
Express의 Request 인터페이스를 모듈 보강하여 모든 핸들러가 같은 계약을 보도록 만듭니다.
export {};
declare global {
namespace Express {
interface Request {
user?: { id: number; username: string };
}
}
}import express from 'express';
import userRoutes from './routes/userRoutes';
import loggerMiddleware from './middleware/loggerMiddleware'; // 미들웨어 임포트
const app = express();
const port = 3000;
app.use(express.json());
app.use(loggerMiddleware); // 모든 요청에 대해 로거 미들웨어 적용
app.get('/', (req, res) => {
const username = req.user?.username ?? 'guest';
res.send(`Hello, ${username}!`);
});
app.use('/api/users', userRoutes);
// ... (에러 핸들링 미들웨어 및 서버 시작 코드)모듈 보강은 속성의 타입만 정의합니다.
실제 req.user 값은 인증 미들웨어가 검증을 마친 뒤 할당해야 합니다.
라우터와 미들웨어가 늘어날수록 요청 타입, 공통 컨텍스트, 응답 형태, 오류 처리를 계층별로 나누어 두는 것이 중요합니다.
Node.js와 Express, 타입스크립트의 이점
Node.js와 Express를 타입스크립트와 함께 사용하는 것은 다음과 같은 이점을 제공합니다.
- 타입 안전성: API 요청/응답, 미들웨어 인자, 데이터베이스 모델 등 모든 데이터 흐름에 타입을 적용하여 런타임 오류를 줄이고 코드의 안정성을 높입니다.
- 생산성 향상: IDE의 자동 완성, 리팩터링 기능이 강화되어 개발 속도가 향상됩니다.
- 코드 가독성 및 유지보수성: 타입 정의는 코드의 의도를 명확하게 하고, 복잡한 비즈니스 로직을 이해하기 쉽게 만들어 장기적인 유지보수에 유리합니다.
- 견고한 API 설계: 명확한 타입 정의는 API 인터페이스를 문서화하는 역할을 하며, 프론트엔드 개발자와의 협업을 원활하게 합니다.
- 단일 언어 스택: 프론트엔드와 백엔드 모두 자바스크립트(타입스크립트)를 사용하여 개발팀의 기술 스택 부담을 줄이고 풀 스택 개발을 용이하게 합니다.
Express 서버는 라우터, 미들웨어, 오류 처리의 경계를 분명히 나누면 타입 정의가 문서와 검증 역할을 함께 하게 됩니다.
서버 기초 요약
Node.js와 Express는 강력하고 유연한 백엔드 개발 환경을 제공합니다.
여기에 타입스크립트의 정적 타입 시스템을 결합하면, 개발 생산성을 높이고, 런타임 오류를 줄이며, 대규모 애플리케이션의 유지보수를 용이하게 하는 견고하고 안정적인 백엔드 서비스를 구축할 수 있습니다.
다음 절에서는 NestJS의 모듈과 의존성 주입 구조를 살펴보겠습니다.
아래 그림은 런타임 검증과 서비스 경계를 추가한 설계입니다. 원문 서버는 JSON 파싱과 정적 타입만 지정하고 Zod·인증·DB 저장을 구현하지 않습니다. NewUserRequest는 잘못된 요청 본문을 거절하지 않으며 parseInt("1abc")도 1을 반환하므로, 전체 문자열의 ID 형식 검증과 다릅니다.
HTTP INPUT · RUNTIME TYPE BOUNDARY
Express 경계에서 unknown을 검증된 도메인 입력으로 바꾼다
확장 설계: 원문 Express 예제는 JSON 파싱과 정적 타입만 구현합니다. 아래 인증·Zod 검증·service 분리는 추가 구성입니다. 인증이 principal을 확인하고 Zod는 입력 DTO를 검증하며, 둘을 framework 객체 없이 service에 전달합니다.
-
신뢰하지 않는 입력
params·query·body는 모두
unknown으로 시작하며 타입 단언으로 검증을 건너뛰지 않습니다. -
parser와 인증
HTTP 형식·크기·content type을 제한하고 인증이 principal identity를 요청 문맥에 붙입니다.
-
runtime validation
Zod가 입력의 구조와 제약을 검사해 DTO를 만듭니다. principal은 앞선 인증 결과에서 가져옵니다.
-
도메인 service
service는 Express의 Request·Response를 모르고 DTO와 context만 받아 도메인 결과를 반환합니다.
-
응답 경계
성공은 response DTO로, 실패는 중앙 error middleware에서 안전한 상태와 오류 body로 바뀝니다.
입력 단계의 실패도 중앙 오류 계약으로 전달합니다. Express 5의 async handler가 반환한 Promise rejection은 자동 전달되지만, callback·별도 비동기 작업은 next(error) 등으로 연결해야 합니다. 정적 타입과 JSON 파싱은 구조 검증이나 인증을 대신하지 않습니다.
요청 값과 서버 응답을 직접 추적하기
이 절의 src/index.ts 블록은 같은 파일을 단계별로 바꾼 모습입니다. 첫 서버, 라우터 분리 서버, 로거 추가 서버를 한 파일에 이어 붙이지 않습니다. 마지막 로거 예제의 주석 부분에는 앞에서 본 오류 처리와 서버 시작 코드가 필요합니다. NewUserRequest는 개발 중 접근하는 속성의 타입을 정할 뿐, 외부 JSON을 검사하거나 DB에 저장하지 않습니다.
기본 POST에 JSON 객체 { "name": 12 }가 전달되었다고 해 봅시다. 핸들러는 req.body를 그대로 받아 201과 같은 객체를 반환합니다. 숫자 이름과 누락된 이메일을 거절하는 조건이 없으므로 201을 회원 저장 성공으로 해석하면 안 됩니다. 앞 그림의 인증·Zod·service 경계를 실제 코드에 연결해야 입력 검사와 저장이 추가됩니다. Express의 요청 본문 설명도 본문 값을 신뢰하기 전에 검증하도록 안내합니다.
ID 라우트에서도 parseInt("1abc")는 1이므로 Alice를 찾습니다. ID 전체가 양의 정수 문자열이어야 한다는 정책을 택한다면 다음처럼 형식과 안전한 숫자 범위를 함께 확인할 수 있습니다. 이 보조 함수는 검증 방법을 보여 주며, 앞 라우터에 자동으로 적용되지는 않습니다.
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;
}이 정책에서는 "1"은 1, "1abc"·"0"·"01"·안전한 정수 범위를 넘는 값은 null입니다. null을 400으로 응답하고, 검증된 ID가 데이터에 없을 때 404로 응답하도록 라우터를 바꾸어야 형식 오류와 미존재를 구별할 수 있습니다.
연습: 파싱·검증·등록의 차이
- 기본 POST에
{ "name": 12 }가 들어왔다. 상태와user값은 무엇인가? 이메일 중복·DB 저장까지 확인되었는가? - 기존 GET 라우트에 ID
"1abc"·"0"·"abc"를 각각 넣었을 때 결과를 구하고, 위 보조 함수를 적용한 뒤의 형식 오류 판정을 설명하라. - 로거 예제에서 인증 미들웨어가
req.user를 할당하지 않았다. 루트 응답의 이름은 무엇인가? 또한 기본 서버의 오류 미들웨어에 상태 400인 JSON 파싱 오류가 이미 전달되었다면 어떤 상태를 보내는가?
풀이: 정적 계약과 실행 중 분기를 따로 확인하기
- 현재 POST는 201과
user: { name: 12 }를 반환한다. 이메일 누락이나 이름 타입을 검사하지 않으며 저장 함수도 호출하지 않는다. 이메일 중복 검사·해시·DB 저장은 추가 구현 대상이다. "1abc"는 1이 되어 Alice를 반환한다."0"은 일치하는 ID가 없어 404,"abc"는NaN이 되어 모든 비교가 실패하므로 404다. 보조 함수는 세 값 모두null로 판정한다. 이를 먼저 연결하고 400 분기를 추가한 뒤에만 세 요청을 형식 오류로 거절한다.- 선택적 속성 접근과
??때문에 이름은guest다. 선언 파일 보강만으로 사용자가 생기지 않는다. 기본 오류 미들웨어는 오류의status를 읽지 않고 항상 500을 보낸다. 파싱 오류를 400으로 유지하려면 오류 종류와 허용한 상태를 확인하는 처리가 필요하다. 로거의next()는 다음 단계로 넘기는 호출이며 인증이나 검증 결과가 아니다.