Hibernate NaturalId 판단 기준은 고유성이 아니라 불변성이다

어떤 프로퍼티 조합이 고유하고 그 값으로 엔티티 하나를 특정할 수 있으면 자연 키 후보로 본다. 회원의 이메일이 그랬고, 강의의 강사와 제목 조합도 그랬다. 그런데 두 경우에 서로 다른 결정이 나왔다. 이메일에는 @NaturalId를 붙였고, 강사와 제목에는 유니크 제약만 걸었다. Hibernate에서 @NaturalId를 붙일지 정하는 판단 기준이 고유성 하나라면 이 차이가 설명되지 않는다

기준은 두 개다. 고유한가, 그리고 바뀌지 않는가. 유니크 제약은 앞의 하나만 요구하고 자연 키는 둘 다 요구한다

이 글은 수강(Enrollment) 애그리거트를 만들면서 그 두 기준이 갈린 자리를 정리한 것이다. 강의 도메인의 상태 전이는 애그리거트 안에서 강제한다에서 강의 애그리거트를 만들고, 사용자 입력 검증은 예외를 모아서 한 번에 던진다에서 그 위에 애플리케이션 서비스를 올렸다. 이번에는 회원과 강의를 잇는 세 번째 애그리거트를 얹는다

이 글에서 자주 나오는 용어

  • 애그리거트(Aggregate): 함께 변경되어야 하는 엔티티들의 묶음. 애그리거트 루트는 그 대표 엔티티이고, 외부는 항상 루트를 통해서만 내부에 접근한다
  • 대리 키(Surrogate Key): 도메인과 무관하게 저장소가 부여하는 식별자. 이 프로젝트에서는 AbstractEntity가 가진 id
  • 자연 키(Natural Key): 도메인 안에서 대상을 가리키는 데 실제로 쓰이는 값. Hibernate에서는 @NaturalId로 표시한다
  • 유니크 제약(Unique Constraint): 컬럼 또는 컬럼 조합의 값이 테이블 안에서 중복되지 않도록 DB가 강제하는 규칙
  • 브릿지 엔티티: 다대다 관계 사이에 놓여 양쪽을 잇는 엔티티
  • 불변식(Invariant): 도메인이 항상 만족해야 하는 조건. 어기면 프로그램에 버그가 있다는 뜻이다
  • 정적 팩토리 메서드: 생성자 대신 이름을 가진 static 메서드로 객체를 만드는 방식
  • 픽스처(Fixture): 테스트에 필요한 객체를 미리 만들어 주는 코드

수강은 회원과 강의 사이에 놓이는 애그리거트다

수강은 어떤 회원이 어떤 강의를 학습하는 것을 말한다. 한 회원은 여러 강의를 수강할 수 있고 한 강의는 여러 회원이 수강할 수 있다. 전형적인 다대다 관계이고, 수강은 그 사이에 놓이는 브릿지다

Member 1 ──── N  Enrollment  N ──── 1 Course
                 member
                 course
                 status
                 enrolledAt
                 completedAt

브릿지라는 성격 때문에 수강을 회원이나 강의 애그리거트에 딸린 엔티티로 둘 수도 있다. 그렇게 하지 않은 이유는 어느 쪽에 넣어도 반대쪽이 어색해지기 때문이다. 회원 애그리거트에 넣으면 강의 쪽에서 수강자 목록을 볼 수 없고, 강의 애그리거트에 넣으면 회원이 자기 수강 목록을 보려고 강의를 먼저 찾아야 한다. 수강은 독립적인 애그리거트가 된다

진도(Progress)는 수강 애그리거트 밖에 두기로 했다. 아직 구현하지 않은 개념이고 용어사전에만 올라가 있는 상태지만, 경계는 미리 갈라 두었다. 진도는 수업을 하나 볼 때마다 바뀌고 수강 상태는 학습을 시작하거나 끝낼 때만 바뀐다. 변경되는 이유와 시점이 다르면 함께 저장해야 할 이유도 없다. 그래서 지금 수강 애그리거트 안에는 엔티티가 Enrollment 하나뿐이다

이름을 Enrollment로 정한 것은 용어사전에서 수강을 “회원이 강의를 학습하는 것”으로 정의하고 그 영문 이름을 Enrollment로 못 박아 두었기 때문이다. 신청 행위만 가리키는 이름으로 읽힐 수 있지만, 신청부터 학습 완료까지의 전 과정을 이 하나로 부르기로 합의한 결과다

식별자는 두 벌이다

Enrollment는 식별자를 두 벌 가진다. AbstractEntity에서 상속받는 대리 키 id와, 회원과 강의로 이루어진 복합 자연 키다.

@Entity
@Getter
@ToString(callSuper = true, exclude = {"member", "course"})
@NoArgsConstructor
@Table(uniqueConstraints = @UniqueConstraint(
    name = "UK_ENROLLMENT_MEMBER_COURSE", columnNames = {"member_id", "course_id"}))
public class Enrollment extends AbstractEntity {
    @NaturalId
    @ManyToOne(optional = false, fetch = LAZY)
    private Member member;

    @NaturalId
    @ManyToOne(optional = false, fetch = LAZY)
    private Course course;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private EnrollmentStatus status;

    @Column(nullable = false)
    private LocalDateTime enrolledAt;

    private LocalDateTime completedAt;

@NaturalId가 두 개 붙어 있으면 그 둘이 묶여 하나의 복합 자연 키가 된다. Hibernate의 자연 키 조회 API도 자연 키가 복합일 수 있다는 것을 전제로 설명된다(Session.byNaturalId()). 회원은 이메일 하나가 자연 키였는데 여기는 두 값이 한 쌍으로 자연 키가 되는 셈이다. 두 값이 한 쌍으로 고유하다는 것은 곧 “한 회원은 같은 강의를 중복해서 수강 신청할 수 없다”는 도메인 규칙이다. 그 규칙이 매핑 선언으로 그대로 드러난다

필드는 모두 private으로 선언했다. 혼자 만드는 프로젝트에서는 접근 제한자를 생략해도 문제가 드러나지 않지만, 필드를 직접 건드리는 시도를 막는 값은 사람이 여럿 붙는 순간부터 생긴다

기본 생성자에서는 그 원칙을 놓쳤다. @NoArgsConstructor에 접근 수준을 주지 않았으므로 Enrollment의 기본 생성자는 public이다. JPA가 기본 생성자를 요구하는 것과 외부 코드가 그것으로 빈 엔티티를 만들 수 있어야 하는 것은 다른 문제다. MemberCourse에는 @NoArgsConstructor(access = AccessLevel.PROTECTED)가 붙어 있는데 수강에는 빠졌다. 나는 앞 편과 같은 선언인 줄 알고 넘겼다가 코드를 다시 대조하면서 발견했다. Enrollment.enroll()이 유일한 생성 경로가 되도록 만들려면 여기도 PROTECTED가 붙어야 한다

두 날짜의 nullable도 서로 다르게 두었다. enrolledAt은 생성 시점에 반드시 들어가므로 nullable = false이고, completedAt은 완료 전에는 값이 없어야 하므로 제약을 걸지 않았다. 상태에 따라 채워지는 필드가 달라지는 구조라는 뜻이고, 상태가 늘어나면 이런 필드도 함께 늘어난다

@ToString에서 membercourse는 제외했다. 제외하지 않으면 EnrollmenttoString()이 회원과 강의 내부까지 따라 들어가 출력한다. @ManyToOne으로 연결된 객체는 지연 로딩 대상이기도 해서, 로그를 찍는 것만으로 쿼리가 나가는 상황도 만들 수 있다. 연관 관계는 toString()에서 빼는 것을 기본으로 두는 편이 안전하다(Lombok @ToString 문서)

고유하지만 바뀌는 값은 자연 키가 아니다

앞 글에서 강의에는 강사와 제목을 묶은 유니크 제약을 걸었다. 어떤 강사의 어떤 제목을 가진 강의는 중복될 수 없다는 규칙이고, DB 제약과 코드 레벨 중복 검사를 함께 두었다. 그런데 그 두 값에는 @NaturalId를 붙이지 않았다

의도한 결정이다. 강의 제목은 바뀐다. 준비하는 중간에도 바뀌고, 공개한 뒤에도 더 나은 제목이 떠오르면 얼마든지 고칠 수 있다. 반면 Enrollment를 만들 때 넣은 회원과 강의는 바뀌지 않는다. 수강을 취소하는 경우는 있을 수 있어도, 이미 만들어진 수강의 회원이나 강의를 다른 것으로 갈아 끼우는 일은 없다

대상고유한가바뀌는가선언
Member.email그렇다바뀌지 않는다@NaturalId + 유니크 제약
Course.instructor + Course.title그렇다제목이 바뀐다유니크 제약만
Enrollment.member + Enrollment.course그렇다바뀌지 않는다@NaturalId + 유니크 제약

표의 가운데 두 열이 서로 다른 선언으로 이어진다. 고유성은 유니크 제약을 결정하고, 불변성이 거기에 더해질 때만 자연 키가 된다. 강의 제목처럼 어느 순간에는 고유해야 하지만 계속 바뀔 수 있는 값은 유니크 제약까지만 걸어 둔다

Hibernate 문서도 자연 키를 기본적으로 불변으로 취급한다. @NaturalIdmutable 속성은 기본값이 false이고, 문서는 이를 “false(기본값)는 불변임을, true는 가변임을 뜻한다”고 적는다. 6.6 javadoc7.0 javadoc이 같은 문장을 쓰고 있어, 이 판단은 6.x와 7.x 사이에서 흔들리지 않는다. 자연 키가 바뀔 수 있다면 mutable = true를 별도로 켜야 한다는 뜻이고, 애노테이션을 그냥 붙였을 때 선언되는 성질은 “바뀌지 않는다”다. 강의 제목에 @NaturalId를 붙였다면 그 기본값과 어긋나는 선언을 하는 셈이 된다

자연 키로 선언하는 것에는 문서로서의 값도 있다. 이 엔티티를 처음 보는 사람이 무엇으로 이 엔티티를 이해해야 하는지가 매핑에 적혀 있다. Enrollment를 파악하게 해 주는 값은 id의 숫자가 아니고 누가 어떤 강의를 듣는가다

다만 자연 키로 선언했다고 자연 키로 조회하게 되는 것은 아니다. Hibernate는 Session.byNaturalId()로 자연 키 전용 조회 경로를 제공하는데, 이 프로젝트는 회원 ID와 강의 ID로 찾는다. 그래서 @NaturalId 선언이 지금 실제로 하는 일은 스키마와 문서 쪽에 머물러 있다. 조회 경로까지 자연 키로 옮기는 것은 별도의 판단이 필요한 주제다

유니크 제약에는 이름을 직접 준다

복합 자연 키를 선언했으면 같은 회원과 같은 강의를 가진 행이 두 개 이상 저장되면 안 된다. 그래서 @Table에 유니크 제약을 명시했다. columnNames에 적는 것은 자바 필드 이름이 아니라 DB 컬럼 이름이다. @ManyToOne@JoinColumn을 따로 주지 않았으므로 컬럼 이름은 매핑 기본값을 따른다. 아래 DDL에서 실제로 member_idcourse_id로 생성된 것을 확인할 수 있다

이름을 직접 준 목적은 제약을 만드는 데 있지 않다. 제약에 읽을 수 있는 이름을 남기는 데 있다. 실제로 생성된 스키마를 보면 차이가 분명하다. 다음은 spring.jpa.show-sql: true 상태로 테스트를 돌렸을 때 남은 출력에서 enrollment 테이블에 관한 문장을 순서대로 옮긴 것이다. 테스트 DB는 H2다

Hibernate: create table enrollment (id bigint generated by default as identity, completed_at timestamp(6), enrolled_at timestamp(6) not null, status enum ('COMPLETED','ENROLLED','STUDYING') not null, course_id bigint not null, member_id bigint not null, primary key (id))
Hibernate: alter table if exists enrollment drop constraint if exists UK_ENROLLMENT_MEMBER_COURSE
Hibernate: alter table if exists enrollment add constraint UK_ENROLLMENT_MEMBER_COURSE unique (member_id, course_id)
Hibernate: alter table if exists enrollment add constraint FKbhhcqkw1px6yljqg92m0sh2gt foreign key (course_id) references course
Hibernate: alter table if exists enrollment add constraint FK9cc49omvv5knjko94ora5eucb foreign key (member_id) references member

같은 테이블에 제약이 세 개 붙었고, 이름을 준 것과 주지 않은 것이 나란히 있다. 유니크 제약은 UK_ENROLLMENT_MEMBER_COURSE로 나왔다. 이름을 주지 않은 외래 키 두 개는 FK9cc49omvv5knjko94ora5eucbFKbhhcqkw1px6yljqg92m0sh2gt로 나왔다. 두 번째 줄의 drop constraint if exists는 스키마를 다시 만들 때 같은 이름을 먼저 지우는 문장이다. 이름을 명시했기 때문에 지울 대상을 이름으로 지목할 수 있다. 운영 중에 제약 위반 오류가 올라올 때 어느 쪽이 상황을 빨리 알려 주는지는 비교할 필요가 없다. UK_ENROLLMENT_MEMBER_COURSE는 그 자체로 “같은 회원이 같은 강의를 두 번 신청했다”를 읽게 해 준다

자동 생성된다는 설명은 확인하지 못했다

강의에서는 이 선언을 넣지 않아도 Hibernate가 @NaturalId가 붙은 컬럼들을 묶어 유니크 제약을 만들어 준다고 설명했다. 다만 이름이 임의로 붙으니 명시적으로 선언하는 편이 좋다는 것이었다. 나는 처음에 그 설명을 그대로 옮겨 적었다

옮겨 적은 뒤 확인해 보려 했는데 나는 확인하지 못했다. 이 프로젝트에서 @NaturalId가 붙은 곳은 Member.emailEnrollment의 회원·강의뿐이고, 두 곳 모두 명시적 유니크 제약이 이미 걸려 있다. 그래서 위 DDL만으로는 제약이 애노테이션 때문에 생긴 것인지 @Table 선언 때문에 생긴 것인지 가릴 수 없다. @NaturalId javadoc을 끝까지 읽어도 유니크 제약이나 스키마 생성에 관한 언급은 없다. 자동 생성 여부를 가리려면 @Table 선언을 지운 상태로 스키마를 다시 뽑아야 하는데, 이번 편에서는 그 실험을 하지 않았다

그래서 이 글에서는 “Hibernate가 자동으로 만들어 준다”는 문장을 쓰지 않는다. 이름을 직접 주는 근거는 그 문장에 기대지 않아도 위의 FK9cc49omvv5knjko94ora5eucb 하나로 충분하다. 제약이 어디서 생기든, 이름을 주지 않으면 이런 이름이 나온다

상태는 셋이고 진입 경로도 셋이다

수강 상태는 세 가지로 잡았다. 신청이 완료된 ENROLLED, 첫 수업을 보기 시작한 STUDYING, 모든 수업을 마친 COMPLETED다. 신청과 학습 시작을 나눈 이유는 실제로 그 사이에 시간 차가 있기 때문이다. 신청은 해 두고 한참 뒤에 첫 수업을 여는 경우가 흔하다

상태를 바꾸는 방법은 세 가지뿐이고, 각각 이름을 가진 메서드다

        enroll(member, course)
                 │
                 ▼
            [ENROLLED] ──startStudying()──▶ [STUDYING] ──complete()──▶ [COMPLETED]
             enrolledAt 기록                                            completedAt 기록

        그 밖의 순서로 호출하면 IllegalStateException

생성은 공개 생성자 대신 enroll이라는 정적 팩토리 메서드로 열었다. new Enrollment(member, course)는 무엇이 만들어지는지만 말한다. Enrollment.enroll(member, course)는 수강 신청을 통해 수강이 만들어진다는 도메인 사건을 말한다. 상태를 ENROLLED로 두고 신청 일시를 남기는 일까지 이 메서드 안에서 끝난다.

    public static Enrollment enroll(Member member, Course course) {
        member.ensureActive();
        course.ensurePublished();

        Enrollment enrollment = new Enrollment();
        enrollment.member = member;
        enrollment.course = course;
        enrollment.status = EnrollmentStatus.ENROLLED;
        enrollment.enrolledAt = LocalDateTime.now();

        return enrollment;
    }

    public void startStudying() {
        state(status == EnrollmentStatus.ENROLLED, "수강 상태가 ENROLLED가 아닙니다");

        this.status = EnrollmentStatus.STUDYING;
    }

    public void complete() {
        state(status == EnrollmentStatus.STUDYING, "수강 상태가 STUDYING이 아닙니다");

        this.status = EnrollmentStatus.COMPLETED;
        this.completedAt = LocalDateTime.now();
    }

state()org.springframework.util.Assert의 정적 메서드이고, 조건이 거짓이면 IllegalStateException을 던진다. 상태 전이의 사전 조건을 메서드 진입부에 두는 방식은 앞 글에서 강의 애그리거트에 적용한 것과 같다

메시지에 ENROLLED, STUDYING 같은 상수 이름을 그대로 넣은 것은 의도한 선택이다. 이 예외는 이 코드를 잘못 쓰고 있다는 신고이고, 사용자가 값을 고쳐서 해결할 수 있는 문제가 아니다. 회원이 읽을 문장일 필요가 없고, 개발자가 로그에서 곧바로 짚을 문장이어야 한다. 사용자가 입력한 값이 잘못된 경우는 성격이 다르다. 그쪽은 오류를 모아서 한 번에 돌려주는 검증 컴포넌트가 맡는다

사전 조건은 상대 애그리거트에게 묻는다

enroll이 확인하는 것은 두 가지다. 회원이 활성 상태인가, 강의가 공개된 상태인가. 두 검사를 Enrollment가 직접 하지 않고 상대 쪽에 넘겼다

    // Member
    public void ensureActive() {
        state(status == MemberStatus.ACTIVE, "회원의 상태가 ACTIVE가 아닙니다");
    }

    // Course
    public void ensurePublished() {
        state(status == CourseStatus.PUBLISHED, "PUBLISHED 상태가 아닙니다.");
    }

Enrollmentmember.getStatus() == ACTIVE를 직접 비교하면 회원의 상태 규칙이 수강 쪽으로 새어 나온다. 상태가 늘어나거나 활성의 정의가 바뀌면 고쳐야 할 자리가 두 곳이 된다. 각 애그리거트 루트가 자기 상태에 대한 질문에 답하게 두면 규칙이 한 자리에 남는다. ensurePublished()는 강의를 만들 때 이미 있었고, ensureActive()는 회원 쪽에 없어서 이번에 추가했다

이 두 호출이 앞에 있으면 membercoursenull일 때도 그 자리에서 NullPointerException으로 먼저 끊긴다. 그래서 별도의 requireNonNull을 붙이지 않았다. 다만 이건 부수 효과이지 null 계약을 표현한 것은 아니다. null 여부를 계약으로 드러내야 하는 자리는 애플리케이션 서비스의 입력 DTO이고, 그건 다음 편의 주제다

실패하는 테스트를 먼저 만든다

상태 전이는 성공 케이스만 검증하면 절반만 검증한 것이 된다. 사전 조건을 걸어 둔 목적이 잘못된 순서의 호출을 막는 것이기 때문이다. 그래서 상태를 바꾸는 startStudying()complete()는 성공 케이스에 이어 같은 메서드를 한 번 더 부른다. 생성 쪽은 공개되지 않은 강의로 enroll을 시도해 사전 조건을 깬다

    @Test
    void complete() {
        Enrollment enrollment = EnrollmentFixture.createEnrollment();
        enrollment.startStudying();

        enrollment.complete();

        assertThat(enrollment.getStatus()).isEqualTo(EnrollmentStatus.COMPLETED);
        assertThat(enrollment.getCompletedAt()).isNotNull();

        assertThatThrownBy(() -> enrollment.complete()).isInstanceOf(IllegalStateException.class);
    }

마지막 줄이 사전 조건 장치를 실제로 걸리게 만드는 부분이다. complete()를 두 번 부르면 두 번째 호출에서 상태가 이미 COMPLETED이므로 state()가 걸린다. 이 줄이 통과했다는 것은 IllegalStateException이 실제로 던져졌다는 뜻이고, 장치가 동작하는지를 장치가 걸리는 상황으로 확인한 것이다. 로컬 실행 결과 이 테스트를 포함한 EnrollmentTest의 테스트 4개가 모두 통과했다

앞의 두 단정은 다른 것을 잡는다. 상태가 COMPLETED로 바뀌었는지와 완료 일시가 채워졌는지다. 사전 조건만 넣고 상태를 바꾸는 줄을 빼면 마지막 줄은 통과하지만 첫 단정이 STUDYING으로 걸릴 것이다. 그 변형까지 만들어 돌려 보지는 않았으므로 이 문장은 관측이 아니라 코드를 읽은 판단이다

픽스처는 nullable 파라미터로 조합한다

startStudying()complete()를 테스트하려면 그 앞에 회원과 강의를 만들고 수강을 만들어야 한다. 그런데 이 테스트에서 어떤 회원인지, 어떤 강의인지는 중요하지 않다. 준비 코드가 검증하려는 것보다 길어지는 상황이다

public class EnrollmentFixture {
    public static Enrollment createEnrollment(@Nullable Member member, @Nullable Course course) {
        return Enrollment.enroll(member == null ? createActiveMember() : member,
            course == null ? createPublishedCourse() : course);
    }

    public static Enrollment createEnrollment() {
        return createEnrollment(null, null);
    }
}

회원과 강의를 파라미터로 받되 둘 다 null을 허용한다. null이면 픽스처가 알아서 만들고, 값이 넘어오면 그것을 쓴다. 어떤 회원인지가 상관없는 테스트는 인자 없는 createEnrollment()를 부르고, 한 회원의 수강을 여러 개 만들어야 하는 테스트는 회원을 넘긴다. 이 조합은 다음 편의 리포지토리 테스트에서 실제로 필요해진다

Course 쪽에도 픽스처를 하나 추가했다. 수강 신청은 공개된 강의에만 할 수 있는데, 기존 CourseFixture에는 생성 직후 상태의 강의를 만드는 메서드만 있었다

    public static Course createPublishedCourse() {
        Course course = createCourse();
        course.updateInfo(createCourseInfoUpdateRequest(course.getTitle()).toInfo());
        course.submitForReview();
        course.publish();
        return course;
    }

강의 소개가 비어 있을 수 있어 updateInfo()를 한 번 거친 뒤 검수 신청과 공개까지 진행한다. 제목은 원래 만들어진 값을 그대로 넘기고 소개는 픽스처가 채운다. 필드 값 자체는 Instancio가 랜덤하게 만들어 주므로, 이 픽스처가 조립하는 것은 값이 아니고 상태다

유니크 제약을 아직 위반시켜 보지 않았다

이 편에서 만든 장치 중 하나는 검증 없이 남았다. 복합 유니크 제약을 걸었지만 같은 회원과 강의로 두 번 저장해 보지 않았다. 강의 쪽에는 uniqueTitleAndInstructor 테스트가 DataIntegrityViolationException을 확인하는데, 수강에는 같은 테스트가 없다. 이 검증은 DB에 실제로 저장해야 하므로 리포지토리 테스트가 필요하고, 코드 레벨에서 먼저 막는 중복 검사와 함께 다음 편에 붙는다

도메인 자체도 무료 강의를 즉시 수강하는 가장 단순한 형태만 담았다. 강의비 결제, 수강 취소, 선수강 요건, 수강 기간 만료는 모두 빠져 있다. 이 중 만료가 들어오면 상태가 하나 늘어나는 것으로 끝나지 않는다. 만료된 수강에서 complete()를 부를 수 있는지, 만료 판정을 누가 언제 내리는지가 따라온다. complete()의 사전 조건도 다시 봐야 한다

자연 키를 고를 때의 질문은 이 값이 지금 고유한가에서 끝나지 않는다. 앞으로도 그대로일 것인가까지 답해야 자연 키다


출처와 범위

여기서 다룬 수강 애그리거트는 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2 강의에 나오는 온라인 학습 서비스 예제 Splearn을 따라 구현한 것이다. 용어와 설계 판단의 출처는 강의다. 인용한 코드와 DDL은 강의 예제를 따라 직접 구현해 로컬에서 돌린 결과이고, 저장소는 공개하지 않아 커밋 URL은 걸지 않는다

참고 자료