관리자 주문 목록 화면을 만든다고 하자. 한 행에 이렇게 나온다.

주문번호 상품명 회원명 금액
1024 무선 이어폰 홍길동 89,000

상품명product 도메인에, 회원명user 도메인에 있다. 조회를 주도하는 것은 order다.

이걸 지금까지 정한 규칙대로 짜려고 하면 첫 줄에서 막힌다.

// order/domain/OrderSearchRepository.java
public interface OrderSearchRepository {

    PagingResult<???> search(OrderSearchCondition condition);   // 무엇을 반환하나?
}

???에 넣을 수 있는 것이 없다.

  • OrderDomain — 상품명·회원명이 없다. 넣으면 OrderDomain이 남의 도메인 데이터를 들고 다니게 되고, 애그리거트 경계가 무너진다
  • OrderSummaryProjectionorder/infra에 package-private이다. order/domain에서 import하면 컴파일 에러다
  • OrderSummaryMapperResult — 같은 이유로 안 된다

패키지 구조 표준은 이 자리를 이렇게 적어뒀다.

네이티브 쿼리로 조인해서 채우고, Repository 구현체가 Domain이나 전용 결과 타입으로 변환해 내보낸다.

그런데 그 “전용 결과 타입”이 무엇인지, 어디 사는지는 어디에도 없다. 이 글에서 그 자리를 채운다.


먼저: 왜 나갈 길이 없는가

세 규칙이 맞물려 있다.

1. infra의 모든 클래스는 package-private이다        (패키지 구조 표준)
2. Repository 인터페이스는 domain에 있다             (DTO 네이밍 표준)
3. domain에 둘 수 있는 것은 셋뿐이다                 (패키지 구조 표준)
   - XxxDomain, XxxRepository, XxxSearchCondition

1번 때문에 infra에서 만든 것은 밖으로 못 나가고, 2번 때문에 반환 타입은 domain이나 common에 있어야 하는데, 3번의 셋 중에는 조인 결과를 담을 것이 없다.

규칙이 틀린 게 아니라 목록이 하나 모자랐다. 들어가는 쪽을 보면 금방 드러난다.

입력:  Request → Query → SearchCondition → (infra)
출력:  (infra) →   ???  → Response
                    ↑
                여기가 비어 있었다

SearchConditionRepository로 들어가기 위해 domain에 둔 조회 전용 타입이다. 나가는 쪽에도 같은 것이 필요했는데 만들지 않았을 뿐이다.


원칙 1. 가르는 질문은 “답이 한 애그리거트 안에 있는가”다

// 주문 상세 — 답이 order 애그리거트 안에 다 있다
Optional<OrderDomain> findById(Long orderId);

// 주문 목록 화면 — 상품명·회원명이 밖에 있다
PagingResult<OrderSummaryView> search(OrderSearchCondition condition);

필드가 몇 개인지, 화면이 어떻게 생겼는지로 가르지 않는다. Domain을 그대로 내보내도 답이 되면 Domain이고, 애그리거트 밖의 값이 하나라도 필요하면 View다.

둘의 성격이 다르다.

  • Domain은 바꿀 수 있는 것이다. order.confirm()을 부르고 저장한다
  • View는 읽고 버리는 것이다. 저장 경로가 아예 없다

원칙 2. XxxViewdomain의 조회 출구다

// order/domain/OrderSummaryView.java
@Getter
public class OrderSummaryView {

    private final Long orderId;
    private final String productName;    // product 도메인에서 온 값
    private final String userName;       // user 도메인에서 온 값
    private final BigDecimal totalPrice;

    private OrderSummaryView(Long orderId, String productName, String userName, BigDecimal totalPrice) {
        this.orderId = orderId;
        this.productName = productName;
        this.userName = userName;
        this.totalPrice = totalPrice;
    }

    public static OrderSummaryView of(Long orderId, String productName, String userName, BigDecimal totalPrice) {
        return new OrderSummaryView(orderId, productName, userName, totalPrice);
    }
}

domain에 둬도 되는 이유

domain 패키지의 조건은 “자기 도메인 값만 갖는다”가 아니다. 지금까지 정한 조건은 이것 하나다.

domain은 같은 도메인의 api, application, infra, bulk, batch를 참조하지 않는다.

OrderSummaryViewString, Long, BigDecimal만 갖는다. JPA도 웹도 MyBatis도 모른다. 다른 계층을 하나도 참조하지 않으므로 domain의 자격을 만족한다.

다른 도메인의 값을 갖는 것도 이미 하고 있는 일이다.

// order/domain/OrderItemDomain.java — 애그리거트 경계 표준이 이미 정한 것
private final Long productId;        // 다른 애그리거트 → ID로 참조
private final BigDecimal unitPrice;  // 주문 시점 가격을 복사해 둔다

애그리거트 표준은 “다른 애그리거트의 값 중 시점에 따라 달라지는 것은 복사해서 보관한다” 고 정했다. unitPrice가 그것이고, productName조회 시점의 값을 복사한 것이라 같은 논리 위에 있다.

타입을 참조하면 결합이고, 값을 복사하면 결합이 아니다. ProductDomain을 import하면 안 되지만 String productName을 갖는 것은 괜찮다.

이름은 층을 가로질러 묶는다

order/domain/OrderSummaryView.java             public
order/infra/OrderSummaryProjection.java        package-private   (JPA로 채울 때)
order/infra/OrderSummaryMapperResult.java      package-private   (MyBatis로 채울 때)
order/api/OrderSummaryResponse.java            public

OrderSummary조회의 이름이고 뒤에 붙는 것이 타입의 종류다. 파일 목록을 훑으면 같은 조회의 층위가 한 덩어리로 보인다.


원칙 3. 채우는 주체는 둘, 타입은 하나

graph LR MR["OrderSummaryMapperResult
infra"] -->|"구현체가 변환"| V["OrderSummaryView
domain"] PV["ProductProvider
UserProvider"] -->|"Service가 조합"| V V -->|"from"| RS["OrderSummaryResponse
api"] style MR fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style PV fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style V fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style RS fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0

건수가 적을 때 — Service가 조합한다.

// order/application/OrderService.java
@Override
@Transactional(readOnly = true)
public PagingResult<OrderSummaryView> searchOrders(OrderSearchQuery query) {

    PagingResult<OrderDomain> orders = orderRepository.findAll(query.getPage(), query.getSize());

    Map<Long, String> productNames = productProvider
            .getProducts(orders.getContent().stream().map(OrderDomain::getProductId).toList())
            .stream().collect(toMap(ProductSnapshot::getId, ProductSnapshot::getName));

    return orders.map(order -> OrderSummaryView.of(
            order.getId(), productNames.get(order.getProductId()), ..., order.getTotalPrice()));
}

목록을 채울 때는 Provider에 목록 조회를 둔다. 항목마다 부르면 20건에 40번이 나가지만, 한 번에 가져오면 쿼리 세 번으로 끝난다.

건수가 늘면 — Repository가 조인한다.

// order/infra/OrderMyBatisSearchRepository.java   ← package-private
@Override
public PagingResult<OrderSummaryView> search(OrderSearchCondition condition) {
    List<OrderSummaryMapperResult> rows = mapper.search(toParam(condition));
    long total = mapper.countBy(toParam(condition));

    List<OrderSummaryView> views = rows.stream()
            .map(r -> OrderSummaryView.of(
                    r.getOrderId(), r.getProductName(), r.getUserName(), r.getTotalPrice()))
            .toList();

    return PagingResult.of(views, condition.getPage(), condition.getSize(), total);
}
// order/application/OrderService.java — 조합 코드가 사라진다
@Override
@Transactional(readOnly = true)
public PagingResult<OrderSummaryView> searchOrders(OrderSearchQuery query) {
    return orderSearchRepository.search(query.toCondition());
}

이주할 때 위층은 안 바뀐다

  바뀌나
OrderSummaryView 그대로
OrderQueryUseCase 시그니처 그대로
OrderSummaryResponse.from() 그대로
OrderQueryController 그대로
OrderService 내부 조합 코드 삭제
새로 생기는 것 OrderSearchRepository + infra 구현체

패키지 구조 표준은 이미 “쿼리가 반복되기 시작하면 전용 결과 타입으로 옮긴다” 고 정해뒀다. 두 경로가 같은 타입을 반환하므로, 그 “옮긴다”가 채우는 주체만 갈아끼우는 일이 된다.

MapperResultinfra에서 죽고 밖으로는 View만 나간다. EntityDomain으로 바꿔 내보내는 것과 똑같은 구조다.


원칙 4. View는 판단하지 않는다

public class OrderSummaryView {

    // ❌ 비즈니스 판단
    public boolean isCancellable() {
        return status == PENDING && createdAt.isAfter(now().minusDays(7));
    }

    // ❌ 상태 변경
    public OrderSummaryView withDiscount(BigDecimal rate) { ... }
}

판단은 Domain이 한다. ViewisCancellable()을 두면 같은 규칙이 두 곳에 생기고, 조회 화면과 실제 처리의 답이 달라지는 날이 온다.

계산된 값이 필요하면 채우는 쪽에서 이미 계산해 넣는다. Domain이 판단한 결과를 boolean cancellable 필드로 담거나, SQL이 계산해서 담는다.

그리고 ViewCommand로 되돌리지 않는다. 읽고 버리는 타입이므로 쓰기 경로로 들어가는 문을 만들지 않는다.


원칙 5. Snapshot과 섞지 않는다

둘 다 getter만 있는 읽기 전용이라 기준이 없으면 반드시 섞인다. 방향이 반대다.

// Snapshot — 우리가 남에게 준다. 우리 도메인 값만 들어 있다
public interface ProductProvider {
    ProductSnapshot getProduct(Long productId);      // product가 만들어 order에게 준다
}

// View — 우리가 남의 값을 받아 섞는다. 여러 도메인 값이 들어 있다
public interface OrderSearchRepository {
    PagingResult<OrderSummaryView> search(...);      // order가 만든다
}
  XxxSnapshot XxxView
사는 곳 application domain
방향 내보낸다 모아온다
담긴 값 우리 도메인만 여러 도메인이 섞임
팩토리 가시성 package-private public

Snapshot의 팩토리를 package-private으로 막은 것은 제공 도메인만 만들 수 있어야 하기 때문이었다. Viewinfra의 Repository 구현체와 application의 Service 둘 다 만들어야 하므로 열어둔다.


판단 기준 정리

질문 결론
조회의 답이 한 애그리거트 안에 있나? 그렇다 XxxDomain을 반환한다
  아니다 XxxView를 반환한다
XxxView는 어디에? 조회를 주도하는 도메인의 domain public. SearchCondition의 짝이다
무엇을 담나? 원시 타입으로 복사한 값 다른 도메인의 타입을 import하지 않는다
누가 채우나? 구현체(조인) 또는 Service(조합) 어느 쪽이든 같은 타입이다
Projection·MapperResult는? infra에서 죽는다 구현체가 View로 바꿔서만 내보낸다
목록을 조합으로 채울 때는? Provider의 목록 조회 항목마다 호출하지 않는다
남의 도메인 값으로 검색·정렬하면? 조합으로는 불가능 조인으로 간다
View에 판단 메서드를 두나? 아니다 판단은 Domain. 계산 결과는 필드로 담는다
Snapshot과 차이는? 방향 Snapshot은 내보내고 View는 모아온다

이 표준을 정하기까지

시작은 장부에 열려 있던 세 항목이었다. “조인 결과의 반출 경로”, “여러 Domain을 조합한 결과 타입”, “동적 검색이면서 조인인 조회”. 따로 적어뒀는데 한 뿌리였다.

막히는 지점이 생각보다 정확했다. infra는 package-private이고, Repository 인터페이스는 domain에 있고, domain에 둘 수 있는 것은 셋으로 못 박혀 있었다. 셋 다 맞는 규칙인데 합치면 조인 결과가 나갈 길이 없어진다. 어느 하나를 버릴 게 아니라 목록에 하나를 더해야 하는 문제였다.

세 번째 항목은 애초에 충돌이 아니었다. Repository 설계 표준은 동적 여부로 가르고(JPA/MyBatis) 패키지 구조 표준은 조인 여부로 갈랐는데(Projection/MapperResult), 다시 보니 가르는 층위가 달랐다. 앞은 인터페이스와 구현 기술을, 뒤는 infra 안쪽 타입을 정한다. 동적이면서 조인이면 XxxSearchRepository + MapperResult로 자연스럽게 정해진다. 자리가 없던 게 아니라 밖으로 내보낼 타입이 없어서 그 조합이 끝까지 못 갔던 것이다.

세 안을 놓고 비교했다. domain에 공개 조회 타입을 하나 두는 안, Repository용과 조합용을 나누는 안, 조인 조회를 아예 하지 않는 안.

나누는 안을 먼저 버렸다. domain에는 DB에서 오는 것만 둔다는 성격 구분이 그럴듯해 보였는데, 따져보니 막으려는 것이 실제 위반이 아니었다. applicationdomain 패키지의 객체를 만드는 것은 의존 방향(application → domain)에 맞고, Service가 OrderDomain.create()를 부르는 것과 같다. 얻는 것은 개념적 정리뿐인데 필드가 똑같은 타입이 둘 생기고 이주할 때마다 위층을 고쳐야 했다.

조합만 하는 안은 더 오래 붙들었다. domain에 아무것도 안 늘어난다는 게 컸다. N+1이 걱정이었는데 Provider에 목록 조회를 두면 쿼리 세 번으로 끝난다는 걸 확인하고 나서는 꽤 유력해 보였다.

그런데 검색과 정렬에서 막혔다. “상품명으로 검색”을 하려면 주문을 페이징으로 20건 가져온 뒤 상품명을 채우는 구조로는 안 된다. 20건 안에서만 걸러진다. 정렬도 페이지 안에서만 된다. 여기서 알았다 — 조인 조회가 필요한 진짜 이유는 표시가 아니라 정렬·필터였다. 표시만이라면 조합으로 충분하다. 이 한 가지가 안을 갈랐다.

결정적인 근거는 이미 쓰여 있던 문장이었다. 패키지 구조 표준이 “단건이나 소량은 조합해도 되고, 쿼리가 반복되기 시작하면 전용 결과 타입으로 옮긴다”고 이주 경로를 정해뒀는데, 조합은 application에서 하고 전용 결과 타입은 infra에 있었다. 자리가 다르니 “옮긴다”가 실제로는 타입을 새로 만들고 Service 시그니처까지 바꾸는 일이었다. 두 경로가 같은 타입을 반환해야 그 문장이 말이 된다.

마지막으로 Snapshot과의 경계를 정했다. 둘 다 getter만 있는 읽기 전용이라 기준이 없으면 섞인다. 방향으로 갈랐다Snapshot은 우리 값을 남에게 내보내고, View는 남의 값을 모아온다. 팩토리 가시성이 다른 것도 여기서 따라 나왔다.


정리

  • 가르는 질문은 “이 조회의 답이 한 애그리거트 안에 있는가”다. 있으면 Domain, 없으면 View
  • XxxViewdomain의 조회 출구다. 들어가는 쪽의 SearchCondition과 짝이다
  • domain에 둘 수 있는 것이 셋에서 넷으로 늘었다. 다른 계층을 참조하지 않는다는 조건은 그대로 만족한다
  • 채우는 주체는 둘, 타입은 하나다. 조합에서 조인으로 옮길 때 application 위쪽이 안 바뀐다
  • Projection·MapperResultinfra에서 죽는다. EntityDomain과 같은 구조다
  • View는 판단하지 않는다. 판단이 두 곳에 생기면 화면과 처리의 답이 갈라진다
  • Snapshot은 내보내고 View는 모아온다. 방향이 반대다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/read-model-standard/SKILL.md

---
name: read-model-standard
description: 애그리거트 하나에 담기지 않는 조회 결과 타입 규칙. 여러 도메인을 조인하거나 조합하는 조회, XxxView, XxxProjection, XxxMapperResult를 만들거나 리뷰할 때 반드시 적용한다.
---

# 조회 전용 타입 표준

애그리거트 하나로 답이 되지 않는 조회는 `XxxDomain`으로 반환할 수 없다. `infra``XxxProjection`·`XxxMapperResult`는 package-private이라 밖으로 나가지도 못한다. 그 자리를 `XxxView`가 맡는다.

## 언제 쓰나
- 판단 기준은 **"이 조회의 답이 한 애그리거트 안에 있는가"**다. 필드 개수나 화면 모양으로 가르지 않는다.
- 답이 한 애그리거트 안에 있으면 `XxxDomain`을 반환한다.
- 여러 도메인이나 여러 애그리거트의 값이 필요하면 `XxxView`를 반환한다.

## XxxView
- 조회를 주도하는 도메인의 `domain` 패키지에 `public`으로 둔다.
- getter만 갖는다. 비즈니스 메서드도 상태 변경 메서드도 두지 않는다.
- private 생성자 + `public` 정적 팩토리로 만들고 모든 필드를 `final`로 선언한다.
- 다른 도메인의 값은 원시 타입으로 복사해 담는다. `ProductDomain` 같은 타입을 import하지 않는다.
- 저장 경로에 넣지 않는다. `XxxCommand``XxxDomain`으로 되돌리지 않는다.
- 계산된 값이 필요하면 채우는 쪽에서 계산해 필드로 담는다. `View`가 판단하지 않는다. 판단은 `XxxDomain`이 한다.

## 채우는 경로
- Repository 인터페이스가 `PagingResult<XxxView>` / `List<XxxView>` / `Optional<XxxView>`를 반환한다.
- `infra` 구현체가 `XxxProjection`(JPA 네이티브 쿼리) 또는 `XxxMapperResult`(MyBatis)를 받아 `XxxView`로 변환해 내보낸다. 두 타입은 `infra` 밖으로 나가지 않는다.
- 건수가 적으면 Service가 각 도메인의 `XxxProvider`를 호출해 조합하고 같은 `XxxView`를 채운다.
- 조합에서 조인으로 옮길 때 `XxxView``XxxUseCase` 시그니처는 바뀌지 않는다. 채우는 주체만 바뀐다.
- 조합으로 목록을 채울 때는 `XxxProvider`에 목록 조회를 두고 한 번에 가져온다. 항목마다 호출하지 않는다.
- **다른 도메인의 값으로 검색하거나 정렬해야 하면 조합으로 할 수 없다.** 페이징한 뒤에 채우면 페이지 안에서만 걸러지고 정렬된다. 이 경우는 조인으로 간다.

## 이름
- `{조회 이름}View`로 짓는다. 예: `OrderSummaryView`.
- 같은 조회의 `infra` 타입은 접두사를 맞춘다. `OrderSummaryProjection`, `OrderSummaryMapperResult`.
- `api`의 응답도 접두사를 맞춘다. `OrderSummaryResponse`.

## 다른 타입과의 경계
- `XxxDomain`은 규칙을 갖고 저장된다. `XxxView`는 규칙이 없고 읽고 버린다.
- `XxxSnapshot``application`에 두고 **우리 도메인의 값을 남에게 내보내는** 창구다. `XxxView``domain`에 두고 **여러 도메인의 값을 모아온** 조회 결과다.
- `XxxResponse``api`의 HTTP 스펙이다. `XxxView`를 그대로 응답으로 내보내지 않는다.
- `XxxView``domain`에 있어도 되는 이유는 다른 계층을 참조하지 않기 때문이다. 원시 타입만 갖고 JPA도 웹도 모른다.

GitHub Copilot — .github/instructions/read-model-standard.instructions.md

---
description: 애그리거트에 담기지 않는 조회 결과 타입 규칙
applyTo: "**/domain/**/*.java, **/infra/**/*.java"
---

- 조회 반환 타입은 "이 조회의 답이 한 애그리거트 안에 있는가"로 가른다. 있으면 `XxxDomain`, 없으면 `XxxView`다.
- `XxxView`는 조회를 주도하는 도메인의 `domain` 패키지에 `public`으로 둔다.
- `XxxView`는 getter만 갖고 비즈니스 메서드나 상태 변경 메서드를 두지 않는다.
- `XxxView`는 private 생성자와 `public` 정적 팩토리로 만들고 모든 필드를 `final`로 선언한다.
- `XxxView`에 다른 도메인의 값은 원시 타입으로 복사해 담고 그 도메인의 타입을 import하지 않는다.
- `XxxView``XxxCommand``XxxDomain`으로 되돌리지 않는다. 저장 경로에 넣지 않는다.
- 계산된 값은 채우는 쪽에서 계산해 필드로 담는다. `XxxView`에서 판단하지 않는다.
- Repository 인터페이스는 `PagingResult<XxxView>` / `List<XxxView>` / `Optional<XxxView>`를 반환한다.
- `infra` 구현체가 `XxxProjection` 또는 `XxxMapperResult``XxxView`로 변환해 내보낸다. 두 타입은 `infra` 밖으로 내보내지 않는다.
- 건수가 적으면 Service가 `XxxProvider`를 호출해 조합하고 같은 `XxxView`를 채운다.
- 조합으로 목록을 채울 때는 `XxxProvider`에 목록 조회를 두고 한 번에 가져온다. 항목마다 호출하지 않는다.
- 다른 도메인의 값으로 검색하거나 정렬해야 하면 조합하지 말고 조인으로 구현한다.
- 이름은 `{조회 이름}View`로 짓고 같은 조회의 `infra`·`api` 타입과 접두사를 맞춘다.
- `XxxSnapshot`(도메인 간 창구)과 `XxxView`(조회 결과)를 섞지 않는다.
- `XxxView`를 그대로 HTTP 응답으로 내보내지 않고 `XxxResponse`로 변환한다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기