TypeScript 실전 프로젝트 | REST API 서버 만들기
이 글의 핵심
타입 문법을 따로 배운 뒤 실제 서버에 붙여 보면 요청 본문, 서비스 반환값, 에러 응답 사이에서 타입이 끊기는 지점이 드러납니다. src/types에 User와 API 응답 타입을 먼저 정의하고 서비스·컨트롤러·라우터가 이를 공유하도록 구성합니다. Request 제네릭으로 body에 타입을 붙여도 들어온 JSON은 따로 런타임 검증이 필요하다는 점도 짚습니다.
들어가며
이 글에서는 TypeScript로 요청·응답 형태가 컴파일 타임에 맞는 REST API 서버를 한 바퀴 구성해 봅니다. Express와 타입 정의를 연결하는 흐름을 따라가시면 됩니다.
프로젝트 설정
초기화
mkdir typescript-api
cd typescript-api
npm init -y
npm install express
npm install --save-dev typescript @types/node @types/express ts-node nodemon
Express는 JavaScript로 작성되어 타입 정보를 포함하지 않으므로, DefinitelyTyped 커뮤니티가 관리하는 @types/express를 따로 설치합니다. 이 패키지의 메이저 버전은 Express 버전과 맞아야 합니다. Express 5를 쓰면서 @types/express@4를 설치하면 실제로는 없는 API가 타입에는 보이거나, 반대로 새 API에 타입 에러가 납니다. ts-node는 TypeScript를 미리 컴파일하지 않고 바로 실행해 주는 도구이고, nodemon은 파일이 바뀔 때마다 프로세스를 다시 시작합니다. ts-node는 실행할 때 타입 검사까지 하므로 프로젝트가 커지면 재시작이 느려지는데, 요즘은 타입 검사 없이 빠르게 실행만 하는 tsx(tsx watch src/index.ts)로 대체하고 타입 검사는 tsc --noEmit으로 따로 돌리는 구성도 많이 씁니다.
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json 스크립트
{
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
tsconfig에서 서버 프로젝트에 특히 중요한 옵션을 짚어 보면, "module": "commonjs"는 컴파일 결과를 require 형식으로 만듭니다. package.json에 "type": "module"을 넣은 ESM 프로젝트라면 "module": "NodeNext"로 바꾸고 import 경로에 .js 확장자를 붙여야 하는데, 두 방식을 섞으면 ERR_REQUIRE_ESM이나 Cannot use import statement outside a module 같은 에러가 납니다. "lib": ["ES2020"]에 DOM을 넣지 않은 것은 의도적입니다. 서버 코드에서 실수로 window나 document를 쓰면 컴파일 에러로 잡히게 하려는 것입니다.
rootDir과 outDir을 지정하면 src/index.ts가 dist/index.js로 같은 구조를 유지하며 컴파일됩니다. src 밖의 파일(예: 루트의 설정 파일)을 src 안에서 import하면 rootDir 밖이라는 에러가 나거나, rootDir을 지정하지 않은 경우 출력 구조가 dist/src/index.js로 바뀌어 start 스크립트가 파일을 찾지 못하게 됩니다. strict: true는 이 글의 전제입니다. 이 옵션이 없으면 undefined 검사 같은 핵심 보호가 꺼져서 TypeScript를 쓰는 의미가 크게 줄어듭니다.
타입 정의
src/types/user.ts
export interface User {
id: string;
name: string;
email: string;
age: number;
createdAt: Date;
}
export type CreateUserDto = Omit<User, "id" | "createdAt">;
export type UpdateUserDto = Partial<CreateUserDto>;
export type UserResponse = Omit<User, "createdAt"> & {
createdAt: string;
};
타입을 계층마다 따로 정의하지 않고 User 하나에서 파생시키는 것이 이 구조의 핵심입니다. CreateUserDto는 서버가 채우는 id와 createdAt을 뺀 입력 모양이고, UpdateUserDto는 그중 일부만 보내도 되는 수정용 모양입니다. User에 phone 필드를 추가하면 세 타입이 모두 자동으로 따라 바뀌어, 생성 로직에서 새 필드를 빠뜨린 곳이 컴파일 에러로 드러납니다.
UserResponse를 따로 둔 이유는 JSON 직렬화 경계 때문입니다. Date 객체는 res.json()을 거치면 ISO 문자열이 되므로, 클라이언트가 받는 실제 모양은 createdAt: string입니다. 이 차이를 타입으로 표현하지 않으면 프런트엔드가 user.createdAt.getTime()을 호출했다가 런타임에 getTime is not a function 에러를 만납니다. 비밀번호 해시처럼 응답에서 빠져야 하는 필드가 생기면 역시 이 응답 타입에서 Omit으로 제외해 두어야, 실수로 노출하는 코드를 컴파일러가 막아 줍니다.
src/types/api.ts
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
ApiResponse<T>는 모든 응답을 { success, data, error } 봉투로 감싸는 형식입니다. 클라이언트가 응답을 일관되게 처리할 수 있다는 장점이 있지만, success: true인데 data가 없거나 success: false인데 error가 없는 조합도 타입상 허용된다는 약점이 있습니다. { success: true; data: T } | { success: false; error: string }처럼 판별 유니언으로 정의하면 if (res.success) 분기 안에서 data가 반드시 존재하는 것으로 좁혀져 더 안전합니다. 또 HTTP 상태 코드가 이미 성공과 실패를 표현하므로 success 필드가 중복이라는 의견도 있어, 팀의 API 규칙에 맞춰 정하면 됩니다. PaginatedResponse는 정의만 하고 이 글에서는 쓰지 않았는데, 목록 API가 커지면 getUsers의 반환 타입으로 쓰게 됩니다.
데이터베이스 (메모리)
실제 DB 대신 Map으로 저장소를 흉내 냈습니다. 서버를 재시작하면 데이터가 사라지지만, 저장소를 클래스 하나로 감싸 두었기 때문에 나중에 Prisma 같은 ORM으로 바꿀 때 서비스 계층은 거의 고치지 않아도 됩니다.
src/database/users.ts
import { User } from "../types/user";
class UserDatabase {
private users: Map<string, User> = new Map();
private currentId = 1;
create(data: Omit<User, "id" | "createdAt">): User {
const user: User = {
id: `U${String(this.currentId++).padStart(3, "0")}`,
...data,
createdAt: new Date()
};
this.users.set(user.id, user);
return user;
}
findAll(): User[] {
return Array.from(this.users.values());
}
findById(id: string): User | undefined {
return this.users.get(id);
}
update(id: string, data: Partial<User>): User | undefined {
const user = this.users.get(id);
if (!user) return undefined;
const updated = { ...user, ...data };
this.users.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.users.delete(id);
}
}
export const userDb = new UserDatabase();
이 코드에는 타입만 보고는 보이지 않는 버그가 두 군데 있습니다. create에서 id를 먼저 쓰고 ...data를 뒤에 펼치기 때문에, 요청 본문에 id 필드가 섞여 들어오면 서버가 만든 ID를 덮어씁니다. 타입상 data에는 id가 없지만, 5장에서 보듯 req.body는 검증 없이 그대로 넘어오므로 런타임에는 어떤 필드든 있을 수 있습니다. update의 Partial<User>는 더 직접적이라 id와 createdAt까지 수정할 수 있게 열려 있습니다. 클라이언트가 {"id": "U002"}를 보내면 Map의 키는 U001인데 객체의 id는 U002인 어긋난 상태가 됩니다.
고치는 방법은 서버가 결정해야 하는 필드를 마지막에 쓰는 것({ ...data, id, createdAt })과, update의 매개변수를 UpdateUserDto로 좁히고 허용된 필드만 골라 복사하는 것입니다. 이런 문제를 대량 할당(mass assignment)이라고 부르며, 사용자가 role: "admin" 같은 필드를 몰래 보내 권한을 올리는 보안 사고로 이어지는 경우가 많습니다. 또 findAll이 내부 객체를 그대로 반환하므로 호출한 쪽에서 객체를 수정하면 저장소 내용도 바뀝니다. 메모리 저장소에서만 생기는 문제지만, 테스트에서 이상한 결과가 나올 때 원인이 되곤 합니다.
서비스 레이어
src/services/userService.ts
import { userDb } from "../database/users";
import { CreateUserDto, UpdateUserDto, User } from "../types/user";
export class UserService {
async createUser(data: CreateUserDto): Promise<User> {
this.validateEmail(data.email);
this.validateAge(data.age);
return userDb.create(data);
}
async getUsers(): Promise<User[]> {
return userDb.findAll();
}
async getUserById(id: string): Promise<User | undefined> {
return userDb.findById(id);
}
async updateUser(id: string, data: UpdateUserDto): Promise<User | undefined> {
if (data.email) {
this.validateEmail(data.email);
}
if (data.age !== undefined) {
this.validateAge(data.age);
}
return userDb.update(id, data);
}
async deleteUser(id: string): Promise<boolean> {
return userDb.delete(id);
}
private validateEmail(email: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("올바른 이메일 형식이 아닙니다");
}
}
private validateAge(age: number): void {
if (age < 0 || age > 150) {
throw new Error("나이는 0-150 사이여야 합니다");
}
}
}
export const userService = new UserService();
서비스 계층은 HTTP를 모르는 비즈니스 로직 자리입니다. Request나 Response를 받지 않고 순수한 데이터만 주고받으므로, 같은 로직을 나중에 CLI 명령이나 메시지 큐 소비자에서도 재사용할 수 있고 Express 없이 단위 테스트하기도 쉽습니다. 메서드들이 지금은 동기 저장소를 쓰는데도 async로 선언한 이유는, 실제 DB로 바꾸면 모든 호출이 비동기가 되기 때문입니다. 처음부터 Promise를 반환하게 해 두면 저장소를 교체할 때 컨트롤러의 await 코드를 고치지 않아도 됩니다.
검증 함수에는 런타임 입력을 생각하면 구멍이 있습니다. validateAge에 "25" 같은 문자열이 들어오면 비교 연산자가 숫자로 변환해 통과하고, 문자열 그대로 저장됩니다. 숫자가 아닌 "abc"는 NaN이 되는데 NaN < 0과 NaN > 150이 모두 false라서 검증을 통과합니다. updateUser의 if (data.email)은 빈 문자열을 거짓으로 보고 검증을 건너뛰므로 이메일이 ""로 바뀔 수 있습니다. 검증 코드를 짤 때 “타입이 이미 보장하니까”라는 가정이 이런 구멍을 만드는데, 입력은 항상 unknown으로 보고 typeof age !== "number" || !Number.isFinite(age)처럼 형태부터 확인해야 합니다. 에러를 일반 Error로 던지면 컨트롤러가 검증 실패(400)와 서버 오류(500)를 구분할 수 없으므로, ValidationError 같은 전용 클래스를 두는 것도 좋습니다.
컨트롤러
src/controllers/userController.ts
import { Request, Response } from "express";
import { userService } from "../services/userService";
import { ApiResponse } from "../types/api";
import { UserResponse } from "../types/user";
export class UserController {
async createUser(req: Request, res: Response): Promise<void> {
try {
const user = await userService.createUser(req.body);
const response: ApiResponse<UserResponse> = {
success: true,
data: {
...user,
createdAt: user.createdAt.toISOString()
}
};
res.status(201).json(response);
} catch (error) {
const response: ApiResponse<never> = {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 에러"
};
res.status(400).json(response);
}
}
async getUsers(req: Request, res: Response): Promise<void> {
try {
const users = await userService.getUsers();
const response: ApiResponse<UserResponse[]> = {
success: true,
data: users.map(user => ({
...user,
createdAt: user.createdAt.toISOString()
}))
};
res.json(response);
} catch (error) {
const response: ApiResponse<never> = {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 에러"
};
res.status(500).json(response);
}
}
async getUserById(req: Request, res: Response): Promise<void> {
try {
const user = await userService.getUserById(req.params.id);
if (!user) {
const response: ApiResponse<never> = {
success: false,
error: "사용자를 찾을 수 없습니다"
};
res.status(404).json(response);
return;
}
const response: ApiResponse<UserResponse> = {
success: true,
data: {
...user,
createdAt: user.createdAt.toISOString()
}
};
res.json(response);
} catch (error) {
const response: ApiResponse<never> = {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 에러"
};
res.status(500).json(response);
}
}
async updateUser(req: Request, res: Response): Promise<void> {
try {
const user = await userService.updateUser(req.params.id, req.body);
if (!user) {
const response: ApiResponse<never> = {
success: false,
error: "사용자를 찾을 수 없습니다"
};
res.status(404).json(response);
return;
}
const response: ApiResponse<UserResponse> = {
success: true,
data: {
...user,
createdAt: user.createdAt.toISOString()
}
};
res.json(response);
} catch (error) {
const response: ApiResponse<never> = {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 에러"
};
res.status(400).json(response);
}
}
async deleteUser(req: Request, res: Response): Promise<void> {
try {
const deleted = await userService.deleteUser(req.params.id);
if (!deleted) {
const response: ApiResponse<never> = {
success: false,
error: "사용자를 찾을 수 없습니다"
};
res.status(404).json(response);
return;
}
const response: ApiResponse<{ message: string }> = {
success: true,
data: { message: "사용자가 삭제되었습니다" }
};
res.json(response);
} catch (error) {
const response: ApiResponse<never> = {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 에러"
};
res.status(500).json(response);
}
}
}
export const userController = new UserController();
컨트롤러는 HTTP 요청을 서비스 호출로 바꾸고, 결과를 상태 코드와 응답 형식으로 바꾸는 번역 계층입니다. ApiResponse<UserResponse>로 응답 변수에 타입을 붙여 두었기 때문에, createdAt을 문자열로 바꾸는 코드를 빠뜨리면 Date를 string 자리에 넣었다는 컴파일 에러가 납니다. 반면 res.json() 자체는 어떤 값이든 받으므로, 이렇게 변수에 타입을 붙이지 않으면 응답 모양을 전혀 검사하지 못합니다. Response<ApiResponse<UserResponse>>처럼 Response 제네릭에 응답 본문 타입을 지정하는 방법도 있습니다.
다섯 메서드에 같은 try/catch와 에러 응답 코드가 반복되는 것이 눈에 띕니다. 규모가 커지면 이 반복을 Express의 에러 처리 미들웨어((err, req, res, next) => { ... }, 매개변수가 네 개여야 에러 핸들러로 인식됨)로 모으고, 컨트롤러는 에러를 던지기만 하게 바꾸는 것이 일반적입니다. 다만 Express 4는 async 함수에서 거부된 Promise를 에러 미들웨어로 넘기지 않아서, try/catch 없이 await에서 예외가 나면 요청이 응답 없이 멈추고 콘솔에 UnhandledPromiseRejection 경고만 남습니다. Express 5는 async 핸들러의 거부를 자동으로 next(err)로 넘기므로, 새 프로젝트라면 Express 5를 쓰는 것이 이 문제를 가장 간단히 없애는 방법입니다.
라우터
src/routes/userRoutes.ts
import { Router } from "express";
import { userController } from "../controllers/userController";
const router = Router();
router.post("/", (req, res) => userController.createUser(req, res));
router.get("/", (req, res) => userController.getUsers(req, res));
router.get("/:id", (req, res) => userController.getUserById(req, res));
router.put("/:id", (req, res) => userController.updateUser(req, res));
router.delete("/:id", (req, res) => userController.deleteUser(req, res));
export default router;
router.post("/", userController.createUser)처럼 메서드를 바로 넘기지 않고 화살표 함수로 감싼 이유는 this 때문입니다. 클래스 메서드를 떼어서 넘기면 호출될 때 this가 undefined가 되어, 메서드 안에서 this.someService를 쓰는 순간 Cannot read properties of undefined 에러가 납니다. 이 예제의 컨트롤러는 this를 쓰지 않아 당장은 문제가 없지만, 나중에 서비스를 생성자로 주입받도록 바꾸면 바로 터지는 함정입니다. 화살표 함수로 감싸거나 userController.createUser.bind(userController)로 묶으면 안전합니다.
/:id의 req.params.id는 항상 문자열입니다. 이 예제는 ID가 "U001" 같은 문자열이라 괜찮지만, 숫자 ID를 쓰는 DB로 바꾸면 변환과 검증이 필요합니다. 라우트 순서도 주의해야 합니다. 나중에 router.get("/search", ...)를 추가할 때 /:id보다 뒤에 두면 "search"가 ID로 해석되어 404가 나므로, 고정 경로는 매개변수 경로보다 앞에 둡니다.
메인 서버
src/index.ts
import express from "express";
import userRoutes from "./routes/userRoutes";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use("/api/users", userRoutes);
app.get("/", (req, res) => {
res.json({ message: "TypeScript API 서버" });
});
app.listen(PORT, () => {
console.log(`서버 실행 중: http://localhost:${PORT}`);
});
express.json()은 Content-Type: application/json 요청의 본문을 파싱해 req.body에 넣는 미들웨어입니다. 이 줄을 빠뜨리거나 라우터 등록 뒤에 두면 req.body가 undefined가 되어 서비스에서 Cannot read properties of undefined (reading 'email') 에러가 납니다. 클라이언트가 Content-Type 헤더를 보내지 않아도 같은 증상이 나타나므로, curl로 테스트할 때 -H "Content-Type: application/json"을 빠뜨리지 않아야 합니다. 잘못된 JSON이 들어오면 express.json()이 SyntaxError를 던지고 기본 에러 핸들러가 HTML 에러 페이지를 돌려주므로, API 서버라면 JSON 형식으로 응답하는 에러 미들웨어를 맨 마지막에 등록해 두는 것이 좋습니다.
PORT를 상수로 고정했는데, 배포 환경은 대부분 포트를 환경 변수로 넘기므로 Number(process.env.PORT) || 3000처럼 읽는 것이 일반적입니다.
테스트
서버 실행
npm run dev
API 테스트
# 사용자 생성
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"홍길동","email":"[email protected]","age":25}'
# 사용자 목록
curl http://localhost:3000/api/users
# 사용자 조회
curl http://localhost:3000/api/users/U001
# 사용자 수정
curl -X PUT http://localhost:3000/api/users/U001 \
-H "Content-Type: application/json" \
-d '{"name":"김철수"}'
# 사용자 삭제
curl -X DELETE http://localhost:3000/api/users/U001
정상 경로만 확인하지 말고 잘못된 입력도 보내 보는 것이 이 구조의 한계를 이해하는 가장 빠른 방법입니다. -d '{"name":"홍길동","email":"[email protected]","age":"abc"}'를 보내면 앞에서 설명한 NaN 구멍 때문에 201 응답과 함께 나이가 "abc"인 사용자가 만들어지고, -d '{"id":"HACK","name":"a","email":"[email protected]","age":1}'를 보내면 생성된 사용자의 id가 HACK으로 바뀌어 있습니다. 컴파일은 모두 통과한 코드인데도 이런 결과가 나온다는 점이 TypeScript의 타입이 컴파일 시점에만 존재한다는 사실을 잘 보여 줍니다.
이 경계를 메우는 표준적인 방법은 Zod 같은 런타임 스키마 라이브러리로 요청 본문을 검증하는 것입니다. const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), age: z.number().int().min(0).max(150) })를 정의하고 컨트롤러에서 CreateUserSchema.parse(req.body)를 통과한 값만 서비스에 넘기면, 알 수 없는 필드는 제거되고 타입이 틀린 값은 400으로 거부됩니다. z.infer<typeof CreateUserSchema>로 타입을 뽑아 CreateUserDto를 대체하면 타입과 검증 규칙을 한곳에서 관리할 수 있습니다.
이 API를 실제 서비스로 옮기려면
이 글의 API는 메모리 저장소와 컴파일 타임 타입만으로 동작하므로, 실제 서비스로 옮기려면 두 가지가 먼저 필요합니다. 서버를 재시작하면 데이터가 사라지는 Map 저장소는 Prisma ORM으로 실제 DB에 연결하고, req.body에 붙인 타입은 런타임에 아무것도 검사하지 않으므로 Zod 같은 스키마 검증을 라우트 입구에 붙여야 합니다. 미들웨어와 에러 처리 구성은 Express 가이드에서 더 자세히 다룹니다.
같이 보면 좋은 글
- TypeScript 시작하기 | 설치, 설정, 기본 문법
- TypeScript 고급 타입 | Union, Intersection, Literal 타입
- TypeScript 인터페이스
- TypeScript 고급 패턴 | 조건부 타입, 템플릿 리터럴 타입
- JavaScript 모듈
- TypeScript REST API Project | Express· Layered Architecture
자주 묻는 질문 (FAQ)
Q. 컨트롤러에서 req.body의 타입은 TypeScript가 검사해 주나요?
A. Express의 Request에서 body는 기본적으로 any라서 req.body를 그대로 서비스에 넘기면 컴파일 단계에서 아무것도 검사되지 않습니다. Request<{}, {}, CreateUserRequest>처럼 제네릭으로 body 타입을 지정하면 코드 안에서는 타입이 붙지만, 이것도 실제로 들어온 JSON이 그 모양이라는 보장은 아닙니다. 외부 입력은 서비스에 넘기기 전에 런타임에서 필수 필드와 형식을 검증해야 합니다.