[Optimization] tsoa 기반 계층형 아키텍처 재정의: Express에서 NestJS로 가는 중간 과정

실서비스를 운영 중인 상황에서 프레임워크를 한 번에 교체하는 것은 서비스 품질에 큰 리스크를 동반합니다.

Express는 자유도가 높지만, 프로젝트 규모가 커질수록 아키텍처의 일관성을 유지하기 어렵습니다.

Express의 높은 자유도가 주는 파편화된 구조와 수동 API 문서 관리 문제를 해결하고, 차기 NestJS 전환을 염두에 둔 점진적 아키텍처 개선 과정을 공유합니다.


1. 문제 분석: 파편화된 구조와 문서화의 늪

기존 MVC 패턴의 Express 서버는 프로젝트가 커짐에 따라 몇 가지 구조적 한계에 직면했습니다.

  • 타입 안정성 부족: 런타임에서 데이터 타입을 보장받기 위해 매번 복잡한 검증 미들웨어를 거쳐야 했습니다.

  • 불필요한 코드의 반복: NestJS처럼 데이터를 return하는 방식이 아닌, res.status().json()을 명시적으로 호출해야 하는 한 줄의 코드가 늘어나는 것조차 생산성 저하로 느껴졌습니다.

  • 비대해진 파일과 깊은 Depth: 도메인별로 파일을 분리해도 Route -> Controller -> Service로 이어지는 물리적 파일 이동이 잦아져 개발 피로도가 높았습니다. 특히 모든 라우트를 app.ts에 수동으로 등록하는 과정도 개선이 필요했습니다.

  • 동기화되지 않는 API 문서: DB 스키마 변경이나 응답값 수정 시 노션(Notion) 문서를 수동으로 업데이트해야 했고, 이는 곧 프론트엔드와의 소통 비용 증가로 이어졌습니다.


2. 기술적 과도기: 직접 구현한 커스텀 미들웨어

NestJS로 가기 위한 중간 단계로, class-transformerclass-validator를 조합한 커스텀 검증 파이프라인을 구축했습니다.

🛠️ 기존 방식: 커스텀 유효성 검사 미들웨어

// 직접 구현해야 했던 복잡한 타입 변환 및 검증 로직
import { plainToClass } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { NextFunction, Request, Response } from 'express';

// 미들웨어 정의
export const validateMiddleware = <Query extends object, Body extends object, Params extends object>({
  query,
  body,
  params,
}: {
  query?: new () => Query;
  body?: new () => Body;
  params?: new () => Params;
}) => {
  return async (req: Request<Params, object, Body, Query>, _res: Response, next: NextFunction) => {
    console.log({ rawQuery: req.query });
    const validatedQuery = query
      ? plainToClass(query, req.query ?? {}, {
          enableImplicitConversion: true,
          excludeExtraneousValues: false,
        })
      : ({} as Query);
    const validatedBody = body ? plainToClass(body, req.body ?? {}, { enableImplicitConversion: true }) : ({} as Body);
    const validatedParams = params
      ? plainToClass(params, req.params ?? {}, { enableImplicitConversion: true })
      : ({} as Params);

    try {
      console.log({ validatedQuery, validatedBody, validatedParams });
      await validateOrReject(validatedQuery, { whitelist: true, forbidUnknownValues: false });
      await validateOrReject(validatedBody, { whitelist: true, forbidUnknownValues: false });
      await validateOrReject(validatedParams, { whitelist: true, forbidUnknownValues: false });

      req.body = validatedBody;
      req.params = validatedParams;
      Object.defineProperty(req, 'query', {
        ...Object.getOwnPropertyDescriptor(req, 'query'),
        value: validatedQuery,
        writable: true,
      });
      next();
    } catch (error) {
      next(error);
    }
  };
};
router.route('/login').post(validateMiddleware({ body: LoginDto }), asyncMiddleware(venoticsController.login));

router
  .route('/search-studies')
  .get(
    venoticsBearerTokenMiddleware,
    validateMiddleware({ query: StudyQueryDto }),
    asyncMiddleware(venoticsController.getStudies)
login: RequestHandler<object, object, LoginDto> = async (req, res) => {
    const result = await this.venoticsService.login({ id: xss(req.body.id), password: xss(req.body.password) });

    return res.status(StatusCodes.OK).json(result);
  };

비록 런타임 안정성은 확보했지만, 새로운 API가 추가될 때마다 작성해야 할 코드와 파일이 늘어나는 구조적 한계는 여전했습니다.


3. tsoa 도입: 실서비스 품질 유지를 위한 최적의 선택

급격한 변화는 리스크를 수반합니다. Express 기반을 유지하면서도 NestJS의 장점을 취할 수 있는 tsoa를 선택했습니다.

🛠️ 왜 tsoa인가?

  • NestJS와의 유사성: 데코레이터 기반의 클래스 컨트롤러 구조를 채택하여 추후 NestJS 전환 시 리팩토링 비용을 최소화합니다.

  • 타입 자동 변환: class-validator 를 활용하면 tsoa의 데코레이터에서 타입을 변환해주어 최소한의 코드양을 작성할 수 있습니다.

  • 선언적 리턴: res 객체를 직접 다루지 않고 데이터를 return하는 것만으로 응답을 처리할 수 있어 코드가 깔끔해집니다.

  • 자동화된 문서화: 코드로부터 Swagger API 문서를 자동으로 추출합니다.


4. 실무 적용: 선언적 API 정의와 보안 계층

tsoa의 데코레이터를 활용하면 코드 자체가 문서이자 검증 로직이 됩니다.

🛠️ 컨트롤러 고도화: Security와 Example 활용 단순히 엔드포인트를 만드는 것을 넘어, @Security 데코레이터로 인가 로직을 분리하고 @Example을 통해 실제 응답 형태를 Swagger에 명시했습니다.

@Route('auth')
@Tags('Auth')
@Middlewares(loggerMiddleware)
export class AuthController {
  private readonly authService = new AuthService();

  /**
   * 유저 로그인 API
   */@Post('/user/login')
  @SuccessResponse('200', '유저 로그인 성공')
  @Example<UserLoginResponse>({
    user: { userId: 1, accountId: 'test', name: '테스트 유저', /* ... */ },
    accessToken: '...',
    refreshToken: '...'
  })
  async userLogin(@Body() body: LoginDto): Promise<UserLoginResponse> {
    // xss 라이브러리를 활용한 보안 강화 및 서비스 호출
    return this.authService.userLogin({ id: xss(body.id), password: xss(body.password) }, Project.preveno);
  }

  /**
   * 비밀번호 변경 (admin 권한 필요)
   */@Put('/password')
  @Security('jwt', ['admin']) // 미들웨어가 아닌 데코레이터로 권한 제어async changePassword(@Body() body: ChangePasswordBodyDto): Promise<ChangePasswordResponse> {
    return this.authService.changePassword({ id: xss(body.id), password: xss(body.password) });
  }
}

이후 package.json 에서 스크립트를 추가해줍니다.

"tsoa:spec": "pnpm exec tsoa spec",
"tsoa:routes": "pnpm exec tsoa routes",
"tsoa:gen": "pnpm exec tsoa spec && pnpm exec tsoa routes",

빌드할 때 같이 위의 스크립트를 실행해주어 tsoa가 생성한 route를 app에 등록시켜주면 됩니다.

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/api-docs-json', (_req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.send(swaggerDocument);
});
RegisterRoutes(tsoaRouter);
app.use(tsoaRouter);

5. 성능 및 효율: "코드와 문서의 완전한 일치"

tsoa 도입 이후 백엔드 개발 워크플로우는 다음과 같이 최적화되었습니다.

  • 파일 수 감소 및 가독성 향상: 직접 작성하던 라우트 파일과 타입 변환 미들웨어가 사라졌습니다. 컨트롤러가 라우팅 정의까지 포함하게 되어 개발 피로도가 현저히 줄어들었습니다.

  • Swagger 문서 자동화: 빌드 시점에 openapi.json이 생성되어 노션을 수동으로 업데이트할 필요가 없어졌습니다. Swagger UI를 통해 실시간 API 테스트가 가능해져 프론트엔드와의 협업 효율이 극대화되었습니다.

  • 런타임 안정성: TypeScript 모델을 기반으로 한 자동 검증 덕분에 잘못된 데이터가 서비스 계층으로 침범하는 것을 입구에서부터 차단할 수 있었습니다.


6. 마치며

Express의 자유로움은 유지하면서 NestJS와 같은 구조적 견고함을 얻기 위한 선택이었습니다. tsoa는 단순한 문서화 도구를 넘어, 코드와 문서를 하나로 묶어주는 강력한 프레임워크 역할을 해주었습니다. 특히 API 명세가 수시로 변하는 초기 개발 단계에서 수동 문서 관리의 스트레스를 해결하고 로직의 정합성을 지켜낸 점이 가장 만족스러운 성과였습니다.

물론 현재의 Express 프로젝트에서 개선해야 할 부분은 여전히 많습니다. 하지만 실서비스의 안정성을 최우선으로 고려하며, tsoa와 같은 도구를 징검다리 삼아 서비스 품질을 유지한 채 꾸준히 기술적 완성도를 높여나갈 예정입니다.