도메인 모델이 뚱뚱해지고 있다면, Read Model을 도입할 때입니다

2026. 2. 25. 11:48·dev.log

개요

Spring Boot + JPA 기반 클린 아키텍처 + DDD 프로젝트를 진행하던 중, 도메인 모델에서 불편한 부분을 발견했습니다.

@Getter
@Builder
public class LabOrder {
    private Long id;
    private Long patientId;
    /** 비즈니스 필드들 ... **/

    // 목록 조회용 필드
    private String patientName;
    private Long doctorId;
    private String doctorName;
}

 

목록 화면에 환자 이름과 의사 이름을 보여줘야 했는데, 이 데이터는 lab_order 테이블이 아니라 patient, doctor 테이블에 있습니다. 도메인 모델만으로 이 조회를 해결하려다 보니, 결국 도메인 모델에 조회용 필드를 추가하는 방향으로 흘러갔던 것입니다.

문제 인식

도메인 모델만으로는 여러 테이블 조회가 한계였습니다

도메인 모델을 통해 이 문제를 해결하려면 두 가지 방법밖에 없었습니다.

방법 1: 도메인 모델 조회 후, 연관 데이터를 추가 조회해서 조합합니다.

List<LabOrder> orders = labOrderRepository.findAll(condition);
for (LabOrder order : orders) {
    Patient patient = patientRepository.findById(order.getPatientId());
    Doctor doctor = doctorRepository.findById(order.getDoctorId());
}

코드가 복잡해지고, N+1 문제가 그대로 발생합니다. 주문 100건이면 환자 조회 100번, 의사 조회 100번이 추가 실행됩니다.

 

방법 2: 도메인 모델에 조회용 필드를 추가하고, Repository에서 한 번에 채워 반환합니다.

성능은 낫지만 도메인 모델이 화면 요구사항에 오염됩니다. 결국 방법 2를 선택했고, 이게 반복되면서 도메인 모델이 비대해지고 조회 코드도 복잡해졌습니다.

왜 나쁜 설계인가

SRP 위반: 도메인 모델의 변경 이유가 두 개가 됩니다. "비즈니스 규칙 변경"과 "화면 요구사항 변경". 목록 화면에 거래처 전화번호를 추가해달라는 요청 하나에 도메인 모델을 건드려야 한다면, 그건 이미 도메인 모델이 아닙니다.

도메인 정체성 희석: patientName이 어떤 비즈니스 규칙에 사용되는지 추적하다가 "아, 이건 조회용이구나"를 깨닫기까지 불필요한 시간을 소비하게 됩니다.

암묵적 규칙 발생: "이 필드는 조회할 때만 채워진다"는 규칙이 코드에 명시되지 않은 채 존재하게 되고, 이런 암묵적 규칙은 버그의 원인이 됩니다.

CQRS (Read Model)

이 문제의 근본 원인은 쓰기(Command)와 읽기(Query)를 같은 모델로 해결하려 했던 것입니다.

CQRS(Command Query Responsibility Segregation)는 명령과 조회의 책임을 분리하는 아키텍처 패턴입니다. Greg Young이 제안한 이 패턴의 핵심은, 데이터를 변경하는 경로와 읽는 경로는 요구사항이 다르므로 각각에 최적화된 별도의 모델을 사용하자는 것입니다.

  • Command: 상태 변경. 도메인 모델을 통해 비즈니스 규칙을 검증합니다. 정합성이 최우선입니다.
  • Query: 데이터 조회. 비즈니스 규칙이 필요 없습니다. 화면에 필요한 데이터를 빠르게 반환하는 것이 최우선입니다.

CQRS의 풀 적용은 쓰기 DB와 읽기 DB를 물리적으로 분리하고 이벤트로 동기화하는 수준까지 갑니다. Kafka, Elasticsearch, 결과적 일관성(Eventual Consistency) 등 상당한 복잡성이 따릅니다.

[풀 CQRS]
Client → Command → Domain → Write DB → Event Bus → Read DB 갱신
Client → Query → Read DB (ES, Redis 등)

저는 이 패턴을 풀로 적용한 것이 아닙니다. CQRS의 핵심 개념 중 Read Model — 조회에 최적화된 별도의 모델을 두자는 아이디어만 차용했습니다. 같은 MariaDB를 바라보면서, 목록 조회에 한해 도메인 모델을 우회하고 조회 전용 DTO로 직접 프로젝션하는 구조를 도입한 것입니다.

해결: Read Model + QueryDSL Projections

도메인 모델 정리

조회용 필드를 제거합니다. 도메인 모델은 환자의 ID만 알면 됩니다. 환자의 이름이 필요한 건 화면이지, 도메인이 아닙니다.

@Getter
@Builder
public class LabOrder {
    private Long id;
    private Long patientId;
    /** 비즈니스 필드들 ... **/

    // patientName, doctorId, doctorName 제거 → Read Model로 이동
}

Read Model(Summary) 생성

화면에 필요한 데이터를 담는 조회 전용 DTO를 만듭니다. patientName과 doctorName은 도메인 모델에서는 이질적이었지만, Read Model에서는 존재하는 것이 자연스럽습니다.

public record LabOrderSummary(
    Long labOrderId,
    String patientName,
    String doctorName,
    LabOrderStatus status,
    LabOrderStep currentStep,
    Printing printing,
    boolean reorder,
    LocalDateTime createdDate
) {}

네이밍은 {도메인}Summary 형태를 사용합니다. ~Response, ~Dto 접미사는 붙이지 않습니다. 이 DTO는 표현 계층이 아닌 애플리케이션 계층에 속하기 때문입니다.

QueryDao 인터페이스

public interface LabOrderQueryDao {
    Page<LabOrderSummary> findLabOrderSummaries(
        LabOrderSearchCondition condition, PageQuery pageQuery);
}

이름을 QueryRepository가 아닌 QueryDao로 한 것은 의도적입니다. DDD에서 이 둘은 명확히 다른 개념입니다.

  Repository DAO
개념 Aggregate 영속성 추상화 (DDD 개념) 데이터 접근 객체 (기술 패턴)
반환 도메인 객체 (LabOrder, Patient) DTO (LabOrderSummary)
위치 core-domain (도메인 계층) core-business (애플리케이션 계층)
목적 상태 변경을 위한 Aggregate 로딩/저장 화면 표시를 위한 데이터 조회

QueryDao는 도메인 모델을 우회하여 직접 DTO를 프로젝션하므로, Repository라고 부르면 DDD의 Repository 개념과 충돌합니다.

QueryDSL Projections로 직접 DTO 프로젝션

이번 개선의 핵심입니다. Projections.constructor()로 여러 테이블을 join한 결과를 Read Model에 직접 매핑합니다.

@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LabOrderQueryDaoImpl implements LabOrderQueryDao {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<LabOrderSummary> findLabOrderSummaries(
            LabOrderSearchCondition condition, PageQuery pageQuery) {

        List<LabOrderSummary> content = queryFactory
            .select(Projections.constructor(LabOrderSummary.class,
                labOrderEntity.id,
                patientEntity.name,          // patient 테이블에서 직접
                doctorEntity.name,           // doctor 테이블에서 직접
                labOrderEntity.status,
                labOrderEntity.currentStep,
                labOrderEntity.printing,
                labOrderEntity.reorder,
                labOrderEntity.createdDate
            ))
            .from(labOrderEntity)
            .join(patientEntity)
                .on(labOrderEntity.patientId.eq(patientEntity.id))
            .join(doctorEntity)
                .on(labOrderEntity.doctorId.eq(doctorEntity.id))
            .where(
                patientNameContains(condition.patientName()),
                statusEq(condition.status()),
                stepEq(condition.step())
            )
            .offset(pageQuery.offset())
            .limit(pageQuery.size())
            .orderBy(labOrderEntity.createdDate.desc())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(labOrderEntity.count())
            .from(labOrderEntity)
            .where(
                patientNameContains(condition.patientName()),
                statusEq(condition.status()),
                stepEq(condition.step())
            );

        return PageableExecutionUtils.getPage(
            content, pageQuery.toPageable(), countQuery::fetchOne);
    }
}

여러 테이블을 한 번의 쿼리로 조합하고, DTO를 직접 생성하므로 N+1 문제가 원천적으로 발생하지 않습니다. 필요한 컬럼만 SELECT하며, 도메인 모델과 JPA 엔티티를 전혀 거치지 않으므로 도메인에 조회용 필드를 추가할 이유가 사라집니다.

단건 상세 조회는 Facade에 그대로 둡니다

모든 조회를 QueryDao로 옮기지는 않았습니다. 단건 상세 조회는 기존 Facade (Application Service)에 그대로 유지했습니다.

Read Model 도입 목적 자체가 도메인 모델에서 조회용 필드(doctorName, patientName 등)를 제거하기 위한 것이었습니다. 이 문제가 발생하는 곳은 목록 조회뿐입니다. 단건 상세 조회는 Aggregate를 그대로 반환하므로 조회용 필드가 필요 없고, 따라서 분리할 이유도 없습니다.

여기에 더해, 상세 조회를 QueryDao에 넣기 어려운 기술적 이유도 있습니다.

  • Projections.constructor()는 플랫 구조 전용입니다. 파일 목록 같은 1:N 컬렉션을 표현할 수 없습니다. 상세 조회에서는 List<LabOrderFile> 같은 하위 컬렉션이 포함됩니다.
  • 상세 조회에는 비즈니스 로직이 필요할 수 있습니다. 권한 검증, 조건부 데이터 로딩 등이 해당하며, 이는 Facade의 역할입니다.
[목록 조회]  Controller → QueryDao → DB (DTO 프로젝션)
[상세 조회]  Controller → Facade (Application Service) → Domain → Repository (Aggregate 반환)
[Command]   Controller → Facade (Application Service) → Domain → Repository

패키지 구조

Facade 패키지 안에 query/ 디렉토리를 두어, 해당 도메인에 Read Model이 존재함을 구조적으로 드러냅니다. query/ 패키지가 있다는 것 자체가 "이 도메인은 QueryDao를 사용한다"는 선언입니다.

core-business/laborder/
  LabOrderFacade.java              ← Facade (Command + 상세 조회)
  command/
    CreateLabOrderCommand.java     ← Facade 입력
    LabOrderDetailResult.java      ← 상세 조회 출력
  query/                           ← Read Model 전용
    LabOrderSummary.java           ← 목록 Read Model
    LabOrderSearchCondition.java   ← 검색 조건
    dao/
      LabOrderQueryDao.java        ← 조회 포트 (인터페이스)
db-core/laborder/
  LabOrderQueryDaoImpl.java        ← QueryDao 구현체 (QueryDSL)

모듈 배치

구성 요소 모듈 이유

Summary, SearchCondition, QueryDao 인터페이스 core-business core-api에서 접근 가능, db-core에서 구현 가능
QueryDaoImpl 구현체 db-core QueryDSL 구현은 인프라의 책임
도메인 모델, Repository core-domain 변경 없음
core-api → core-business → core-domain
                ↑                ↑
              db-core ───────────┘

core-domain에는 Read Model 관련 코드를 넣지 않습니다. 도메인 계층의 순수성을 지키는 것이 이 설계의 핵심입니다.

마무리

정리하면, 도메인 모델에서 조회용 필드를 제거하고 목록 조회에 한해 QueryDSL Projections로 직접 DTO 프로젝션하는 Read Model을 도입했습니다. CQRS를 풀로 적용한 것이 아니라 Read Model 개념만 차용한 것이고, 단건 상세 조회는 기존 Facade에 그대로 유지했습니다.

 

솔직히 처음에는 "모든 조회를 다 분리해야 하나?" 고민했습니다. 하지만 실제로 문제가 되는 건 목록 조회뿐이었고, 상세 조회까지 억지로 분리하면 Projections.constructor()의 플랫 구조 한계 때문에 오히려 코드가 복잡해집니다. 문제가 없는 곳까지 패턴을 적용하는 건 개선이 아니라 과설계라고 생각합니다.

 

주석으로 용도를 설명해야 하는 필드가 도메인 모델에 있다면, 그건 주석의 문제가 아니라 설계의 문제입니다. 도메인 모델은 비즈니스 규칙에 집중하고, 조회 관심사는 Read Model로 분리해야 합니다. 그렇게 코드가 스스로 의도를 드러내면, 주석은 자연스럽게 사라진다고 생각합니다.

'dev.log' 카테고리의 다른 글

계층형 CTE 한 줄이 주석 처리된 코드 세 줄을 살린다  (0) 2026.03.08
PUT vs PATCH, 팩트만 정리합니다  (0) 2026.03.01
AI가 코드를 짜는 시대, 개발자로서 느낀 것들  (0) 2026.02.17
'dev.log' 카테고리의 다른 글
  • 계층형 CTE 한 줄이 주석 처리된 코드 세 줄을 살린다
  • PUT vs PATCH, 팩트만 정리합니다
  • AI가 코드를 짜는 시대, 개발자로서 느낀 것들
kyeongwoo.ryu
kyeongwoo.ryu
꾸준함이야말로 가장 가치 있는 능력이라고 생각하며, 하루하루 배우고 흘러가는 과정을 기록합니다.
  • kyeongwoo.ryu
    Rio.dev
    kyeongwoo.ryu
    • 분류 전체보기 (10) N
      • dev.log (4)
      • life.log (6) N
  • 링크

    • GitHub
  • 최근 글

kyeongwoo.ryu
도메인 모델이 뚱뚱해지고 있다면, Read Model을 도입할 때입니다
상단으로

티스토리툴바