들어가며
기존에 Node.js(Express) + React로 작성된 인증 보일러플레이트를 TypeScript로 전환하는 작업을 진행했습니다. 단순히 파일 확장자를 바꾸는 것이 아니라, 프로젝트 구조 재설계, 비동기 패턴 현대화, React 메이저 버전 업그레이드까지 한 번에 수행한 과정에서 마주친 기술적 고민들을 정리합니다.
전환 전 상태:
- 백엔드: Express 4.17, Mongoose 5.11 (콜백 스타일), JWT 인증, 하드코딩된 설정값
- 프론트엔드: React 17, CRA (react-scripts 5), Redux 4, React Router v5
- TypeScript, 테스트, 환경변수 설정 모두 없음
1. 백엔드 tsconfig.json 설계: NodeNext를 선택한 이유
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true
}
}
module: "NodeNext" vs "commonjs"
TypeScript에서 Node.js 백엔드의 모듈 시스템을 설정할 때 가장 큰 결정은 CJS를 유지할 것인가, ESM으로 갈 것인가입니다.
NodeNext를 선택한 이유는 다음과 같습니다:
package.json에"type": "module"을 선언하여 ESM을 기본으로 사용합니다. 이는import/export구문을 네이티브로 사용할 수 있게 해줍니다.NodeNext는 Node.js의 실제 모듈 해석 알고리즘을 따르므로, import 경로에.js확장자를 명시해야 합니다.
// ✅ NodeNext에서 올바른 import
import { env } from './config/env.js';
// ❌ 이렇게 하면 런타임에서 모듈을 찾지 못함
import { env } from './config/env';
이것이 처음에는 어색하게 느껴질 수 있습니다. 소스 파일은 .ts인데 import에서는 .js를 쓰는 것이 직관적이지 않기 때문입니다. 하지만 TypeScript 컴파일러는 .ts → .js로 변환할 때 import 경로를 수정하지 않으므로, 컴파일 결과물(.js)에서 정상 동작하려면 처음부터 .js 확장자로 적어야 합니다.
target: "ES2022"
Node.js 18+ 환경을 타겟으로 하므로 ES2022를 선택했습니다. 이렇게 하면 top-level await, Array.at(), Object.hasOwn() 등 최신 문법을 다운레벨 컴파일 없이 그대로 사용할 수 있습니다.
strict: true의 의미
strict: true는 다음 옵션들을 한 번에 켭니다:
| 옵션 | 효과 |
|---|---|
strictNullChecks |
null/undefined 체크를 강제 |
noImplicitAny |
타입 추론 불가 시 에러 |
strictFunctionTypes |
함수 파라미터 타입 공변성 체크 |
strictBindCallApply |
bind, call, apply 타입 체크 |
기존 JS 코드에서 strict: true를 바로 켜면 대량의 에러가 발생합니다. 하지만 소규모 코드베이스(백엔드 7개, 프론트엔드 14개 파일)이므로 처음부터 strict로 시작하는 것이 장기적으로 유리하다고 판단했습니다.
2. 프론트엔드 tsconfig.json: CRA와의 호환성
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react-jsx",
"noEmit": true,
"isolatedModules": true
}
}
프론트엔드는 백엔드와 다른 설정을 사용합니다:
module: "esnext" + moduleResolution: "node"
CRA(react-scripts)가 webpack을 통해 번들링하므로, 모듈 해석은 webpack에 위임합니다. moduleResolution: "node"를 사용하면 import 경로에 확장자를 생략할 수 있어 기존 코드와의 호환성이 좋습니다.
// 프론트엔드에서는 확장자 생략 가능
import App from "./App";
import rootReducer from "./reducers";
jsx: "react-jsx"
React 17부터 도입된 새로운 JSX Transform을 사용합니다. 이를 통해 import React from 'react'를 매 파일마다 작성하지 않아도 JSX를 사용할 수 있습니다.
// react-jsx 모드에서는 React import 불필요 (JSX만 사용하는 경우)
// 하지만 useState, useEffect 등 훅은 여전히 import 필요
import React, { useState } from "react";
noEmit: true와 isolatedModules: true
CRA에서 TypeScript는 타입 체크만 담당하고, 실제 트랜스파일은 Babel이 합니다. 따라서:
noEmit: true: TypeScript가.js파일을 생성하지 않음isolatedModules: true: Babel의 파일 단위 트랜스파일과 호환되도록 강제
3. Mongoose와 TypeScript: 가장 까다로운 타입 설계
Mongoose는 JavaScript의 동적 특성을 최대한 활용하는 라이브러리이기 때문에, TypeScript와의 통합이 가장 어려운 부분이었습니다.
Document 인터페이스 설계
// 문서 인스턴스 타입 (인스턴스 메서드 포함)
export interface IUserDocument extends Document {
name?: string;
email: string;
password: string;
role: number;
token?: string;
tokenExp?: number;
comparePassword(plainPassword: string): Promise<boolean>;
generateToken(): Promise<IUserDocument>;
}
// 모델 타입 (static 메서드 포함)
export interface IUserModel extends Model<IUserDocument> {
findByToken(token: string): Promise<IUserDocument | null>;
}
여기서 핵심적인 구분은 인스턴스 메서드와 정적 메서드의 분리입니다:
comparePassword,generateToken→IUserDocument에 정의 (인스턴스 메서드)findByToken→IUserModel에 정의 (정적 메서드)
이 두 가지를 분리하지 않으면 TypeScript가 User.findByToken()이나 user.comparePassword()를 인식하지 못합니다.
모델 생성 시 제네릭 파라미터
const User = mongoose.model<IUserDocument, IUserModel>('User', userSchema);
mongoose.model에 두 개의 제네릭 파라미터를 전달해야 인스턴스 메서드와 정적 메서드를 모두 타입 안전하게 사용할 수 있습니다.
Express Request 확장: AuthRequest 타입
import type { Request } from 'express';
import type { IUserDocument } from '../models/User.js';
export interface AuthRequest extends Request {
user?: IUserDocument;
token?: string;
}
인증 미들웨어가 req.user에 사용자 정보를 주입하는 패턴에서, 기본 Request 타입에는 user 프로퍼티가 없습니다. 이를 해결하는 방법은 두 가지입니다:
- 인터페이스 확장 (선택한 방법):
AuthRequest extends Request - Declaration Merging:
Express.Request인터페이스에 전역으로 추가
인터페이스 확장을 선택한 이유는, 인증이 필요한 라우트에서만 AuthRequest를 사용하고 일반 라우트에서는 기본 Request를 사용하여 의도를 명확히 할 수 있기 때문입니다.
// 인증 불필요: 기본 Request 사용
export const register = async (req: Request, res: Response): Promise<void> => { ... };
// 인증 필요: AuthRequest 사용 → req.user 접근 가능
export const getAuth = async (req: AuthRequest, res: Response): Promise<void> => {
res.status(200).json({ _id: req.user!._id });
};
Non-null assertion (!)의 사용
req.user!._id에서 !는 "이 값은 null/undefined가 아님"을 TypeScript에 알리는 것입니다. auth 미들웨어를 거친 후에는 반드시 req.user가 존재하므로 안전합니다. 다만, 이는 런타임 보장이 아닌 개발자의 약속이므로 남용하지 않아야 합니다.
4. 콜백 → async/await 전환 전략
bcrypt 전환
// Before: 콜백 스타일
bcrypt.genSalt(saltRounds, function(err, salt) {
bcrypt.hash(user.password, salt, function(err, hash) {
user.password = hash;
next();
});
});
// After: async/await
userSchema.pre('save', async function (next) {
if (this.isModified('password')) {
const salt = await bcrypt.genSalt(saltRounds);
this.password = await bcrypt.hash(this.password, salt);
}
next();
});
bcrypt v5는 이미 Promise를 반환하는 오버로드를 제공하므로, 콜백을 제거하기만 하면 됩니다.
jwt.verify: Promise 래핑이 필요한 경우
userSchema.statics.findByToken = async function (token: string): Promise<IUserDocument | null> {
const decoded = await new Promise<string>((resolve, reject) => {
jwt.verify(token, env.JWT_SECRET, (err: Error | null, decoded: unknown) => {
if (err) return reject(err);
resolve(decoded as string);
});
});
return this.findOne({ _id: decoded, token });
};
jsonwebtoken의 verify는 콜백을 전달하면 비동기, 전달하지 않으면 동기로 동작합니다. 동기 버전을 사용할 수도 있지만, 일관성을 위해 Promise로 래핑했습니다. 여기서 decoded의 타입이 unknown인 점에 주의해야 합니다. JWT payload는 어떤 형태든 될 수 있으므로, as string으로 타입 단언이 필요합니다.
Mongoose pre hook에서 this 타입
userSchema.pre('save', async function (next) {
const user = this; // this는 IUserDocument
if (user.isModified('password')) { ... }
});
Mongoose pre hook에서 this는 문서 인스턴스를 가리킵니다. 화살표 함수를 사용하면 this가 바인딩되지 않으므로, 반드시 function 키워드를 사용해야 합니다. TypeScript에서는 스키마에 제네릭으로 전달한 IUserDocument 덕분에 this의 타입이 자동 추론됩니다.
5. body-parser 제거
// Before
const bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// After
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
Express 4.16.0부터 express.json()과 express.urlencoded()가 내장되었습니다. body-parser는 별도 패키지로 설치할 필요가 없으며, 의존성을 하나 줄일 수 있습니다.
6. React 17 → 18 업그레이드: createRoot 전환
// Before (React 17)
import ReactDOM from "react-dom";
ReactDOM.render(
<Provider store={store}><App /></Provider>,
document.getElementById("root")
);
// After (React 18)
import { createRoot } from "react-dom/client";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(
<Provider store={store}><App /></Provider>
);
왜 createRoot인가?
React 18의 핵심 기능인 Concurrent Features (Suspense, Transitions, Automatic Batching)는 createRoot를 사용해야만 활성화됩니다. 기존 ReactDOM.render를 사용하면 React 18을 설치하더라도 레거시 모드로 동작합니다.
document.getElementById("root")!
getElementById는 HTMLElement | null을 반환합니다. 하지만 index.html에 #root가 반드시 존재하므로 non-null assertion을 사용합니다.
7. Redux 액션 타입: as const와 리터럴 타입
// types.ts
export const LOGIN_USER = "login_user" as const;
export const REGISTER_USER = "register_user" as const;
export type ActionType = typeof LOGIN_USER | typeof REGISTER_USER | ...;
as const를 사용하면 문자열이 string 타입이 아닌 리터럴 타입("login_user")으로 추론됩니다. 이를 통해 reducer의 switch 문에서 TypeScript가 각 케이스별 payload 타입을 좁힐 수 있습니다.
// as const 없이: type은 string
const LOGIN_USER = "login_user"; // type: string
// as const 사용: type은 "login_user" 리터럴
const LOGIN_USER = "login_user" as const; // type: "login_user"
8. 환경변수 중앙화: as const 객체 패턴
import 'dotenv/config';
export const env = {
DATABASE_URL: process.env.DATABASE_URL || 'mongodb://localhost:27017/boilerplate',
JWT_SECRET: process.env.JWT_SECRET || 'blahblahblah',
PORT: Number(process.env.PORT) || 4000,
} as const;
as const의 효과
객체에 as const를 적용하면 모든 프로퍼티가 readonly가 됩니다. 이는 환경변수가 런타임에 변경되지 않아야 한다는 의도를 타입 시스템으로 표현한 것입니다.
import 'dotenv/config' vs dotenv.config()
// 방법 1: 사이드 이펙트 import (선택한 방법)
import 'dotenv/config';
// 방법 2: 명시적 호출
import dotenv from 'dotenv';
dotenv.config();
import 'dotenv/config'는 모듈이 로드될 때 자동으로 .env 파일을 읽습니다. 이 방식을 선택한 이유는 코드가 더 간결하고, ESM 환경에서 dotenv의 권장 패턴이기 때문입니다.
Number() 변환
process.env의 모든 값은 string | undefined입니다. PORT처럼 숫자가 필요한 경우 Number()로 명시적 변환해야 합니다. parseInt도 가능하지만, Number()가 더 엄격합니다 (예: Number("3000abc")는 NaN, parseInt("3000abc")는 3000).
9. 디렉토리 구조 재설계: 관심사 분리
// Before (루트에 모든 파일이 혼재)
├── auth.js
├── db.js
├── index.js ← 미들웨어 + 라우팅 + 서버 시작 모두 포함
├── init.js
├── models/User.js
└── userController.js
// After (관심사별 분리)
src/
├── server.ts ← 서버 시작만
├── app.ts ← Express 앱 설정 + 미들웨어 마운트
├── config/
│ ├── database.ts ← DB 연결
│ └── env.ts ← 환경변수
├── middleware/
│ └── auth.ts ← 인증 미들웨어
├── models/
│ └── User.ts ← Mongoose 스키마 + 타입
├── controllers/
│ └── userController.ts ← 요청 처리 로직
├── routes/
│ └── userRoutes.ts ← 라우트 정의
└── types/
└── index.ts ← 공유 타입 정의
server.ts와 app.ts를 분리한 이유
// server.ts - 서버 시작만 담당
import app from './app.js';
import './config/database.js';
import { env } from './config/env.js';
app.listen(env.PORT, () => console.log(`✅ Listening on port ${env.PORT}`));
// app.ts - Express 앱 설정만 담당
import express from 'express';
import cookieParser from 'cookie-parser';
import userRoutes from './routes/userRoutes.js';
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
export default app;
이렇게 분리하면 테스트 시 app만 import하여 supertest로 테스트할 수 있습니다. server.ts를 import하면 실제로 포트를 리슨하게 되어 테스트와 충돌합니다.
10. 프론트엔드 TypeScript 전환 시 주의점
dispatch의 타입 문제
const dispatch = useDispatch();
(dispatch(loginUser(body)) as any).then((response: any) => { ... });
현재 Redux에서 dispatch의 반환 타입은 기본적으로 Dispatch<AnyAction>이며, redux-promise 미들웨어를 통해 실제로는 Promise를 반환합니다. 하지만 TypeScript는 이를 알지 못하므로 as any로 단언해야 합니다.
이것은 의도적으로 남긴 기술 부채입니다. Phase 4에서 Redux를 TanStack Query로 교체하면 이 문제가 자연스럽게 해결되므로, 현 단계에서 AppDispatch 타입을 만드는 것은 곧 삭제될 코드에 시간을 투자하는 것입니다.
React Router v5의 타입
import { RouteComponentProps } from "react-router-dom";
const LoginPage: React.FC<RouteComponentProps> = (props) => {
props.history.push("/");
};
React Router v5에서는 RouteComponentProps를 사용하여 history, location, match 등의 props 타입을 얻습니다. 이 역시 Phase 4에서 v6으로 업그레이드하면 useNavigate 훅으로 대체됩니다.
withRouter HOC의 유지
export default withRouter(LoginPage);
withRouter는 React Router v5에서 컴포넌트에 라우터 props를 주입하는 HOC입니다. v6에서 제거되었지만, 현 단계에서는 기존 동작을 유지하기 위해 그대로 두었습니다.
11. 의존성 관리: peer dependency 충돌 해결
TypeScript 5를 설치하면 react-scripts와 peer dependency 충돌이 발생합니다:
npm error peerOptional typescript@"^3.2.1 || ^4" from react-scripts@5.0.1
CRA의 react-scripts는 TypeScript 4까지만 공식 지원합니다. 하지만 실제 동작에는 문제가 없으므로, --legacy-peer-deps 플래그로 설치했습니다. 이 문제는 Phase 2에서 CRA를 Vite로 교체하면 완전히 해결됩니다.
제거한 의존성
body-parser: Express 4.16+ 내장으로 대체nodemon:tsx watch로 대체 (TypeScript 네이티브 지원)redux-devtools-extension: 백엔드package.json에 잘못 배치되어 있었음
추가한 의존성
tsx: Node.js용 TypeScript 실행기.ts-node보다 빠르고 ESM을 별도 설정 없이 지원dotenv: 환경변수 로드- 각종
@types/*: TypeScript 타입 정의
12. tsx vs ts-node: 왜 tsx를 선택했는가
| 비교 항목 | tsx | ts-node |
|---|---|---|
| ESM 지원 | 설정 불필요 | --esm 플래그 + 추가 설정 필요 |
| 속도 | esbuild 기반 (매우 빠름) | TypeScript 컴파일러 기반 |
| Watch 모드 | tsx watch 내장 |
별도 도구(nodemon) 필요 |
| 타입 체크 | 하지 않음 (esbuild) | 기본적으로 수행 |
tsx는 타입 체크를 하지 않는 대신 매우 빠릅니다. 타입 체크는 에디터(VSCode)와 CI에서 tsc --noEmit으로 수행하고, 개발 중 실행은 tsx watch로 빠르게 피드백을 받는 전략입니다.
마치며: 점진적 전환의 원칙
이번 Phase 1에서 가장 중요하게 여긴 원칙은 "기존 동작을 깨뜨리지 않으면서 타입 안전성을 확보한다" 입니다.
- 곧 교체될 코드에 과도한 타입을 씌우지 않는다 — Redux의
as any, React Router v5의RouteComponentProps등은 의도적으로 남긴 기술 부채입니다. - strict 모드는 처음부터 켠다 — 나중에 켜면 한꺼번에 대량의 에러를 수정해야 합니다.
- 디렉토리 구조는 TypeScript 전환과 함께 정리한다 — 어차피 모든 파일을 만지는 김에 관심사 분리도 같이 합니다.
- 테스트 가능한 구조를 미리 준비한다 —
server.ts/app.ts분리는 Phase 5 테스트를 위한 포석입니다.
다음 Phase에서는 CRA를 Vite로 교체하여 빌드 도구를 현대화할 예정입니다.
'Language > Typescript' 카테고리의 다른 글
| React Native/Typescript 환경에서 절대경로 설정하는 방법 (0) | 2022.04.01 |
|---|---|
| React Native/Typescript 환경에서 데코레이터 함수 사용하는 방법 (0) | 2022.04.01 |
| [Typescript] 자주 쓰이는 Utility Types 정리하기 (1) | 2022.02.16 |
| React 환경에서 서버로 올바른 데이터 전송하는 방법 (with Typescript) (0) | 2021.12.08 |
| Typescript 환경에서 역직렬화하는 방법 (1) | 2021.11.30 |
댓글