모놀리스를 MSA로 바꿔야 하는 상황

PeekCart는 처음부터 모놀리스로 시작하기로 정한 프로젝트였습니다. 처음부터 서비스를 나누면 네트워크, 배포, 관측성 문제에 시간이 쏠려 주문과 결제 로직을 다듬기 어렵다고 봤기 때문입니다. 그래서 한 애플리케이션 안에서 기능을 만들고(Phase 1), Kafka와 Outbox로 이벤트 발행을 다듬고(Phase 2), Kubernetes 위에서 부하를 측정해 기준선을 남긴 뒤(Phase 3), 마지막 단계에서 서비스를 나누는 순서를 택했습니다.

이번 전환의 목적은 모놀리스가 한계에 부딪혀서가 아니라, 잘 나뉘어 있다고 생각한 모놀리스를 실제로 떼어낼 때 무엇이 불편해지는지 직접 겪어 보는 것이었습니다. 패키지가 도메인별로 나뉘어 있으면 서비스 분리도 패키지를 옮기는 일로 끝날 것처럼 보입니다. 정말 그런지는 떼어내 봐야만 알 수 있었습니다.

무엇을 “분리했다”고 할까요?

서비스를 나눈다는 말은 생각보다 넓습니다. 코드 저장소를 나누는 것일 수도 있고, 배포 단위를 나누는 것일 수도 있고, 데이터베이스를 나누는 것일 수도 있습니다. 그래서 Phase 4를 시작하기 전에 완료 조건을 먼저 정했습니다.

  • 모든 서비스를 독립적으로 배포하고 정상 동작을 확인한다.
  • 결제가 실패하면 주문이 취소되고 재고가 복구되는 보상 흐름을 검증한다.
  • Gateway를 거쳐 라우팅하고 JWT로 인증한다.
  • 서비스 사이를 직접 호출하지 않고, 이벤트와 로컬 캐시로 필요한 데이터를 조합한다.

마지막 조건이 이 글 전체의 열쇠입니다. 모듈을 나누는 것만으로는 이 조건이 채워지지 않기 때문입니다.

몇 개로 나눌까요?

순서를 정하려면 먼저 무엇을 떼어낼지 알아야 합니다. 초기 설계를 서비스 경계로 구체화하면서 첫 번째 함정을 발견했습니다. 분리 대상의 범위에 두 가지로 갈림길이 있었습니다.

  • Order, Payment, Notification 세 개만 분리한다. 확장과 장애 격리가 특히 필요한 도메인에 집중하는 범위입니다.
  • User와 Product까지 다섯 개를 분리한다. Gateway 라우팅, 서비스별 DB, 토픽과 Saga 흐름을 다섯 서비스 경계에 맞추는 구조입니다.

어느 쪽이 맞을까요? 경계를 판단할 단서는 코드에도 있었습니다. 모놀리스는 처음부터 도메인별 패키지로 나뉘어 있었고, 각 패키지는 자기 엔티티를 가지고 있었습니다.

도메인소유 엔티티
UserUser, Address, RefreshToken
ProductProduct, Category, Inventory
OrderCart, CartItem, Order, OrderItem
PaymentPayment, WebhookLog
NotificationNotification

패키지 경계를 그대로 서비스 경계로 정하면 다섯 개가 됩니다. 반대로 세 개만 나누면 서비스별 DB, Gateway 라우팅, CQRS 캐시의 경계를 다시 정해야 했습니다. 그래서 서비스를 다섯 개로 나누기로 정했습니다.

경계를 정하자 드러난 두 가지 함정

다섯 서비스가 각각 무엇을 소유할지 정하면서 초기 설계의 함정 두 가지가 더 드러났습니다.

  • Notification에도 DB가 필요합니다. 처음에는 상태가 없는 서비스로 생각했지만, 구현 과정에서 발송한 알림을 저장하는 엔티티가 생겼습니다. 서비스로 분리한 뒤에도 이 상태를 소유해야 하므로 Notification에 DB를 배정했습니다.
  • 재고의 주인은 Product입니다. 초기 주문 생성 흐름은 재고 차감, 주문 저장, Outbox 저장을 한 트랜잭션으로 묶었습니다. 하지만 서비스를 나누면 Order는 Product가 소유한 재고를 같은 트랜잭션에서 직접 차감할 수 없습니다.

두 번째 함정은 소유자를 명시하는 것만으로는 풀리지 않습니다. 주문과 재고가 다른 서비스로 나뉜 뒤에도 “재고가 있을 때만 주문이 성립한다”를 지키려면 한 트랜잭션 대신 다른 방법이 필요합니다. 코드를 옮기기 전, 초기 설계를 서비스 경계에 맞춰 보면서 발견한 문제였습니다. 실제로 코드를 옮기기 시작하면 무엇이 더 나올까요?

어떤 순서로 떼어낼 수 있을까요?

다섯 서비스를 한 번에 떼어내면 무엇이 깨졌는지 알기 어렵습니다. 단계별로 따로 검증하고 되돌리기 위해서입니다. 그래서 한 번에 한두 서비스씩 떼어내고, 남은 모놀리스는 매번 정상적으로 부팅되도록 유지하기로 했습니다.

먼저 무엇을 공통으로 뺄까요?

서비스를 떼어내기 전에 할 일이 있습니다. 여러 도메인이 함께 쓰는 코드를 먼저 빼 두는 것입니다. 공통 응답 형식, 예외, 캐시와 락 설정, 이벤트 DTO 같은 코드가 여기에 해당합니다. 이 코드가 모놀리스 안에 남아 있으면, 떼어낸 서비스가 모놀리스를 다시 의존하게 됩니다.

그래서 첫 작업에서는 서비스를 하나도 떼지 않았습니다. common 모듈과 관측성 모듈만 만들어 공통 코드를 옮겼고, 모놀리스는 이 두 모듈을 의존해 그대로 부팅되게 했습니다. 이때 규칙을 하나 세웠습니다. 서비스 모듈은 공통 모듈만 의존하고, 다른 서비스 모듈은 의존하지 않는다. 이 규칙은 사람이 지키는 약속이 아니라 Gradle 빌드가 검사하게 했습니다.

떼어내는 단위는 서비스 하나의 세로 조각입니다

서비스 하나를 떼어낼 때는 그 서비스에 필요한 것을 한 번에 옮깁니다.

  • 모듈 골격과 실행 진입점(bootJar), 설정 파일
  • 도메인 패키지
  • 그 서비스만 쓰는 공통 인프라 코드
  • 테스트

이렇게 세로로 자른 조각 하나를 떼어내고, 떼어낸 서비스가 혼자 부팅되는지와 남은 모놀리스가 여전히 부팅되는지를 함께 확인합니다. 마지막 조각을 떼어내면 모놀리스에는 코드가 남지 않습니다.

그럼 누구부터 떼어낼까요?

초기 계획은 Notification, Product, User, Order와 Payment 순서였습니다. Notification은 소비 전용이라 분리 절차를 먼저 확립하기에 적합했고, Saga로 묶인 Order와 Payment는 마지막에 두었습니다.

순서대상계획에서 확인되는 이유
1Notification다른 도메인의 이벤트를 받아 알림을 보내기만 합니다. 떼어내는 방법을 먼저 확립하기에 알맞았습니다
2Product도메인 자체가 다른 모듈과 독립적이고 다른 모듈 -> product의 의존성이 많아 Order 전에 분리를 해야했습니다
3User도메인 자체가 다른 모듈과 독립적이기에 먼저 떼어내기 좋습니다
4Order + Payment주문과 결제는 Saga로 묶여 있어 함께 떼어냅니다. 이때 모놀리스가 사라집니다

Order와 Payment가 서로 묶여 있다는 점은 계획에 반영됐습니다. 하지만 Product를 두 번째로 옮겨도 남은 모놀리스가 부팅되는지는 실행 시점의 빈 호출까지 확인해야 알 수 있었습니다.

떼어내려고 보니 이런 문제가 있었습니다

첫 조각부터: 공통과 전속의 경계가 코드와 달랐습니다

가장 단순해 보였던 Notification부터 예상과 달랐습니다.

먼저 인증입니다. 설계에서는 인증 코드를 User 서비스 전속으로 두었습니다. 요청의 토큰은 Gateway가 검증한다고 가정했기 때문입니다. 그런데 Gateway는 아직 없었습니다. 그 사이에는 Notification도 “누가 요청했는가”를 알아야 했고, 다시 확인해 보니 다섯 서비스 모두 토큰을 검증해야 했습니다. 상품 조회만 공개된 Product조차 관리자 API에는 인증이 필요했습니다. 인증이 필요 없는 서비스는 하나도 없었습니다.

다음은 Slack 알림입니다. Slack으로 메시지를 보내는 코드는 Notification 전속으로 분류해 두었습니다. 그런데 옮기려고 보니 Outbox 발행 실패와 Kafka DLQ 경고도 같은 코드로 Slack에 알리고 있었습니다. 알림 도메인의 코드가 아니라 여러 곳이 함께 쓰는 공통 인프라였습니다.

마지막으로 남은 모놀리스의 테스트입니다. 멱등성, Outbox, DLQ를 검증하는 통합 테스트가 Notification의 이벤트 소비를 검증 수단으로 쓰고 있었습니다. Notification을 떼어내자 이 테스트들은 검증 대상을 잃었습니다.

Product 차례에서: import로는 호출 방향이 보이지 않았습니다

두 번째로 떼어낼 Product를 확인하다가 계획이 멈췄습니다. Order는 Product 패키지를 import하지 않았습니다. import 화살표는 오히려 반대였습니다. Order가 필요한 기능을 자기 패키지에 ProductPort라는 인터페이스로 정의하고, Product가 그 인터페이스를 구현했기 때문입니다. 그런데 실행 시점에는 Order가 이 인터페이스를 통해 Product의 구현 빈을 부르고 있었습니다. 주문을 만드는 트랜잭션 안에서 재고를 차감하고 단가까지 읽었습니다.

주문 생성 트랜잭션 (Order)
 ├─ ProductPort.재고 차감 + 단가 조회   ← Product의 빈을 같은 프로세스에서 직접 호출
 ├─ 주문 저장
 └─ Outbox 저장

인터페이스로 분리돼 있으니 코드만 보면 Order는 Product의 구현을 모릅니다. 하지만 실행 시점에는 Spring이 같은 애플리케이션 안의 Product 빈을 주입해 줍니다. Product를 별도 모듈로 떼어내면 남은 모놀리스의 Order는 주입받을 빈을 잃고 부팅에 실패합니다.

초기 계획에는 패키지 import와 실행 시점의 빈 호출이 서로 다른 방향이라는 점이 반영되지 않았습니다. 경계를 정할 때 발견한 트랜잭션 결합이 코드에서는 이 빈 호출의 모양으로 남아 있었습니다.

반대로 User는 다른 도메인 어디에서도 import하지 않았습니다. 그래서 순서를 바꿔 User를 먼저 떼어냈습니다.

Payment도 같은 모양이었습니다

Order와 Payment는 Saga로 묶여 있으니 마지막에 함께 떼어내기로 했습니다. 그런데 함께 떼어낸다는 것이 같은 모듈에 넣는다는 뜻은 아니었습니다. 두 서비스는 각자 모듈이 되고, 서비스 모듈끼리는 서로 의존할 수 없다는 규칙이 있었습니다. Payment는 결제 소유자 확인과 주문 상태 전이를 Order의 OrderPort 빈으로 동기 호출하고 있었습니다. Product와 같은 문제였습니다.

공통이라고 믿은 실행 인프라가 모놀리스에 있었습니다

Product는 이벤트를 발행하는 첫 번째 서비스였습니다. 그런데 common 모듈에 있는 것은 이벤트 DTO뿐이었습니다. Outbox를 읽어 발행하는 폴러, 중복 소비를 막는 멱등성 처리, 스케줄러 락 설정 같은 실행 코드는 모두 모놀리스 안에 있었습니다. 이 누락은 계획 리뷰에서 드러났습니다. 그대로 모듈만 떼었다면 필요한 코드가 없어 빌드가 깨졌을 것입니다.

실행 코드를 복제할 때는 공유 DB의 폴러 경합도 계획 리뷰에서 확인했습니다. 모놀리스의 폴러와 Product의 폴러가 같은 Outbox 테이블을 읽으면 한쪽이 다른 쪽 이벤트까지 발행하거나 같은 이벤트를 두 번 발행할 위험이 있습니다.

더 찾기 어려운 문제는 컴파일은 통과하는데 실행에서 깨지는 결합이었습니다.

  • User 서비스는 공통 모듈의 컴포넌트를 스캔으로 함께 떠안았습니다. 그중 Slack 클라이언트가 필수 설정값을 요구해서, Slack을 쓰지 않는 User 서비스가 부팅에 실패했습니다.
  • Product를 옮기는 작업에서는 스케줄러 락 이름, API 경로, 캐시 이름, 이벤트 종류 문자열처럼 이름으로만 이어진 결합 네 종류도 수정해야 했습니다. 이 결합은 빌드와 이관 테스트 과정에서 드러났습니다.

정리하면

Gradle의 기본 컴파일은 모듈 사이의 코드 의존은 잡아 줍니다. 이번에 드러난 불편은 대부분 그 바깥에 있었습니다.

모양예기본 컴파일로 드러나나
다른 모듈 import서비스가 다른 서비스 클래스를 참조잡음
같은 프로세스 빈 호출ProductPort, OrderPort모듈을 떼야 부팅 실패로 드러남
한 트랜잭션에 묶인 두 도메인주문 생성과 재고 차감잡지 못함
공유 DB의 같은 테이블두 Outbox 폴러잡지 못함
이름으로만 이어진 결합락 이름, 캐시 이름, 이벤트 종류잡지 못함

제 체감으로는 모듈을 나누는 일보다 표 아래쪽의 결합을 찾아 끊는 데 시간이 더 들었습니다. 그렇다면 빈 호출과 트랜잭션 결합은 어떻게 끊어야 할까요?

계획대로 직접 떼어내 보세요

초기 계획 순서대로 도메인을 눌러 모놀리스 밖으로 꺼내 보세요. 남은 모놀리스가 컴파일되고 부팅되는지 확인합니다.

초기 계획

    당시 코드의 대표 연결만 그린 모형입니다. 이벤트, 공유 DB, 이름으로 이어진 결합은 그리지 않았습니다. 부팅 실패 메시지는 Spring Boot의 형식을 줄여 쓴 예시입니다.

    좀 더 적절한 해결책은 무엇일까요?

    앞에서 본 문제는 두 종류였습니다. 하나는 코드가 어디에 있어야 하는가의 문제였습니다. 인증, Slack, Outbox 실행 코드가 여기에 해당합니다. 다른 하나는 두 도메인이 어떻게 대화하는가의 문제였습니다. ProductPort, OrderPort, 그리고 주문과 재고를 묶은 트랜잭션이 여기에 해당합니다. 앞의 것은 위치를 바로잡으면 되지만, 뒤의 것은 대화 방식 자체를 바꿔야 합니다.

    인증: 검증만 떼어 공용 모듈로

    Gateway가 생기기 전까지 다섯 서비스가 모두 토큰을 검증해야 합니다. 검증 코드를 어디에 둘지에 대한 선택지는 네 가지였습니다.

    선택지문제
    common에 넣기엔티티, 응답, Kafka, Redis 코드 옆에 인증까지 섞입니다. Gateway가 생긴 뒤에 걷어내기도 어렵습니다
    서비스마다 복제하기같은 필터와 검증 코드가 다섯 벌이 되고, 고칠 때마다 다섯 곳을 맞춰야 합니다
    Gateway를 먼저 만들기Gateway가 라우팅할 서비스가 아직 분리되지 않았습니다. 닭이 먼저냐 달걀이 먼저냐의 문제입니다
    검증 전용 모듈모듈이 하나 늘지만, 인증을 한곳에 모으고 Gateway 이후에 모듈째 줄일 수 있습니다

    검증 전용 모듈(peekcart-common-auth)을 택했습니다(ADR-0014). 토큰을 발급하고 블랙리스트에 기록하는 일은 User에 남기고, 검증만 공용 모듈로 뺐습니다. 다만 이 모듈은 Gateway가 오기 전까지의 임시 해법입니다. 다섯 서비스가 같은 비밀키를 나눠 가져야 했고, 이 대가는 Gateway를 도입할 때까지 안고 가야 했습니다.

    나머지 위치 문제는 소유자를 바로잡았습니다

    • Slack 알림은 여러 곳이 쓰는 공통 인프라였으므로 common으로 옮겼습니다.
    • Outbox 실행 코드는 공통 모듈로 옮기는 대신 발행하는 서비스마다 복제했습니다. 공통 추상화로 만들려면 남은 모놀리스까지 함께 고쳐야 해서, 서비스 하나를 떼어내는 작업의 범위를 넘었습니다. 대신 폴러가 서로의 이벤트를 건드리지 않도록 각 폴러가 자기 도메인의 이벤트 종류만 읽게 하고 스케줄러 락 이름도 나눴습니다.
    • 스캔으로 떠안은 공통 빈은 그 기능을 실제로 쓰는 서비스에서만 켜지도록 조건을 달았습니다.
    • 남은 모놀리스의 테스트는 떼어낸 서비스 대신 남은 쪽에서 관찰할 수 있는 결과로 다시 썼고, 떼어낸 서비스를 검증하던 부분은 그 서비스로 옮겼습니다.

    빈 호출을 HTTP로 바꾸는 것만으로는 부족했습니다

    ProductPort의 구현을 HTTP 클라이언트로 바꾸면 코드상으로는 모듈을 떼어낼 수 있습니다. 인터페이스는 그대로 두고 Product 서비스의 API를 호출하면, 모듈은 바로 분리됩니다.

    하지만 이 방법은 두 가지를 해결하지 못합니다. 첫째, 처음에 적은 완료 조건 “서비스 사이를 직접 호출하지 않는다”에 어긋납니다. 둘째, 주문 생성과 재고 차감은 여전히 한 트랜잭션으로 묶을 수 없습니다. HTTP로 재고를 차감한 뒤 주문 저장이 실패하면 차감한 재고를 누가 되돌릴까요? 호출 방식만 바꾸면 결합은 그대로 남고, 네트워크 실패라는 새 문제가 더해집니다.

    그래서 호출을 하나씩 들여다보고, 무엇을 주고받는 호출인지에 따라 다르게 바꿨습니다.

    호출성격바꾼 방식
    재고 차감다른 서비스가 소유한 상태를 바꾸는 쓰기Order가 order.created를 발행하면 Product가 재고를 예약하고, 결과를 stock.reservation.result로 알립니다
    단가 조회, 상품 존재 확인다른 서비스의 데이터를 읽기Product가 변경될 때마다 product.updated를 발행하고, Order는 필요한 값만 로컬 캐시에 보관합니다
    결제 소유자 확인, 결제 시작 전이읽기와 상태 전이Payment가 필요한 값을 자기 테이블에 들고, 결제 시작은 payment.requested 이벤트로 Order에 알립니다

    이렇게 바꾸면 대가가 따릅니다. 아직 도착하지 않은 데이터를 다뤄야 합니다. 새 상품이 등록됐는데 product.updated가 Order의 캐시에 아직 닿지 않았다면, 그 상품은 장바구니에 담을 수 없습니다. 이때 Product에 다시 물어보는 동기 호출을 남겨 두면 편해 보이지만, 그러면 떼어내려던 연결이 그대로 남습니다. 그래서 캐시에 없으면 “잠시 후 다시 시도하라”는 명시적인 실패(409)로 돌려보내기로 했습니다.

    이벤트의 도착 순서도 보장되지 않습니다. 결제 시작 이벤트가 주문의 예약 확정보다 먼저 도착할 수 있고, 주문 취소가 결제 생성보다 먼저 도착할 수도 있습니다. 먼저 도착한 이벤트를 버리지 않도록 표시(marker)를 영속해 두고, 나중에 짝이 도착하면 그 표시를 적용하게 했습니다.

    한 번에 바꾸지 않고, 모놀리스 안에서 하나씩

    로드맵은 각 변경을 독립적으로 검증하고 PR 단위로 되돌릴 수 있게 순서를 나눴습니다. 그래서 모듈은 그대로 둔 채 모놀리스 안에서 호출을 하나씩 이벤트로 바꾸는 단계를 먼저 밟았습니다. 통신 방식과 모듈 위치를 한꺼번에 바꾸지 않아, 각 단계의 검증 범위를 분명히 할 수 있었습니다.

    1. 재고 차감을 예약 이벤트로 옮깁니다. 이 단계의 “예약 성공”은 아직 “이미 차감됨”이라는 임시 의미이고, 단가는 여전히 동기로 읽습니다.
    2. 단가를 로컬 캐시에서 읽습니다. 동기 단가 조회가 사라집니다.
    3. 예약 확정·해제와 결제 전 확인을 갖춥니다.
    4. 장바구니의 상품 존재 확인을 캐시로 바꿉니다. 이제 ProductPort를 지울 수 있습니다. 여기서 Product를 떼어냅니다.
    5. Payment의 OrderPort 호출을 로컬 상태와 이벤트로 바꿉니다. OrderPort를 지우고 Order와 Payment를 떼어냅니다.

    호출을 지울 때마다, 다시 생기지 않도록 Order와 Product, Order와 Payment가 서로의 패키지를 참조하면 빌드가 실패하는 검사도 붙였습니다.

    호출을 하나씩 끊은 다음 떼어냈습니다

    빨간 실선은 같은 프로세스 안의 동기 빈 호출, 초록 점선은 Kafka 이벤트입니다. 화면에 보이면 단계가 자동으로 넘어가고, 단계를 누르면 그 자리에서 멈춥니다.

    대표 호출만 표시한 모형입니다. 이벤트 지연, 실패 경로, DB 분리 시점은 표현하지 않았습니다.

    이렇게 다섯 번의 단계와 네 번의 분리를 거친 뒤, 모놀리스에는 무엇이 남았을까요?

    최종적으로 이렇게 됐습니다

    모놀리스에는 코드가 남지 않았습니다

    마지막으로 Payment를 떼어내자, 처음에 모든 코드를 담고 있던 루트 애플리케이션에는 소스가 하나도 남지 않았습니다. 루트는 더 이상 실행되는 애플리케이션이 아니라, 하위 모듈에 공통 빌드 설정을 내려 주고 검사를 돌리는 빌드 집합점이 됐습니다. 실행 파일(bootJar)도 만들지 않습니다.

    실제로는 공통 코드를 먼저 추출한 뒤 Notification, User, Product, Order, Payment 순서로 옮겼습니다. 어느 시점에 루트에 무엇이 남았는지 단계별로 보면, Product와 User의 순서가 바뀐 결과도 드러납니다.

    루트 모놀리스에는 무엇이 남았을까요?

    실제로 떼어낸 순서대로 도메인이 루트를 떠납니다. 단계를 누르면 그 시점에서 멈춥니다.

    peekcart (root) bootJar

    5 도메인이 이 애플리케이션에서 실행됨

    User
    Product
    Order
    Payment
    Notification
    common
    observability

    서비스 모듈 0 / 5

    1. notification-service
    2. user-service 계획 3번째 → 실제 2번째
    3. product-service 계획 2번째 → 실제 3번째
    4. order-service
    5. payment-service

    공통 모듈 서비스 모듈은 여기만 의존

    아직 루트 안에 있음

    코드와 실행 모듈이 옮겨지는 과정만 표시했습니다. 이 단계에서도 데이터베이스는 공유했고, 물리적 DB 분리는 뒤의 작업이었습니다. 인증 검증 모듈(peekcart-common-auth)은 생략했습니다.

    그 시점의 모듈은 여덟 개였습니다.

    peekcart (루트: 빌드 설정과 검사만)
    ├── common                        공통 응답·예외·이벤트 DTO·Slack 알림
    ├── peekcart-common-observability 메트릭 설정
    ├── peekcart-common-auth          토큰 검증 (Gateway 전까지의 임시 해법)
    ├── notification-service
    ├── user-service                  토큰 발급
    ├── product-service
    ├── order-service
    └── payment-service

    서비스 모듈은 공통 모듈 세 개만 의존할 수 있고, 다른 서비스 모듈을 의존하면 빌드가 실패합니다. Order와 Product, Order와 Payment가 서로의 패키지를 참조해도 빌드가 실패합니다. 앞에서 정리한 표의 첫 줄(다른 모듈 import)과, 지운 동기 호출이 되살아나는 경우는 이제 빌드가 막아 줍니다.

    서비스 사이의 대화는 동기 호출 대신 일곱 개의 토픽이 맡습니다. 처음부터 있던 주문·결제 토픽 넷에, 재고 예약 결과(stock.reservation.result)와 상품 변경(product.updated), 결제 시작(payment.requested)이 더해졌습니다.

    배포도 다섯 벌이 됐습니다

    모듈이 다섯 개의 실행 파일을 만들게 되자, 하나의 앱을 전제로 만든 배포 구성도 다시 짜야 했습니다. 세 번에 나눠 바꿨습니다.

    • 이미지: Dockerfile 하나가 서비스 이름을 인자로 받아 그 서비스의 실행 파일을 이미지로 만듭니다. CI는 다섯 서비스의 이미지를 각각 빌드하고 기동해 봅니다.
    • Kubernetes: 서비스마다 Deployment, Service, ConfigMap, Secret, 메트릭 수집 설정을 따로 둡니다.
    • 관측성: 메트릭과 경고 규칙이 application=peekcart 하나를 전제하던 것을, 서비스 이름별로 나눠 보도록 바꿨습니다.

    검증 범위도 작업 단위마다 달랐습니다. ProductPort를 지운 뒤에는 ./gradlew assertNoOrderProductSourceCoupling test가 통과했고, Product를 떼어낸 뒤에는 새 멀티모듈에서 ./gradlew build가 통과했습니다. 배포 구성을 바꿀 때는 다섯 이미지의 docker build와 minikube·GKE 매니페스트 렌더를 확인했습니다. 관측성 규칙은 문법·서비스 집합·필수 경고 누락을 일부러 만들어 정적 검사가 실패하는지 확인했습니다. 이 검증이 실제 서비스별 DB 분리나 운영 중 경고 발화까지 증명하지는 않습니다.

    완료 조건 하나의 전제만 갖췄습니다

    처음에 적은 완료 조건 네 가지로 돌아가 보겠습니다.

    완료 조건이 단계에서
    모든 서비스를 독립적으로 배포한다빌드와 배포의 전제만 갖췄습니다. DB는 아직 하나입니다
    결제 실패 시 주문 취소와 재고 복구예약 흐름은 생겼지만, 보상 흐름 검증은 뒤 단계의 몫입니다
    Gateway 라우팅과 JWT 인증Gateway는 아직 없습니다. 서비스마다 각자 검증합니다
    직접 호출 없이 이벤트와 로컬 캐시로 조합동기 호출은 지웠지만, 캐시 전반의 설계는 뒤 단계에서 마무리합니다

    모듈을 다섯 개로 나눴다고 해서 서비스가 독립한 것은 아니었습니다. 이 단계가 끝났을 때도 분리를 위해 잠시 남겨 둔 연결이 있었습니다.

    • DB를 여전히 하나 공유합니다. 마이그레이션은 한곳에서만 실행하고, 다른 서비스는 그 마이그레이션이 끝나기를 기다렸다가 뜹니다. 앞의 표에서 본 “공유 DB의 같은 테이블”은 빌드가 여전히 잡지 못합니다. 이 전환기에 실제 장애나 데이터 불일치는 겪지 않았지만, 물리적 DB 분리 전까지 남는 위험이었습니다.
    • 같은 HS256 비밀키를 다섯 서비스가 나눠 가집니다. 각자 토큰을 검증하며 Redis 블랙리스트도 읽는 전환기 구조였습니다.
    • Outbox 실행 코드가 서비스마다 복사돼 있습니다. 떼어내는 단계에서는 복제가 범위에 맞는 선택이었지만, 같은 코드가 여러 벌 있다는 사실은 그대로 남았습니다.
    • 캐시가 아직 도착하지 않은 짧은 순간에는 주문과 장바구니 담기가 409로 실패할 수 있습니다. 이 순간이 실제로 얼마나 자주 생기는지는 측정하지 않았습니다.

    이 목록이 다음 작업의 출발점입니다. DB를 서비스별로 나누는 일, Gateway로 토큰 검증을 한곳에 모으는 일, 결제 실패의 보상 흐름을 닫는 일이 각각 여기서 이어집니다.

    그렇다면 이렇게 떼어낸 방식이 최선이었을까요?

    더 좋은 방법은 없었을까요?

    여기부터는 작업 당시가 아니라 지금 돌아보며 생각해 본 내용입니다. 당시에 이 안들을 비교한 기록은 Outbox 하나뿐입니다.

    실행 시점의 의존을 먼저 조사했다면

    돌아보면 초기 순서에는 “누가 누구를 import하는가”와 “누가 누구를 호출하는가”의 차이가 반영되지 않았습니다. 이 프로젝트의 결합은 import로는 거꾸로 보이는 모양이었습니다.

    Order는 필요한 기능을 자기 패키지에 ProductPort라는 인터페이스로 정의했고, Product가 그 인터페이스를 구현했습니다. 의존성 역전입니다. 레이어를 깔끔하게 나누는 좋은 습관이지만, 그 결과 import만 보면 Order가 Product에 전혀 기대지 않는 것처럼 보입니다. 실제로는 주문 트랜잭션 한가운데서 Product를 부르고 있었는데도 말입니다.

    분리 순서를 정하기 전에 두 가지를 조사했다면 이 결합을 더 일찍 발견할 수 있었을 것입니다. 다른 도메인의 인터페이스를 구현한 어댑터가 어디 있는지, 그리고 다른 도메인의 빈을 주입받는 곳이 어디인지입니다. 이 프로젝트는 분리 과정에서 Order와 Product가 서로의 패키지를 참조하면 빌드가 실패하는 검사를 붙였습니다. 검사 범위는 운영 코드인 src/main의 패키지 문자열 참조입니다. 빈 호출의 런타임 동작이나 이벤트 지연까지 검사하지는 않습니다.

    모놀리스 안에서 모듈 경계부터 강제했다면

    서비스로 떼어내기 전에, 모놀리스 안에서 모듈 경계를 먼저 검사하는 방법도 있습니다. Spring Modulith 문서에 따르면 기본 구성에서는 메인 패키지 바로 아래 패키지를 모듈로 봅니다. 하위 패키지를 가진 모듈에서는 기준 패키지만 다른 모듈에 공개되고, 하위 패키지는 내부로 취급합니다. 문서는 다른 모듈이 내부 코드를 참조하지 않아야 한다고 설명합니다.

    이 규칙을 당시 PeekCart의 패키지에 적용했다면, ProductPortAdapter가 Order의 하위 패키지(order.application.port)를 참조하는 것을 위반으로 확인했을 가능성이 있습니다.

    다만 한계도 분명합니다. Spring Modulith는 모듈이 다른 모듈에 제공하는 API에 Spring 빈과 이벤트를 모두 포함합니다. 인터페이스를 공개 위치로 옮기기만 하면, 빈 호출 자체는 정당한 의존이 됩니다. 이 도구는 의존을 드러내 줄 뿐 동기 호출을 금지하지는 않습니다. 그리고 공유 DB의 같은 테이블이나 이름으로만 이어진 결합은 이 검사로도 잡히지 않습니다. 당시 이 방법은 검토하지 않았습니다.

    Outbox 실행 코드를 처음부터 공용 모듈로 뒀다면

    Product를 떼어낼 때 Outbox 실행 코드는 공통 모듈로 옮기지 않고 서비스에 복제했습니다. 이 선택은 나중에 대가를 치렀습니다. 이후 DLQ 원장이 여러 서비스의 공통 경로가 되고 Notification도 Outbox를 갖게 되면서, 네 서비스가 같은 클래스를 각자 들고 있게 됐습니다. 서비스당 약 35개 파일 중 32개가 네 벌 모두 바이트 단위로 같았습니다. 사본이 갈라지지 않도록 lint로 동일성을 강제하자, 한 곳을 고치려면 네 곳을 함께 고쳐야 했습니다. 결국 Phase 5에서 peekcart-common-messaging이라는 공용 모듈로 올렸습니다(ADR-0033).

    그렇다면 처음부터 공용 모듈이 정답이었을까요? 두 가지를 함께 봐야 합니다.

    • common에 넣는 것은 답이 아니었습니다. User 서비스도 common을 의존하고 같은 패키지를 스캔합니다. 넣는 순간 User가 JPA 엔티티와 스케줄러, DLQ 인프라를 떠안게 됩니다. 앞에서 본 “스캔으로 떠안은 공통 빈”과 같은 문제입니다.
    • 복제가 비싸진 것은 전제가 바뀐 뒤였습니다. 복제를 택할 때 발행 서비스는 많지 않았습니다. 네 서비스가 같은 코드를 갖게 된 것은 그 뒤에 DLQ와 재처리 설계가 더해졌기 때문입니다.

    돌아보면, 인증 검증을 전용 모듈로 뺀 선례를 Outbox에도 그대로 적용할 수 있었습니다. common과 별개인 메시징 전용 모듈입니다. 다만 그 시점에 이 비용이 보였을지는 단정하기 어렵습니다.

    이것은 Strangler Fig였을까요?

    이번 작업 단위의 이름에는 “strangler”가 붙어 있습니다. Martin Fowler의 Strangler Fig 설명에 따르면 이 패턴은 기존 시스템을 한 번에 갈아엎지 않고, 그 옆에 새 구성 요소를 따로 만들어 기능을 조금씩 옮겨 가는 방식입니다. 기존 시스템에서 떼어낼 수 있는 **이음매(seam)**를 찾고, 둘이 함께 동작하는 동안에만 필요한 **임시 구조(transitional architecture)**를 둡니다. 임시 구조는 낭비처럼 보이지만, 점진적 전환으로 위험을 줄이는 이득이 그 비용보다 크다고 생각했습니다.

    PeekCart의 작업도 이 생각을 따랐습니다. ProductPort와 OrderPort가 이음매였고, 공용 인증 모듈, 폴러의 이벤트 종류 제한, 한곳에서만 돌리는 마이그레이션, “예약 성공 = 이미 차감됨”이라는 임시 의미가 임시 구조였습니다.

    차이도 있습니다. Fowler의 설명은 기존 시스템 옆에 새 구성 요소를 만들어 키워 가는 쪽에 무게를 둡니다. PeekCart는 새 서비스를 따로 짓지 않았습니다. 기존 코드를 그대로 옮겨 서비스로 만들었고, 옮기기 전에 모놀리스 안에서 동기 호출을 이벤트로 바꿨습니다. 같은 이름 아래에서도 “어디에서 조여 가는가”는 다를 수 있습니다.

    정리하며

    도메인별로 패키지가 나뉘어 있다는 것은 분리의 출발점이었습니다. 이 사례에서 분리 순서를 바꾼 것은 import에 드러나지 않는 빈 호출과 트랜잭션 결합이었습니다. 이 작업이 끝났을 때도 DB와 비밀키는 공유돼 있었고, 캐시가 늦게 도착하는 순간이 실제로 얼마나 생기는지는 측정하지 않았습니다.