Python 모놀리식에서 Java MSA로: 주력 서비스 백엔드 전환기

seonwoo_jung·2026년 5월 24일

1. 들어가며

1.1 두 개의 전환을 동시에: 언어(Python→Java) + 아키텍처(Monolith→MSA)

우리 주력 서비스의 백엔드는 Python(Django) 기반의 모놀리식으로 시작했다. 빠르게 기능을 붙이기에는 좋았지만, 서비스가 커지면서 한 덩어리의 코드와 한 덩어리의 배포가 점점 더 큰 부담이 되기 시작했다. 결국 두 가지를 동시에 바꾸기로 했다.

구분BeforeAfter
언어Python (Django)Java (Spring Boot)
아키텍처MonolithMSA (5개 서비스)
데이터베이스단일 DB서비스별 독립 DB
서비스 간 통신함수 호출REST(동기) + Outbox(비동기)
트랜잭션 경계@Transactional 하나Outbox 패턴 기반 분산 트랜잭션

"하나만 바꾸는 게 안전하지 않냐"는 질문을 많이 받았는데, 뒤에서 다시 풀어쓰겠지만 결론부터 말하면 둘을 묶어서 한 번에 가는 편이 오히려 합리적이었다. 어차피 모듈 단위로 다시 그릴 거라면, 언어와 경계를 한꺼번에 재정의하는 게 비용을 줄였다.

1.2 내가 담당한 범위

product-service를 제외한 네 개 서비스의 마이그레이션 작업은 내가 단독으로 끌고 갔다. 도메인 분석, 서비스 경계 정의, 언어 전환, Outbox 패턴 도입, CI/CD 구성까지 네 서비스의 의사결정과 구현 전부가 내 손에서 나왔다.

서비스도메인담당
general-service공통/플랫폼 (알림, 파일, 메타데이터 등)본인
auth-service인증·인가본인
payment-service결제본인
contract-service계약본인
product-service상품다른 개발자

product-service는 별도의 개발자가 처음부터 끝까지 독립적으로 담당했다. 설계, 구현, 운영 방식 모두 그쪽 영역이라 이 글에서는 다루지 않는다. 이 글의 시행착오와 의사결정 기록은 내가 직접 손을 댄 네 서비스에 한정된다.

1.3 이 글에서 다룰 핵심

  • MSA로 전환하면서 얻은 이점
  • 분산 트랜잭션을 Outbox 패턴으로 푼 과정

이 두 가지가 이 글의 중심이다. 일반론보다 "이래서 이렇게 했다"에 무게를 두고 썼다.


2. 왜 다시 만들기로 했나

2.1 Python 모놀리식에서 겪었던 한계

문제증상결과
도메인 간 강결합결제 수정 한 줄에 계약·상품·유저까지 회귀 검토 필요작은 변경의 비용이 큼
단일 배포 단위사소한 픽스도 전체 서버 재기동배포의 심리적 무게 ↑
팀 기술 스택 미스매치팀 주력은 Java, 모놀리식은 Python유지보수·온보딩 비용 ↑
인터프리터 실행 속도동일 로직 대비 응답 지연자원 투입 비용 ↑

도메인 간 강결합
결제 로직을 고치려고 들어가면 계약, 상품, 유저 모델이 줄줄이 따라왔다. ORM 관계를 타고 도메인이 서로의 내부 구조에 의존하다 보니, "결제만 고친다"는 변경이 사실상 불가능해졌다. 작은 수정에도 회귀 범위가 컸다.

단일 배포 단위의 리스크
어느 한 줄의 변경도 전체 서버를 다시 띄워야 반영됐다. 결제 모듈의 사소한 픽스도 계약/상품 트래픽에 영향을 줄 수 있는 구조였고, 장애도 곧 전사 장애로 이어졌다.

팀 기술 스택과의 미스매치
현재 회사 백엔드 개발자들의 주력 스택은 Java이고, Python에 익숙한 인원이 거의 없었다. 모놀리식이 Python으로 짜여 있다는 사실 자체가 유지보수와 신규 합류자 온보딩의 큰 장벽이 됐다. "이 코드를 누가 자신 있게 고칠 수 있는가"라는 질문에 매번 답이 궁색했다.

인터프리터 언어의 실행 속도 한계
Python은 인터프리터 언어 특성상, 동일한 로직을 컴파일 언어로 짤 때보다 실행 속도가 느렸다. 트래픽이 늘면서 단순 요청 처리 지연이 누적적으로 체감됐고, 같은 응답시간을 유지하려면 더 많은 자원을 투입해야 했다. 도메인이 복잡해질수록 이 비용은 더 커졌다.

2.2 "어차피 다시 짤 거라면" — 언어 전환과 MSA를 묶은 이유

MSA 전환의 핵심은 결국 경계 재설계다. 이미 도메인이 엉켜 있는 모놀리식 코드를 그대로 두고 서비스 경계만 그어봐야, 그 경계는 코드 위의 선에 불과하다. 어차피 도메인 모델을 처음부터 다시 그려야 한다면, 그 시점에 언어와 프레임워크도 같이 바꾸는 비용 추가분이 생각보다 크지 않다.

또한 두 작업을 분리해서 진행할 경우, "Python 모놀리식 → Python MSA → Java MSA" 식의 두 번의 큰 전환이 필요하다. 중간 단계 자체도 비용이고, 두 번의 전환 모두 운영 부담을 동반한다. 차라리 한 번에 끝내자는 결정이었다.

2.3 Java + Spring 생태계를 택한 근거

근거설명
회사 백엔드 팀의 주력 스택이 Java팀이 가장 자신 있게 다룰 수 있는 언어, 온보딩 비용 ↓
인터프리터 대비 빠른 실행 속도JIT/컴파일 기반 — 같은 자원으로 더 많은 요청 처리
정적 타입과 도메인 모델링도메인 불변식을 타입으로 강제
Spring 생태계의 성숙도트랜잭션·메시징·인증·모니터링 표준화
JVM 운영 도구힙 덤프, 스레드 덤프, JFR 등

3. 서비스 경계 설계

이번 장부터는 내가 직접 마이그레이션한 네 개 서비스를 중심으로 한다. product-service는 담당 개발자가 독립적으로 진행한 영역이라 이 글에서는 다루지 않는다.

3.1 도메인 분석과 Bounded Context

가장 먼저 한 일은 기존 Django 앱의 모델과 화면을 늘어놓고 도메인 이벤트를 추출하는 작업이었다. "어떤 사건이 발생하는가"를 기준으로 묶어보면, ORM 관계와는 다른 자연스러운 경계가 보였다.

핵심 질문은 두 가지였다.

  1. 이 데이터의 최종 책임자는 누구인가? (소유권)
  2. 이 도메인의 변화 주기는 어떤가? (변경 속도)

소유권이 다르거나 변화 주기가 크게 다르면 같은 서비스 안에 둘 이유가 없었다.

3.2 네 서비스로 나눈 기준 — 한눈에 보기

서비스책임분리한 가장 큰 이유
general-service알림, 파일, 공통 메타데이터도메인에 귀속되지 않는 횡단 관심사
auth-service인증·인가, 토큰 발급/검증모든 서비스가 의존, 보안 변경의 영향 범위가 큼
payment-service결제 처리, 외부 PG 연동정합성·신뢰성 요구가 가장 높음
contract-service계약 상태 머신, 효력 관리자체 규칙 복잡, 결제와의 결합 끊어야 함

general-service: 공통 책임의 정의
알림, 파일 업로드, 공통 메타데이터 등 어느 한 도메인에 귀속시키기 어려운 횡단 관심사를 모았다. 다만 "모르겠으면 일단 여기" 같은 쓰레기통이 되지 않도록, 이 서비스에 들어오는 책임은 매번 의식적으로 검토했다.

auth-service: 인증·인가를 가장 먼저 떼어낸 이유

  • 모든 서비스가 의존하는 횡단 관심사
  • 보안 변경이 잦고, 영향 범위가 크다
  • 토큰 발급/검증 트래픽 패턴이 다른 도메인과 다르다

가장 먼저 떼어내야 다른 서비스의 마이그레이션이 깔끔하게 진행될 수 있었다.

payment-service: 결제 도메인의 독립성과 신뢰성 요구

  • 외부 PG/금융 연동의 책임 격리
  • 정합성 요구 수준이 가장 높다
  • 장애가 났을 때 다른 도메인을 같이 흔들면 안 된다
  • 보안·감사 요구사항이 다른 도메인과 다르다

결제는 다른 어떤 서비스보다도 분리되어 있어야 할 도메인이었다.

contract-service: 계약 도메인의 상태 머신 복잡도
계약은 그 자체로 복잡한 상태 머신을 가진다.

계약 상태 머신

작성, 검토, 서명, 효력 발생, 종료까지의 전이가 비즈니스 규칙으로 빽빽하다. 결제 이벤트와 강하게 연동되지만, 동시에 그 자체의 규칙을 자신의 컨텍스트 안에 가두어야 했다.

3.3 서비스 간 책임 분배에서 고민했던 지점들

가장 어려웠던 건 payment ↔ contract 사이였다. "결제가 완료되면 계약이 효력을 발생한다"는 흐름은 어느 한쪽에서 다른 쪽의 상태를 직접 바꾸고 싶은 유혹을 만든다. 그러나 그 순간 두 도메인은 한 덩어리가 된다.

잘못된 방식 vs 올바른 방식

원칙은 단순했다.

한 서비스는 다른 서비스의 상태를 직접 바꾸지 않는다. 자신의 사실(fact)을 이벤트로 알릴 뿐이다.

결제는 "결제 완료"라는 사건만 발행하고, 계약은 그 사건을 듣고 자신의 규칙에 따라 상태를 전이시킨다. 이 원칙이 뒤의 Outbox 도입으로 자연스럽게 이어진다.


4. 아키텍처 설계

4.1 전체 구성도

전체 아키텍처

4.2 서비스 간 통신 — 동기 vs 비동기, 그리고 Kafka를 도입하지 않은 이유

서비스 간 통신을 어떻게 가져갈지가 초기 의사결정에서 큰 비중을 차지했다. 일반적인 MSA 가이드에서는 Kafka 같은 메시지 브로커를 통한 비동기 이벤트 통신을 권장한다. 하지만 이번 전환에서는 Kafka를 도입하지 않았다.

항목Kafka 도입REST(동기) + Outbox
팀 운영 경험없음익숙함
학습 허들높음 (브로커·컨슈머·파티셔닝 등)낮음
인프라 컴포넌트 추가브로커, 모니터링 등 추가없음
이번 트래픽 규모에 충분한가과한 도구충분
트랜잭셔널 이벤트 전파가능 (Outbox 병행 필요)Outbox로 달성

1. 현재 개발팀이 Kafka를 사용하고 있지 않다
Kafka는 강력한 도구지만, 운영을 위해서는 브로커/주키퍼(또는 KRaft), 컨슈머 그룹, 파티셔닝, 리밸런싱, 백프레셔, 모니터링 등 알아야 할 개념과 운영 노하우가 적지 않다. 팀에 Kafka 운영 경험이 없는 상태에서 도입하면, 도메인을 다듬는 일보다 메시지 인프라를 다듬는 일에 더 많은 시간을 쓰게 된다.

2. 학습 허들과 운영 부담
새로운 인프라 컴포넌트의 도입은 단순히 "한 번 띄우면 끝"이 아니다. 장애가 났을 때 디버깅할 수 있어야 하고, 팀원 누구나 큰 부담 없이 만질 수 있어야 한다. 지금 우리에게 Kafka는 그 단계의 도구가 아니었다.

3. 동기 REST 통신으로도 충분히 풀 수 있다
도메인 흐름을 다시 살펴보니, 대부분의 서비스 간 호출은 동기 REST로도 충분히 처리 가능했다. 호출자가 응답을 기다리는 시나리오가 많고, 비동기성이 꼭 필요한 곳은 제한적이었다. 굳이 모든 통신을 비동기로 강제할 이유가 없었다.

결과적으로 이번 전환의 서비스 간 통신 기본값은 REST 기반 동기 호출로 정했다. 비동기성이 꼭 필요한 지점(예: 결제–계약 간 사건 전파)은 Outbox 테이블을 활용해 별도 발행 경로로 다루었고(5장에서 상세 설명), 메시지 브로커 도입은 팀의 운영 역량이 무르익은 뒤로 미뤘다.

통신 방식 선택 기준 요약

시나리오선택
호출자가 응답을 즉시 필요로 함REST (동기)
사건의 전파, 호출자가 응답을 기다릴 필요 없음Outbox 기반 비동기

4.3 데이터베이스 분리 원칙 (DB per Service)

각 서비스는 자기 DB만 본다. 다른 서비스의 데이터가 필요하면 API를 호출하거나 이벤트로 받아 자기 쪽에 필요한 만큼 복제한다. 처음에는 "join이 안 되니까 불편하지 않냐"는 우려가 컸지만, 실제로는 join이 필요하다고 느낀 지점들이 대부분 서비스 경계가 잘못 그어진 신호였다.

4.4 인증 토큰 전파와 서비스 간 신뢰 모델

auth-service가 JWT를 발급하고, 게이트웨이에서 1차 검증을 한 뒤 각 서비스로 토큰을 전파한다. 서비스 간 호출에서는 호출자 토큰을 그대로 전달하거나, 시스템 토큰을 사용한다. "이 호출이 누구의 권한으로 일어나는가"를 흐릿하게 두지 않는 것이 원칙이었다.


5. 분산 트랜잭션, Outbox 패턴으로 풀다 ⭐

5.1 모놀리식에서는 @Transactional 하나면 됐는데

모놀리식 vs MSA 트랜잭션 경계

모놀리식에서 결제와 계약은 한 트랜잭션 안에 있었다.

# 모놀리식 시절 (의사 코드)
with transaction.atomic():
    payment = Payment.create(...)
    contract.activate(payment)
    Notification.create(...)

하나라도 실패하면 전부 롤백. 단순하고 안전했다. MSA로 나누는 순간 이 단순함이 사라진다. payment-service의 DB 커밋과 contract-service의 상태 전이는 더 이상 같은 트랜잭션이 아니다.

5.2 왜 2PC도, Saga만으로도 부족했나

옵션장점우리가 쓰지 않은 이유
2PC강한 일관성가용성 ↓, 코디네이터 SPOF, 운영 부담 ↑
Saga 단독비동기·분산 친화"DB 커밋 됐는데 이벤트 발행 실패" 갭이 남음
Outbox트랜잭셔널 이벤트 발행 보장(선택)

2PC(Two-Phase Commit)
이론적으로 가장 깔끔하지만, 가용성을 크게 깎는다. 코디네이터가 단일 장애점이 되고, 한 참가자가 느려지면 전체가 멈춘다. 운영 부담도 크다. 우리 트래픽 규모와 외부 PG가 끼는 환경에서는 현실적 선택지가 아니었다.

Saga
Saga 패턴은 좋은 아이디어지만, 이벤트 발행 자체의 신뢰성을 보장해 주지는 않는다. "DB 커밋은 됐는데 발행은 실패"한 경우, Saga는 시작도 못 한다. 이 갭을 메우지 않으면 Saga의 보상 트랜잭션도 의미가 없다.

5.3 Outbox 패턴 선택 이유

Outbox 원자 커밋

핵심 한 줄로 요약하면 이렇다.

"DB 트랜잭션 안에 이벤트 발행 의도를 함께 저장한다."

도메인 데이터 변경과 동일한 트랜잭션 안에서 outbox 테이블에 발행할 이벤트를 INSERT한다. 둘은 같이 커밋되거나 같이 롤백된다. 그 후 별도의 발행자가 outbox를 읽어 후속 서비스로 내보낸다.

이렇게 하면 "DB는 커밋됐는데 후속 처리는 누락"되는 상황 자체가 사라진다.

5.4 실제 구현

Outbox 테이블 스키마

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발행 상태 추적

도메인 이벤트와 Outbox 저장의 원자성

@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 저장이 같은 트랜잭션이라는 점이 전부다. 이게 깨지지 않도록 코드 리뷰에서 가장 신경 쓰는 지점이기도 하다.

발행 경로 — Polling 기반 동기 호출

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로 여러 인스턴스가 같이 떠도 중복 발행이 일어나지 않도록 했다.

멱등성 보장 — Consumer 측 중복 처리 방어

워커가 재시도하면 같은 이벤트가 두 번 전달될 수 있다(at-least-once). 따라서 수신 측에서 멱등성을 책임져야 한다.

  • 모든 이벤트는 eventId(UUID)를 가진다
  • 수신 서비스는 processed_event 테이블에 처리한 eventId를 기록
  • 이미 처리한 이벤트는 조용히 무시(200 OK 반환)
@Transactional
public void on(PaymentCompletedEvent e) {
    if (processedEventRepository.exists(e.getEventId())) return;

    contractService.activate(e.getContractId());
    processedEventRepository.save(e.getEventId());
}

도메인 처리와 멱등성 기록을 같은 트랜잭션으로 묶는 게 핵심이다.

5.5 결제–계약 도메인 간 실제 적용 사례

결제-계약 시퀀스

핵심 포인트:

  1. 결제 응답은 외부 PG 결과를 받고 자기 DB만 커밋되면 사용자에게 돌려준다
  2. 계약 활성화는 outbox publisher의 비동기 호출로 전파된다
  3. 발행 실패는 publisher가 자동 재시도한다

5.6 운영하며 부딪힌 문제와 해결

문제해결
outbox 테이블 적체일정 기간 보관 후 아카이브 테이블로 이동하는 배치
이벤트 순서 보장publisher가 aggregateId 단위로 직렬화해서 호출
실패 이벤트 재처리임계치 초과 시 실패 테이블(DLQ)로 이동 + 알림
무한 재시도 위험자동 무한 재시도 금지, 수동 검토 후 재처리

5.7 Outbox를 도입하고 얻은 것

  • 정합성: "DB는 됐는데 후속 처리는 못 보낸" 상황이 사라졌다
  • 가용성: 수신 서비스 일시 장애에도 비즈니스 트랜잭션은 계속 진행된다 (이벤트는 outbox에 쌓였다가 복구 후 재발행)
  • 관찰 가능성: 모든 이벤트가 DB에 기록되므로, 무엇이 언제 발행됐는지 추적이 쉽다
  • 재처리 가능성: 사고가 나도 outbox에서 다시 발행할 수 있다
  • 인프라 단순함: 메시지 브로커를 도입하지 않고도 트랜잭셔널한 이벤트 전파를 달성했다

6. Python → Java 전환에서 얻은 것

항목Python (Before)Java (After)
타입 안전성런타임에 발견컴파일 타임에 발견
도메인 모델링동적 dict 중심정적 타입 + 불변식 강제
인프라 통합직접 조립Spring 생태계로 표준화
실행 성능인터프리터JIT/컴파일
운영 진단 도구제한적힙·스레드 덤프, JFR 등

6.1 정적 타입과 컴파일 타임 안전성

Python 시절 가장 큰 시간 낭비는 "이 dict의 키가 뭔지", "이 함수가 None을 리턴할 수 있는지"를 코드와 데이터를 번갈아 보며 추적하는 일이었다. Java로 옮기면서 이런 시간 자체가 거의 사라졌다. 컴파일러가 잡아주는 만큼은 더 이상 사람이 잡지 않아도 된다.

6.2 Spring 생태계의 인프라 통합 이점

트랜잭션 관리, 인증, 메트릭, 트레이싱까지 Spring 생태계는 거의 모든 인프라 통합이 표준화되어 있다. Outbox 같은 패턴을 구현할 때도 @Transactional, @Scheduled, ApplicationEventPublisher 같은 기본 도구들이 그대로 활용 가능했다.

6.3 JVM 성능과 운영 도구

CPU 바운드 작업에서의 성능 향상도 있었지만, 그보다 더 큰 이득은 운영 도구였다. 힙 덤프, 스레드 덤프, JFR(Java Flight Recorder) 같은 도구들 덕분에 장애 분석 속도가 비교가 안 되게 빨라졌다.

6.4 도메인 모델을 다시 그릴 수 있었던 기회

이게 사실 가장 컸을지도 모르겠다. 단순 포팅이 아니라 도메인을 새로 그리는 기회였다. 모놀리식 시절 적층된 핵 같은 코드를 그대로 옮기지 않고, 모델과 규칙을 다시 정의했다. 같은 도메인이지만 코드는 훨씬 명확해졌다.


7. MSA로 전환하며 얻은 이점 ⭐

한눈에 보는 Before / After

영역모놀리식 (Before)MSA (After)
배포 단위전체 서버 1개서비스 단위로 독립 배포
장애 범위한 모듈 장애 = 전체 장애서비스 단위로 격리
스케일링전체 동일 스케일도메인별 독립 스케일
책임 경계코드 안에서만 식별서비스 = 책임 단위
변경 속도전체 빌드·테스트 필요한 서비스만 빌드·테스트
트랜잭션@Transactional 하나Outbox로 트랜잭셔널 이벤트

7.1 독립 배포 — 결제 배포가 더 이상 전사 배포가 아니다

전에는 결제 모듈의 두 줄 수정도 전체 서버 재기동이 필요했다. 이제는 payment-service만 배포하면 된다. 배포의 심리적 무게가 완전히 달라졌고, 자연스럽게 배포 빈도도 올라갔다.

7.2 장애 격리 — 한 서비스 장애가 전체를 멈추지 않는다

contract-service가 죽어도 결제는 계속 받을 수 있다. 이벤트는 outbox에 쌓이고, contract가 복구되면 처리된다. 모놀리식 시절이라면 한 모듈의 NPE가 전체 프로세스를 흔들 수 있었다.

7.3 독립 스케일링 — 트래픽 패턴이 다른 도메인을 따로 키운다

인증 트래픽:    ▂▃▂▃▃▂▃▂▃▃▂▃▂▃▃▂▃▂▃▃   (상시 평탄)
결제 트래픽:    ▁▁▁█▇▁▁▁▁▁▁█▇▁▁▁▁▁▁▁    (이벤트성 스파이크)

결제와 인증의 트래픽 패턴은 완전히 다르다. 인증은 상시 평탄하고, 결제는 이벤트성 스파이크가 있다. 이제는 필요한 서비스만 늘릴 수 있다. 전체를 같이 키우느라 리소스를 낭비하지 않는다.

7.4 도메인 오너십 — 코드 경계가 곧 책임 경계

서비스가 나뉘면서 어디서 무엇이 일어나는지가 명확해졌다. 결제 이슈가 나면 payment 코드만 보면 되고, 계약 변경은 contract만 영향을 본다. 코드 리뷰의 범위와 깊이가 같이 좋아졌다.

7.5 기술 선택의 자유

지금은 모든 서비스가 Java/Spring Boot이지만, 필요하다면 서비스 단위로 다른 선택을 할 수 있는 여지가 열렸다. 인프라적 자유도는 미래의 자산이다.

7.6 변경 속도 — 빌드/테스트/배포 시간의 변화

체감이 가장 컸던 부분이다. 모놀리식 시절 전체 테스트와 빌드는 한 번 도는 데 꽤 시간이 걸렸다. 이제는 한 서비스의 테스트만 돌리면 되고, CI/CD 한 사이클이 짧아져 피드백 루프 자체가 빨라졌다.

7.7 신뢰성 향상 — Outbox 기반 이벤트 흐름이 가져온 안정성

MSA의 이점은 Outbox라는 받침대가 있어야 비로소 안전하게 누릴 수 있다. "이벤트는 반드시 전달된다"는 보장이 있으니, 서비스 간 통신을 신뢰하고 설계할 수 있다. MSA + Outbox 조합은 단순히 도메인을 나눈 것 이상의 신뢰성을 가져다줬다.


8. 회고

8.1 MSA가 공짜로 주는 것은 없다 — 새로 생긴 복잡도

줄어든 것새로 생긴 것
모듈 간 강결합분산 트레이싱 필요성
전체 배포 리스크로컬에서 여러 서비스 동시 구동
단일 장애점서비스 간 계약(API/이벤트 스키마) 관리
거대 코드베이스데이터 일관성을 코드 위에서 보장

MSA는 "복잡도를 줄이는" 게 아니라 "복잡도를 옮기는" 일에 가깝다. 도메인 안의 복잡도를 도메인 간의 복잡도로 옮기는 것이다. 옮긴 자리에서 그 복잡도를 다룰 도구와 규율이 없다면, 모놀리식보다 더 나빠질 수 있다.

8.2 다시 한다면 다르게 했을 것들

  • 분산 트레이싱을 1일차에 깔았을 것: 나중에 붙이면 두 배 들어간다
  • 이벤트 스키마 레지스트리를 더 일찍 도입했을 것: 초반에는 약속만으로 관리했는데, 서비스 수가 늘면서 부담이 커졌다
  • Outbox publisher를 처음부터 별도 컴포넌트로 분리: 서비스 본체에 묶어두면 본체의 라이프사이클에 영향을 받는다

8.3 네 서비스를 혼자 끌고 가면서 배운 것

product-service를 제외한 네 개 서비스(general, auth, payment, contract)의 마이그레이션은 처음부터 끝까지 내가 단독으로 끌고 갔다. 도메인 분석부터 패턴 선택, 구현, CI/CD 구성까지의 의사결정과 작업 책임이 한 사람에게 있었다는 뜻이다.

그래서 가장 신경 쓴 것은 "내가 없어도 굴러가는 시스템을 만드는 것"이었다. 구체적으로는

  • 도메인 경계를 보수적으로 그어, 한 사람이 한 번에 머릿속에 담을 수 있는 단위를 유지
  • 네 서비스의 규약(이벤트 스키마, 에러 포맷, 트랜잭션 경계)을 일관되게 통일
  • 같은 패턴(Outbox, 멱등성 처리, 표준 예외)을 네 서비스 모두에 동일하게 적용해, 한 서비스를 이해하면 다른 서비스도 이해되도록 구성

혼자 만들었기에 일관성을 끝까지 유지할 수 있었던 점은 분명한 장점이었다. 동시에, 검토자가 없는 결정의 무게는 매번 직접 짊어져야 했다. 이 글이 그 결정들에 대한 작은 기록이기도 하다.


9. 마치며

9.1 두 전환을 묶은 결정에 대한 평가

지금 돌아보면 "Python → Java"와 "Monolith → MSA"를 동시에 진행한 선택은 옳았다. 따로 했다면 중간 단계의 운영 부담과 재작업이 더 컸을 것이다. 어차피 모델을 다시 그릴 거라면, 한 번에 끝내는 편이 합리적이었다.

9.2 앞으로의 과제

  • 서비스 간 API/이벤트 스키마의 버전 관리 자동화
  • 분산 트레이싱과 비즈니스 메트릭의 연결
  • Outbox 발행 지연(latency)에 대한 SLO 정의
  • 팀의 역량이 무르익으면 메시지 브로커(Kafka 등) 도입 재검토

MSA는 도착점이 아니라 출발점이다. 도메인이 자라면 경계도 다시 그어야 한다. 지금의 서비스 구성이 영원한 정답은 아닐 것이고, 그 변화를 두려워하지 않는 것이 이번 마이그레이션에서 배운 가장 큰 한 가지다.

0개의 댓글