레거시 광고 서버를 없애야 했다.

기존 서버는 primary 광고 시스템에 장애가 발생할 때 광고를 대신 내보내는 역할을 했다. 대시보드 미리보기, 외부 플랫폼 연동, 테스트 요청 처리 같은 부가 기능도 담당하고 있었다. 오래된 코드베이스였다 — 유지보수 비용이 계속 올라가고 있었다.

레거시를 제거하려면 그 역할을 이어받을 서버가 필요했다. 장애 대응 전용 fallback 서버를 새로 설계하게 됐다.

요구사항

fallback 서버의 조건은 명확했다.

primary 시스템이 정상일 때는 요청이 들어오지 않는다. primary 시스템이 광고를 내보낼 수 없다고 판단한 경우에만 동작한다. 평소에는 유휴 상태로 서버비를 절감하는 것이 목적 중 하나였다.

요청이 들어오면 광고 후보 조회, 필터링, 응답 생성 같은 처리를 한 번에 수행해야 했다. 다수의 필터가 각각 독립적인 데이터 소스를 참조하는 구조였다.

단일 API 엔드포인트에 복잡한 비즈니스 로직이 집중된 구조였다.

기술 스택

Nest.js를 선택했다. 사내 운영 환경이 Node.js/TypeScript 중심이었고, Nest.js가 제공하는 DI 컨테이너와 Module 시스템이 이 규모의 비즈니스 로직을 정리하는 데 적합하다고 판단했다.

HTTP 서버는 Express 대신 Fastify를 택했다. 광고 서버는 처리량이 중요하고, Fastify가 같은 API 형태로 더 나은 성능을 낸다.

ORM은 TypeORM을 사용했다. 광고 데이터와 추천 데이터가 서로 다른 DB에 있어서 다중 데이터소스 연결이 필요했는데, TypeORM이 이를 자연스럽게 지원했다.

Nest.js의 설계 철학과 핵심 개념은 Nest.js 기본 — DI와 Module 시스템에서 정리했다.

아키텍처 결정

Nest.js의 기본 접근은 vertical module slicing이다. 도메인별로 모듈을 나누고, 각 모듈 안에 Controller, Service, Repository를 두는 구조다. 사용자 모듈, 상품 모듈, 주문 모듈처럼.

이 프로젝트에는 맞지 않다고 봤다.

도메인 경계가 모호했다. 광고 조회, 필터링, 사용자 파싱 같은 기능이 있었지만, 독립된 도메인이 아니었다. 모든 요청이 동일한 흐름을 타고, 모든 기능이 하나의 응답을 만들기 위해 순차적으로 동작했다.

그래서 수평 계층 아키텍처(horizontal layered architecture)를 선택했다. 기술적 책임 기준으로 계층을 나눴다.

  • Presentation: HTTP 요청/응답 처리. Controller, Interceptor, Filter가 여기 속한다.
  • Application: 비즈니스 로직. 여러 서비스를 조율하고 데이터를 변환한다. 인프라 계층과의 경계를 위해 인터페이스를 이 계층에 정의했다.
  • Domain: 비즈니스 엔티티. 기술적 의존성이 없는 순수한 모델이다.
  • Infra: 외부 시스템 연동. DB 접근, Redis 캐시, 외부 API 호출의 구현체가 여기 속한다.

의존성 방향을 지키기 위해 DIP를 적용했다. Application 계층에 인터페이스를 정의하고, Infra 계층에서 구현체를 제공한다. Nest.js의 DI 컨테이너가 Symbol 기반 토큰으로 이 연결을 관리한다.

결과와 배운 점

설계 검증은 외부 컴포넌트 교체 작업에서 왔다. 기존 내부 구현을 외부 라이브러리로 바꿀 때 Infra 계층의 구현체만 교체하면 됐다. Application 계층의 인터페이스는 그대로였다. 계층 분리가 실제로 유연성을 만들어낸 순간이었다.

동료로부터 “수직 분할 방식으로 잘라서 모듈을 선언하는 것이 좋지 않겠냐"는 피드백도 받았다. Nest.js의 기본 패턴을 따르자는 자연스러운 의견이었다. 하지만 이 프로젝트는 도메인이 여러 개인 서비스가 아니라, 하나의 API가 복잡한 파이프라인을 실행하는 구조였다. 그 특성에는 수평 계층 구조가 더 적합하다고 판단했다.

서비스 클래스가 비대해지는 문제는 있었다. 필터링 서비스 하나에 다수의 필터 조합이 들어가면서 코드가 길어졌다. 파사드 패턴으로 외부에 노출하는 인터페이스를 줄이고, 내부 서비스를 더 작은 단위로 분리하는 방향으로 대응했다.

레거시를 없애야 했다. 그래서 새로 만들었다. 단일 API에 다수의 필터가 몰리는 서버에서 — 프레임워크가 권하는 구조가 항상 답은 아니었다.

참고