애그리거트와 리포지토리 패턴
1. 애그리거트의 핵심 역할
1-1. 비즈니스 무결성을 보장하는 경계
애그리거트(Aggregate)는 도메인 주도 설계(DDD)에서 비즈니스 규칙의 일관성을 유지하는 최소 단위로 사용된다. 즉, 하나의 트랜잭션 안에서 상태 변경이 일어날 수 있는 객체의 범위를 정의한다. 여러 엔터티와 값 객체가 구성돼 있을 수 있지만, 외부에서는 루트 엔터티를 통해서만 조작 가능하다.
예를 들어 Order는 루트이고, 그 안에 OrderLine, ShippingInfo, PaymentInfo 등의 하위 객체가 있다. 외부에서는 직접 OrderLine을 수정할 수 없고, 반드시 Order를 통해 추가/삭제해야 한다. 이러한 구조는 객체 간 무결성을 유지하며, 변경 범위를 명확히 제한해준다. 결국 애그리거트는 단순한 객체 모음이 아니라, 도메인 규칙의 경계(Bounded Consistency)를 지정하는 도구다. 이 경계를 통해 시스템은 동시성, 일관성, 유지보수성 측면에서 안정성을 확보할 수 있다.
1-2. 애그리거트 루트의 책임
애그리거트 루트는 외부와 내부를 연결하는 유일한 진입점이다. 루트 엔터티는 하위 구성요소들의 상태를 검증하고, 변경을 승인하거나 거부하는 로직을 포함한다.
예를 들어 Order.addItem()은 단순히 리스트에 요소를 추가하는 것이 아니라, 재고 확인, 중복 검증, 가격 변경 여부 등을 판단한 후에야 OrderLine을 추가한다. 이러한 설계는 내부 객체들의 무분별한 변경을 방지하며, 도메인 로직을 한 곳에서 통제할 수 있게 해준다.
즉, 루트는 행동의 통제자이자 도메인 규칙의 수문장이다. 하위 엔터티들은 루트에 종속되며, 외부에서 직접 수정할 수 없다. 루트의 책임이 많아질 경우, 오히려 단일 책임 원칙(SRP)을 침해할 수 있으므로 상태 조작은 루트에서 하되, 복잡한 계산은 도메인 서비스로 위임하는 것이 바람직하다.
1-3. 트랜잭션 경계의 단위
애그리거트는 트랜잭션의 단위를 명확히 나누는 기준이 된다. 하나의 트랜잭션은 하나의 애그리거트만 수정하는 것이 원칙이며, 다수의 애그리거트를 동시에 변경하는 설계는 동시성 문제나 일관성 오류를 유발하기 쉽다.
예를 들어 Order와 Inventory는 각각 다른 애그리거트일 수 있으며, 주문 생성과 재고 차감은 이벤트 기반의 비동기 처리로 나눠야 한다.
즉, 직접적인 상태 변경이 아닌, 이벤트 발행과 구독으로 연결하는 구조가 적합하다. 이러한 분리 전략은 시스템의 확장성, 성능, 장애 대응력까지 고려한 설계의 핵심이다. "하나의 트랜잭션 = 하나의 애그리거트 변경"이라는 원칙은 마이크로서비스 분리 기준과도 맞닿아 있어 실무에서 매우 중요한 판단 기준이 된다.
1-4. 일관성, 응집성, 경계의 철학
애그리거트는 단순히 객체 묶음이 아닌, 개념적 경계를 정의하는 설계 철학이다. 같은 테이블이더라도 어떤 필드가 함께 일관성을 유지해야 하는지, 어떤 책임이 함께 묶여야 하는지를 기준으로 묶는 것이 애그리거트다. 이 경계를 정확히 설정하지 않으면, 한 곳의 변경이 다른 객체에 영향을 미치거나 불필요한 커플링과 불안정한 트랜잭션 구조가 발생할 수 있다.
따라서 도메인 전문가와의 협업을 통해 개념적으로 응집된 책임 단위를 먼저 도출한 후, 이를 애그리거트로 설계하는 방식이 바람직하다. 결국 애그리거트는 데이터가 아닌 도메인 규칙 중심의 사고로 모델링할 때 가장 효과적으로 작동한다. 이는 도메인 주도 설계에서 가장 철학적인 핵심 개념 중 하나다.
2. 리포지토리의 역할과 책임
2-1. 애그리거트의 저장소 역할
리포지토리(Repository)는 도메인 계층과 데이터 접근 계층을 분리하는 중간 추상화 계층이다. 즉, 도메인 객체를 저장하거나 조회하는 방식이 어떤 데이터베이스를 사용하든 간에 도메인 계층은 Repository를 통해서만 애그리거트를 다루도록 설계된다.
예를 들어 Order 애그리거트는 OrderRepository를 통해 저장하거나 조회한다. 이는 도메인 계층에서 JPA, MyBatis, MongoDB, REST API 등 어떤 저장소 기술을 사용하든 알 필요 없이 일관된 방식으로 데이터를 조작할 수 있도록 해준다. 이 구조를 통해 도메인 로직과 기술 구현을 철저히 분리할 수 있으며, 테스트 시에도 InMemoryRepository로 교체할 수 있어 유연한 테스트 구조가 만들어진다. 결국 Repository는 애그리거트를 위한 가상의 컬렉션처럼 동작하며, 도메인 중심 설계의 추상화 수단이 된다.
2-2. 인터페이스와 구현의 분리
리포지토리는 인터페이스로 먼저 정의하고, 그 구현체는 인프라 계층에 위치시켜야 한다.
예를 들어 OrderRepository는 save(), findById(), delete() 같은 핵심 도메인 인터페이스만을 제공하고, JPA 기반 구현은 JpaOrderRepository에서 따로 처리한다. 이런 구조는 도메인 계층이 기술 스택에 의존하지 않도록 만들어준다. 또한, 나중에 NoSQL로 저장소를 변경하거나 외부 API를 사용하는 구조로 바뀌더라도, 도메인 로직을 수정하지 않고 구현체만 교체하면 된다.
Spring에서는 @Repository 인터페이스에 구현체를 자동 주입하거나, @Profile로 환경별 구현체를 바꾸는 것도 가능하다. 중요한 건 도메인 계층은 오직 인터페이스만을 바라보며, “어떻게 저장되는지”는 몰라도 되는 구조가 되어야 한다는 점이다.
2-3. 리포지토리의 올바른 책임 범위
리포지토리는 애그리거트를 단위로 저장하거나 조회해야 하며, 절대 개별 엔터티나 속성 단위의 조작을 담당해서는 안 된다. 이는 리포지토리가 애그리거트의 일관성과 응집성을 보장하는 출입구 역할을 하기 때문이다.
예를 들어 OrderRepository는 Order 전체를 저장하고 불러오는 데 사용되며, OrderLine이나 ShippingInfo만 따로 수정하는 API는 존재해서는 안 된다. 그렇게 되면 애그리거트 경계를 무시한 설계가 되고, 결과적으로 도메인 규칙을 깨뜨릴 위험이 높아진다. 또한 리포지토리는 가능한 한 쿼리 중심이 아닌 도메인 중심 인터페이스를 제공해야 한다.
예: findActiveOrdersByUserId(userId)처럼 의미 있는 도메인 개념으로 메서드를 구성하는 것이 바람직하다. 이는 읽는 사람에게 도메인의 의도를 더 잘 전달하고, 유지보수도 용이하게 만든다.
2-4. 리포지토리와 트랜잭션의 경계
실무에서 자주 혼동되는 부분은 트랜잭션과 리포지토리의 관계다. 리포지토리는 트랜잭션 경계가 아니라, 데이터 접근 책임만을 가진다. 즉, 트랜잭션은 애플리케이션 서비스에서 시작되고, 그 내부에서 여러 리포지토리를 호출하더라도 하나의 트랜잭션으로 묶여야 한다.
예를 들어 @Transactional은 애플리케이션 서비스 계층에 적용되며, 그 안에서 orderRepository.save()와 notificationRepository.save()가 함께 호출될 수 있다. 이 구조를 통해 리포지토리는 단순한 IO 추상화로 역할을 제한하고,
트랜잭션 관리 책임은 상위 계층에서 집중적으로 처리할 수 있다. 또한 여러 애그리거트를 동시에 수정하는 구조를 피하고,
가능하면 하나의 리포지토리 → 하나의 애그리거트 변경으로 트랜잭션 경계를 제한하는 것이 유지보수에 유리하다.
3. 애그리거트 설계의 실무 원칙
3-1. 루트를 기준으로 도메인 행위 분리하기
애그리거트 루트는 단순히 외부 접근을 제한하는 게 아니라, 도메인 무결성의 중심축 역할을 맡는다. 하지만 루트에 지나치게 많은 책임을 몰아주면, 응집도가 떨어지고 코드가 비대해지기 쉽다.
예를 들어 Order 애그리거트가 주문 행위뿐 아니라 결제, 재고 처리, 배송 예약까지 전부 직접 담당한다면 이는 명백히 과한 책임 분담이며, 단일 책임 원칙(SRP)을 위반하는 설계다. 이 경우 주문 자체에 집중하도록 책임을 분리하고, 결제나 재고는 별도 도메인 서비스로 분리하는 것이 맞다. 이처럼 루트는 가급적 핵심 도메인 규칙만을 담당하고, 외부 시스템과의 연결이나 부수적 동작은 메시지나 이벤트를 통해 분리하는 구조가 바람직하다. “루트는 행동의 문지기일 뿐, 집사의 모든 일을 해선 안 된다.”
3-2. 불필요한 연관 관계 제거
JPA를 사용하는 실무에서 흔히 발생하는 문제는, 모든 엔터티 간 관계를 @OneToMany, @ManyToOne 등으로 직접 연결하려는 설계다. 이 방식은 자칫 애그리거트 간 강결합을 유발하고, 의도치 않은 지연 로딩과 순환 참조 문제를 야기할 수 있다.
예를 들어 Product → OrderLine → Order 구조가 서로 양방향으로 얽혀 있다면, 쿼리 하나로 여러 애그리거트를 건드리는 매우 위험한 설계가 된다. 게다가 Order를 저장할 때 Product까지 변경되는 트랜잭션 확장이 자동으로 일어날 수도 있다.
실무에서는 애그리거트 간 관계를 ID만으로 연결하고, 진짜 관계는 도메인 서비스나 리포지토리 조회를 통해 조립하는 것이 일반적이다. 이러한 참조는 ID로, 행위는 협력으로 처리하는 원칙은 대규모 시스템 설계에서 안정성을 확보하는 열쇠다.
3-3. 읽기 모델과 쓰기 모델의 분리 고려
애그리거트는 기본적으로 쓰기 모델 중심으로 설계된다. 즉, 데이터 정합성을 보장하기 위한 규칙, 행위, 책임 분리가 최우선 고려사항이다. 그에 반해 조회는 복잡한 계산 없이 화면 표시만 잘되면 되기 때문에, 전혀 다른 구조가 필요하다. 실무에서는 이를 위해 CQRS (Command Query Responsibility Segregation) 를 적용하기도 한다. 쓰기 모델은 애그리거트 중심으로 도메인 규칙을 지키도록 구성하고, 읽기 모델은 ViewModel 형태로 별도의 쿼리용 DTO를 만들어 성능과 응답 속도를 최적화한다.
이러한 분리는 단순히 퍼포먼스 향상이 아니라, 도메인의 복잡성과 읽기 목적을 분리하여 각자에 맞는 설계를 적용할 수 있다는 점에서 의미가 크다. 특히 대용량 트래픽 환경에서는 필수적으로 고려되는 전략이기도 하다.
3-4. 트랜잭션 설계는 애그리거트 기준으로
도메인 설계의 핵심 원칙 중 하나는, 트랜잭션 경계는 애그리거트를 따라야 한다는 점이다. 하나의 트랜잭션에서 여러 애그리거트를 동시에 수정하려는 순간, 시스템의 안정성은 급격히 떨어진다.
예를 들어 주문 생성과 동시에 재고 차감, 쿠폰 사용까지 한 트랜잭션으로 묶으려 하면, 재고 실패나 쿠폰 오류 하나로 전체 트랜잭션이 롤백될 수 있다. 이런 구조는 장애 포인트가 너무 많고, 서비스 간 독립성이 무너진다. 정답은 도메인 이벤트와 비동기 메시지를 활용한 후속 처리다. 주문은 Order 애그리거트에서 처리하고, ‘주문 생성됨’ 이벤트를 통해 재고 서비스가 반응하는 방식이다. 이렇게 하면 각 애그리거트는 자신만의 트랜잭션 안에서 책임을 다하며, 시스템은 더 견고해진다.
4. 리포지토리 패턴 실전 적용법
4-1. JPA에서 애그리거트를 안전하게 저장하기
JPA 기반의 리포지토리 구현 시 가장 중요한 원칙은 애그리거트 전체를 한 번에 저장하고, 루트를 중심으로 조작해야 한다는 점이다.
예를 들어 Order 애그리거트를 저장할 때는 Order 객체 전체를 persist하거나 merge해야 하며, OrderLine이나 ShippingInfo 같은 하위 구성 요소는 cascade 설정을 통해 함께 저장되도록 구성한다. 이때 중요한 건 하위 객체에 직접 접근해서 save를 호출하지 않는 것이다. JPA의 영속성 컨텍스트는 루트 객체를 기준으로 상태를 추적하고 관리하기 때문에,
하위 엔터티만 따로 저장하면 무결성 문제가 생기거나 동작하지 않을 수 있다.
따라서 리포지토리는 항상 루트를 기준으로 저장하고 조회하도록 설계되어야 하며, 명확한 트랜잭션 범위 안에서 작업이 이뤄지도록 @Transactional 경계를 정확히 잡아야 한다. 이러한 설계는 도메인 규칙을 코드 구조에 반영하는 데에도 필수적이다.
4-2. 조회 성능과 fetch 전략
실무에서 리포지토리를 사용할 때 가장 자주 부딪히는 문제 중 하나는 조회 성능 이슈다. JPA에서는 지연 로딩(LAZY)과 즉시 로딩(EAGER)이라는 fetch 전략을 선택할 수 있는데, 잘못 사용하면 예상치 못한 N+1 문제나 전체 데이터를 한꺼번에 끌어오는 문제가 발생할 수 있다.
예를 들어 Order 조회 시 OrderLine이 LAZY 설정이면, 리스트를 순회하는 순간마다 쿼리가 날아가게 되어 성능이 급격히 저하된다. 반대로 EAGER로 설정해버리면 조회하지도 않을 객체를 모두 끌어와 메모리 낭비가 심해진다. 이 문제를 해결하려면 fetch join이나 @EntityGraph 같은 기법을 활용해, 정말 필요한 경우에만 관련 엔티티를 명시적으로 조회하는 것이 중요하다. 또한 조회 전용 DTO를 직접 정의해, 리포지토리에서 select new 구문을 통해 필요한 필드만 가져오는 방식도 추천된다.
4-3. 테스트 가능한 구조 만들기
리포지토리는 테스트가 가능하도록 설계되어야 한다. 단순히 Spring Data JPA에 의존하면 인터페이스만으로 자동 구현되지만, 이 구조는 비즈니스 로직이 리포지토리에 섞이기 시작할 경우 테스트가 어려워진다. 이 문제를 해결하려면 리포지토리 인터페이스와 그 구현체를 명확히 분리하고, 단위 테스트에서는 인메모리 리포지토리 또는 가짜 구현(MockRepository) 을 사용하면 좋다. 이렇게 하면 JPA 설정 없이도 비즈니스 로직 테스트가 가능해지고, 테스트 속도도 훨씬 빨라진다.
또한 애플리케이션 서비스 계층에서 리포지토리를 사용할 때는, 실제 DB I/O가 필요한 테스트는 통합 테스트로 구분하고, 대부분의 로직은 가짜 리포지토리 기반으로 검증하는 방식이 이상적이다. 이런 테스트 전략은 복잡한 시스템에서도 안정성을 유지하는 중요한 기반이 된다.
4-4. 커스텀 쿼리 작성 시 주의사항
Spring Data JPA나 Querydsl 등을 사용하면 커스텀 쿼리를 작성할 수 있는데, 이 과정에서도 애그리거트 설계 원칙을 지키는 것이 중요하다. 즉, 커스텀 쿼리라고 해서 하위 객체를 직접 수정하거나, 루트를 우회해서 데이터에 접근하면 안 된다.
예를 들어 “3일 이상 결제 대기 중인 주문 리스트”를 조회할 경우, OrderRepository.findDelayedOrders() 같은 식으로 도메인 개념을 반영한 쿼리를 작성하는 것이 이상적이다. 단순히 select * from orders where status = 'WAITING' and created_at < now - 3 days가 아닌, 비즈니스 의미가 담긴 메서드로 설계하는 것이 유지보수성을 높인다.
또한 조회 쿼리는 성능상으로도 신중하게 작성되어야 하며, Index 적용 여부, join depth, fetch 전략 등을 주기적으로 리팩토링해줘야 한다. 리포지토리는 단순한 DAO가 아닌, 도메인의 질서를 유지하는 통제 기구라는 점을 잊지 말아야 한다.
5. 도메인 중심의 저장소 설계 전략
5-1. 리포지토리를 도메인의 언어로 표현하라
리포지토리는 단순히 데이터를 저장하고 조회하는 구조가 아니라, 도메인 언어(Ubiquitous Language)를 반영한 인터페이스여야 한다. 즉, 개발자가 비즈니스와 동일한 용어로 코드를 작성하고 읽을 수 있도록, 메서드 명세부터 비즈니스 용어로 구성하는 것이 중요하다.
예를 들어 OrderRepository는 단순히 findById(id)만 제공하는 것이 아니라, findUnpaidOrdersByCustomerId(), findOrdersReadyToShip() 같은 도메인 개념 중심의 조회 메서드를 노출해야 한다. 이러한 방식은 개발자가 비즈니스 요구사항을 자연스럽게 이해하고, 코드에서 용어와 의미가 일치되도록 돕는다. 이러한 ‘언어 기반’ 리포지토리는 단순 기술 추상화를 넘어서 업무 개념을 정확히 모델링한 코드 구성으로 이어지고, 이는 유지보수성, 가독성, 신규 개발자 온보딩 등 모든 측면에서 큰 이점을 제공한다.
5-2. 저장소 구현보다 인터페이스에 집중하라
이 항목은 DIP(Dependency Inversion Principle) 의 관점에서, 리포지토리의 핵심은 '인터페이스 설계'에 있으며, 구체적인 구현은 분리되어야 한다는 철학에 초점을 둔다. 도메인 계층은 구체적인 기술에 의존하지 않고, 추상화된 저장소 계약에만 의존해야 한다.
많은 개발자들이 리포지토리를 설계할 때 JPA나 Querydsl 같은 구현 기술에 초점을 맞추곤 한다. 그러나 진정한 리포지토리 패턴은 기술보다 인터페이스 설계가 중심이 되어야 한다. 즉, ‘어떻게’ 저장할지를 고민하기 전에, ‘무엇을’ 저장하고 ‘무엇을’ 조회할지부터 정의해야 한다.
이 원칙은 DIP(Dependency Inversion Principle)과도 맞닿아 있다. 도메인 계층은 리포지토리의 추상화(인터페이스) 에만 의존하고, 구체적인 저장 방식은 인프라스트럭처 계층에서 선택할 수 있도록 해야 한다. 이러한 분리 구조 덕분에 구현 기술 변경, 테스트, 확장성에 모두 유연하게 대처할 수 있다. Spring에서는 @Repository와 @PersistenceContext, @Profile 등을 통해 다양한 구현체를 같은 인터페이스에 바인딩할 수 있으므로, 실무에서도 이 원칙은 실현 가능하고 실용적이다.
5-3. 인프라 기술을 도메인 안으로 끌어들이지 마라
기술 의존성이 도메인 계층을 침투하지 않도록 차단하는 설계 원칙을 말한다. JPA, Querydsl, EntityManager와 같은 기술 객체는 오직 인프라 계층에서만 사용되어야 하며, 도메인 모델은 오직 순수한 비즈니스 개념만으로 구성되어야 한다.
실무에서 흔히 저지르는 실수 중 하나는, JPA Entity나 Querydsl Q타입을 도메인 계층으로 끌고 오는 것이다. 이런 설계는 도메인 모델이 기술 스택에 강하게 의존하게 만들고, 결국 비즈니스 중심 모델링을 불가능하게 만든다.
예를 들어 QOrder 타입이나 EntityManager 객체가 도메인 서비스 안에 등장한다면 그 구조는 이미 저장소 추상화를 깨뜨린 셈이다. 도메인은 순수하게 비즈니스 개념만 표현하고, 데이터 접근은 리포지토리 구현체에 완전히 위임해야 한다. 이러한 관심사의 분리는 아키텍처 안정성의 기초이자, 협업 구조의 기준점이다. 기술 스택 변경이나 모듈화가 쉬워지는 것은 물론이고, 도메인 코드 자체가 문서처럼 읽히는 유의미한 설계가 가능해진다.
5-4. CQRS, 이벤트 기반 저장소 설계로 확장하라
시스템이 커지고 복잡해지면, 단순 CRUD 기반의 리포지토리로는 한계에 봉착하게 된다. 이때 고려할 수 있는 전략 중 하나가 CQRS와 이벤트 기반 리포지토리 구조다. 즉, 쓰기와 읽기를 분리하고, 상태 변화는 이벤트로 비동기 처리하는 구조다. 이런 구조에서는 CommandRepository, QueryRepository로 명확히 나누고, 이벤트 핸들러에서 후속 작업을 처리하거나, 조회용 DB를 따로 두는 방식으로 확장한다. 이때 핵심은 도메인의 규칙과 흐름을 동기화가 아닌 메시지 기반으로 조율한다는 점이다.
이러한 아키텍처는 높은 부하 처리, 마이크로서비스 간 연계, 데이터 일관성 유지 등 복잡한 시스템에서도 도메인 모델의 중심성을 유지할 수 있도록 도와준다. 단순한 리포지토리 개념을 넘어, 도메인 모델이 시스템 전체의 중심으로 작동하는 구조로 진화하는 것이다.