들어가며
지금까지 회원 API를 작성해보았다. 물론 회원 API까지만 잘 작성했다면 문제가 없을 것이지만, 다른 도메인이 개입되는 순간 애기가 달라진다. 예를 들어, ‘상품’기능과 ‘주문’기능이 추가 되었을 땐 모놀리한 코드와 아키텍쳐를 선택했다면, 문제가 발생하게 된다. 어떤 문제가 있을까?
비즈니스 로직에 다른 도메인 비즈니스 로직을 추가해보자.
비즈니스 요구사항에 따라 이 모놀리스에 상품(Product)와 주문(Order)을 코드베이스에 추가한다고 가정해보자.
ProductController, ProductService, ProductRepository, Product 엔티티…OrderController, OrderService, OrderRepository, Order, OrderLine 엔티티…
이 코드들을 기존 com.ecommerce.member 패키지 옆에 com.ecommerce.product, com.ecommerce.order 패키지를 만들어 추가하기 시작한다. 이럴때 모놀리스 아키텍쳐의 명확한 한계점들이 드러난다.
문제 1. 도메인의 강결합 문제
OrderService는 주문 생성 로직을 구현해야 한다. 따라서 1. 상품의 재고를 확인하고, 2. 회원의 포인트를 적립해야 한다.
모놀리스 환경에서는 코드를 다음과 같이 작성할 것이다.
/// src/main/kotlin/com/ecommerce/order/OrderService.kt (가상)
@Service
class OrderService(
private val orderRepository: OrderRepository,
// 치명적인 결합 (tight coupling)
private val productRepository: ProductRepository,
private val memberRepository: MemberRepository
) {
@Transactional
fun createOrder(memberId: Long, request: CreateOrderRequest) {
// 1. '주문' 도메인이 '회원' 도메인의 Repository를 직접 참조
val member = memberRepository.findByIdOrNull(memberId) ?: ...
// 2. '주문' 도메인이 '상품' 도메인의 Repository를 직접 참조
val product = productRepository.findByIdOrNull(request.productId)
// 3. '상품' 엔티티의 내부 로직(재고)을 '주문' 서비스가 직접 수정
product.decreaseStock(request.quantity)
// productRepository.save(product) -> 이건 언제 호출?
// 4. '회원' 엔티티의 내부 로직(포인트)을 '주문' 서비스가 직접 수정
member.addPoints(request.totalAmount * 0.01)
// memberRepository.save(member) -> 이건 언제 호출?
val order = Order(...)
orderRepository.save(order)
}
}
하지만 이렇게 작성한 경우 앞에서 정의한 바운디드 컨텍스트 간의 명확한 경계가 코드 레벨에서 무너진다.
- 책임의 실종: 주문이 상품과 회원의 비즈니스 로직을 모두 안다.
- tight coupling: 만약 상품쪽에서 decreaseStock 메서드 시그니처를 변경하는 순간 아무 상관 없어보이는 OrderService 코드가 컴파일도 안된다.
- 유지보수의 어려움: 복잡한 상호 의존성을 파악하는 데만 몇 달을 소모할 것이다.
문제 2. 단일 DB의 트랜잭션 결합 문제
위의 코드에서는 @Transaction의 문제도 있다.
모놀리스의 장점은 단일 DB 트랜잭션이 있지만, createOrder메서드는 단 하나의 트랜잭션 하에서 실행 된다. 이 트랜잭션은 orders 테이블, products 테이블, members 테이블에 동시에 Row-level lock을 설정할 수 있다.
시나리오
- User A가
createOderAPI를 호출한다. products테이블의 티셔츠 row에 Lock이 걸린다.- User B가 티셔츠의 상품 설명을 수정하는 API를 호출한다.
- User B의 트랜잭션 2는 User A의 트랜잭션 1이 끝날 때까지
products테이블의 Lock이 풀리기를 기다린다.
주문과 상품 설명 수정이라는 비즈니스 로직이 서로 엮여있어 시스템 전체의 처리량(Throughput)이 급격하게 저하된다.
문제 3. 리소스의 결합
product 기능에 실시간 인기 검색어 기능이 추가되어 CPU 사용량이 폭증했다고 가정해보자. 그렇다면 .jar를 더 배포해야한다.
결과적으로 거의 사용되지 않는 MemberService 로직과 메모리만 차지하는 Member 캐시까지 10배로 복제되는 리소스의 낭비가 발생한다.
각각의 도메인은 서로 다른 생명주기, 서로 다른 요구사항, 비즈니스 책임을 가지기 때문에 MSA의 도입을 고려해야한다.
왜 지금 분리해야하는가?
앞에서 확인한 모놀리스의 한계점은 상품과 주문 기능을 넣었을 때 생기는 side effect가 생길 것이 분명하다.
하지만 아직 서비스가 커지지도 않았는데 너무 이른 결정이라고 반문할 수 있지만 실상은 그렇지 않다.
모놀리스 퍼스트(Monolith First) 전략의 핵심은 모놑리스가 거대해질 때까지 기다리는 것이 아니라 도메인의 경계가 명확해지는 첫 순간에 분리하는 것이다. 2장의 DDD 설계를 통해 우리는 회원, 상품, 주문의 경계가 다르다는 것을 확인했다.
-
비용의 문제: 기술 부채는 이자가 붙는다. 회원 서비스에서 상품 서비스를 분리하는 비용은 현재 10이라고 가정하면 코드가 쌓이다보면 100, 1000 그 이상으로 기하급수적으로 쌓인다. 따라서 기술 부채가 복리로 쌓이기 전에 부채를 갚아야한다.
-
고통의 증명: 이미 설계가 불가능하다. 우리는
OrderService가ProductRepository와MemberRepository를 직접 참조하는 ‘나쁜 설계’ 외에는 뾰족한 수가 없음을 이미 확인했다. 도메인 결합과 트랜잭션 결합 문제는 코드를 짜기 전, 설계 단계에서부터 이미 명백해졌다. 이 설계가 잘못되었음을 안다면, 코드를 작성해서는 안 된다. -
전략적 우선순위, 핵심 도메인의 분리: 우리는 주문과 상품을 핵심 도메인을 회원을 서브 도메인으로 식별했다. 핵심 도메인은 가장 많은 변경과 높은 성능을 요구한다. 이 핵심 도메인을 같은 모놀리스에 두는 것은 핵심적인 경쟁력에 족쇄를 채우는 것과 같다.
마치며
모놀리스에서 MSA 아키텍쳐로 변화시켜야하는 것은 분명하다. 하지만 MSA 분리에 따르는 문제들은 많은데, 다음 글부터 도메인을 하나씩 분리해가며 그에 따르는 문제들을 해결해보자.