Language/Typescript

Express + React 보일러플레이트를 TypeScript로 전환하며 고려한 기술적 사항들

Joonfluence 2026. 3. 21.

들어가며

기존에 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를 선택한 이유는 다음과 같습니다:

  1. package.json"type": "module"을 선언하여 ESM을 기본으로 사용합니다. 이는 import/export 구문을 네이티브로 사용할 수 있게 해줍니다.
  2. 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: trueisolatedModules: 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, generateTokenIUserDocument에 정의 (인스턴스 메서드)
  • findByTokenIUserModel에 정의 (정적 메서드)

이 두 가지를 분리하지 않으면 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 프로퍼티가 없습니다. 이를 해결하는 방법은 두 가지입니다:

  1. 인터페이스 확장 (선택한 방법): AuthRequest extends Request
  2. 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 });
};

jsonwebtokenverify콜백을 전달하면 비동기, 전달하지 않으면 동기로 동작합니다. 동기 버전을 사용할 수도 있지만, 일관성을 위해 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")!

getElementByIdHTMLElement | 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.tsapp.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에서 가장 중요하게 여긴 원칙은 "기존 동작을 깨뜨리지 않으면서 타입 안전성을 확보한다" 입니다.

  1. 곧 교체될 코드에 과도한 타입을 씌우지 않는다 — Redux의 as any, React Router v5의 RouteComponentProps 등은 의도적으로 남긴 기술 부채입니다.
  2. strict 모드는 처음부터 켠다 — 나중에 켜면 한꺼번에 대량의 에러를 수정해야 합니다.
  3. 디렉토리 구조는 TypeScript 전환과 함께 정리한다 — 어차피 모든 파일을 만지는 김에 관심사 분리도 같이 합니다.
  4. 테스트 가능한 구조를 미리 준비한다server.ts/app.ts 분리는 Phase 5 테스트를 위한 포석입니다.

다음 Phase에서는 CRA를 Vite로 교체하여 빌드 도구를 현대화할 예정입니다.

반응형

댓글