같은 서버인데 왜 더 안전해질까?
지금까지 만든 Express 서버는 잘 돌아간다. 그런데 잘 돌아가는 것과, 실수를 빨리 잡아주는 것은 다른 이야기다. JavaScript에서는 req.body.titel처럼 오타를 내도 서버가 실행은 된다. 대신 요청이 들어온 뒤에야 조용히 문제가 터진다. req.params.id가 문자열인데 숫자로 착각해 비교하는 실수도 자주 나온다. TypeScript는 이런 실수를 코드를 실행하기 전에 먼저 알려주는 안전벨트 역할을 한다. TypeScript는 JavaScript를 버리고 새 언어로 갈아타는 도구가 아니라, 기존 JavaScript 위에 타입 정보를 얹어서 더 똑똑하게 검사해주는 도구다.1
TypeScript를 도입하면 특히 이런 점이 좋아진다.
오타를 저장하자마자 에디터에서 바로 잡을 수 있다.
함수 인자, 응답 데이터, 환경 변수처럼 자주 헷갈리는 값의 모양을 미리 고정할 수 있다.
파일이 많아질수록 리팩터링이 훨씬 덜 무섭다.
서버 입장에서는 같은 일을 하는데, 개발자인 우리는 헬멧을 쓰고 자전거를 타는 느낌이 된다. 넘어질 수는 있어도, 덜 아프다.
TypeScript는 마법이 아니라 컴파일러다
TypeScript 파일은 결국 JavaScript로 바뀐 뒤 실행된다. 즉, Node.js가 직접 TypeScript를 이해해서 먹는 것이 아니라, TypeScript 컴파일러가 먼저 코드를 검사하고 JavaScript로 바꿔주는 흐름이다.1 그래서 TypeScript를 붙여도 서버의 역할 자체가 달라지는 것은 아니다. 달라지는 것은 실수를 발견하는 시점이다.
여기서 아주 중요한 사실도 하나 있다. TypeScript는 런타임 검증기가 아니다. req.body에 타입을 적었다고 해서 클라이언트가 정말 그 모양대로 보내준다는 뜻은 아니다. TypeScript는 내 코드가 그 데이터를 어떻게 다룰지를 검사해준다. 사용자가 보낸 값이 정말 맞는지는 여전히 검증 로직이 필요하다. 즉, TypeScript는 경비원이라기보다 체크리스트에 가깝다.
기존 Express 프로젝트에 TypeScript 설치하기
기존 JavaScript Express 프로젝트에 TypeScript를 붙일 때 보통 필요한 패키지는 아래 정도다. Express와 Node.js의 타입 정의 파일도 함께 설치해야 에디터가 Request, Response, process 같은 것의 모양을 이해할 수 있다.23
npm install -D typescript ts-node nodemon @types/node @types/express이제 설정 파일을 만든다.
npx tsc --init기존 JavaScript 서버를 조금씩 옮길 계획이라면, 처음 tsconfig.json은 너무 빡빡하게 시작하지 말고 이런 식으로 잡는 편이 편하다.13
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"checkJs": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}여기서 자주 보는 옵션만 먼저 이해해보자.
rootDir은 원본 코드 폴더다. 보통src를 쓴다.outDir은 변환된 JavaScript가 나갈 폴더다. 보통dist를 쓴다.strict는 타입 검사를 엄격하게 켠다. 처음엔 조금 귀찮아도 결국 이 옵션 덕분에 이득을 많이 본다.allowJs는 기존.js파일도 함께 읽게 한다.checkJs는 JavaScript 파일에도 타입 검사를 시도하게 한다.
특히 allowJs와 checkJs가 중요하다. 이 둘을 켜면 프로젝트를 한 번에 뒤집지 않고도, 기존 JavaScript 서버를 조금씩 TypeScript로 옮길 수 있다.1
package.json 스크립트도 같이 정리하자
개발용 실행, 타입 검사, 빌드, 배포 실행을 나눠두면 마음이 편하다.
{
"scripts": {
"dev": "nodemon --watch src --ext ts,js --exec ts-node src/server.ts",
"type-check": "tsc --noEmit",
"build": "tsc",
"start": "node dist/server.js"
}
}type-check는 파일을 실제로 만들지 않고 타입만 검사한다. 지금 프로젝트가 어디서 삐걱거리는지 보고 싶을 때 아주 유용하다. 마치 서버 건강검진 같은 명령이다.
JavaScript 서버를 TypeScript가 읽게 만들기
기존 프로젝트를 옮길 때는 보통 두 가지 전략이 있다. 작은 프로젝트라면 .js를 .ts로 한 번에 바꾸고 오류를 고칠 수 있다. 하지만 파일이 많다면 그렇게 했다가 콘솔이 빨간 경고 파티장이 되기 쉽다. 실무에서는 JavaScript와 TypeScript가 한동안 섞여 있어도 괜찮으니, 파일 단위로 조금씩 옮기는 방식이 흔하다.14
추천 순서는 이렇다.
먼저 TypeScript와
@types패키지를 설치한다.allowJs,checkJs를 켜고npm run type-check로 현재 상태를 본다.유틸 함수나 데이터 타입처럼 단순한 파일부터
.ts로 바꾼다.그다음 라우터와 컨트롤러로 올라온다.
마지막에 서버 진입 파일인
server.ts나app.ts를 정리한다.당장 타입을 정확히 모르겠다면 잠깐
any를 쓸 수 있지만, 나중에 꼭 갚아야 할 빚이라고 생각하자.
핵심은 서버를 멈추지 않고 옮기는 것이다. TypeScript 전환은 대청소라기보다, 운영 중인 가게 간판을 조금씩 바꾸는 작업에 더 가깝다.
JavaScript 코드가 TypeScript로 바뀌면 뭐가 달라질까?
먼저 아주 평범한 JavaScript Express 코드부터 보자.
const express = require('express')
const app = express()
app.use(express.json())
const todos = []
app.post('/todos', (req, res) => {
const todo = {
id: Date.now(),
title: req.body.title,
done: false
}
todos.push(todo)
res.status(201).json(todo)
})
app.get('/todos/:id', (req, res) => {
const todo = todos.find((item) => item.id === req.params.id)
if (!todo) {
return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
}
res.json(todo)
})겉보기에는 멀쩡하지만, 몇 가지 위험이 숨어 있다. todos 배열 안에 어떤 모양의 데이터가 들어가야 하는지 정해져 있지 않다. req.body.title이 정말 문자열인지도 모른다. 더 큰 문제는 req.params.id가 문자열인데 item.id는 보통 숫자라는 점이다. 이렇게 되면 데이터가 있어도 못 찾는 황당한 일이 생긴다.
이제 같은 코드를 TypeScript로 바꿔보자.
import express, { Request, Response } from 'express'
interface Todo {
id: number
title: string
done: boolean
}
type TodoParams = {
id: string
}
type CreateTodoBody = {
title: string
}
const app = express()
app.use(express.json())
const todos: Todo[] = []
app.post(
'/todos',
(req: Request<{}, {}, CreateTodoBody>, res: Response<Todo>) => {
const todo: Todo = {
id: Date.now(),
title: req.body.title,
done: false
}
todos.push(todo)
res.status(201).json(todo)
}
)
app.get(
'/todos/:id',
(req: Request<TodoParams>, res: Response<Todo | { message: string }>) => {
const id = Number(req.params.id)
const todo = todos.find((item) => item.id === id)
if (!todo) {
return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
}
res.json(todo)
}
)이제 TypeScript는 이렇게 도와준다.
todos에는Todo모양의 객체만 넣으라고 알려준다.req.body.titel처럼 오타를 내면 바로 잡아준다.req.params.id가 문자열이라는 사실을 눈앞에 드러내 준다.응답으로 어떤 데이터가 나가는지도 코드에서 읽기 쉬워진다.
같은 서버인데, 훨씬 덜 불안하다. 이게 바로 업그레이드의 핵심이다.
서버에서 자주 쓰는 TypeScript 기본 타입만 먼저 익히자
처음부터 고급 문법을 외울 필요는 없다. Express 서버를 안전하게 만드는 데 자주 쓰는 문법은 생각보다 적다.
const port: number = 3000
const appName: string = 'todo-server'
const isOpen: boolean = true
const tags: string[] = ['express', 'typescript']
type Mode = 'development' | 'production'
interface User {
id: number
name: string
email?: string
}
function findUser(id: number): User | undefined {
return { id, name: 'mango' }
}여기서 핵심만 뽑으면 이렇다.
string,number,boolean은 가장 기본적인 타입이다.string[]처럼 배열 안에 들어갈 데이터 타입도 지정할 수 있다.interface는 객체의 모양을 설명할 때 많이 쓴다.email?: string처럼?를 붙이면 선택 속성이다.User | undefined처럼|는 여러 가능성을 함께 표현하는 유니언 타입이다.
초보자 관점에서 제일 중요한 감각은 이것이다. 값이 어떤 모양인지 먼저 말하고, 그다음 코드를 쓴다. JavaScript가 자유로운 스케치라면, TypeScript는 밑그림을 먼저 그려놓는 방식이다.
Express에서 가장 자주 만나는 타입: Request와 Response
Express에서는 Request와 Response 타입을 가장 많이 쓴다. 일단 이것만 익혀도 절반은 왔다.
import { Request, Response } from 'express'
app.get('/ping', (req: Request, res: Response) => {
res.json({ message: 'pong' })
})이렇게만 써도 기본 타입 정보가 붙는다. 그런데 Express는 여기서 한 단계 더 나간다. Request는 제네릭을 이용해서 파라미터, 요청 본문, 쿼리의 모양을 더 구체적으로 적을 수 있다.23
자주 보는 순서는 아래처럼 이해하면 된다.
Request<Params, ResBody, ReqBody, ReqQuery>이걸 한꺼번에 외우기 어렵다면, 라우트 상황별로 쪼개서 기억하면 쉽다.
type UserParams = { id: string }
app.get('/users/:id', (req: Request<UserParams>, res: Response) => {
const userId = Number(req.params.id)
res.json({ id: userId })
})위 코드는 req.params.id가 있다는 사실을 분명하게 알려준다. 그리고 문자열이라는 점도 함께 보여준다. 그래서 숫자로 써야 한다면 Number(...) 같은 변환을 의식적으로 하게 된다.
요청 본문은 이렇게 적을 수 있다.
type CreateUserBody = {
name: string
email: string
}
app.post(
'/users',
(req: Request<{}, {}, CreateUserBody>, res: Response) => {
const userName = req.body.name
res.status(201).json({ name: userName })
}
)이제 req.body.name은 존재해야 하고 문자열이어야 한다는 약속이 생긴다. 물론 실제 요청값 검증은 별도지만, 적어도 내 코드 안에서는 덜 흔들린다.
쿼리 문자열도 마찬가지다.
type UserQuery = {
keyword?: string
page?: string
}
app.get(
'/users',
(req: Request<{}, {}, {}, UserQuery>, res: Response) => {
const keyword = req.query.keyword ?? ''
const page = Number(req.query.page ?? '1')
res.json({ keyword, page })
}
)여기서도 중요한 점이 하나 있다. 쿼리 문자열은 대부분 문자열로 들어온다. page=3이라고 보내도 숫자 3이 아니라 문자열 '3'이다. TypeScript는 이런 현실적인 사실을 코드에서 숨기지 않고 드러내 준다. 약간 귀찮지만, 그 덕분에 덜 속는다.
응답 타입은 이렇게 가볍게 시작하면 된다
초보자가 TypeScript에서 가장 헷갈리는 지점 중 하나가 응답 타입이다. 처음부터 너무 복잡하게 잡지 않아도 된다. Response<T>에서 T는 보통 res.json(...)으로 보내는 JSON 모양이라고 생각하면 편하다.3
예를 들어 이렇게 시작할 수 있다.
type TodoResponse = {
id: number
title: string
done: boolean
}
app.get('/todos/example', (_req: Request, res: Response<TodoResponse>) => {
res.json({
id: 1,
title: '예시 할 일',
done: false
})
})에러 응답까지 함께 표현하고 싶다면 유니언 타입으로도 쓸 수 있다.
type TodoSuccessResponse = {
id: number
title: string
done: boolean
}
type ErrorResponse = {
message: string
}
app.get(
'/todos/:id',
(req: Request<TodoParams>, res: Response<TodoSuccessResponse | ErrorResponse>) => {
const id = Number(req.params.id)
const todo = todos.find((item) => item.id === id)
if (!todo) {
return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
}
res.json(todo)
}
)처음엔 이 정도 감각이면 충분하다. 나중에 책 뒤쪽으로 갈수록 응답 타입을 더 정교하게 다듬게 된다.
require에서 import로, module.exports에서 export로
기존 JavaScript Express 서버를 TypeScript로 옮기면 자주 하는 일이 하나 더 있다. require 문법을 import로, module.exports를 export로 바꾸는 것이다.1
예를 들어 JavaScript에서 이렇게 쓰던 코드는
const express = require('express')
module.exports = appTypeScript에서는 보통 이렇게 바뀐다.
import express from 'express'
export default app이때 esModuleInterop 옵션이 켜져 있으면 CommonJS 패키지를 훨씬 편하게 가져올 수 있다.3 초보자에게는 이 옵션이 작은 쿠션처럼 느껴진다. 문법 충돌로 머리 아플 일을 많이 줄여준다.
자주 만나는 함정 네 가지
TypeScript를 붙였다고 해서 모든 문제가 자동 해결되지는 않는다. 특히 아래 네 가지는 초반에 자주 만난다.
process.env.PORT는 문자열이다. 숫자로 쓸 거라면Number(process.env.PORT)처럼 변환해야 한다.라우트 파라미터는 거의 항상 문자열이다.
:id를 숫자로 쓰고 싶으면 직접 바꿔야 한다.req.body에 타입을 적어도 실제 입력 검증이 되는 것은 아니다. 검증 미들웨어나 검사 로직은 여전히 필요하다.라이브러리마다 타입 선언이 필요한 경우가 있다. Express, Node.js처럼 많이 쓰는 패키지는
@types패키지를 설치해 해결하는 경우가 많다.23
이 네 가지만 기억해도 '왜 타입을 적었는데 또 에러가 나지?' 하는 순간을 꽤 줄일 수 있다.
오늘 챕터의 핵심 정리
TypeScript를 도입한다고 서버가 다른 서버가 되는 것은 아니다. 여전히 같은 Express 서버고, 같은 요청을 받고, 같은 응답을 보낸다. 다만 개발 과정에서 실수를 훨씬 빨리 잡을 수 있게 된다. 그 차이가 프로젝트가 커질수록 엄청나게 크게 느껴진다.
오늘 기억할 포인트는 세 가지다.
TypeScript는 JavaScript를 대체하기보다, JavaScript에 타입 안전성을 더하는 도구다.
기존 Express 서버는
allowJs,checkJs를 이용해 조금씩 옮길 수 있다.1Request,Response,interface,type만 잘 써도 서버 코드가 훨씬 읽기 쉬워지고 안전해진다.
지금까지는 JavaScript 서버에 TypeScript라는 갑옷을 입히는 단계였다. 다음 단계는 그 갑옷의 치수를 딱 맞게 줄이는 일이다. 라우트 파라미터, 요청 본문, 응답 형식을 더 정교하게 타입으로 묶기 시작하면, 서버는 한층 더 든든해진다.
참고
1TypeScript: Migrating from JavaScript
3How to Set Up TypeScript with Node.js and Express
4How to Convert an Existing Node.js App from JavaScript to TypeScript
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.