객체지향 설계의 핵심은 의존성 관리다: 조영호 님의 배달 앱 예제로 읽는 도메인 분리 전략
객체지향 프로그래밍을 처음 익힐 때 개발자는 역할, 책임, 자율성이라는 추상적인 미학을 먼저 마주하게 됩니다. 하지만 시스템 규모가 커지고 기능 수정 요청이 밀려오는 실제 개발 현장에서 코드가 엉키며 변경이 두려워지는 핵심 원인은 대부분 객체 및 패키지 사이의 '의존성(Dependency)' 관리 실패에서 출발합니다.
이 글은 우아한형제들 조영호 님의 우아한객체지향 세미나에서 제시된 배달 애플리케이션 주문 예제를 바탕으로, 객체 참조와 도메인 경계 문제를 글쓴이의 운영·코드 리뷰 관점에서 재구성해 정리한 글입니다. 마이크로서비스(MSA)와 분산 아키텍처를 도입하는 팀이 늘어난 오늘날에도, 이 강연이 제시하는 메시지는 정밀한 도메인 경계를 그리는 명확한 지침이 되어 줍니다.
이 글은 강연의 목차 순서를 그대로 따라가는 대신, "운영에서 어떤 신호가 보일 때 의존성을 의심하고, 무엇부터 끊을 것인가" 라는 질문을 축으로 순서를 다시 짰습니다. 그래서 개념 정의가 아니라 코드 리뷰와 운영 진단에서 실제로 마주치는 경고 신호에서 출발합니다.
출처와 정리 기준
이 글은 우아한테크세미나 「우아한객체지향」(2019, 우아한형제들 조영호) 강연을 원본으로, 운영·코드 리뷰 관점에서 재구성한 정리입니다. 강연에 귀속되는 내용은 본문에 "강연에서/원본 영상에서"로 표시했습니다.
1. 코드 리뷰에서 먼저 걸러야 할 의존성 경고 신호
시스템이 커지고 변경이 두려워지는 코드는 대부분 몇 가지 공통된 냄새를 풍깁니다. 설계 개념을 논하기 전에, 코드 리뷰와 운영 진단에서 곧바로 잡아낼 수 있는 신호부터 정리합니다. 이 신호들은 뒤에서 다룰 개념(연관관계·순환 의존성·트랜잭션 경계)이 실제 코드에서 어떻게 드러나는지를 미리 보여 줍니다.
⚠️ 코드 리뷰·운영 진단에서 잡아내는 3가지 결합도 경고 신호 1. 하나의
@Transactional메서드 안에서 3개 이상의 서로 다른 리포지토리(Save/Update)가 연속적으로 호출되고 있다. 2. 엔티티 수정 로직을 작성할 때 연관 객체를 타넘기 위해getXXX().getYYY().setZZZ()형태의 도트 연산자가 3단계 이상 이어진다. 3. 패키지 단위를 정렬했을 때 상위 도메인 패키지끼리 서로의 클래스를 인포트하여 빌드 시 순환 참조 에러나 스캐닝 경고가 발생한다.
세 번째 신호(패키지 순환)는 사람이 매 PR마다 눈으로 확인하기 어렵습니다. ArchUnit 같은 아키텍처 테스트 도구를 쓰면 패키지 의존 규칙을 테스트 코드로 고정해 CI에서 자동으로 검출할 수 있습니다.
// ArchUnit: 주문 패키지가 배달 패키지를 직접 참조하지 못하게 강제
noClasses().that().resideInAPackage("..order..")
.should().dependOnClassesThat().resideInAPackage("..delivery..")
.check(importedClasses);
// 패키지 슬라이스 사이에 순환이 없는지 검증
slices().matching("com.example.(*)..")
.should().beFreeOfCycles()
.check(importedClasses);
경고 신호를 감지했다면, 다음은 "왜 이런 결합이 생기고, 무엇이 서로 얽히는가"를 이해할 차례입니다.
2. 무엇이 얽히는가: 정적 관계와 동적 협력
의존성이란 한 요소가 변경될 때 그 영향으로 다른 요소까지 함께 변경될 가능성을 의미합니다. 자바 코드에서 import 구문이 추가되는 순간 정적 의존 관계가 형성되며, 이는 곧 두 클래스 사이에 변경 여파가 전달될 통로가 열렸음을 뜻합니다.
2-1. 연관관계와 의존관계의 클래스 레벨 차이
클래스 사이에 형성되는 관계는 크게 연관관계(Association), 의존관계(Dependency), 상속관계(Inheritance), 실체화관계(Realization)로 분류됩니다. 연관관계는 한 객체에서 다른 객체로 언제든지 이동할 수 있는 탐색 경로가 인스턴스 변수로 상시 유지되는 상태를 의미합니다. 반면 의존관계는 메서드의 파라미터나 리턴 타입, 혹은 메서드 내부에서 일시적으로 객체를 생성해 협력한 뒤 인스턴스를 놓아주는 형태를 가리킵니다.
설계자가 두 객체 사이에 연관관계를 맺고 인스턴스 변수로 직접 쥐고 있도록 만들면 런타임상에서 상시 연결된 결합이 만들어집니다. 이 방식은 인스턴스 탐색을 편하게 도와주는 장점이 존재하지만, 객체의 생명주기와 영속성 트랜잭션 경계가 의도치 않게 하나로 통합되는 결합도 상승 문제를 유발합니다.
2-2. 패키지 순환 의존성(Cycle)이 불러오는 구조적 연쇄 파괴
단일 클래스 수준의 결합보다 더 위협적인 요소는 패키지 사이의 양방향 참조 및 순환 의존성(Cycle)입니다. A 패키지의 클래스가 B 패키지를 바라보고 B 패키지의 클래스가 다시 A 패키지의 클래스를 인포트하는 구조는 두 패키지가 논리적으로 분리되지 못한 채 사실상 하나의 거대한 덩어리로 결합되어 있음을 가리키는 위험 신호입니다.
Robert C. Martin의 컴포넌트 의존성 원칙(ADP)에서도 지적하듯, 순환 의존성이 존재하면 개별 패키지 단위의 독자적인 테스트와 빌드가 불가능해집니다. 특정 도메인 로직 하나를 변경했을 때 관련 없는 다른 패키지의 단위 테스트가 연동되어 실패하거나, 배포 단위를 찢어내지 못해 모놀리식 구조에 갇히게 되는 주요 원인이 바로 이 순환 구조에 있습니다.
주문 패키지가 가게 패키지를 직접 바라보는 상황에서 가게 패키지의 검증 서비스가 다시 주문 패키지의 엔티티 구조를 파라미터로 요구하면 양방향 순환 고리가 맺어집니다. 패키지 사이의 순환 고리를 끊어내지 못하면 아키텍처는 유연성을 잃어버리고 점차 손대기 두려운 코드로 전락합니다.
여기서 적용하는 판단 기준은 원본 강연에서 가져온 것으로, 조영호 님은 좋은 의존성 관리의 기본 가이드로 ① 양방향 의존성을 피하고, ② 다중성(컬렉션)이 적은 방향을 선택하며, ③ 불필요한 의존성은 제거하고, ④ 패키지 사이클을 만들지 말라고 설명합니다. 다만 이 규칙은 "정답이 아니라 가이드"라는 단서와 함께 제시된 것으로, 예외가 있습니다. 명령(Command) 모델에서는 데이터 소유권·변경 책임·배포 단위·팀 경계를 먼저 확인한 뒤 의존성 방향을 정해야 하지만, 조회 전용 모델처럼 변경 전파가 제한된 영역에서는 같은 기준을 적용하지 않고 별도 읽기 구조로 분리하는 편이 더 단순할 수 있습니다.

위 다이어그램은 두 패키지 사이의 양방향 인포트로 인해 발생하는 순환 의존성 고리와 이를 단방향으로 개선한 구조를 비교해 보여줍니다. 상단 패키지가 하단 패키지를 직접 참조할 뿐만 아니라 하단 패키지도 상단 패키지의 엔티티를 참조하면서 상호 변경 파급 효과가 극대화되는 모습을 확인할 수 있습니다. 중간 추상화 인터페이스나 데이터 전달 객체(DTO)를 도출해 의존성 방향을 한쪽으로 정리하면 모듈 간 독립성을 보장받게 됩니다.
3. 주문 검증 예제: 무엇을 왜 검증하는가
경고 신호와 개념을 잡았으니, 이제 그것이 드러나는 구체적 예제로 들어갑니다. 음식 주문 과정은 주문 생성부터 시작해 판매 가능 상태 검증, 주문 가능 금액 검증, 상품 카탈로그 스냅샷과 주문 데이터의 일치 여부 검증으로 이어집니다. 이러한 런타임 협력 흐름을 정적 클래스 구조로 변환하는 과정에서 연관관계의 방향과 참조 방식을 잘못 설계하면 단순한 비즈니스 로직 수정이 전체 서비스 장애로 이어질 위험이 큽니다.
강연 예제의 검증 항목을 나열 순서 그대로 옮기기보다, 글쓴이 나름의 기준으로 세 갈래로 묶으면 각 항목이 왜 필요한지와 뒤에서 어떤 참조 전략을 골라야 하는지가 분명해집니다.
- 스냅샷 정합성 검증 — 사용자가 장바구니에 담은 시점과 현재 판매 데이터가 어긋났는가. 여기에 해당하는 항목은 메뉴 이름, 옵션 그룹 이름, 옵션 이름, 옵션 가격입니다. 사장님이 담긴 뒤 메뉴 구성이나 가격을 바꿀 수 있으므로 값 단위로 대조해야 합니다(강연에서 든 예로, "짜장면 8,000원"이 "짬뽕 8,000원"으로 바뀌면 금액은 같아도 다른 음식입니다).
- 실시간 상태 검증 — 지금 이 순간 주문이 가능한 상태인가. 가게 영업 여부가 여기에 속합니다. 담을 때는 영업 중이었어도 주문 시점에 준비 중으로 바뀔 수 있어 항상 최신 상태를 물어야 합니다.
- 정책 검증 — 주문이 비즈니스 규칙을 만족하는가. 최소 주문 금액 충족 여부가 대표적입니다.
이렇게 나누면 뒤에서 "무엇을 같은 트랜잭션·같은 애그리거트에 둘지, 무엇을 ID 참조로 끊을지"를 판단할 때 기준이 됩니다. 스냅샷 정합성은 값 비교라 객체 소유가 필요 없고, 실시간 상태는 항상 최신 조회가 필요하다는 차이가 이 분류에서 드러나기 때문입니다.
주문 검증 단계에서는 객체 사이에서 주고받는 메시지 이동 경로를 명확하게 정립해야 합니다. 주문 객체는 가게 객체에 영업 상태를 문의하고, 주문 항목에 포함된 옵션 데이터가 사장님이 등록해 둔 실제 메뉴 스펙과 맞는지 확인 메시지를 보냅니다. 이러한 런타임 탐색 경로를 형성하기 위해 초기 클래스 다이어그램에서는 객체 간 직접 참조 방식의 연관관계를 설정하곤 합니다.

런타임 객체 탐색 경로를 표현한 위 그래픽은 주문 객체가 가게 객체 및 메뉴 객체와 동적으로 메시지를 주고받는 검증 메커니즘을 상세히 보여줍니다. 화면 중앙의 주문 엔티티는 영업 여부와 가격 비교를 위해 타 객체의 런타임 메서드를 순차적으로 호출하며 흐름을 제어합니다. 이러한 동적 협력 구조를 정적 클래스 인포트 관계로 변환할 때는 객체 간 결합도를 최소화하도록 탐색 범위를 신중하게 제어해야 합니다.
4. JPA 운영에서 객체 직접 참조가 드러내는 실패 모드
도메인 모델링 초기 단계에서 객체들을 선으로 연결해 직접 참조하게 만들면 구조가 매우 직관적이고 이해하기 쉽게 느껴집니다. 원본 영상은 객체 직접 참조가 탐색 범위와 수정 범위를 흐리게 만든다고 문제를 제기합니다. 이를 JPA 기반 운영 코드 관점에서 보면 다음과 같은 실패 모드로 드러납니다.
4-1. 조회 경계 불명확성과 지연 로딩(Lazy Loading) 실패
모든 도메인 객체가 인스턴스 변수로 직접 연결되어 있으면 개발자는 런타임에 메모리상으로 어디까지 객체 그래프를 탐색해 읽어야 할지 명확한 기준을 세우기 어려워집니다. JPA 환경에서 지연 로딩 속성을 활용할 때 영속성 컨텍스트 범위를 벗어난 서비스 외곽이나 뷰 레이어에서 객체 그래프를 참조하려 시도하면 LazyInitializationException이 발생하게 됩니다.
반대로 예외를 회피하고자 즉시 로딩(Eager Fetching)을 지정하면 단건의 주문 정보를 조회했을 뿐인데 연관된 가게, 메뉴, 옵션 그룹, 옵션 테이블 전체에 대한 Join 쿼리가 발동하거나 N+1 조회가 쏟아지는 성능 장애를 겪게 됩니다. 탐색 경로가 전역적으로 열려 있으면 조회의 정밀한 경계가 상실됩니다.
4-2. 트랜잭션 경계 확장과 DB 락(Lock) 경합
객체 직접 참조의 두 번째 위험성은 트랜잭션의 실행 범위가 제어 범위를 넘어서서 과도하게 커진다는 점입니다. 하나의 트랜잭션 메서드 안에서 객체 참조를 타고 내려가 주문 상태를 변경하고, 배달 상태를 갱신하며, 가게 수수료 금액까지 한 번에 수정하는 코드가 대표적인 비패턴 사례입니다.
@Transactional
public void completeDelivery(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.changeStatus(OrderStatus.DELIVERED);
Delivery delivery = deliveryRepository.findByOrderId(orderId);
delivery.complete();
Shop shop = shopRepository.findById(order.getShopId());
shop.calculateCommission(order.calculateTotalAmount());
}
단일 트랜잭션 내에서 여러 엔티티의 상태를 동기 방식으로 동시에 변경하려 들면 DB 레코드 단위의 락(Lock) 점유 시간이 길어지게 됩니다. 이 과정에서 사장님의 가게 정보 수정 작업이나 시스템 배치 작업이 동시에 겹칠 경우 사용자의 실시간 주문 트랜잭션과 DB 락 경합을 일으켜 서비스 전체가 응답 불능 상태에 도달할 위험이 생깁니다.
객체 직접 참조는 연관 객체를 한 트랜잭션 안에서 함께 수정하도록 유도하기 쉬우며, 실제로 여러 엔티티를 변경하면 락 점유와 경합이 커질 수 있습니다. 변경 주기가 서로 다른 비즈니스 로직들이 하나의 트랜잭션으로 강하게 결합되면 시스템 응답 속도는 급격하게 떨어지게 됩니다.
도메인 요소들의 생성 및 변경 라이프사이클을 면밀히 관찰하여 이 무거운 결합을 철저하게 분리해 내야 합니다.
주문 완료 처리 트랜잭션이 배달과 가게 엔티티까지 묶어서 락을 보유하면, 여러 엔티티가 동기식 트랜잭션으로 연쇄 락에 걸리면서 뒤이어 진입하는 사장님 앱의 정보 수정 요청과 배치 작업이 모두 대기 상태로 블로킹됩니다. 객체 참조를 잘라내고 트랜잭션 경계를 분리하면 이러한 DB 락 경합 가능성을 낮출 수 있습니다.
5. 무엇을 언제 끊을 것인가: 조건–선택지 기준표
실패 모드를 확인했으니, 이제 결합을 끊는 선택지들을 "무조건 이렇게 하라"가 아니라 어떤 조건에 어떤 선택지를 고르는가의 기준표로 정리합니다. 강연은 데이터 경계·실행 흐름 경계·패키지 경계 세 관점의 방법을 안내하는데, 이를 상황별 의사결정 표로 재설계하면 다음과 같습니다.
| 상황·조건 | 권장 선택지 | 이유 · 예외 |
|---|---|---|
| 생명주기·변경 주기가 완전히 일치 (Order–OrderLineItem) | 객체 직접 참조 + JPA cascade | 하나의 애그리거트로 묶음. 단, 컬렉션이 매우 커지면 성능상 분리·페이징 검토 |
| 다른 애그리거트를 식별만 하면 됨 (Order→Shop) | ID 참조(Long shopId) |
탐색·영속성 경계 격리. 단, 화면에서 항상 함께 읽어야 하면 조회 전용 모델로 join 처리 |
| 두 도메인이 같은 데이터를 비교만 하고 소유하지 않음 | 얇은 DTO / Value Object | 서로의 엔티티 생명주기를 소유하지 않아도 됨 |
| 호출 방향은 유지하되 구현 패키지를 반대편에 두고 싶음 | 인터페이스로 의존성 역전(DIP) | 패키지 사이클 제거. 방향은 유지, 컴파일 의존만 역전 |
| 후속 처리에 새로운 도메인 개념이 드러남 (정산 등) | 패키지 분리 | 중간 객체로 덮기보다 개념을 명시적으로 분리 |
| 후속 처리가 시간차 허용(결과적 일관성) | 도메인 이벤트 발행 | 본 트랜잭션과 분리. 단, 실패 재처리·보상 로직은 별도 설계 필요 |
아래에서 이 표의 핵심 선택지 세 가지를 코드로 살펴봅니다.
5-1. ID 참조를 통한 영속성 단위(Aggregate Boundary) 격리
첫 번째 개선책은 영구적인 객체 직접 참조(Shop shop)를 제거하고, 식별자 기반의 ID 참조(Long shopId)로 엔티티 코드를 전환하는 방식입니다.
// 객체 직접 참조에 의한 강한 결합 상태
public class Order {
private Shop shop;
private List<OrderLineItem> orderLineItems;
}
// ID 참조를 통한 도메인 경계 분리 상태
public class Order {
private Long shopId;
private List<OrderLineItem> orderLineItems;
}
Martin Fowler의 DDD Aggregate 규범에 명시되어 있듯, 애그리거트(Aggregate)는 데이터 변경의 보장 단위이며 하나의 트랜잭션은 하나의 애그리거트만을 수정해야 합니다. ID 참조 방식을 적용하면 탐색 경로가 끊어지므로 무분별한 객체 그래프 조회가 차단되고 각 객체는 독립적인 영속성 단위로 깨끗하게 격리됩니다.
5-2. 추상화 레이어를 활용한 의존성 역전(DIP)
두 번째 방법은 패키지 간 양방향 순환 구조가 형성될 때 중간에 스펙(Specification) 클래스나 추상화 객체를 도출하거나 인터페이스를 배치하여 의존성의 방향을 뒤집는(Dependency Inversion Principle) 전략입니다. 원본 영상에서는 중간 객체 도입, 인터페이스를 통한 의존성 역전, 새로운 패키지 분리를 패키지 사이클을 끊는 선택지로 설명합니다.
기준표에서 정리했듯, 주문 검증에 필요한 최소한의 데이터만 담는 얇은 전달 구조체(DTO 또는 Value Object)는 두 도메인이 같은 데이터를 비교하지만 서로의 엔티티 생명주기를 소유하지 않을 때 적합합니다. 인터페이스를 통한 역전은 호출 방향은 유지해야 하지만 구현 패키지를 반대편으로 밀어야 할 때 유리합니다. 반대로 결제나 정산처럼 새로운 도메인 개념이 드러난 경우에는 중간 객체로 덮기보다 패키지를 분리하는 편이 더 명확합니다.
5-3. 도메인 이벤트 기반의 실행 흐름 분리
세 번째 패턴은 도메인 이벤트(Domain Event)의 발행입니다. 여기서 원본 예제의 흐름을 정확히 짚을 필요가 있습니다. 영상 예제에서 배달은 주문이 결제되는 시점에 생성되고, 가게 수수료는 배달이 완료되는 시점에 부과됩니다. 즉 배달 생성과 수수료 정산은 하나의 '주문 완료' 순간에 함께 트리거되는 것이 아니라 서로 다른 시점에 일어나는 후속 처리입니다. 조영호 님은 이처럼 본 트랜잭션과 시간차가 허용되는 후속 작업을, 상태가 바뀔 때 도메인 이벤트를 발행하고 각 핸들러(배달·정산)가 이를 수신해 처리하도록 분리하는 방식을 보여줍니다. 이렇게 다루면 주문·배달 처리와 정산을 결과적 일관성(Eventual Consistency) 영역으로 둘 수 있습니다.
public class Order extends AbstractAggregateRoot<Order> {
public void complete() {
this.status = OrderStatus.COMPLETED;
registerEvent(new OrderCompletedEvent(this.id, this.shopId, calculateTotalAmount()));
}
}
Spring Data Reference의 Domain Events 설명 문서가 설명하는 @DomainEvents 메커니즘을 구현한 AbstractAggregateRoot를 활용하면, 엔티티 내부에서 이벤트를 등록하고 리포지토리의 save()/delete() 호출 과정에서 Spring 이벤트로 발행할 수 있습니다. 비동기 처리는 @Async나 별도 ApplicationEventMulticaster 설정이 필요합니다. 수수료 정산 및 배달 패키지는 이 이벤트를 수신하여 각자의 독립적인 트랜잭션에서 후속 처리를 마무리합니다.
도메인 이벤트를 활용한 아키텍처에서는 주문 패키지가 가게나 배달 패키지의 구체적인 존재를 알 필요가 완전히 사라집니다. 이벤트 핸들러가 중간에서 메시지를 받아 필요한 도메인 서비스를 호출해 주므로 정적 패키지 구조상 의존성은 단방향으로 흐르거나 깔끔하게 분리됩니다.
이러한 구조적 이점은 차후 특정 도메인을 독립된 서비스로 찢어낼 때 코드 재작성 비용을 대폭 절감시켜 줍니다.
주문·배달의 상태가 바뀔 때 도메인 이벤트가 발행되면 비동기 이벤트 핸들러를 거쳐 정산·배달 등 후속 서비스로 전달됩니다. 비동기 AFTER_COMMIT 리스너나 별도 메시징으로 구성하면 원 트랜잭션과 후속 처리의 결합을 낮출 수 있습니다. 다만 실패 재처리와 보상 처리는 별도로 설계해야 합니다. 결과적 일관성을 바탕으로 각 시스템이 독립적인 트랜잭션을 유지하며 전체 시스템의 안정성이 향상됩니다.
6. 모놀리스에서 마이크로서비스로 이어지는 아키텍처 진화 전략
단일 애플리케이션(Monolith) 내부에서 @TransactionalEventListener와 같은 이벤트 전달 메커니즘을 도입해 패키지 간 의존성을 잘라낸 코드는 향후 시스템 규모가 확장될 때 유연성을 제공합니다. 다만 내부 이벤트를 외부 메시지로 전환할 때는 메시지 유실, 중복 처리, 발행 보장, 데이터 일관성 같은 운영상 쟁점을 별도로 다뤄야 합니다.
내부 이벤트 분리가 완성된 아키텍처에서는 인메모리 이벤트 발행 코드를 Kafka, RabbitMQ, AWS SNS/SQS 등 외부 메시지 브로커 전송 로직으로 확장할 수 있는 기반을 마련할 수 있습니다. 실제 분산 서비스 전환에는 메시지 발행 보장과 데이터 일관성 패턴을 추가로 설계해야 합니다.
반면 도메인 경계가 어지럽게 엉켜 있고 객체 직접 참조로 트랜잭션이 크게 묶여 있는 시스템을 억지로 MSA로 전환하려 들면, 서비스 간 분산 트랜잭션(2PC, Saga Pattern) 관리에 실패하여 아키텍처 전환이 좌절되는 경우가 많습니다. 결국 마이크로서비스 전환 전에는 먼저 패키지 사이클을 제거했는지, 각 도메인의 데이터 소유권이 분리되어 있는지, 이벤트 실패를 재처리할 기준이 있는지 점검해야 합니다.
💡 원스의 인사이트: 복잡한 의존성을 조율하는 판단 기준과 리뷰 체크리스트
원본 영상에서는 설계를 개선할 때 의존성을 그려 보고, 객체 참조를 언제 유지하고 언제 끊을지 판단해야 한다고 설명합니다. 이 글의 적용 관점에서는 그 메시지를 "언제 객체 참조를 유지하고, 언제 ID 참조로 잘라낼 것인가?" 라는 실무 판단 기준으로 확장해 볼 수 있습니다. 모든 객체 간 연결을 무조건 ID 참조로 잘라내면 코드가 절차지향적으로 변하고 리포지토리 조회가 빈번해져 도메인 모델의 가독성이 떨어질 수 있기 때문입니다. 한 화면에서 검증 흐름을 순서대로 읽어야 하는 경우나 여러 객체의 상태를 비교만 하고 소유하지 않는 경우에는 절차형 서비스가 더 명확할 수 있습니다.
실전 행동 제안: 도메인 묶음(Aggregate) 설정 3원칙 — 아래 원칙은 강연의 판단 기준을 실무 관점으로 정리한 것이며, 각 원칙에는 그대로 적용하기 어려운 예외 조건을 함께 적어 둡니다.
첫째, 라이프사이클이 사실상 동일한 객체들만 하나의 객체 참조 그룹으로 묶으세요. 예를 들어 '주문(Order)'과 '주문항목(OrderLineItem)'은 주문이 생성될 때 같이 생성되고 주문이 삭제될 때 함께 사라집니다. 이렇게 생명주기와 변경 주기가 일치하는 요소들은 직접 객체 참조로 묶고 JPA cascade 옵션을 부여하는 것이 자율적인 객체 설계에 유리합니다. (예외: 자식 컬렉션이 수천 건 규모로 커지면 하나의 애그리거트로 묶는 대신 페이징 조회나 별도 집계 모델로 분리하는 편이 성능상 안전합니다.)
둘째, 비즈니스 제약사항을 공유하지 않는 도메인 요소는 망설임 없이 ID 참조로 전환하세요. 가게(Shop)의 상태가 준비 중으로 바뀌는 것과 사용자가 주문(Order)을 넣는 것은 생성 시점도, 비즈니스 규칙도 다릅니다. 이 둘은 객체 직접 참조가 아닌 Long shopId 형태의 ID 참조로 잘라내어 탐색 범위와 영속성 경계를 명확히 구분해야 합니다. (예외: 목록·상세 화면에서 두 엔티티를 항상 함께 노출해야 한다면, 도메인 모델은 ID 참조로 유지하되 조회 전용 모델에서 join으로 조합하는 편이 낫습니다.)
셋째, 타 도메인의 상태 변경을 유발하는 부수 효과 로직은 도메인 이벤트 발행으로 전환하세요. 주문·배달 처리 후 "알림톡 발송", "포인트 적립", "사장님 앱 푸시", "수수료 정산" 등 본체 로직과 직접적인 트랜잭션 동기화가 필요 없는 후속 작업은 이벤트를 발행해 비동기로 처리하는 것이 시스템 전체의 응답 속도와 안정성을 높이는 길입니다. (예외: 강한 정합성이 필수인 결제 승인처럼 즉시 실패해야 하는 처리는 이벤트로 미루지 말고 동기 트랜잭션에 두어야 합니다.)
의존성 리뷰 체크리스트 (PR 리뷰 시 점검)
- [ ] 새로 추가된
import가 패키지 경계를 넘는가? 넘는다면 방향이 단방향인가?- [ ] 이 엔티티에 추가한 연관 필드는 항상 함께 로딩·변경되는가? 아니라면 ID 참조 후보인가?
- [ ] 하나의
@Transactional메서드가 2개 이상의 애그리거트를 수정하고 있지 않은가?- [ ] 후속 처리(알림·정산·푸시)가 본 트랜잭션과 강결합되어 있지 않은가? 이벤트로 분리할 후보인가?
- [ ] 패키지 순환을 막는 ArchUnit 등 아키텍처 테스트가 존재하고 CI에서 실행되는가?







