우리 주력 서비스의 백엔드는 Python(Django) 기반의 모놀리식으로 시작했다. 빠르게 기능을 붙이기에는 좋았지만, 서비스가 커지면서 한 덩어리의 코드와 한 덩어리의 배포가 점점 더 큰 부담이 되기 시작했다. 결국 두 가지를 동시에 바꾸기로 했다.
| 구분 | Before | After |
|---|---|---|
| 언어 | Python (Django) | Java (Spring Boot) |
| 아키텍처 | Monolith | MSA (5개 서비스) |
| 데이터베이스 | 단일 DB | 서비스별 독립 DB |
| 서비스 간 통신 | 함수 호출 | REST(동기) + Outbox(비동기) |
| 트랜잭션 경계 | @Transactional 하나 | Outbox 패턴 기반 분산 트랜잭션 |
"하나만 바꾸는 게 안전하지 않냐"는 질문을 많이 받았는데, 뒤에서 다시 풀어쓰겠지만 결론부터 말하면 둘을 묶어서 한 번에 가는 편이 오히려 합리적이었다. 어차피 모듈 단위로 다시 그릴 거라면, 언어와 경계를 한꺼번에 재정의하는 게 비용을 줄였다.
product-service를 제외한 네 개 서비스의 마이그레이션 작업은 내가 단독으로 끌고 갔다. 도메인 분석, 서비스 경계 정의, 언어 전환, Outbox 패턴 도입, CI/CD 구성까지 네 서비스의 의사결정과 구현 전부가 내 손에서 나왔다.
| 서비스 | 도메인 | 담당 |
|---|---|---|
general-service | 공통/플랫폼 (알림, 파일, 메타데이터 등) | 본인 |
auth-service | 인증·인가 | 본인 |
payment-service | 결제 | 본인 |
contract-service | 계약 | 본인 |
product-service | 상품 | 다른 개발자 |
product-service는 별도의 개발자가 처음부터 끝까지 독립적으로 담당했다. 설계, 구현, 운영 방식 모두 그쪽 영역이라 이 글에서는 다루지 않는다. 이 글의 시행착오와 의사결정 기록은 내가 직접 손을 댄 네 서비스에 한정된다.
이 두 가지가 이 글의 중심이다. 일반론보다 "이래서 이렇게 했다"에 무게를 두고 썼다.
| 문제 | 증상 | 결과 |
|---|---|---|
| 도메인 간 강결합 | 결제 수정 한 줄에 계약·상품·유저까지 회귀 검토 필요 | 작은 변경의 비용이 큼 |
| 단일 배포 단위 | 사소한 픽스도 전체 서버 재기동 | 배포의 심리적 무게 ↑ |
| 팀 기술 스택 미스매치 | 팀 주력은 Java, 모놀리식은 Python | 유지보수·온보딩 비용 ↑ |
| 인터프리터 실행 속도 | 동일 로직 대비 응답 지연 | 자원 투입 비용 ↑ |
도메인 간 강결합
결제 로직을 고치려고 들어가면 계약, 상품, 유저 모델이 줄줄이 따라왔다. ORM 관계를 타고 도메인이 서로의 내부 구조에 의존하다 보니, "결제만 고친다"는 변경이 사실상 불가능해졌다. 작은 수정에도 회귀 범위가 컸다.
단일 배포 단위의 리스크
어느 한 줄의 변경도 전체 서버를 다시 띄워야 반영됐다. 결제 모듈의 사소한 픽스도 계약/상품 트래픽에 영향을 줄 수 있는 구조였고, 장애도 곧 전사 장애로 이어졌다.
팀 기술 스택과의 미스매치
현재 회사 백엔드 개발자들의 주력 스택은 Java이고, Python에 익숙한 인원이 거의 없었다. 모놀리식이 Python으로 짜여 있다는 사실 자체가 유지보수와 신규 합류자 온보딩의 큰 장벽이 됐다. "이 코드를 누가 자신 있게 고칠 수 있는가"라는 질문에 매번 답이 궁색했다.
인터프리터 언어의 실행 속도 한계
Python은 인터프리터 언어 특성상, 동일한 로직을 컴파일 언어로 짤 때보다 실행 속도가 느렸다. 트래픽이 늘면서 단순 요청 처리 지연이 누적적으로 체감됐고, 같은 응답시간을 유지하려면 더 많은 자원을 투입해야 했다. 도메인이 복잡해질수록 이 비용은 더 커졌다.
MSA 전환의 핵심은 결국 경계 재설계다. 이미 도메인이 엉켜 있는 모놀리식 코드를 그대로 두고 서비스 경계만 그어봐야, 그 경계는 코드 위의 선에 불과하다. 어차피 도메인 모델을 처음부터 다시 그려야 한다면, 그 시점에 언어와 프레임워크도 같이 바꾸는 비용 추가분이 생각보다 크지 않다.
또한 두 작업을 분리해서 진행할 경우, "Python 모놀리식 → Python MSA → Java MSA" 식의 두 번의 큰 전환이 필요하다. 중간 단계 자체도 비용이고, 두 번의 전환 모두 운영 부담을 동반한다. 차라리 한 번에 끝내자는 결정이었다.
| 근거 | 설명 |
|---|---|
| 회사 백엔드 팀의 주력 스택이 Java | 팀이 가장 자신 있게 다룰 수 있는 언어, 온보딩 비용 ↓ |
| 인터프리터 대비 빠른 실행 속도 | JIT/컴파일 기반 — 같은 자원으로 더 많은 요청 처리 |
| 정적 타입과 도메인 모델링 | 도메인 불변식을 타입으로 강제 |
| Spring 생태계의 성숙도 | 트랜잭션·메시징·인증·모니터링 표준화 |
| JVM 운영 도구 | 힙 덤프, 스레드 덤프, JFR 등 |
이번 장부터는 내가 직접 마이그레이션한 네 개 서비스를 중심으로 한다.
product-service는 담당 개발자가 독립적으로 진행한 영역이라 이 글에서는 다루지 않는다.
가장 먼저 한 일은 기존 Django 앱의 모델과 화면을 늘어놓고 도메인 이벤트를 추출하는 작업이었다. "어떤 사건이 발생하는가"를 기준으로 묶어보면, ORM 관계와는 다른 자연스러운 경계가 보였다.
핵심 질문은 두 가지였다.
소유권이 다르거나 변화 주기가 크게 다르면 같은 서비스 안에 둘 이유가 없었다.
| 서비스 | 책임 | 분리한 가장 큰 이유 |
|---|---|---|
general-service | 알림, 파일, 공통 메타데이터 | 도메인에 귀속되지 않는 횡단 관심사 |
auth-service | 인증·인가, 토큰 발급/검증 | 모든 서비스가 의존, 보안 변경의 영향 범위가 큼 |
payment-service | 결제 처리, 외부 PG 연동 | 정합성·신뢰성 요구가 가장 높음 |
contract-service | 계약 상태 머신, 효력 관리 | 자체 규칙 복잡, 결제와의 결합 끊어야 함 |
general-service: 공통 책임의 정의
알림, 파일 업로드, 공통 메타데이터 등 어느 한 도메인에 귀속시키기 어려운 횡단 관심사를 모았다. 다만 "모르겠으면 일단 여기" 같은 쓰레기통이 되지 않도록, 이 서비스에 들어오는 책임은 매번 의식적으로 검토했다.
auth-service: 인증·인가를 가장 먼저 떼어낸 이유
가장 먼저 떼어내야 다른 서비스의 마이그레이션이 깔끔하게 진행될 수 있었다.
payment-service: 결제 도메인의 독립성과 신뢰성 요구
결제는 다른 어떤 서비스보다도 분리되어 있어야 할 도메인이었다.
contract-service: 계약 도메인의 상태 머신 복잡도
계약은 그 자체로 복잡한 상태 머신을 가진다.
작성, 검토, 서명, 효력 발생, 종료까지의 전이가 비즈니스 규칙으로 빽빽하다. 결제 이벤트와 강하게 연동되지만, 동시에 그 자체의 규칙을 자신의 컨텍스트 안에 가두어야 했다.
가장 어려웠던 건 payment ↔ contract 사이였다. "결제가 완료되면 계약이 효력을 발생한다"는 흐름은 어느 한쪽에서 다른 쪽의 상태를 직접 바꾸고 싶은 유혹을 만든다. 그러나 그 순간 두 도메인은 한 덩어리가 된다.
원칙은 단순했다.
한 서비스는 다른 서비스의 상태를 직접 바꾸지 않는다. 자신의 사실(fact)을 이벤트로 알릴 뿐이다.
결제는 "결제 완료"라는 사건만 발행하고, 계약은 그 사건을 듣고 자신의 규칙에 따라 상태를 전이시킨다. 이 원칙이 뒤의 Outbox 도입으로 자연스럽게 이어진다.
서비스 간 통신을 어떻게 가져갈지가 초기 의사결정에서 큰 비중을 차지했다. 일반적인 MSA 가이드에서는 Kafka 같은 메시지 브로커를 통한 비동기 이벤트 통신을 권장한다. 하지만 이번 전환에서는 Kafka를 도입하지 않았다.
| 항목 | Kafka 도입 | REST(동기) + Outbox |
|---|---|---|
| 팀 운영 경험 | 없음 | 익숙함 |
| 학습 허들 | 높음 (브로커·컨슈머·파티셔닝 등) | 낮음 |
| 인프라 컴포넌트 추가 | 브로커, 모니터링 등 추가 | 없음 |
| 이번 트래픽 규모에 충분한가 | 과한 도구 | 충분 |
| 트랜잭셔널 이벤트 전파 | 가능 (Outbox 병행 필요) | Outbox로 달성 |
1. 현재 개발팀이 Kafka를 사용하고 있지 않다
Kafka는 강력한 도구지만, 운영을 위해서는 브로커/주키퍼(또는 KRaft), 컨슈머 그룹, 파티셔닝, 리밸런싱, 백프레셔, 모니터링 등 알아야 할 개념과 운영 노하우가 적지 않다. 팀에 Kafka 운영 경험이 없는 상태에서 도입하면, 도메인을 다듬는 일보다 메시지 인프라를 다듬는 일에 더 많은 시간을 쓰게 된다.
2. 학습 허들과 운영 부담
새로운 인프라 컴포넌트의 도입은 단순히 "한 번 띄우면 끝"이 아니다. 장애가 났을 때 디버깅할 수 있어야 하고, 팀원 누구나 큰 부담 없이 만질 수 있어야 한다. 지금 우리에게 Kafka는 그 단계의 도구가 아니었다.
3. 동기 REST 통신으로도 충분히 풀 수 있다
도메인 흐름을 다시 살펴보니, 대부분의 서비스 간 호출은 동기 REST로도 충분히 처리 가능했다. 호출자가 응답을 기다리는 시나리오가 많고, 비동기성이 꼭 필요한 곳은 제한적이었다. 굳이 모든 통신을 비동기로 강제할 이유가 없었다.
결과적으로 이번 전환의 서비스 간 통신 기본값은 REST 기반 동기 호출로 정했다. 비동기성이 꼭 필요한 지점(예: 결제–계약 간 사건 전파)은 Outbox 테이블을 활용해 별도 발행 경로로 다루었고(5장에서 상세 설명), 메시지 브로커 도입은 팀의 운영 역량이 무르익은 뒤로 미뤘다.
통신 방식 선택 기준 요약
| 시나리오 | 선택 |
|---|---|
| 호출자가 응답을 즉시 필요로 함 | REST (동기) |
| 사건의 전파, 호출자가 응답을 기다릴 필요 없음 | Outbox 기반 비동기 |
각 서비스는 자기 DB만 본다. 다른 서비스의 데이터가 필요하면 API를 호출하거나 이벤트로 받아 자기 쪽에 필요한 만큼 복제한다. 처음에는 "join이 안 되니까 불편하지 않냐"는 우려가 컸지만, 실제로는 join이 필요하다고 느낀 지점들이 대부분 서비스 경계가 잘못 그어진 신호였다.
auth-service가 JWT를 발급하고, 게이트웨이에서 1차 검증을 한 뒤 각 서비스로 토큰을 전파한다. 서비스 간 호출에서는 호출자 토큰을 그대로 전달하거나, 시스템 토큰을 사용한다. "이 호출이 누구의 권한으로 일어나는가"를 흐릿하게 두지 않는 것이 원칙이었다.
@Transactional 하나면 됐는데모놀리식에서 결제와 계약은 한 트랜잭션 안에 있었다.
# 모놀리식 시절 (의사 코드)
with transaction.atomic():
payment = Payment.create(...)
contract.activate(payment)
Notification.create(...)
하나라도 실패하면 전부 롤백. 단순하고 안전했다. MSA로 나누는 순간 이 단순함이 사라진다. payment-service의 DB 커밋과 contract-service의 상태 전이는 더 이상 같은 트랜잭션이 아니다.
| 옵션 | 장점 | 우리가 쓰지 않은 이유 |
|---|---|---|
| 2PC | 강한 일관성 | 가용성 ↓, 코디네이터 SPOF, 운영 부담 ↑ |
| Saga 단독 | 비동기·분산 친화 | "DB 커밋 됐는데 이벤트 발행 실패" 갭이 남음 |
| Outbox | 트랜잭셔널 이벤트 발행 보장 | (선택) |
2PC(Two-Phase Commit)
이론적으로 가장 깔끔하지만, 가용성을 크게 깎는다. 코디네이터가 단일 장애점이 되고, 한 참가자가 느려지면 전체가 멈춘다. 운영 부담도 크다. 우리 트래픽 규모와 외부 PG가 끼는 환경에서는 현실적 선택지가 아니었다.
Saga
Saga 패턴은 좋은 아이디어지만, 이벤트 발행 자체의 신뢰성을 보장해 주지는 않는다. "DB 커밋은 됐는데 발행은 실패"한 경우, Saga는 시작도 못 한다. 이 갭을 메우지 않으면 Saga의 보상 트랜잭션도 의미가 없다.
핵심 한 줄로 요약하면 이렇다.
"DB 트랜잭션 안에 이벤트 발행 의도를 함께 저장한다."
도메인 데이터 변경과 동일한 트랜잭션 안에서 outbox 테이블에 발행할 이벤트를 INSERT한다. 둘은 같이 커밋되거나 같이 롤백된다. 그 후 별도의 발행자가 outbox를 읽어 후속 서비스로 내보낸다.
이렇게 하면 "DB는 커밋됐는데 후속 처리는 누락"되는 상황 자체가 사라진다.
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(100) NOT NULL, -- e.g. "Payment"
aggregate_id VARCHAR(100) NOT NULL, -- e.g. payment id
event_type VARCHAR(100) NOT NULL, -- e.g. "PaymentCompleted"
payload JSON NOT NULL,
headers JSON NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
retry_count INT NOT NULL DEFAULT 0,
INDEX idx_status_created (status, created_at)
);
| 컬럼 | 역할 |
|---|---|
aggregate_type / aggregate_id | 어떤 도메인의 어떤 인스턴스에서 발생했는지 |
event_type | 이벤트 종류 (라우팅에 사용) |
payload | 이벤트 본문 |
status / retry_count | 발행 상태 추적 |
@Transactional
public void completePayment(PaymentCommand cmd) {
Payment payment = paymentRepository.findById(cmd.id());
payment.complete();
// 같은 트랜잭션 안에서 Outbox에 이벤트 저장
outboxRepository.save(OutboxEvent.of(
"Payment",
payment.getId(),
"PaymentCompleted",
PaymentCompletedPayload.from(payment)
));
}
도메인 상태 변경과 outbox 저장이 같은 트랜잭션이라는 점이 전부다. 이게 깨지지 않도록 코드 리뷰에서 가장 신경 쓰는 지점이기도 하다.
Kafka 같은 메시지 브로커를 두지 않았기에, outbox에 쌓인 이벤트는 별도 워커가 주기적으로 폴링해서 후속 서비스의 API를 호출하는 구조로 풀었다.
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> events =
outboxRepository.findPendingForUpdate(BATCH_SIZE);
for (OutboxEvent e : events) {
try {
eventDispatcher.dispatch(e); // 후속 서비스 REST 호출
e.markPublished();
} catch (Exception ex) {
e.incrementRetry();
}
}
}
SELECT ... FOR UPDATE SKIP LOCKED로 여러 인스턴스가 같이 떠도 중복 발행이 일어나지 않도록 했다.
워커가 재시도하면 같은 이벤트가 두 번 전달될 수 있다(at-least-once). 따라서 수신 측에서 멱등성을 책임져야 한다.
eventId(UUID)를 가진다processed_event 테이블에 처리한 eventId를 기록@Transactional
public void on(PaymentCompletedEvent e) {
if (processedEventRepository.exists(e.getEventId())) return;
contractService.activate(e.getContractId());
processedEventRepository.save(e.getEventId());
}
도메인 처리와 멱등성 기록을 같은 트랜잭션으로 묶는 게 핵심이다.
핵심 포인트:
| 문제 | 해결 |
|---|---|
| outbox 테이블 적체 | 일정 기간 보관 후 아카이브 테이블로 이동하는 배치 |
| 이벤트 순서 보장 | publisher가 aggregateId 단위로 직렬화해서 호출 |
| 실패 이벤트 재처리 | 임계치 초과 시 실패 테이블(DLQ)로 이동 + 알림 |
| 무한 재시도 위험 | 자동 무한 재시도 금지, 수동 검토 후 재처리 |
| 항목 | Python (Before) | Java (After) |
|---|---|---|
| 타입 안전성 | 런타임에 발견 | 컴파일 타임에 발견 |
| 도메인 모델링 | 동적 dict 중심 | 정적 타입 + 불변식 강제 |
| 인프라 통합 | 직접 조립 | Spring 생태계로 표준화 |
| 실행 성능 | 인터프리터 | JIT/컴파일 |
| 운영 진단 도구 | 제한적 | 힙·스레드 덤프, JFR 등 |
Python 시절 가장 큰 시간 낭비는 "이 dict의 키가 뭔지", "이 함수가 None을 리턴할 수 있는지"를 코드와 데이터를 번갈아 보며 추적하는 일이었다. Java로 옮기면서 이런 시간 자체가 거의 사라졌다. 컴파일러가 잡아주는 만큼은 더 이상 사람이 잡지 않아도 된다.
트랜잭션 관리, 인증, 메트릭, 트레이싱까지 Spring 생태계는 거의 모든 인프라 통합이 표준화되어 있다. Outbox 같은 패턴을 구현할 때도 @Transactional, @Scheduled, ApplicationEventPublisher 같은 기본 도구들이 그대로 활용 가능했다.
CPU 바운드 작업에서의 성능 향상도 있었지만, 그보다 더 큰 이득은 운영 도구였다. 힙 덤프, 스레드 덤프, JFR(Java Flight Recorder) 같은 도구들 덕분에 장애 분석 속도가 비교가 안 되게 빨라졌다.
이게 사실 가장 컸을지도 모르겠다. 단순 포팅이 아니라 도메인을 새로 그리는 기회였다. 모놀리식 시절 적층된 핵 같은 코드를 그대로 옮기지 않고, 모델과 규칙을 다시 정의했다. 같은 도메인이지만 코드는 훨씬 명확해졌다.
| 영역 | 모놀리식 (Before) | MSA (After) |
|---|---|---|
| 배포 단위 | 전체 서버 1개 | 서비스 단위로 독립 배포 |
| 장애 범위 | 한 모듈 장애 = 전체 장애 | 서비스 단위로 격리 |
| 스케일링 | 전체 동일 스케일 | 도메인별 독립 스케일 |
| 책임 경계 | 코드 안에서만 식별 | 서비스 = 책임 단위 |
| 변경 속도 | 전체 빌드·테스트 필요 | 한 서비스만 빌드·테스트 |
| 트랜잭션 | @Transactional 하나 | Outbox로 트랜잭셔널 이벤트 |
전에는 결제 모듈의 두 줄 수정도 전체 서버 재기동이 필요했다. 이제는 payment-service만 배포하면 된다. 배포의 심리적 무게가 완전히 달라졌고, 자연스럽게 배포 빈도도 올라갔다.
contract-service가 죽어도 결제는 계속 받을 수 있다. 이벤트는 outbox에 쌓이고, contract가 복구되면 처리된다. 모놀리식 시절이라면 한 모듈의 NPE가 전체 프로세스를 흔들 수 있었다.
인증 트래픽: ▂▃▂▃▃▂▃▂▃▃▂▃▂▃▃▂▃▂▃▃ (상시 평탄)
결제 트래픽: ▁▁▁█▇▁▁▁▁▁▁█▇▁▁▁▁▁▁▁ (이벤트성 스파이크)
결제와 인증의 트래픽 패턴은 완전히 다르다. 인증은 상시 평탄하고, 결제는 이벤트성 스파이크가 있다. 이제는 필요한 서비스만 늘릴 수 있다. 전체를 같이 키우느라 리소스를 낭비하지 않는다.
서비스가 나뉘면서 어디서 무엇이 일어나는지가 명확해졌다. 결제 이슈가 나면 payment 코드만 보면 되고, 계약 변경은 contract만 영향을 본다. 코드 리뷰의 범위와 깊이가 같이 좋아졌다.
지금은 모든 서비스가 Java/Spring Boot이지만, 필요하다면 서비스 단위로 다른 선택을 할 수 있는 여지가 열렸다. 인프라적 자유도는 미래의 자산이다.
체감이 가장 컸던 부분이다. 모놀리식 시절 전체 테스트와 빌드는 한 번 도는 데 꽤 시간이 걸렸다. 이제는 한 서비스의 테스트만 돌리면 되고, CI/CD 한 사이클이 짧아져 피드백 루프 자체가 빨라졌다.
MSA의 이점은 Outbox라는 받침대가 있어야 비로소 안전하게 누릴 수 있다. "이벤트는 반드시 전달된다"는 보장이 있으니, 서비스 간 통신을 신뢰하고 설계할 수 있다. MSA + Outbox 조합은 단순히 도메인을 나눈 것 이상의 신뢰성을 가져다줬다.
| 줄어든 것 | 새로 생긴 것 |
|---|---|
| 모듈 간 강결합 | 분산 트레이싱 필요성 |
| 전체 배포 리스크 | 로컬에서 여러 서비스 동시 구동 |
| 단일 장애점 | 서비스 간 계약(API/이벤트 스키마) 관리 |
| 거대 코드베이스 | 데이터 일관성을 코드 위에서 보장 |
MSA는 "복잡도를 줄이는" 게 아니라 "복잡도를 옮기는" 일에 가깝다. 도메인 안의 복잡도를 도메인 간의 복잡도로 옮기는 것이다. 옮긴 자리에서 그 복잡도를 다룰 도구와 규율이 없다면, 모놀리식보다 더 나빠질 수 있다.
product-service를 제외한 네 개 서비스(general, auth, payment, contract)의 마이그레이션은 처음부터 끝까지 내가 단독으로 끌고 갔다. 도메인 분석부터 패턴 선택, 구현, CI/CD 구성까지의 의사결정과 작업 책임이 한 사람에게 있었다는 뜻이다.
그래서 가장 신경 쓴 것은 "내가 없어도 굴러가는 시스템을 만드는 것"이었다. 구체적으로는
혼자 만들었기에 일관성을 끝까지 유지할 수 있었던 점은 분명한 장점이었다. 동시에, 검토자가 없는 결정의 무게는 매번 직접 짊어져야 했다. 이 글이 그 결정들에 대한 작은 기록이기도 하다.
지금 돌아보면 "Python → Java"와 "Monolith → MSA"를 동시에 진행한 선택은 옳았다. 따로 했다면 중간 단계의 운영 부담과 재작업이 더 컸을 것이다. 어차피 모델을 다시 그릴 거라면, 한 번에 끝내는 편이 합리적이었다.
MSA는 도착점이 아니라 출발점이다. 도메인이 자라면 경계도 다시 그어야 한다. 지금의 서비스 구성이 영원한 정답은 아닐 것이고, 그 변화를 두려워하지 않는 것이 이번 마이그레이션에서 배운 가장 큰 한 가지다.