본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #21] ORM과 DDD: JPA Entity는 도메인 모델인가

페이지 수정

Java와 Spring 환경에서 DDD를 이야기하면 거의 반드시 JPA 이야기를 만나게 된다. Entity라는 단어가 겹치기 때문이다. JPA에도 Entity가 있고, DDD에도 Entity가 있다. 그래서 많은 팀이 자연스럽게 JPA Entity를 도메인 모델로 사용한다.

이 선택이 항상 잘못된 것은 아니다. 하나의 클래스가 JPA Entity이면서 DDD Entity 역할을 할 수도 있다. 하지만 두 개념을 같은 것으로 생각하면 문제가 생긴다.

ORM은 도메인 모델을 저장하기 위한 도구다. ORM이 도메인 모델을 대신 설계해주지는 않는다.

JPA Entity와 DDD Entity는 관심사가 다르다

JPA Entity는 데이터베이스 테이블과 객체를 매핑하기 위한 기술적 개념이다. 식별자, 컬럼, 연관관계, 지연 로딩, 영속성 컨텍스트 같은 관심사가 중요하다.

DDD Entity는 도메인에서 식별성과 생명주기를 가진 모델링 개념이다. 어떤 상태 변화가 허용되는지, 어떤 불변식을 지켜야 하는지, 어떤 도메인 행위를 표현해야 하는지가 중요하다.

두 관심사는 겹칠 수 있지만 동일하지 않다.

예를 들어 구독 테이블이 다음과 같다고 해보자.

subscriptions
- id
- member_id
- plan_id
- status
- started_at
- expired_at
- canceled_at

이 테이블 구조만 보고 Subscription 도메인 모델을 설계하면 중요한 질문이 빠질 수 있다.

구독은 어떤 상태 전이를 갖는가?
만료된 구독은 다시 활성화될 수 있는가?
해지와 만료는 어떻게 다른가?
결제 실패로 인한 중지는 어떤 상태인가?
요금제 변경은 즉시 적용인가, 다음 결제일부터 적용인가?

DB 컬럼은 이 질문에 답하지 않는다. DDD Entity는 테이블을 객체로 옮긴 결과가 아니라 도메인 질문에 답한 결과여야 한다.

테이블 중심 모델의 위험

ORM을 사용하면 테이블 구조가 곧 객체 구조가 되기 쉽다. 테이블마다 Entity를 만들고, 외래키마다 연관관계를 맺고, 화면에 필요한 데이터를 따라가며 조회한다.

처음에는 편하다. 하지만 Aggregate 경계가 무너질 수 있다.

@Entity
public class Subscription {
    @ManyToOne
    private Member member;

    @ManyToOne
    private Plan plan;

    @OneToMany(mappedBy = "subscription")
    private List<Payment> payments;
}

이 구조는 객체 그래프처럼 보인다. 하지만 도메인 관점에서 모든 것이 하나의 Aggregate라는 뜻은 아니다. 회원, 요금제, 결제는 각자 다른 생명주기와 규칙을 가질 수 있다.

DDD에서는 다른 Aggregate를 객체 참조가 아니라 식별자로 참조하는 것이 더 나을 때가 많다.

public class Subscription {
    private SubscriptionId id;
    private MemberId memberId;
    private PlanId planId;
}

이렇게 하면 구독 Aggregate가 회원과 요금제의 내부 상태에 직접 의존하지 않는다. 필요한 정보는 Application Service나 Domain Service가 별도로 조회해서 전달할 수 있다.

연관관계는 편하지만 경계를 흐린다

JPA의 연관관계는 강력하다. subscription.getMember().getEmail()처럼 객체를 따라가며 데이터를 사용할 수 있다. 하지만 이 편리함은 경계를 흐리게 만든다.

어느 순간 구독 도메인 로직 안에서 회원의 상세 상태를 확인하고, 결제 이력을 따라가고, 요금제의 내부 속성을 직접 변경하게 될 수 있다.

subscription.getMember().changeGrade(VIP);
subscription.getPayments().get(0).cancel();

이런 코드가 자연스럽게 가능하다는 것은 위험 신호일 수 있다. Aggregate는 자신의 경계 안에서 일관성을 지켜야 한다. 다른 Aggregate의 내부를 마음대로 바꾸면 각 모델의 책임이 무너진다.

연관관계는 기술적으로 가능하다는 이유만으로 열어두면 안 된다. 도메인 경계가 허용하는 관계인지 먼저 물어야 한다.

지연 로딩은 도메인 로직을 예측하기 어렵게 만든다

JPA의 지연 로딩은 편리하지만, 도메인 로직 안에서 언제 DB 조회가 발생하는지 알기 어렵게 만들 수 있다.

public void cancel() {
    if (payments.stream().anyMatch(Payment::isRefundRequired)) {
        // ...
    }
    this.status = CANCELED;
}

이 코드에서 payments가 지연 로딩이라면, 도메인 메서드를 호출하는 순간 DB 조회가 발생할 수 있다. 트랜잭션이 닫혀 있다면 예외가 발생할 수도 있다. 성능 문제도 숨겨진다.

도메인 메서드는 가능한 한 명시적인 입력을 받아 판단하는 편이 예측하기 쉽다.

subscription.cancel(refundPolicy, paymentSummary, now);

이 구조에서는 구독이 필요한 정보를 외부에서 전달받는다. DB 조회 시점은 Application Service나 Repository에서 명확하게 관리할 수 있다.

도메인 모델과 영속성 모델을 분리할 것인가

DDD를 진지하게 적용하다 보면 도메인 모델과 영속성 모델을 분리해야 하는지 고민하게 된다.

도메인 모델: Subscription
영속성 모델: SubscriptionJpaEntity

분리하면 도메인 모델을 JPA 제약에서 자유롭게 설계할 수 있다. 기본 생성자, protected setter, 양방향 연관관계, 프록시 제약 같은 영향을 줄일 수 있다. 외부 저장 구조가 바뀌어도 도메인 모델이 덜 흔들린다.

하지만 비용도 크다. 매핑 코드가 필요하고, 모델이 중복되며, 단순한 기능에서도 작업량이 늘어난다.

따라서 무조건 분리해야 한다고 말할 수는 없다. Core Domain처럼 복잡한 규칙이 많고 모델 보호가 중요한 영역에서는 분리를 고려할 수 있다. 단순 CRUD나 Supporting Subdomain에서는 JPA Entity를 도메인 모델로 함께 사용하는 것이 더 실용적일 수 있다.

중요한 것은 선택의 이유다. 편해서 합치는 것과, 비용과 복잡도를 판단해 합치는 것은 다르다.

현실적인 타협점

많은 프로젝트에서는 JPA Entity와 도메인 Entity를 같은 클래스로 사용한다. 이 경우에도 몇 가지 원칙을 지키면 도메인 모델을 어느 정도 보호할 수 있다.

첫째, setter를 무분별하게 열지 않는다.

subscription.cancelByMember(reason, now);

도메인 행위를 통해 상태를 바꾸도록 한다.

둘째, 연관관계를 최소화한다. 다른 Aggregate는 객체 참조보다 ID 참조를 고려한다.

private MemberId memberId;
private PlanId planId;

셋째, JPA 편의보다 Aggregate 경계를 우선한다. 테이블 관계가 있다고 해서 같은 Aggregate는 아니다.

넷째, 조회 요구와 변경 모델을 구분한다. 화면 조회를 위해 Aggregate를 과도하게 확장하지 않는다.

다섯째, 외부 시스템 DTO나 API 응답 모델을 Entity 안으로 들이지 않는다.

이 정도만 지켜도 JPA와 DDD의 충돌을 상당히 줄일 수 있다.

ORM은 도구일 뿐이다

ORM은 강력한 도구다. 객체와 테이블 사이의 반복적인 매핑을 줄여주고, 트랜잭션과 변경 감지를 편리하게 다룰 수 있게 해준다. 하지만 ORM이 도메인 모델을 설계해주지는 않는다.

도메인 모델은 도메인 질문에서 출발해야 한다.

이 객체는 어떤 생명주기를 갖는가?
어떤 상태 변화가 허용되는가?
어떤 규칙을 스스로 지켜야 하는가?
어디까지가 같은 Aggregate인가?
어떤 관계는 ID로 충분한가?

ORM은 이 답을 저장하는 방법을 제공할 뿐이다. 도메인 질문보다 테이블 매핑 질문이 먼저 오면, 모델은 데이터 구조에 끌려간다.

마치며

JPA Entity는 DDD Entity가 될 수 있다. 하지만 자동으로 그렇게 되는 것은 아니다. JPA Entity는 영속성 기술의 개념이고, DDD Entity는 도메인 모델링의 개념이다.

두 개념을 같은 클래스로 구현하더라도 관심사를 혼동하지 않아야 한다. 테이블 관계가 Aggregate 경계를 결정하지 않는다. 외래키가 객체 참조를 강제하지도 않는다. 지연 로딩의 편리함이 도메인 로직의 명확성보다 중요하지도 않다.

ORM은 도메인 모델을 저장하기 위한 도구다. 도구가 모델을 끌고 가기 시작하면 DDD는 테이블 중심 설계로 돌아간다.

좋은 DDD 구현은 ORM을 부정하지 않는다. 다만 ORM이 도메인의 언어와 경계를 대신 결정하지 못하게 한다.


페이지 수정
공유하기:

이전 글
[DDD Intro #22] 트랜잭션과 일관성: 무엇을 한 번에 지켜야 하는가
다음 글
[DDD Intro #20] Hexagonal Architecture: 도메인을 중심에 두는 구조