DEV Community

Artistrator
Artistrator

Posted on

NestJS MSA Lite 실전 아키텍처 (3/3) — 운영, 배포, 그리고 진화

여러 레포에 흩어져 있던 B2B SaaS 서비스를 MSA Lite 모노레포로 통합한 경험을 정리한 시리즈의 마지막 글이다.

  • Part 1: 서비스 구조와 통신 설계
  • Part 2: 데이터 레이어와 비동기 처리
  • Part 3: 운영, 배포, 그리고 진화 (이 글)

Terraform + ECS Fargate — 백엔드 2명의 인프라 선택

MSA를 도입하면 배포 단위가 늘어난다. 우리는 5개 서비스(Gateway, AI 처리, 문서 생성, 데이터 파이프라인, 관리자 대시보드)를 운영하고 있다. 이걸 수동으로 관리하면 인프라 변경 하나에 반나절이 날아간다.

Terraform: 인프라 변경 이력이 Git에 남고, planapply 2단계로 실수를 사전 차단한다.
ECS Fargate: EC2 인스턴스 관리 부담 없이 서비스별 CPU/메모리를 독립 설정 가능. Kubernetes보다 운영 복잡도가 낮다.

모듈 구조

14개 모듈로 인프라 전체를 관리한다:

terraform/
├── environments/prod/
│   ├── main.tf                 # 모듈 조합 + 서비스별 설정
│   ├── variables.tf
│   ├── backend.tf              # S3 + DynamoDB 상태 관리
│   └── task-definitions/       # ECS 태스크 정의 템플릿
└── modules/
    ├── alb/                    # Application Load Balancer
    ├── alerting/               # CloudWatch 알람 + Slack 알림
    ├── cloudwatch-dashboard/   # 통합 모니터링 대시보드
    ├── codedeploy/             # Blue/Green 배포 자동화
    ├── ecs-cluster/            # ECS 클러스터 + Service Discovery
    ├── ecs-service/            # ECS Fargate 서비스 정의
    ├── grafana/                # AWS Managed Grafana
    ├── iam/                    # IAM 역할 + 정책
    ├── log-router/             # 에러 로그 분리 라우팅
    ├── network/                # 보안 그룹
    ├── secrets/                # Secrets Manager
    └── waf/                    # Web Application Firewall
Enter fullscreen mode Exit fullscreen mode

핵심 설계 원칙: 하나의 모듈은 하나의 인프라 관심사만 담당한다. 모듈 간 의존성은 main.tf에서 output → input으로 연결한다.

서비스별 리소스 설계

서비스 CPU Memory 배포 전략 비고
Gateway 512 1024MB Blue/Green HTTP 진입점, SSE
Service A (AI) 1024 2048MB Rolling AI 추론 → 높은 리소스
Service B (문서) 512 1024MB Rolling PDF 생성
Service C (데이터) 512 1024MB Blue/Green ETL + MCP 서버
Admin Dashboard 256 512MB Rolling React SPA

Service A가 다른 서비스의 2배 리소스를 쓴다. 외부 AI 서버와 스트리밍 통신하면서 후처리하는 작업이 CPU/메모리를 잡아먹기 때문이다. Fargate의 장점이 여기서 빛난다 — 서비스별로 리소스를 독립 조정할 수 있다.

Service Discovery — TCP 통신의 핵심

ECS 서비스 간 TCP 통신에 AWS Cloud Map을 사용한다. 하드코딩된 IP 대신 DNS 기반으로 동적 연결한다:

// NestJS TCP 클라이언트 설정
ClientsModule.register([
  {
    name: ServiceToken.SERVICE_A,
    transport: Transport.TCP,
    options: {
      host: 'service-a.my-project.local',  // Cloud Map DNS
      port: 4001,
    },
  },
])
Enter fullscreen mode Exit fullscreen mode

배포 시 태스크가 교체되면 Cloud Map이 자동으로 DNS 레코드를 업데이트한다. TTL 10초이므로 최대 10초 내에 새 태스크로 전환된다.

보안 계층

3중 보안으로 인프라를 보호한다:

인터넷 → WAF (Rate Limiting) → ALB → 보안 그룹 → ECS 태스크
Enter fullscreen mode Exit fullscreen mode
  • WAF: 5분간 2000 요청 초과 시 IP 차단
  • 보안 그룹: 마이크로서비스 포트는 개발 서버 + 사무실 IP만 허용
  • Secrets Manager: 환경 변수에 시크릿 하드코딩 없이 ECS가 런타임에 주입

무중단 배포 — Expand-Contract 패턴

Blue/Green 배포 중 Gateway(v2)와 Microservice(v1)가 공존하는 순간이 있다. MessagePattern이나 DTO를 함부로 바꾸면 이 순간에 장애가 발생한다.

DTO 변경 규칙

// Bad: 기존 호출자가 mode를 안 보내면 validation 실패
@IsString()
mode: string;

// Good: 기존 호출자가 없어도 기본값으로 동작
@IsOptional()
@IsString()
mode?: string = "default";
Enter fullscreen mode Exit fullscreen mode
변경 유형 허용 여부 조건
Request에 필드 추가 O @IsOptional() + 기본값
Request에서 필드 제거 X 2단계 배포 필요
Response에 필드 추가 O 호출자가 모르는 필드는 무시
Response에서 필드 제거 X 2단계 배포 필요

MessagePattern 변경: 3단계 배포

Stage 1 (Expand):   마이크로서비스에 신규 패턴 추가, 기존 패턴 유지
Stage 2 (Migrate):  Gateway가 신규 패턴 사용으로 전환
Stage 3 (Contract): 기존 패턴 제거
Enter fullscreen mode Exit fullscreen mode

이 규칙 덕분에 1년 넘게 배포 중 서비스 간 통신 장애가 0건이다.

Blue/Green vs Rolling Update

외부 노출 서비스(Gateway, 데이터 파이프라인)는 CodeDeploy Blue/Green으로 트래픽을 즉시 전환한다. 내부 서비스(AI 처리, 문서 생성)는 Rolling Update로 리소스를 절약한다. 실패 시 Blue/Green은 자동 롤백, Rolling은 이전 Task Definition으로 수동 복구한다.

PM2 무중단 배포

// ecosystem.config.js
{
  name: "service-a",
  script: "./dist/main.js",
  exec_mode: "fork",        // SSE 연결 유지를 위해 cluster 아닌 fork
  wait_ready: true,         // process.send("ready") 대기
  listen_timeout: 30000,
  kill_timeout: 5000,
}
Enter fullscreen mode Exit fullscreen mode

NestJS 부트스트랩 완료 후 process.send("ready")를 호출하면 PM2가 트래픽을 새 프로세스로 전환한다. 기존 프로세스는 5초 유예 후 종료.

테스트 전략 — jest.fn() 없는 세계

MSA에서 테스트가 어려운 이유는 서비스 간 의존성이 복잡하기 때문이다. Fake 구현체 패턴으로 이 문제를 풀었다.

┌─────────────────────────────────────────────┐
│  E2E (Cucumber BDD, 8 병렬 워커)             │
│  "사용자 시나리오가 동작하는가?"                │
├─────────────────────────────────────────────┤
│  Integration (InMemory DB + Fake 구현체)     │
│  "Service → Repository 쿼리가 맞는가?"       │
├─────────────────────────────────────────────┤
│  Unit (Component / 순수 함수)                │
│  "이 판단/변환 로직이 맞는가?"                │ ← 여기를 최대한 늘린다
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

jest.fn() 금지, Fake 구현체 사용

// jest.fn() — 타입 안전성 없음, 인터페이스 변경 시 조용히 통과
const mockDb = { getAgentRepository: jest.fn().mockResolvedValue(mockRepo) };
const service = new OrderService(mockDb as any);  // as any 필수

// Fake 구현체 — 컴파일 타임 검증, 실제 동작
class FakeAgentDatabaseService {
  constructor(private readonly dataSource: DataSource) {}

  async getAgentRepository<T>(
    _tenantId: string,
    entity: EntityTarget<T>,
  ): Promise<Repository<T>> {
    return this.dataSource.getRepository(entity);  // InMemory DB
  }
}
Enter fullscreen mode Exit fullscreen mode

Fake는 implements 키워드로 인터페이스를 구현하므로, 원본 인터페이스가 변경되면 컴파일 에러가 발생한다. jest.fn()as any로 타입을 우회하기 때문에 이런 보호가 없다.

공유 Fake 패키지

// @myorg/infrastructure/testing
export { FakeAgentDatabaseService, createInMemoryDataSource };
export { NoOpLogger };
export { FakeS3Service };
export { FakeConfigService };
export { FakeProducer };
Enter fullscreen mode Exit fullscreen mode

한 번 만들면 프로젝트 전체에서 재사용한다. 레포가 분리되어 있었을 때는 각 레포에 비슷한 Fake를 따로 만들어야 했지만, 모노레포 통합 후에는 한 곳에서 관리하고 모든 서비스가 동일한 테스트 인프라를 쓴다. Fake 자체도 테스트 자산으로 축적된다.

단위 테스트: mock 0개

Usecase에서 순수 로직을 Component로 추출하면, NestJS DI 없이 new로 생성해서 테스트할 수 있다:

describe("StatusComponent", () => {
  const component = new StatusComponent();  // DI 불필요

  it.each(["READY", "SENT", "FAILED"])(
    "%s 상태이면 재처리 가능하다", (status) => {
      const order = new OrderEntity({ status });
      expect(() => component.validateCanRetry(order)).not.toThrow();
    },
  );
});
Enter fullscreen mode Exit fullscreen mode

모니터링 — CloudWatch + Grafana + Slack

에러 로그 분리

일반 로그와 에러 로그를 분리한다. 에러만 따로 모으면 장애 대응 시간이 단축된다:

ECS 로그 → CloudWatch Log Group (/ecs/service-a)
                    │
                    ├── 전체 로그 (30일 보관)
                    └── ERROR/WARN 필터 → 에러 전용 로그 그룹
                                              │
                                              └→ CloudWatch Alarm → SNS → Slack
Enter fullscreen mode Exit fullscreen mode

5분간 5xx 에러 10회 이상이면 Slack으로 즉시 알림이 온다. Grafana에서 서비스 간 호출 지연, 에러율 트렌드, 리소스 사용 패턴을 한 눈에 파악한다.

운영 스크립트

장애 발생 시 AWS 콘솔을 뒤지는 대신 터미널 한 줄로 대응한다:

./scripts/deploy/service-status.sh gateway     # 서비스 상태 확인
./scripts/deploy/scale-service.sh service-a 4  # 긴급 스케일링
./scripts/deploy/rollback-service.sh gateway   # 이전 버전 롤백
./scripts/deploy/logs-service.sh service-b     # 실시간 로그
Enter fullscreen mode Exit fullscreen mode

여러 레포 → MSA Lite, 1년간의 체감 비교

관점 여러 레포 (Before) MSA Lite 모노레포 (After)
배포 단위 레포 전체 재배포 서비스별 독립 배포
장애 격리 한 서비스 장애가 다른 레포에 영향 없지만 내부는 전체 다운 서비스별 격리 (Gateway 살아있으면 부분 동작)
스케일링 서비스 통째로만 가능 CPU 집약 서비스만 선택적 스케일 아웃
코드 공유 복사-붙여넣기 공유 패키지로 명시적 관리
디버깅 레포 넘나들며 추적 분산 트레이싱 (AsyncLocalStorage)
로컬 개발 npm start 하나 여러 서비스 동시 기동 필요 (PM2/Docker)
인프라 복잡도 낮음 중간 (Redis, 큐, Service Discovery)
새 기능 추가 레포 선택부터 고민 해당 서비스에 추가하면 끝

솔직한 평가: 백엔드 2명이서 통합 작업을 진행하는 건 쉽지 않았다. 하지만 한 번 통합하고 나니 이후 모든 개발 속도가 올라갔다. 특히 새 서비스 추가 비용이 극적으로 줄었다 — 공유 패키지에서 가져다 쓰고, Terraform 모듈 하나 추가하면 인프라까지 끝이다.

아쉬웠던 점

  • Gateway의 역할이 커졌다. 공통 로직을 Gateway에 넣다 보니 가장 큰 서비스가 됐다. 인증/세션을 별도 서비스로 분리할지 고민 중이지만, 백엔드 2명 규모에서는 분리 복잡도가 이점보다 크다.
  • TCP 통신의 타입 안전성 부재. client.send()에 잘못된 타입을 넣어도 컴파일러가 통과시킨다. gRPC의 .proto 같은 계약이 없는 셈이다.
  • Terraform 모듈 분리 시점을 놓쳤다. main.tf 하나에 모든 리소스를 넣다가 1000줄이 넘어간 후에야 분리했다. 처음부터 모듈 단위로 설계했어야 했다.
  • 로컬 개발 환경 복잡도. 모든 서비스를 동시에 띄워야 해서 PM2 + Docker 의존성이 늘었다. npm start 하나로 끝나던 시절이 그립기도 하다.

추후 발전 방향

단기 (3~6개월)

  • OpenTelemetry 도입: AsyncLocalStorage 트레이싱을 OTEL 표준으로 마이그레이션. Jaeger/Tempo 연동
  • Circuit Breaker: TCP 통신에 재시도/서킷 브레이커 패턴 추가
  • Auto Scaling: 현재 고정 태스크 수 → CPU/메모리 기반 자동 스케일링

중기 (6~12개월)

  • 이벤트 기반 통신 병행: 동기 TCP + 비동기 이벤트(Kafka/NATS) 하이브리드
  • TCP 타입 안전성 강화: 공유 패키지에 서비스별 인터페이스 정의, 컴파일 타임 페이로드 검증
  • 카나리 배포: CodeDeploy Linear/Canary 전략으로 10% 트래픽 먼저 전환

장기 (1년 이상)

  • Kubernetes 마이그레이션: ECS → EKS. Service Mesh로 트래픽 관리 고도화
  • GitOps: ArgoCD/Flux로 인프라 변경을 Git PR 기반으로 관리

시리즈를 마치며 — 배운 점

  • "백엔드 2명도 MSA를 할 수 있다. 단, 범위를 잘 잡아야 한다." 풀 MSA의 인프라 오버헤드를 NestJS 내장 기능 + Terraform 모듈화로 대체하면, 소규모 팀에서도 서비스 분리의 이점을 누릴 수 있다.
  • 모노레포가 MSA의 고통을 반으로 줄인다. 코드 공유, 일관된 린트/빌드, 원자적 커밋. 여러 레포로 분산되어 있었을 때의 패키지 버전 관리 지옥이 사라졌다.
  • 인프라 코드는 자산이다. TracingClientProxy, MicroserviceExceptionFilter, DistributedCron 같은 인프라 레이어와 Terraform 모듈을 공들여 만들면, 이후 서비스 추가 비용이 극적으로 줄어든다.
  • IaC 없는 MSA는 관리 불가능하다. 서비스가 3개를 넘어가면 수동 인프라 관리는 현실적으로 불가능하다. Terraform 도입 비용은 첫 달에 회수된다.
  • Fake 구현체는 프로젝트 자산이다. 한 번 만들면 모든 서비스에서 재사용한다. jest.fn()을 매번 setup하는 것보다 효율적이고, 인터페이스 변경 시 컴파일 에러로 동기화된다.
  • 과도하게 나누지 마라. 기능 단위로 적절히 분리하는 선에서 멈췄다. 처음부터 10개 서비스로 시작하면 2명이서 인프라 관리에 매몰된다.

AI 활용 포인트

Terraform 모듈 설계에 Claude Code를 활용했다. 14개 모듈 간 의존성 그래프를 분석하고, 순환 참조 없이 output → input을 연결하는 구조를 잡는 데 효과적이었다. 보안 그룹 규칙 검증 — 불필요하게 열린 포트나 과도한 IP 범위를 탐지하는 데도 AI가 도움이 됐다. 테스트 전략 수립 시에도 코드베이스 전체의 의존성 패턴을 분석해서 Fake 구현체의 인터페이스를 설계하는 데 활용했다.

Top comments (0)