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

[DDD Intro #12] Entity: 식별성과 생명주기를 가진 도메인 객체

페이지 수정

Value Object는 값으로 식별된다. 10,000원은 10,000원이고, 같은 시작일과 종료일을 가진 기간은 같은 기간이다. 중요한 것은 그 값 자체다.

Entity는 다르다. Entity의 핵심은 식별성이다. 속성이 바뀌어도 같은 대상으로 추적되어야 하는 도메인 개념이 있다. 시간 속에서 상태가 변하고, 그 변화에도 불구하고 “같은 것”으로 남아야 하는 객체가 있다.

구독 서비스에서 Subscription은 좋은 예시다. 구독은 생성되고, 갱신되고, 요금제가 변경되고, 일시정지되고, 해지되고, 만료될 수 있다. 상태는 계속 바뀌지만 우리는 그것을 같은 구독으로 본다.

이것이 Entity다.

Entity는 데이터 묶음이 아니다

Entity를 단순히 데이터베이스 테이블에 대응하는 객체로 생각하면 중요한 점을 놓친다. DDD에서 Entity는 단순한 데이터 묶음이 아니라, 도메인에서 식별성과 생명주기를 갖는 개념이다.

예를 들어 두 구독이 같은 요금제, 같은 시작일, 같은 만료일을 갖고 있다고 해보자.

구독 A
- 요금제: PRO
- 시작일: 2026-07-01
- 만료일: 2026-08-01

구독 B
- 요금제: PRO
- 시작일: 2026-07-01
- 만료일: 2026-08-01

속성만 보면 둘은 같다. 하지만 실제로는 다른 사용자의 다른 구독일 수 있다. 환불, 갱신, 사용 권한, 결제 이력도 각각 다르게 이어진다.

따라서 Entity는 속성 값만으로 동일성을 판단하지 않는다. 식별자가 필요하다.

public class Subscription {
    private SubscriptionId id;
    private MemberId memberId;
    private PlanId planId;
    private SubscriptionStatus status;
    private SubscriptionPeriod period;
}

여기서 SubscriptionId는 단순한 DB PK 이상의 의미를 가진다. 이 구독을 시간 속에서 추적하기 위한 도메인적 식별자다.

식별자는 언제 부여되는가

Entity를 설계할 때 식별자를 언제 부여할지도 고민해야 한다. 데이터베이스에 저장된 뒤에 생성되는 식별자를 사용할 수도 있고, 도메인 객체를 만들 때부터 식별자를 부여할 수도 있다.

단순한 시스템에서는 DB가 생성하는 ID를 사용해도 충분하다. 하지만 도메인 이벤트를 발행하거나, 저장 전에도 객체를 식별해야 하거나, 여러 시스템 사이에서 식별자가 필요하다면 애플리케이션에서 먼저 식별자를 만들 수 있다.

Subscription subscription = Subscription.start(
    SubscriptionId.newId(),
    memberId,
    plan,
    now
);

중요한 것은 식별자의 생성 방식 자체가 아니라, 도메인에서 이 객체를 어떻게 추적할 것인가다. Entity는 한 시점의 데이터가 아니라 시간 속에서 이어지는 대상이기 때문이다.

Entity에는 생명주기가 있다

Entity는 생성된 뒤 상태가 변한다. 이 변화는 아무렇게나 일어나지 않는다. 도메인 규칙에 따라 허용되는 변화와 허용되지 않는 변화가 있다.

구독을 예로 들면 다음과 같은 생명주기가 있을 수 있다.

무료 체험 시작
정기 구독 전환
요금제 변경
결제 실패로 인한 유예 기간 시작
갱신 성공
사용자 해지
기간 만료
관리자 직권 종료

이 생명주기를 단순한 setter로 표현하면 도메인 규칙이 사라진다.

subscription.setStatus(SubscriptionStatus.CANCELED);

이 코드는 “왜 취소되었는가?”, “취소 가능한 상태였는가?”, “환불이 필요한가?”, “만료일까지 이용 가능한가?” 같은 질문에 답하지 않는다. 단지 상태 값을 바꿀 뿐이다.

Entity는 자신의 생명주기 규칙을 행위로 표현해야 한다.

subscription.cancelByMember(reason, now);
subscription.expire(now);
subscription.renew(paymentResult, now);
subscription.changePlan(targetPlan, policy, now);

이런 메서드는 단순히 값을 바꾸는 것이 아니라 도메인 행위를 나타낸다.

상태 변경과 도메인 행위는 다르다

Entity 설계에서 가장 흔한 실수는 상태 변경을 도메인 행위라고 착각하는 것이다.

subscription.setPlanId(targetPlanId);
subscription.setStatus(ACTIVE);
subscription.setExpiredAt(nextMonth);

이 코드는 요금제를 변경하는 것처럼 보이지만, 실제로는 필드 값을 바꾸고 있을 뿐이다. 요금제를 변경할 수 있는 상태인지, 추가 결제가 필요한지, 남은 기간은 어떻게 계산할지, 기업 요금제의 최소 좌석 수를 만족하는지 같은 규칙은 보이지 않는다.

도메인 행위는 이런 질문을 객체 안으로 가져온다.

subscription.changePlan(targetPlan, planChangePolicy, now);

이 메서드 안에서 구독은 자신의 상태를 확인하고, 정책에 따라 변경 가능 여부를 판단하며, 필요한 상태 변화를 수행한다. 외부 코드는 “필드를 어떻게 바꿀지”가 아니라 “무엇을 요청할지”를 말한다.

이 차이가 Entity를 데이터 객체가 아니라 도메인 객체로 만든다.

JPA Entity와 DDD Entity는 같은 말이 아니다

Java와 Spring을 사용하는 팀에서는 Entity라는 말을 들으면 JPA Entity를 먼저 떠올리는 경우가 많다. JPA Entity도 식별자를 갖고 데이터베이스 테이블과 매핑된다. 그래서 DDD Entity와 비슷해 보인다.

하지만 둘은 같은 개념이 아니다.

JPA Entity는 ORM의 영속성 단위다. 데이터베이스와 객체를 매핑하기 위한 기술적 개념이다. DDD Entity는 도메인에서 식별성과 생명주기를 가진 모델링 개념이다.

두 개념이 같은 클래스에 함께 존재할 수는 있다. 실제 프로젝트에서 DDD Entity를 JPA Entity로 구현하는 경우도 많다. 하지만 이때도 중요한 것은 기술이 도메인을 압도하지 않게 하는 것이다.

예를 들어 JPA 편의를 위해 모든 연관관계를 양방향으로 열어두면 Aggregate 경계가 흐려질 수 있다. 기본 생성자나 프록시 제약 때문에 도메인 객체가 불완전한 상태로 만들어질 수도 있다. 영속성 기술의 요구가 도메인 모델의 불변식을 깨뜨릴 수 있다.

따라서 JPA Entity로 DDD Entity를 구현하더라도 질문은 계속 도메인 중심이어야 한다.

이 객체는 어떤 도메인 식별성을 갖는가?
어떤 생명주기를 갖는가?
어떤 상태 변화가 허용되는가?
어떤 불변식을 스스로 지켜야 하는가?

DB 테이블 구조가 이 질문을 대신해주지는 않는다.

Entity는 가능한 한 스스로를 보호해야 한다

좋은 Entity는 아무 상태로나 변하지 않는다. 자신의 불변식을 지켜야 한다.

예를 들어 만료된 구독은 요금제를 변경할 수 없다고 해보자.

public void changePlan(Plan targetPlan, PlanChangePolicy policy, Instant now) {
    if (this.status == SubscriptionStatus.EXPIRED) {
        throw new IllegalStateException("만료된 구독은 요금제를 변경할 수 없습니다.");
    }

    PlanChangeResult result = policy.calculate(this, targetPlan, now);
    this.planId = targetPlan.id();
    this.period = result.nextPeriod();
}

이 규칙이 Application Service에만 있으면 다른 경로에서 쉽게 우회될 수 있다. 관리자 기능, 배치 작업, 이벤트 핸들러가 같은 Entity를 수정할 때 규칙을 빠뜨릴 수 있다.

Entity가 스스로를 보호하면, 어떤 유스케이스에서 호출되더라도 최소한의 도메인 규칙은 유지된다.

물론 모든 규칙을 Entity에 넣어야 한다는 뜻은 아니다. 여러 Aggregate가 관련된 판단이나 외부 정책이 필요한 계산은 Domain Service나 Policy 객체로 분리할 수 있다. 하지만 Entity 자신에 대한 핵심 불변식은 Entity가 아는 것이 자연스럽다.

마치며

Entity는 데이터베이스 행이 아니다. Entity는 시간 속에서 정체성을 유지하는 도메인 개념이다. 속성이 바뀌어도 같은 대상으로 추적되어야 하고, 그 변화는 도메인 규칙에 따라 일어나야 한다.

Entity를 잘 설계하려면 “어떤 필드를 가질 것인가?”보다 먼저 “이 객체는 어떤 생명주기를 갖는가?”를 물어야 한다. 그리고 “상태를 어떻게 바꿀 것인가?”보다 “도메인에서 어떤 행위가 일어나는가?”를 물어야 한다.

setStatus(CANCELED)cancelByMember(reason, now)는 같은 결과를 만들 수 있다. 하지만 두 코드는 전혀 다른 이야기를 한다. 전자는 데이터를 수정하고, 후자는 도메인의 행위를 표현한다.

DDD에서 Entity는 바로 그 차이를 코드 안에 남기는 도구다.


페이지 수정
공유하기:

이전 글
[DDD Intro #13] 도메인 객체에 행위를 넣는다는 것
다음 글
[DDD Intro #11] Value Object: 원시값이 아니라 도메인 개념으로 표현하기