Python FastAPI로 백엔드 개발을 해오다가, 이번 팀 프로젝트에서 Node.js + Express + TypeScript 조합으로 전환하게 됐다. 같은 REST API를 만드는 건데 프레임워크가 달라지니까 "이건 Express에서는 어떻게 하지?"라는 질문이 계속 나왔다.
오늘은 FastAPI에서 익숙했던 구조를 Express에서 어떻게 대응시켰는지 정리해본다.
FastAPI에서는 보통 이런 구조로 개발했다.
app/
├── api/ # 라우터 (엔드포인트)
├── core/ # 설정, DB 연결
├── schema/ # Pydantic 모델 (요청/응답 검증)
├── models/ # ORM/ODM 모델
├── service/ # 비즈니스 로직
└── util/ # 공통 유틸
Express + TypeScript에서는 이렇게 대응시켰다.
src/
├── api/
│ ├── routes/ # 라우터 (URL 매핑만)
│ └── controllers/ # 컨트롤러 (요청 처리 + 응답)
├── core/
│ ├── config/ # 환경변수, Swagger 설정
│ ├── db/ # DB 연결
│ ├── middlewares/ # 인증, 에러 핸들러
│ └── security/ # JWT 관련
├── models/ # Mongoose 스키마
├── schemas/ # Zod 검증 스키마
├── services/ # 비즈니스 로직
├── types/ # TypeScript 타입 선언
└── server.ts # 진입점
가장 큰 차이는 controller 레이어의 유무다. FastAPI에서도 service 레이어는 분리해서 사용했지만, 라우터 함수가 곧 컨트롤러 역할을 했다. Express에서는 route, controller, service 세 단계로 명시적으로 나뉜다.
라우터 함수가 컨트롤러 역할까지 했다. service는 별도로 분리했지만, 라우터에서 직접 service를 호출하고 응답을 반환했다.
# router (컨트롤러 역할 포함)
@router.post("/login")
async def login(request: LoginRequest): # 검증 자동
result = await auth_service.login(request.code)
return result # return만 하면 끝
# service
async def login(code: str):
token = await get_kakao_token(code)
user = await find_or_create_user(...)
return {"accessToken": token}
controller가 추가로 들어간다.
route — URL이랑 함수 연결만
router.post("/login", loginHandler);
controller — 요청 파싱 + 서비스 호출 + 응답 반환
export const loginHandler = async (req, res, next) => {
try {
const { code } = loginSchema.parse(req.body);
const result = await login(code);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
service — 순수 비즈니스 로직 (FastAPI의 service와 동일)
export const login = async (code: string) => {
const kakaoToken = await getKakaoToken(code);
const { user, isNewUser } = await findOrCreateUser(...);
return { accessToken, userId, ... };
};
FastAPI에서는 라우터가 검증, 응답 반환까지 자동으로 해줘서 controller가 필요 없었다. Express는 그런 자동화가 없어서 controller 레이어가 그 역할을 대신한다. 결국 service 로직 자체는 거의 동일하고, HTTP 처리 방식만 다른 셈이다.
Express로 오면서 가장 체감했던 건 FastAPI가 얼마나 많은 걸 자동으로 해줬는지였다.
# FastAPI — 파라미터 타입만 쓰면 자동 검증
async def login(request: LoginRequest):
// Express — 수동으로 해야 함
const { code } = loginSchema.parse(req.body);
FastAPI는 Pydantic 모델을 파라미터에 넣으면 자동 검증 + 422 응답까지 해줬다. Express는 Zod로 직접 검증하고, 에러 핸들러에서 422를 반환하도록 직접 만들어줘야 한다.
# FastAPI — HTTPException 던지면 끝
raise HTTPException(status_code=401, detail="인증 실패")
// Express — try/catch + next(err) 필수
try {
// ...
} catch (err) {
next(err);
}
# FastAPI — response_model로 자동 필터링
@router.post("/login", response_model=Token)
// Express — 인터페이스로 수동 매핑
const response: LoginResponse = {
accessToken: result.accessToken,
userId: result.userId,
// ...
};
res.status(200).json(response);
| 역할 | FastAPI (Python) | Express (Node.js) |
|---|---|---|
| 프레임워크 | FastAPI | Express |
| 요청 검증 | Pydantic | Zod |
| ODM | Beanie (MongoDB) | Mongoose |
| JWT | python-jose | jsonwebtoken |
| 환경변수 | pydantic-settings | dotenv + env.ts |
| 비밀 저장소 | azure-keyvault-secrets | @azure/keyvault-secrets |
Beanie에서 Mongoose로 전환하면서 느낀 차이도 있다. Beanie는 Document 클래스가 타입 + 스키마 + ODM 역할을 동시에 했는데, Mongoose는 인터페이스(타입) + 스키마(검증) + 모델(DB 조작)이 분리되어 있다.
// 1. 타입 정의
interface IUser extends Document {
name: string;
kakao_id: string;
}
// 2. 스키마 정의
const userSchema = new Schema<IUser>({
name: { type: String, required: true },
kakao_id: { type: String, required: true, unique: true },
});
// 3. 모델 생성
export const UserModel = model<IUser>("User", userSchema);
Beanie에서는 한 클래스로 끝났던 걸 세 단계로 나눠야 하지만, 오히려 각 역할이 명확해지는 장점이 있었다.
TypeScript를 쓰면서 가장 낯설었던 건 types/ 폴더에 .d.ts 파일을 직접 만들어줘야 하는 상황이었다.
JavaScript 라이브러리는 세 가지 유형이 있다.
| 유형 | 예시 | 해결 |
|---|---|---|
| 타입 내장 | mongoose, zod | 바로 사용 가능 |
| @types 패키지 있음 | ws → @types/ws | npm install -D @types/ws |
| 타입 없음 | y-websocket, y-protocols | 직접 .d.ts 작성 |
타입이 없는 라이브러리를 import하면 에러가 난다.
import { WebsocketProvider } from "y-websocket";
// ❌ Cannot find module 'y-websocket' or its corresponding type declarations.
이럴 때 직접 타입 선언 파일을 만들어줘야 한다.
// src/types/y-websocket.d.ts
declare module "y-websocket" {
export class WebsocketProvider {
constructor(serverUrl: string, roomname: string, doc: any);
destroy(): void;
}
}
Python은 타입이 없어도 실행되지만, TypeScript는 타입을 모르면 컴파일 자체가 안 되기 때문이다.
또 하나 유용했던 건 Express의 Request 타입 확장이다. 인증 미들웨어에서 req.user를 넣어주는데, Express 기본 타입에는 user가 없다.
// src/types/express/index.d.ts
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
name: string;
};
}
}
}
export {};
이걸 만들어두면 (req as any).user 대신 req.user로 타입 안전하게 쓸 수 있다.