MSA에 대한 고찰 (3)

들어가며

많은 팀이 마이크로 서비스 아키텍쳐를 도입할 때 저지르는 실수는 기술적인 관점에서 서비스를 분리하려는 것이다.

  • 회원 기능은 MemberService로
  • 상품 기능은 PostService로
  • 주문 기능은 OrderService로

언뜻 보면 합리적이지만, 이는 분산된 모놀리스라는 최악의 아키텍쳐이다.

왜일까? 주문 이라는 비즈니스 로직을 생각해보면 주문을 하려면 회원의 등급을 확인해서 쿠폰을 적용하고, 상품의 재고를 확인해야한다. 만약 OrderService가 MemberService와 ProductService의 데이터베이스 테이블 구조나 내부 로직을 직접 참조하여 강하게 강결합이 되어있다면 어떻게 될까?

회원 등급 정책이 변경되어 MemberService의 코드가 수정되면, 이와 결합된 OrderService 도 장애가 발생하거나 함께 수정 후 배포해야한다. 이는 MSA 목표인 독립적인 개발 배포와 정면으로 위배된다.

DDD: 올바른 경계를 찾아보자

MSA의 성공은 얼마나 잘게 쪼갰는가가 아니라 어떻게 올바른 경계로 쪼갰는가에 달려있다. 이 경계는 기술이 아닌 비즈니스 도메인 그 자체에서 찾아야한다. 그리고 이 경계를 찾는 가장 강력하고 검증된 방법론이 바로 DDD(Domain Driven Design)이다.

DDD는 소프트웨어의 복잡성이 기술이 아닌 도메인 자체에 있다고 본다. 따라서 개발자는 이커머스라는 도메인을 전문가와 함게 이해하고 이 도메인의 복잡성을 관리하는데 집중해야한다.

DDD는 이 복잡한 도메인을 관리 가능한 작은 단위로 분리하는 도구를 제공한다. 그 중 MSA와 직접적으로 연결되는 개념이 바로 바운디드 컨텍스트(Bounded Context)이다.

바운디드 컨텍스트: 하나의 용어가 하나의 의미로만 사용되는 명확한 경계를 의미한다. 예를 들어, 이커머스 시스템에서 상품이라는 용어는 여러 의미로 쓰인다.

  • 상품 컨텍스트: 상품은 이름, 이미지, 설명, 카테고리 등 ‘전시’를 위한 정보를 의미한다.
  • 재고 컨텍스트: 상품은 SKU-1234라는 재고 관리 번호화 재고 수량을 의미한다.
  • 주문 컨텍스트: 상품은 주문서에 찍힌 주문 항목이며 주문 시점의 가격을 의미한다.

DDD는 이렇게 모호한 상품을 전시, 재고, 주문 컨텍스트라는 명확한 바운디드 컨텍스트로 분리할 수 있어 마이크로서비스의 이상적인 경계가 된다.

1. 유비쿼터스 랭귀지(Ubiquitous Language) 정의: 이커머스 용어집 만들기

DDD의 처음은 공통 언어를 확립하는 것에서 시작한다. 유비쿼터스 랭귀지란 개발자와 도메인 전문가가 모두 동의하고 사용하는 단 하나의 공통 용어집을 의미한다.

왜 필요할까?

소프트웨어 개발 프로젝트가 실패하는 큰 이유는 하나의 소통의 실패이다. 예를 들어보자.

  • 기획자: 이번에 ‘프리미엄 고객’ 들에게는 가격을 10% 할인 해줘야해요.
  • 개발자: 프리미엄 고객? User 테이블의 grade가 GOLD인 사람을 말하는 건지, Product 테이블의 price를 말하는건지, ItemOption의 discountPrice를 말하는 건지 모른다.

기획자가 말하는 고객이 개발자의 코드에서는 User가 되고 또 다른 개발자의 코드에서는 Account가 된다면 이 시스템은 처음부터 잘못된 경계로 나뉘게 된다. 기획자가 말하는 고객과 개발자가 구현하는 Customer 클래스가 1:1로 동일한 의미를 가져야 혼선이 없다.

유비쿼터스 랭귀지는 회의실에서 쓰는 용어와 코드 속 클래스 이름을 일치시키는 것을 목표로 한다. 이 언어가 명확할 수록, 다음에 다룰 바운디드 컨텍스트의 경계가 드러난다.

E-commerce의 유비쿼터스 랭귀지(초안)

E-commerce에선 핵심 용어들을 다음과 같이 정의한다.

컨텍스트 (Context) 용어 (Term) 정의 (Definition) 유의어/배제 용어
회원 (Membership) 회원 (Member) 우리 시스템에 가입하여 고유 ID를 발급받은 개인. 사용자(User), 고객(Customer), 계정(Account). (모두 Member로 통일)
  회원 등급 (Member Grade) 회원의 활동(예: 구매액)에 따라 부여되는 혜택 수준. (예: BRONZE, SILVER, GOLD) 레벨(Level)
상품/전시 (Catalog) 상품 (Product) 고객에게 ‘전시’되고 판매되는 대상. 이름, 설명, 대표 이미지, 판매 가격을 갖는다. 아이템(Item)
  판매 가격 (Sales Price) 할인이 적용된 실제 고객 결제 기준 가격. (프로모션, 쿠폰 적용 전) 정가(List Price)
재고 (Inventory) 재고 상품 (Stock Keeping Unit, SKU) 재고 관리를 위한 물리적/논리적 최소 단위. 상품(Product)과 1:N 관계일 수 있음. (예: ‘나이키 티셔츠’ 상품의 ‘S/Blue’ SKU) 상품(Product) (재고 컨텍스트에서는 상품 대신 SKU로 명확히 구분)
  재고 수량 (Stock Quantity) 주문 가능한 실제 가용 수량. 수량(Quantity) (모호함)
주문 (Ordering) 주문 (Order) 회원이 하나 이상의 주문 항목을 구매하겠다고 요청한 단일 트랜잭션. 주문 번호를 갖는다. 구매(Purchase)
  주문 항목 (Order Line) 주문에 포함된 개별 SKU와 수량, 주문 당시의 ‘주문 가격’을 스냅샷으로 기록한다. 주문 상품(Order Product)
  주문 가격 (Order Price) 주문 항목이 생성될 당시의 SKU 판매 가격. (이후 상품의 판매 가격이 변경되어도 불변) 판매 가격(Sales Price)
  주문 상태 (Order Status) 주문의 생명주기. (예: PENDING, PAID, SHIPPED, CANCELLED)  
결제 (Payment) 결제 (Payment) 주문에 대한 금액 지불 시도 및 그 결과. 주문과는 1:1 또는 1:N(분할 결제) 관계. 청구(Billing)
배송 (Shipping) 배송 (Shipment) 결제가 완료된 주문을 고객에게 전달하는 물리적 프로세스. 송장 번호를 갖는다. 딜리버리(Delivery)

2. 바운디드 컨텍스트(Bounded Context) 식별: 서비스 경계 나누기(회원, 상품, 주문, 결제, 배송)

앞서 정의한 유비쿼터스 랭귀지를 통해 같은 용어가 다른 의미로 쓰이는 지점이라는 것을 확인할 수 있었다. 바운디드 컨텍스트는 이를 통해 100% 일관성을 갖는 명확한 경계를 긋는 작업이다. 이 경계 안에서 Product는 오직 하나의 의미만 가진다.

MSA 설계에서 이 바운디드 컨텍스트 하나가 마이크로서비스 하나가 된다. 이 경계를 나누는 기준은 데이터와 기능이 아니라 비즈니스 책임이 되어야한다. 이 서비스는 비즈니스적으로 어떤 책임을 담당하는지 생각해야한다.

따라서 앞에서 정의한 5개의 핵심 도메인을 기반으로 다음과 같이 바운디드 컨텍스트를 식별할 수 있다.

1. 회원 컨텍스트

  • 핵심 책임(Responsibility): 고객의 신원(Identity)를 관리하고 인증을 책임진다.
  • 주요 UL: 회원, 회원 등급
  • 주요 기능: 회원 가입, 로그인, 회원 정보 조회 및 수정, 회원 탈퇴
  • 경계: 회원의 구매 내역이나 배송지 정보는 회원 컨텍스트의 핵심 책임이 아니다. 이 컨텍스트는 오직 시원 관리만 집중한다.
  • 결과: Member-service

2. 상품 컨텍스트

  • 핵심 책임(Responsibility): 판매할 상품의 정보를 전시하고 판매 가능한 재고를 관리한다.
  • 주요 UL: 상품, SKU(Stock Keeping Unit), 판매 가격(Sales Price), 재고 수량(Stock Quantity)
  • 주요 기능: 상품 등록/수정/조회, 카테고리 관리, 재고 관리(입고/차감)
  • 경계: 상품이 누구에게 팔렸는지 이 컨텍스트의 관심사가 아니라 판매할 대상과 판매할 재고에만 집중한다.
  • 결과: Product-service

3. 주문 컨텍스트

  • 핵심 책임(Responsibility): 고객의 주문(구매 요청)을 생성하고, 그 상태(LifeCycle)을 추적한다.
  • 주요 UL: 주문, 주문 항목, 주문 가격, 주문 상태
  • 주요 기능: 주문 생성, 주문 내역 조회, 주문 취소
  • 경계: 주문은 어떻게 결제되었는지 상세히 알 필요가 없고 단지 결제 완료라는 주문 상태만 관리한다. 마찬가지로 어떻게 배송되는지 알 필요없이 배송 시작 상태만 알면 된다.

  • 결과: Order-servcie

4. 결제 컨텍스트

  • 핵심 책임(Responsibility): 주문에 대한 금액 지불을 처리하고 환불을 관리한다.
  • 주요 UL: 결제
  • 주요 기능: 외부 PG 연동을 통한 결제 시도, 결제 승인 실패 처리, 결제 취소
  • 경계: 결제는 어떤 상품이 결제되는지 관심이 없고 주문 컨텍스트로부터 전달받은 주문 번호와 결제할 총액에만 집중한다.
  • 결과: Payment-service

5. 배송 컨텍스트

  • 핵심 책임(Responsibility): 결제가 완료된 주문을 고객에게 물리적으로 전달하는 프로세스를 관리한다.
  • 주요 UL: 배송, 송장 번호
  • 주요 기능: 결제 완료 이벤트 수신, 배송 요청 생성, 송장 번호 발급 및 배송 상태 추적
  • 경계: 배송은 주문이얼마에 결제되었는지 관심 없고 오직 배송할 주문 항목과 배송지 주소에만 집중한다.
  • 결과: Shipping-service

3. 컨텍스트 맵(Context Map) 작성: 서비스 간의 관계 정의

바운디드 컨텍스트(BC)를 식별하여 마이크로서비스의 경계를 확장했다. 하지만 이 서비스들은 서로 끊임없이 유기적으로 소통해야한다.

컨텍스트 맵(Context Map)은 이 BC들 간의 관계와 통신 방식을 정의하는 전략적 청사진이다. 이는 어떤 팀이 어떤 팀에 의존하는가, 두 팀의 모델이 변경될 때 서로에게 어떤 영향을 주는가를 명확히 하는 MSA 프로젝트의 정치 지도와도 같다. 이 관계를 명확히 하지 않으면, 서비스 경계를 나눈것이 무의미로 돌아간다.

핵심 관계 패턴

DDD는 BC 간의 관계를 정의하는 여러 패턴을 제시한다. 우리는 그중 MSA에 가장 필수적인 패턴들을 살펴볼 것이다.

1. 업스트림(Upstream)과 다운스트림(Downstream)

모든 관계의 기본이다.

  • 업스트림(Upstream): 상류에 있는 서비스. 다른 서비스에게 정보나 기능을 ‘제공’한다.
  • 다운스트림(Downstream): 하류에 있는 서비스. 업스트림의 정보나 기능을 ‘소비’한다.

이 관계에서 다운스트림은 업스트림의 결정에 영향을 받는다. 사움 서비스가 API 스펙을 바꾸면, 주문 서비스는 코드를수정해야 할 수도 있다.

2. 셰어드 커널(Shared Kernel, SK)

  • 정의: 두 개 이상의 BC가 도메인 모델의 일부 문자 그대로 공유하는 관계이다.
  • 이커머스 예시: 모든 서비스가 공통으로 사용하는 Money Value Object나 MemberId 클래스를 common-domain.jar 같은 공통 라이브러리로 만들어 공유하는 것이다.
  • 장점: 코드 중복이 줄어든다
  • 위험성: MSA에서는 위험한 안티 패턴이 될 수 있다. 만약 이 common-domain.jar가 변경되면, 이 커널을 공유하는 모든 서비스가 동시에 수정 및 재배포되어야한다. 이렇게 되면 MSA의 독립 배포 원칙이 깨진다. 따라서 셰어드 커널은 MemberId처럼 절대 변하지 않는, 확실한 핵심모델에만 제한적으로 사용한다.

3. 안티-커럽션 레이어(Anti-Corruption Layer, ACL)

  • 정의: 다운스트림이 업스트림의 ‘오염된’ 모델로부터 자신의 ‘순수한’도메인 모델을 보호하기 위해 구축하는 번역기 계층이다.

  • 예시:
    1. 주문 BC(다운스트림)가 상품 BC(업스트림) API를 호출한다.
    2. 상품 BC는 전시/마케팅 정보까지 포함된 50개의 필의 거대한 ProductResponseDto를 반환한다.
    3. 주문 BC는 이 DTO가 자신의 핵심 도메인(Order)까지 침투하는 것을 원치 않는다.
    4. 주문 BC 내부에 ACL을 둔다. 이 ACL은 ProductResponseDto를 받아서, 주문 도메인에 필요한 3개의 필드만 추출한 뒤 주문 BC 내부의 순수한 모델인 ProductInfo로 번역해준다.
  • 장점: 느슨한 결합(Loose Coupling), 나중에 상품 BC가 ProductResponseDto에 20개 필드를 추가하더라도 주문 BC의 핵심 도메인은 아무런 영향을 받지 않는다. ACL 계층의 번역 로직만 수정하면 끝이다.

4. 오픈 호스트 서비스(Open Host Service) & 발행 언어(Published Language, PL)

  • 정의: 업스트림이 자신의 기능을 외부에 공개하는 방식이다. 잘 정의된 REST API와 이 API가 사용하는 DTO 또는 이벤트를 제공한다.
  • 이커머스 예시: 상품 BC가 GET /v1/products/{id}라는 API(OHS)를 제공하고, 응답으로 ProductResponseDto(PL)를 반환하는 것이다.

5. 컨포미스트(Conformist, CF)

  • 정의: ‘순응자’라는 뜻 그대로 다운스트림이 업스트림 팀의 모델을 아무런 번역이나 수정없이 그대로 받아들여 사용하는 관계이다. 다운 스트림은 업스트림 모델에 대해 어떠한 영향력도 행사하지않으며 전적으로 순응한다.

  • 이커머스 예시:
    1. 회원 서비스(업스트림)가 id, name, grade 필드를 가진 MemberDto를 API 응답으로 제공한다.
    2. 주문 서비스는 이 MemberDto를 자신의 서비스 계층과 비즈니스 로직에서 직접 사용한다.
    3. 별도의 번역계층이나 OrderMember와 같은 내부 모델을 만들지 않는다.
  • 장점: 극도의 단순함, ACL과 같은 번역 계층을 만들 필요가 없어, 개발 속도가 매우 빠르고 초기 구현이 간단하다.
  • 위험성: 강한 결합(Tight Coupling)이 발생한다. 다운 스트림은 업스트림에 완전히 종속된다. 만약 회원 서비스 팀이 MemberDtograde필드를 level로 변경하면, 주문 서비스의 코드는 컴파일 에러를 일으키며 즉시 깨진다. 이로 인해 주문 서비스는 자신의 비즈니스 로직을 변경하지 않아도 오직 업스트림의 코드를 수정하고 테스트하고, 재배포해야하므로 독립적인 배포 원칙이 위배된다.

이커머스 프로젝트의 컨텍스트 맵

이제 5개의 BC 관계를 정의해보자.

  1. 상품(Product)(U) vs 주문(Order)(D)
    • 관계: Anto-Corruption Layer(ACL)
    • 이유: 주문은 상품의 모든 정보가 필요없다. 주문은 상품의 API가 변경되더라도 자신의 도메인을 보호해야한다.
  2. 회원(Member)(U) vs 주문(Order)(D)
    • 관계: Open Host Servic / Conformist(CF)
    • 이유: 회원 모델은 MemberId, Name, Grade 정도로 매우 단순하고 안정적이다. 주문은 회원의 모델이 변경될 위험이 적다고 판단, 회원이 제공하는 DTO를 그대로 수용한다.
  3. 주문(Order)(U) vs 결제(Payment)(D)
    • 관계: OHS + ACL
    • 이유: 주문이 결제를 동기식으로 호출할 때 주문은 최소 정보만 제공한다. 결제는 주문의 복잡한 내부 모델을 알 필요가 없다.
  4. 주문(Order)(U) vs 배송(Shipping)(D)
    • 관계: 이벤트 기반(Event-Driven) / Published Language(PL)
    • 이유: 주문은 결제가 완료되면 OrderPaid 이벤트를 Kafka로 발행한다. 이 이벤트가 주문의 PL이다. 배송인 이 이벤트를 구독하며 작업을 시작한다. 두 서비스는 서로의 존재조차 모를 수 있다.
  5. 결제(Payment)(U) vs 주문(Order)(D)
    • 관계: 이벤트 기반(Event-Driven) / Published Language(PL)
    • 이유: 결제가 완료되면, PaymentCompeted 이벤트 Kafka로 발행한다. 주문은 이 이벤트를 구독하여 주문의 상태를 PAID로 변경한다.

4. 핵심 도메인과 서브 도메인 식별

그렇다면 우리가 분리한 다섯개의 도메인이 모두 중요할까? 리소스를 어디에 집중시켜야할까? 모든 비즈니스에는 핵심 경쟁력이 있고 있으면 좋지만 필수적이지 않은 것, 그리고 돈 주고 사 와도 된다는 것이다. DDD는 이 비즈니스 가치에 따라 서브 도메인을 3가지로 분류할 것을 요구한다.

1. 핵심 도메인(Core Domain)

  • 정의: 비즈니스의 핵심이다. 비즈니스의 특징이자 무기 그 자체를 의미한다.
  • 전략: 절대 아웃소싱하거나 외부 솔루션을 사서 쓰면 안된다. 회사의 최고급 엔지니어를 모두 여기에 투입해야한다. 가장 유연하고 가장 확장 가능하며, 가장 견고하게 설계해야한다.
  • 주문과 상품:
    • 주문: 얼마나 쉽고, 빠르고, 안정적으로 주문을 완료시키는지 이커머스의 제일 중요한 부분.
    • 상품: 재고를 얼마나 실시간으로 정확하게 관리하고 상품을 얼마나 매력적으로 전시, 추천하는지 매출과 직결된다.

2. 지원 서브 도메인(Supporting Subdomain)

  • 정의: 비즈니스를 운영하기 위해 필수적이지만, 그 자체가 아닌 도메인이다. 비즈니스 고유의 로직이 포함되어 있어 외부 솔루션을 사서 쓰기엔 애매하다.
  • 전략: 직접 구축하되, 핵심 도메인처럼 과도하게 복잡한 엔지니어링을 투입할 필요는 없다. 표준적인 아키텍쳐와 검증된 기술로 잘 만들면 된다.
  • 회원과 배송:
    • 회원: 로그인, 가입은 필수기능이지만, 로그인이 잘된다고 해서 우리는 쇼핑몰을 쓰지 않는다.
    • 배송: 배송도 필수 기능이지만, 우리 고유의 배송 추적 로직이 필요할 뿐, 이 자체가 경쟁력은 아니다.

3. 일반 서브 도메인(Generic Subdomain)

  • 정의: 이미 세상에 정답이 있는 영역. 모든 비즈니스가 필요로 하지만 차별점은 없는 기능
  • 전략: 절대 직접 구축하지 않는다 외부 SaaS, 오픈소스를 활용한다. 우리의 payment-service는 신용카드 번호를 직접 처리하는 시스템이 되어서는 안된다.
  • 결제:
    • 결제: 신용카드 승인, 본인 인증, PCI-DSS 보안 규격 준수 등은 매우 복잡하지만 해결된 문제이다. 우리는 payment-service는 이 외부 PG를 호출하고, 그 결과를 주문 서비스에 알려주는 어댑터 또는 파사드의 역할만 수행하면된다.

전략적 리소스 분배.

  • 주문/상품(Core): 엔지니어가 가장 유연한 구조로 설계한다.
  • 회원/배송(Supporting): 표준적인 MSA 구조로 견고하게 직접 구현한다.
  • 결제(Generic): 외부 솔루션을 연동하는 어댑터로 구현하여 리소스를 관리한다.

5. 애그리거트(Aggregate)와 루트 엔티티(Root Entity) 설계: 트랜잭션 경계의 확립

지금까지 전략적 설계(Strategic Design)을 통해 서비스의 경계(BC)와 관계(Context Map), 그리고 우선순위 라는 거시적인 청사진을 그렸다.

이제 코드로 구현하면서 애그리거트(Aggregate)에 대해 알아보자.

애그리거트(Aggregate)란 무엇인가?

‘주문(Order) 도메인을 생각해보자. ‘주문’이 생성된다는 것은 Order 객체 뿐만 아니라 여러 개의 OrderLine 객체, 그리고 ShippingInfo 객체가 한 묶음으로 생성된다는 의미이다. 애그리거트란 이처럼 데이터 변경의 일관성을 유지하기 위해 하나의 단위로 취급되어야하는 객체들의 묶음을 의미한다.

애그리거트 루트(Aggregate Root)

이 객체 묶음은 아무렇게나 접근해서는 안된다. 묶음 내의 일관성을 보장하기 위한 단일 진입점(Gateway)가 필요하다. 이 진입점이 바로 애그리거트 루트이다.

  • 애그리거트 루트: 애그리거트 묶음 전체를 대표하는 루트 엔티티다.
  • 핵심 규칙 1: 애그리거트 외부의 객체는 오직 ‘루트’만을 참조할 수 있다.(ex: Order는 참조 O, Order 참조 X)
  • 핵심 규칙 2: 애그리거트 내부의 객체는 반드시 루트를 통해서만 가능하다.
  • 핵심 규칙 3: 애그리거트의 모든 객체는 하나의 트랜잭션으로 저장/수정/삭제 되어야한다.

애그리거트 = Transaction의 경계

애그리거트 루트를 Repository.save(order)하는 순간, 이 애그리거트에 속한 객체는 반드시 하나의 원자적(Atomic) 트랜잭션으로 DB에 저장되어야 한다. 만약 Order는 저장됐는데, 3개 중 하나만 저장되고 누락된다면 데이터의 정합성이 깨지게 된다.

애그리거트는 이 데이터의 일관성을 보장하는 최소한의 단위이다.

이커머스 Order 애그리거트 설계

Order Agreegate

  • Order(Root Entity): 애그리거트 루트, id, orderer, status, shippintInfo, totalAmount
  • OrderLine(Entity): 애그리거트 내부 엔티티, productId, price, quantity
  • ShippintInfo(Value Object) recipient(수신자), address(주소), phone(연락처)
  • Orderer(Value Object): memberId

이 설계에는 중요한 DDD 규칙이 숨어있다고 한다.

규칙: 애그리거트는 다른 애그리거트를 ‘ID’로만 참조한다.

OrderLine은 Product 객체 자체를 참조하지 않고, 오직 productId: Long 이라는 ID값만 가지고있다.

  • 왜? 만약 Order 애그리거트가 Product 애그리거트를 직접 참조한다면 두 애그리거트가 강하게 결합된다.
  • Order를 조회할 때마다 불필요하게 Product 정보까지 로드해야 할 수도 있고, Product의 변경이 Order 트랜잭션에 영향을 줄 수 있다.
  • Product는 ‘상품’ BC, Order는 주문 BC 소속이다. 서로 다른 BC의 애그리거트는 ID로만 참조하는 것이 경계를 명확히 하고 느슨한 결합(Loose Coupling)을 유지하는 핵심 비결이다.

애그리거트 루트가 비즈니스 규칙을 강제하는 법

애그리거트 루트는 묶음의 ‘일관성 규칙’을 강제한다.

  • 규칙(Invariant): 배송이 시작된 Order에는 OrderLine을 추가할 수 없다.
  • 규칙(Invariant): Order이 totalAmount는 항상 모든 OrderLine의 (가격 * 수량)의 합과 같아야한다.

이 규칙을 강제하는 방법은 OrderLine을 직접 수정하는 것을 막고 Order 루트를 통하도록 강제한다.

[나쁜 코드]

// '루트'를 통하지 않고 'OrderLine'을 직접 추가하려는 시도
// (orderLineRepository가 있다고 가정. 애초에 만들면 안됨)
val line = OrderLine(productId = 1, price = 1000, quantity = 1)
orderLineRepository.save(line) // 금지! 'Order'의 totalAmount와 불일치 발생

[좋은 코드(애그리거트 루트 사용)]

// 1. 애그리거트 루트를 조회
val order = orderRepository.findById(orderId)

// 2. '루트'의 메서드를 통해 내부 객체(OrderLine)를 조작
order.addOrderLine(productId = 1, price = 1000, quantity = 1)

// 3. '루트'를 저장 (이때 Order, OrderLine이 한 트랜잭션으로 저장됨)
orderRepository.save(order)
// Order 클래스 (애그리거트 루트)
@Entity
class Order(
    // ...
    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
    val orderLines: MutableList<OrderLine> = mutableListOf(),
    var totalAmount: Long = 0,
    var status: OrderStatus
) {
    /**
     * 애그리거트 루트가 '일관성 규칙(Invariants)'을 강제한다.
     */
    fun addOrderLine(productId: Long, price: Long, quantity: Int) {
        // [규칙 1 검사] 배송 중이면 추가 불가
        if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.CANCELLED) {
            throw IllegalStateException("이미 배송되었거나 취소된 주문입니다.")
        }

        // 내부 객체 추가
        val newOrderLine = OrderLine(productId, price, quantity)
        this.orderLines.add(newOrderLine)

        // [규칙 2 적용] 총액 자동 계산
        recalculateTotalAmount()
    }

    private fun recalculateTotalAmount() {
        // 모든 'OrderLine'의 합계를 계산하여 'totalAmount'를 갱신
        this.totalAmount = orderLines.sumOf { it.price * it.quantity }
    }
}

이커머스라는 도메인을 분석하여 유비쿼터스 랭귀지를 정의했고, 서비스 경계인 바운디드 컨텍스트 5개를 식별했다. 서비스 간 관계 컨텍스트 맵을 그렸고, 리소스 집중을 위한 핵심 도메인을 선정했다. 마지막으로 데이터의 일관성의 단위인 애그리거트까지 설계했다.