왜 "MSA Lite"인가
혼자 B2B SaaS 백엔드 코드베이스를 여러 개 운영하고 있었다. 같은 의료 도메인을 다루는 여러 개의 NestJS 서비스가 별도 레포에서 돌아가고 있었는데, 서비스가 커지면서 코드 중복, 기능별 스케일링 불가, 배포 파이프라인 다중 관리가 한계에 달했다.
그렇다, 결국엔 합쳐야 했다.
하지만 단순히 하나의 모놀리스로 합치면 또 다른 문제가 생길 것 같았다. 코드 중복은 줄겠지만, 수십 건이 병렬로 도는 ETL 배치나 외부 AI 서버와 장시간 스트리밍 통신하는 기능이 가벼운 CRUD API와 같은 프로세스에 묶이면, 기능별 스케일링은 여전히 불가능하고 한쪽의 부하가 전체 응답성을 끌어내린다.
합치되, 나눌 수 있는 구조가 필요했다.
풀 MSA를 도입하려면 서비스 메시, API Gateway 전용 솔루션, 분산 트랜잭션 관리, 독립 배포 파이프라인 등 인프라 투자가 막대하다. 스타트업에서 백엔드 개발자 2명이 이 모든 걸 감당하기는 불가능에 가깝다. 🥲
내가 절충하여 선택한 방식은 모노레포 안에서 서비스를 논리적으로 분리하되, 통신은 NestJS 내장 TCP 트랜스포트를 사용한다. Kafka나 gRPC 같은 외부 의존성을 추가하지 않으면서도, 서비스별 독립 스케일링과 장애 격리를 확보하는 구조다. 이것을 "MSA Lite"라 부르기로 했다.
┌─────────────────────────────────────────────────────────┐
│ Monorepo │
│ │
│ apps/ packages/ │
│ ├── gateway (HTTP) ├── api (DTO/SDK) │
│ ├── service-a (TCP) ├── infrastructure (Core) │
│ ├── service-b (TCP) └── shared (Constants) │
│ ├── service-c (TCP) │
│ └── admin-ui (React) │
│ │
└─────────────────────────────────────────────────────────┘
1년 넘게 모놀리식을 프로덕션에서 운영하다가 MSA Lite 전환 후 3개월차가 된 현재, 느낀 장단점과 고도화 포인트를 공유한다.
출발점 — 여러 개의 레포를 하나로 합치다
위에서 말한 상황을 정리하면 여러 레포에 흩어진 서비스들이 같은 데이터 구조를 다루면서 각자 Entity, DTO, 유틸리티를 들고 있었고, 인증/인가는 한쪽 레포에서 담당하며 나머지가 이를 공유하는 구조였다.
구체적으로 터지고 있던 문제들:
- 코드 중복: 한쪽에서 버그를 고치면 다른 레포에는 여전히 남아있다
- 기능별 스케일링 불가: ETL 배치나 스트리밍 통신이 CRUD API와 같은 프로세스에 묶여 있어 따로 스케일업할 수 없었다
- 패키지 버전 불일치: 레포마다 TypeORM 버전이 달라 미묘한 동작 차이로 원인 불명의 버그가 발생
- 배포 파이프라인 다중 관리: 레포마다 CI/CD를 따로 설정. 인프라 변경 시 모든 파이프라인을 동시에 수정해야 했다
기존 서비스들의 비즈니스 로직을 보존하면서, 공통 코드는 공유 패키지로 추출하고, 기능 단위로 서비스를 재분리하고, 서비스 간 통신은 TCP로 표준화하는 방향으로 통합을 시작했다.
서비스 토폴로지
Gateway — HTTP 진입점 + 공통 비즈니스 로직
Gateway는 단순한 TCP 프록시가 아니다. 인증/인가, 세션 관리, 계정 관리 등 모든 서비스가 공통으로 필요한 비즈니스 로직을 담당한다. 기존에 한쪽 레포에서 담당하던 인증 로직을 Gateway로 끌어올린 것이다.
Gateway가 직접 처리하는 영역:
| 영역 | 핵심 기능 | 규모 |
|---|---|---|
| 인증/인가 | JWT 발급/검증, 역할 기반 접근 제어, 권한 가드 | 8개 Guard/Strategy |
| 세션 관리 | Redis 기반 세션 생명주기, 재접속 유예, SSE 동기화 | 7일 TTL, 30초 grace period |
| 계정 관리 | CRUD, 비밀번호 재설정, 초대 시스템, 관리자 계정 | 35개+ Usecase |
| 감사 로그 | 민감 필드 마스킹, 비동기 저장, 경로 필터링 | BullMQ 기반 |
| 병원 관리 | 온보딩 워크플로우, 설정, 가격 정책 | Slack 알림 연동 |
| 사용자 설정 | 컬럼 커스터마이징, 필터 프리셋 | 테넌트별 격리 |
// Gateway의 인증 — 단순 프록시가 아닌 실제 비즈니스 로직
@Post('auth/login')
@Throttle({ short: { limit: 15, ttl: 60000 } }) // 브루트포스 방지
async login(@Body() body: LoginRequest, @Res() res: Response) {
// 1. 자격 증명 검증 (DB 직접 조회)
// 2. JWT 토큰 발급
// 3. Redis 세션 생성 (7일 TTL)
// 4. SSE로 세션 상태 브로드캐스트
// 5. 쿠키 설정 후 응답
}
// 세션 관리 — 네트워크 불안정 대응
@Injectable()
export class AuthSessionService {
// 연결 끊김 시 30초 유예 → 재접속하면 세션 유지
async handleDisconnect(sessionId: string) {
await this.redis.set(
`grace:${sessionId}`, 'disconnected',
'EX', GRACE_PERIOD_SEC // 30초
);
}
}
Gateway가 TCP로 위임하는 영역:
도메인 특화 비즈니스 로직은 해당 마이크로서비스에 TCP로 위임한다.
// TCP 위임 — 도메인 로직은 마이크로서비스에서 처리
@Post('orders')
async createOrder(@Body() body: CreateOrderRequest) {
return await firstValueFrom(
this.client.send(Messages.OrderService.Order.create, body),
);
}
이 설계의 근거:
인증/세션 같은 횡단 관심사를 각 마이크로서비스에 분산시키면 일관성 유지가 어렵다. 기존에도 한쪽 레포에서 인증을 중앙 처리하고 있었으므로, 이 패턴을 Gateway로 자연스럽게 이관한 것이다. Gateway를 수평 확장할 때도 세션은 Redis에 있으므로 상태 문제가 발생하지 않는다.
Microservices — TCP + HTTP 이중 스택
각 마이크로서비스는 TCP(서비스 간 통신)와 HTTP(헬스체크/Swagger)를 동시에 서빙한다.
// main.ts (마이크로서비스)
const app = await NestFactory.create(AppModule);
// TCP 서버 연결 (서비스 간 통신)
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.TCP,
options: { host: '0.0.0.0', port: TCP_PORT },
});
await app.startAllMicroservices();
await app.listen(HTTP_PORT);
// PM2 무중단 배포 시그널
if (process.send) process.send('ready');
왜 TCP인가? NestJS 내장이라 추가 인프라 없이 바로 쓸 수 있고, JSON 직렬화 기반이라 디버깅이 쉽다. gRPC 대비 성능은 떨어지지만, 우리 트래픽 규모(초당 수백 요청)에서는 병목이 되지 않는다. 무엇보다 백엔드 2명이 별도 인프라를 관리할 여유가 없었다. Kafka는 이벤트 소싱이 필요할 때 도입하기로 하고, 현재는 BullMQ로 비동기 처리를 충분히 커버한다.
공유 패키지 3계층 — 모노레포의 핵심 무기
여러 레포를 합치면서 가장 먼저 한 일이 중복 코드를 공유 패키지로 추출하는 것이었다. 거의 같은 도메인을 다루던 서비스들이었기에 중복이 상당했고, 이것을 정리하는 것이 모노레포 전환의 가장 큰 수확이다.
packages/
├── api/ # DTO, Request/Response 타입
│ └── dto/ # Swagger 데코레이터 포함 → SDK 자동 생성
├── infrastructure/ # Entity, DB, 공통 서비스
│ ├── database/ # TypeORM Entity + Migration
│ ├── bullmq/ # 큐 설정 + Bull Board
│ ├── filter/ # 예외 필터
│ ├── interceptor/ # 인터셉터
│ ├── sse/ # Server-Sent Events 베이스 클래스
│ ├── lock/ # 분산 락 + @DistributedCron
│ └── testing/ # Fake/Stub (FakeDBService, NoOpLogger 등)
└── shared/ # 상수, MessagePattern, ConfigService
| 패키지 | 역할 | 의존 방향 |
|---|---|---|
shared |
상수, 메시지 패턴, 설정 | 없음 (leaf) |
api |
DTO 정의 + Swagger |
shared, infrastructure
|
infrastructure |
핵심 인프라 서비스 | shared |
이전에 여러 레포에서 복사해서 쓰던 코드가 이제 한 곳에만 존재한다. 버그를 고치면 모든 서비스에 즉시 반영된다. Turbo 빌드 파이프라인이 의존 순서를 자동으로 해결한다:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
분산 트레이싱 — AsyncLocalStorage로 서비스 경계를 넘는 Request ID
MSA에서 한 요청이 여러 서비스를 거치면 로그 추적이 지옥이 된다. 이 문제를 Node.js AsyncLocalStorage + TCP 페이로드 주입 조합으로 해결했다.
Client → Gateway(RequestIdMiddleware) → TracingClientProxy → TCP → Microservice(RequestContextInterceptor)
│ │ │
│ AsyncLocalStorage에 │ 페이로드에 │ 페이로드에서
│ requestId 저장 │ __requestId 주입 │ __requestId 추출 →
│ │ │ AsyncLocalStorage에 저장
1단계: Gateway에서 Request ID 생성
// RequestIdMiddleware
const requestId = req.headers['x-request-id'] || randomUUID();
res.setHeader('x-request-id', requestId);
runWithRequestId(requestId, () => next());
2단계: TCP 호출 시 자동 주입
// TracingClientProxy — NestJS ClientProxy 래퍼
private injectRequestId<T>(data: T): T {
const requestId = getRequestId(); // AsyncLocalStorage에서 조회
if (!requestId || typeof data !== 'object') return data;
return { ...data, __requestId: requestId } as T;
}
3단계: 마이크로서비스에서 추출 + 전파
// RequestContextInterceptor
intercept(context: ExecutionContext, next: CallHandler) {
const data = context.switchToRpc().getData();
const requestId = data?.__requestId;
delete data.__requestId; // 비즈니스 로직에 노출되지 않도록 제거
return new Observable((subscriber) => {
runWithRequestId(requestId, () => {
next.handle().subscribe(subscriber);
});
});
}
이 설계의 강점: 비즈니스 코드에서 Request ID를 전혀 의식하지 않는다. 파라미터로 전달하지 않아도 getRequestId() 한 줄이면 어디서든 조회 가능하다.
예외 전파 체인 — 서비스 경계를 넘는 에러 핸들링
MSA에서 가장 놓치기 쉬운 부분이 예외 전파다. Microservice에서 NotFoundException을 던졌는데 Gateway가 500으로 응답하면 클라이언트는 원인을 알 수 없다.
Microservice Gateway Client
NotFoundException(404) RpcToHttpInterceptor HTTP 404
↓ ↓ ↓
MicroserviceExceptionFilter {statusCode:404, message} {statusCode:404,
↓ → HttpException(404) message:"Not found"}
throwError({statusCode, msg})
Microservice 측: 모든 예외를 정규화
@Catch()
export class MicroserviceExceptionFilter implements RpcExceptionFilter {
catch(exception: unknown): Observable<never> {
let statusCode = 500;
let message = 'Internal server error';
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
message = exception.message;
}
return throwError(() => ({ statusCode, message }));
}
}
Gateway 측: 정규화된 에러를 HTTP 예외로 변환
@Injectable()
export class RpcToHttpInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
catchError((error) => {
if (error instanceof HttpException) throw error;
const statusCode = error?.statusCode ?? 500;
throw new HttpException(
{ statusCode, message: error?.message },
statusCode,
);
}),
);
}
}
결과: 클라이언트는 어느 서비스에서 에러가 발생했든 일관된 HTTP 응답을 받는다.
TCP 통신의 계약 — MessagePattern 설계
서비스가 늘어나면 "어떤 서비스가 어떤 메시지를 처리하는가"를 추적하기 어려워진다. 모든 메시지 패턴을 하나의 객체로 중앙 관리하는 방식을 택했다.
// packages/shared/src/message/message-pattern-map.ts
const Messages = createMessagePatternMap({
ServiceA: {
Order: {
create: null,
findOne: null,
updateStatus: null,
},
Report: {
generate: null,
getResult: null,
},
},
ServiceB: {
Pipeline: {
start: null,
getStatus: null,
},
},
});
// 자동 생성: Messages.ServiceA.Order.create = "ServiceA.Order.create"
이렇게 하면 IDE에서 자동완성이 되고, 존재하지 않는 패턴을 호출하면 컴파일 타임에 잡힌다. grep으로 "누가 이 메시지를 보내고, 누가 처리하는가"를 한 번에 추적할 수 있다.
한계: 메시지 패턴 자체는 타입 세이프하지만, 페이로드 타입까지 강제하지는 못한다. client.send(Messages.ServiceA.Order.create, 잘못된타입)을 넣어도 컴파일러는 통과시킨다. gRPC의 .proto 같은 계약이 없는 셈이다. 이 부분은 공유 패키지에 서비스별 인터페이스를 정의하는 방향으로 개선 중이다.
서비스 간 의존성 관리 — 누가 누구를 호출하는가
MSA에서 서비스 간 호출이 자유로우면 스파게티가 된다. 우리는 단순한 규칙을 세웠다:
Gateway ──→ ServiceA
Gateway ──→ ServiceB
Gateway ──→ ServiceC
ServiceA ──→ ServiceB (필요시)
❌ ServiceB → Gateway (역방향 금지)
❌ ServiceA ↔ ServiceC (순환 금지)
원칙:
- Gateway는 모든 마이크로서비스를 호출할 수 있다
- 마이크로서비스 간 직접 호출은 최소화한다. 필요하면 한 방향으로만
- 순환 호출은 절대 금지. 순환이 생기면 설계가 잘못된 것이다
- 비동기로 풀 수 있는 건 BullMQ로 돌린다 (2편에서 다룸)
TCP 클라이언트 등록도 이 규칙을 코드로 강제한다:
// ServiceA의 모듈 — ServiceB만 호출 가능
TracingClientsModule.registerAsync([
{
name: ServiceToken.SERVICE_B,
useFactory: (config: ConfigService) => ({
transport: Transport.TCP,
options: {
host: config.get('SERVICE_B_HOST'),
port: config.get('SERVICE_B_PORT'),
},
}),
inject: [ConfigService],
},
])
등록하지 않은 서비스는 물리적으로 호출할 수 없다. "하지 마"라는 문서보다 "할 수 없다"는 코드가 더 확실하다.
공유 패키지의 경계 — 무엇을 공유하고 무엇을 분리하는가
모노레포에서 가장 흔한 실수가 "공유 패키지에 뭐든 넣는 것"이다. 우리도 초기에 이 함정에 빠졌다.
공유해야 하는 것:
- Entity 정의 (DB 스키마는 하나)
- DTO / Request / Response (서비스 간 계약)
- MessagePattern (통신 계약)
- 인프라 모듈 (DB 접속, 로깅, 예외 필터 등)
공유하면 안 되는 것:
- 서비스 고유 비즈니스 로직
- 서비스 내부에서만 쓰는 헬퍼/유틸리티
- 특정 서비스의 설정값
경험적으로, "2개 이상의 서비스에서 import하는가?"가 공유 패키지에 넣을지 말지의 기준이 됐다. 한 곳에서만 쓰는데 공유 패키지에 넣으면, 변경할 때 불필요한 리빌드가 전파된다.
packages/api/ → 서비스 간 "계약"만. DTO, Request, Response
packages/infrastructure/ → 서비스가 "공통으로 쓰는 인프라". DB, 큐, 로깅
packages/shared/ → 서비스가 "공통으로 참조하는 값". 상수, 메시지 패턴
apps/service-a/src/ → ServiceA만의 비즈니스 로직. 여기 있는 건 절대 공유 안 함
여러 레포 → MSA Lite 모노레포, 구조 관점의 전과 후
| 관점 | 여러 레포 (Before) | MSA Lite 모노레포 (After) |
|---|---|---|
| 코드 공유 | 레포 간 복사-붙여넣기 | 공유 패키지로 한 곳에서 관리 |
| 통신 계약 | 암묵적 (문서 또는 구두) | MessagePattern 중앙 관리 |
| 서비스 경계 | 레포 단위 (우연한 분리) | 기능 단위 (의도적 분리) |
| 의존성 방향 | 추적 불가 | 코드로 강제 |
| 패키지 버전 | 레포마다 제각각 | 하나의 lockfile로 통일 |
| 리팩토링 | 여러 레포 동시 PR | 원자적 커밋 하나로 완료 |
다음 편 예고
이번 편에서는 MSA Lite의 서비스 구조와 통신 설계를 다뤘다. 하지만 서비스를 나누고 통신을 연결하는 건 시작일 뿐이다. 실제 프로덕션에서는 멀티테넌트 DB 관리, 비동기 작업 처리, 실시간 이벤트 전파 같은 데이터 레이어 문제가 기다리고 있다.
2편: 데이터 레이어와 비동기 처리에서는 테넌트별 DataSource 동적 관리, BullMQ 3계층 패턴, Redis PubSub 기반 SSE 설계를 다룬다.
3편: 운영, 배포, 그리고 진화에서는 무중단 배포의 Expand-Contract 패턴, 분산 크론, 테스트 전략, 그리고 1년간의 회고를 공유한다.
Top comments (0)