JPA OrderColumn과 orphanRemoval은 수업 이동에서 충돌한다

도메인 코드에서 수업을 지우는 방법은 리스트에서 빼는 것이다. JPA의 orphanRemoval은 그렇게 빠진 엔티티를 DB에서도 지워 준다. 편한 설정이지만 커리큘럼에서는 켤 수 없었다. 수업 이동이 “한 리스트에서 빼서 다른 리스트에 넣는” 동작이고, JPA 스펙은 고아가 된 엔티티를 다른 관계에 다시 넣지 말라고 한다. 이 글은 JPA OrderColumn과 orphanRemoval의 충돌을 중심으로, 커리큘럼 애그리거트를 저장하며 내린 결정을 정리한다

2편까지는 DB 없이 자바 객체만으로 커리큘럼을 편집했다. 이 편은 그 트리를 JPA로 저장하고 다시 읽는다. 순서 보존, 이동과 삭제, 두 단계 컬렉션의 조회 쿼리 수, 그리고 매핑을 orm.xml로 옮기며 드러난 스키마 규칙을 다룬다

이 글에서 자주 나오는 용어

  • 영속성 컨텍스트: JPA가 엔티티를 관리하는 1차 캐시. flush()는 쌓인 변경을 DB로 보내고, clear()는 캐시를 비운다
  • Cascade: 부모 엔티티에 한 저장·삭제 같은 연산을 자식에게 전파하는 설정
  • orphanRemoval: 부모와의 관계에서 빠진 자식 엔티티를 자동으로 삭제하는 설정
  • 지연 로딩(Lazy Loading): 연관 객체를 실제로 쓰는 순간에 SELECT를 실행하는 방식
  • N+1 문제: 부모를 1번 조회한 뒤, 자식을 가져오는 쿼리가 부모 수만큼 더 나가는 현상

리스트의 순서는 DB에 저장되지 않는다

커리큘럼은 일대다가 두 번 겹친 구조다

Curriculum 1 ──< Section N           sections: List<Section>
                 Section 1 ──< Lesson N    lessons: List<Lesson>

자바 List는 넣은 순서를 기억한다. 2편의 편집 코드는 전부 그 약속 위에 서 있다. 그런데 테이블의 행에는 순서가 없다. 섹션을 중간에 끼워 넣고 저장한 뒤 다시 읽으면, ORDER BY가 없는 한 어떤 순서로 돌아올지 보장이 없다

강의에서 처음 저장했을 때의 section 테이블에는 id, title, curriculum_id만 있었다. 순서를 복원할 값이 어디에도 없다. Hibernate 6.6 사용자 가이드도 “리스트의 순서는 기본적으로 유지되지 않는다”고 적는다. 순서를 유지하려면 @OrderColumn을 명시해야 한다

방법은 두 가지다. 엔티티에 position 같은 필드를 두고 직접 관리하거나, JPA에 맡기거나. 직접 관리하면 삽입할 때 뒤쪽 번호를 밀고, 삭제할 때 당기고, 이동할 때 두 번 다 해야 한다. 2편에서 List가 공짜로 해 주던 일을 도메인 코드가 다시 하는 셈이다. 그래서 JPA에 맡겼다

OrderColumn은 순서를 0부터 빈틈없이 다시 쓴다

Jakarta Persistence 3.1 스펙의 OrderColumn 절은 책임을 이렇게 나눈다

The persistence provider is responsible for updating the ordering upon
flushing to the database to reflect any insertion, deletion, or reordering
affecting the list.
...
The persistence provider must maintain a contiguous (non-sparse) ordering of
the values of the order column when updating the association or element
collection. The order column value for the first element of the list must be 0.

삽입, 삭제, 재정렬을 반영하는 일은 구현체가 flush할 때 한다. 그리고 순서 값은 0부터 빈틈없이 이어져야 한다. 중간 원소 하나를 지우면 그 뒤 원소들의 순서 값이 모두 하나씩 줄어야 한다는 뜻이다

매핑은 최종적으로 orm.xml에 들어갔다

<entity class="kimspring.splearn.domain.curriculum.Section">
    <attributes>
        <basic name="title">
            <column name="title" length="200"/>
        </basic>
        <many-to-one name="curriculum" optional="false" fetch="LAZY"/>
        <one-to-many name="lessons" mapped-by="section">
            <order-column name="lesson_order"/>
            <cascade>
                <cascade-all/>
            </cascade>
        </one-to-many>
    </attributes>
</entity>

lesson 테이블에 lesson_order 컬럼이 생긴다. Lesson 클래스에는 이 필드가 없다. 스펙 표현대로 순서 컬럼은 엔티티 상태의 일부가 아니다. 이름을 그냥 order로 하면 SQL 예약어와 부딪히므로 앞에 대상 이름을 붙였다

강의 화면의 SQL 로그에서 동작이 보인다. 섹션과 수업을 INSERT한 뒤, section_order와 lesson_order를 UPDATE로 한꺼번에 채운다. S1의 첫 수업 L1을 S2의 맨 뒤로 옮기는 테스트는 moveLesson(0, 0, 1, 1)이다. 이때 먼저 L1의 section_id를 S2로 바꾸는 UPDATE가 나갔다. 이어서 순서 값을 다시 쓰는 UPDATE가 나갔다. 강의 설명으로는 L2가 두 번째 자리에서 첫 번째로 당겨졌고, 위치가 바뀌지 않은 L3의 순서 값도 다시 쓰였다. 강의가 말한 UPDATE 수는 세 번이다. 원본 로그는 나에게 없어서 SQL 문장은 옮기지 않는다

위치가 바뀌지 않은 L3도 다시 쓰였다는 점이 중요하다. 리스트가 바뀌면 그 리스트의 순서 값을 전부 다시 쓰는 것으로 보인다. 이 해석은 로그에 찍힌 UPDATE에서 나온 것이고, Hibernate 소스로 확인하지는 않았다

이 비용 때문에 순서 컬럼을 피하는 팀도 있다. 원소가 수천 개인 리스트에서 하나를 앞으로 옮기면 UPDATE가 수천 번 나간다. 그런 경우에는 position을 직접 관리하고 “이 번호 이후를 모두 +1” 같은 벌크 UPDATE 한 번으로 미는 편이 낫다. 대신 번호가 하나만 어긋나도 순서가 깨지고, 코드가 객체 중심에서 멀어진다. 커리큘럼의 섹션과 수업은 수십 개 수준이므로 @OrderColumn 쪽을 골랐다. 수천 개일 때 얼마나 느려지는지는 재지 않았다

수업 이동이 있으면 orphanRemoval을 켤 수 없다

도메인을 처음 만들 때 나는 두 컬렉션 모두에 orphanRemoval = true를 걸었다

@OneToMany(mappedBy = "section", cascade = ALL, orphanRemoval = true)
private List<Lesson> lessons = new ArrayList<>();

removeLesson이 리스트에서 수업을 빼기만 해도 DB에서 지워 주니, 도메인 코드가 저장소를 몰라도 된다. 그런데 Jakarta Persistence 3.1 스펙 2.9절의 orphanRemoval 설명에 이런 문장이 있다

The orphanRemoval functionality is intended for entities that are privately
"owned" by their parent entity. Portable applications must otherwise not depend
upon a specific order of removal, and must not reassign an entity that has been
orphaned to another relationship or otherwise attempt to persist it.

고아가 된 엔티티를 다른 관계에 다시 연결하지 말라는 것이다. 2편의 moveLesson이 정확히 그 일을 한다.

to.addLesson(toLessonIndex, from.removeLesson(fromLessonIndex));

from의 리스트에서 빠지는 순간 수업은 고아가 된다. 그 수업을 곧바로 to의 리스트에 넣는다. 우리 의도는 이동인데, JPA 입장에서는 “삭제 대상이 된 엔티티를 다시 붙이는” 동작이다. 스펙은 이 경우의 결과를 보장하지 않는다. 섹션 삭제의 moveAllLessonsTo도 수업을 옮기므로 같은 문제를 안는다

그래서 두 컬렉션의 orphanRemoval을 모두 뺐다. 처음 켠 설정이 스펙과 맞지 않아 뒤집힌 자리다. 섹션 컬렉션은 섹션 자체가 이동하지 않으니 켜 둬도 된다. 하지만 수업은 끄고 섹션은 켜 두면 삭제 방식이 두 가지가 되어 헷갈린다. 강의를 따라 둘 다 명시적 삭제로 통일했다

강의에서는 켠 채로 이동하면 실행 중에 문제가 생긴다고 설명했다. 나는 orphanRemoval을 켠 채 이동 테스트를 돌려 그 실패를 재현하지 않았다. 근거는 스펙 문장이다

지운 엔티티는 리포지토리로 한 번 더 지운다

orphanRemoval을 끄면 무엇이 달라지는지도 강의 로그에 있다. S1의 첫 수업을 리스트에서 빼고 저장하자 SQL은 UPDATE 하나였다. 남은 수업의 lesson_order를 앞으로 당기는 쿼리다. 빠진 수업의 행은 DB에 그대로 남았다. orphanRemoval을 다시 켜고 같은 작업을 하면 DELETE 한 번과 UPDATE 한 번이 나갔다

그래서 도메인의 삭제 메서드가 지운 엔티티를 돌려주게 바꿨다. 받은 쪽이 저장소에서 지운다

public Lesson removeLesson(int sectionIndex, int lessonIndex) {
    return this.sections.get(sectionIndex).removeLesson(lessonIndex);
}

public Section removeSection(int sectionIndex) {
    ...
    return removed;
}

삭제만 하는 리포지토리를 두 개 추가했다

public interface LessonRepository extends Repository<Lesson, Long> {
    void delete(Lesson lesson);
}

public interface SectionRepository extends Repository<Section, Long> {
    void delete(Section section);
}

리포지토리는 애그리거트 루트마다 하나만 둔다는 원칙에서 벗어난다. 그래서 조회와 저장 메서드는 넣지 않았다. 스프링 데이터의 빈 Repository 인터페이스를 상속하고 delete 하나만 선언하면 구현이 만들어진다. 바깥에서 이 리포지토리로 수업을 읽어 와 루트를 우회하는 길이 생기지 않는다

강의는 이 두 리포지토리를 처음에 테스트 소스에 만들었고, 코드 리뷰에서야 제품 코드의 required 패키지로 옮겼다. 나는 처음부터 application.curriculum.required에 두었다. 대신 다른 곳에서 강의와 같은 실수를 했다. 애플리케이션 서비스에서 이 delete를 부르지 않았다. 그 이야기는 4편에서 한다

리포지토리 테스트는 저장 → 비우기 → 다시 읽기 → 편집 → 저장 → 비우기 → 다시 읽기 순서로 짰다

@Test
void removeLesson() {
    Long curriculumId = saveCurriculum();
    Curriculum curriculum = curriculumRepository.findWithSectionsById(curriculumId).orElseThrow();

    Lesson lesson = curriculum.removeLesson(0, 0);
    lessonRepository.delete(lesson);

    curriculumRepository.save(curriculum);

    entityManager.flush();
    entityManager.clear();

    curriculum = curriculumRepository.findWithSectionsById(curriculumId).orElseThrow();
    assertThat(SectionContent.from(curriculum)).containsExactly(
        section("S1", lesson("L2")),
        section("S2", lesson("L3"))
    );
}

메모리에 남은 객체로 확인하면 DB를 거치지 않는다. flush()로 변경을 DB에 보내고 clear()로 캐시를 비운 뒤 다시 읽어야 DB에 반영된 결과를 본다. 강의에서도 한 번 이 부분을 틀렸다. 다시 읽은 객체 대신 처음 저장한 객체로 단언하는 바람에 쿼리가 나가지 않았다

이 테스트는 삭제 누락을 잡지 못한다

위 테스트를 다시 보면 확인하는 것은 다시 읽은 트리 모양뿐이다. 그런데 강의 로그에서 delete를 부르지 않았을 때도 다시 읽은 트리는 올바르게 나왔다. 행은 남아 있는데 트리에는 보이지 않은 것이다. 남은 행이 왜 트리에서 빠지는지는 확인하지 않았다

결과적으로 lessonRepository.delete(lesson) 줄을 지워도 이 테스트는 통과할 가능성이 높다. 삭제를 검증하려면 지운 엔티티를 아이디로 찾아 없어졌는지 봐야 한다

assertThat(entityManager.find(Lesson.class, lesson.getId())).isNull();

이 단언은 아직 저장소에 없다. 삭제를 명시적으로 하기로 해 놓고, 그 삭제가 일어났는지는 어느 테스트도 확인하지 않는다

두 단계 컬렉션을 지연 로딩하면 쿼리가 섹션 수만큼 는다

findById로 커리큘럼을 읽으면 curriculum 테이블만 조회한다. 섹션과 수업은 지연 로딩이다. 트리를 순회하는 순간 쿼리가 따라 나간다

findById(curriculumId)          SELECT curriculum            1
getSections() 순회               SELECT section ...           2
S1.getLessons() 순회             SELECT lesson ... S1         3
S2.getLessons() 순회             SELECT lesson ... S2         4

섹션이 두 개면 수업 조회가 두 번, 열 개면 열 번이다. 전형적인 N+1이다

로그를 눈으로 세는 대신 테스트로 셀 수 있다. Hibernate의 통계 기능을 쓴다

protected Statistics prepareStatistics() {
    Statistics statistics = entityManager.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
    statistics.setStatisticsEnabled(true);
    statistics.clear();
    return statistics;
}

unwrap으로 JPA 표준 객체 뒤의 Hibernate SessionFactory를 꺼내고, 통계를 켜고 비운다.Statistics.getPrepareStatementCount()는 그 뒤로 준비된 JDBC 문의 수를 돌려준다

Statistics statistics = prepareStatistics();

Curriculum found = curriculumRepository.findById(curriculumId).orElseThrow();
assertThat(statistics.getPrepareStatementCount()).isEqualTo(1);

assertThat(SectionContent.from(found)).containsExactly(
    section("S1", lesson("L1"), lesson("L2")),
    section("S2", lesson("L3"))
);
assertThat(statistics.getPrepareStatementCount()).isEqualTo(4);

1과 4는 저장소 테스트의 단언값이다. 강의 화면에서도 같은 수가 출력됐다. SectionContent.from()이 트리를 끝까지 순회하므로 지연 로딩이 모두 일어난다. 이 테스트는 “findById는 N+1을 일으킨다”는 사실 자체를 고정한다. 나중에 누군가 매핑을 바꿔 이 수가 달라지면 테스트가 알려 준다

EntityGraph로 애그리거트를 한 번에 읽는다

애그리거트 전체를 다룰 때는 한 번에 읽는 메서드를 따로 둔다

@EntityGraph(attributePaths = {"sections", "sections.lessons"})
Optional<Curriculum> findWithSectionsById(Long curriculumId);

스프링 데이터 JPA의 @EntityGraph에 attributePaths를 주면 그 경로의 연관을 함께 가져오는 그래프를 즉석에서 만든다. 같은 테스트를 이 메서드로 돌리면 단언값이 1과 1이다. 강의 로그의 SQL은 curriculum에 section과 lesson을 LEFT OUTER JOIN한 한 문장이었다. 읽어 온 행을 section_order와 lesson_order로 정렬해 리스트에 채운다

LEFT OUTER JOIN이어야 하는 이유가 있다. 수업이 없는 섹션도 편집 중에는 존재한다. INNER JOIN이면 그 섹션이 결과에서 사라진다

메서드 이름에 조회 방식이 드러나는 것은 기술이 도메인 쪽으로 새는 것이다. 애플리케이션 서비스가 “섹션까지 읽을지”를 골라야 하기 때문이다. 그래도 지연 로딩에 맡겨 N+1을 방치하는 것보다 낫다고 봤다

실제로 고르는 쪽이 일관되지 않다. 조회 서비스의 firstLesson, nextLesson은 findWithSections를 쓴다. 반면 편집 서비스의 편집 메서드는 모두 find로 읽는다

public Curriculum moveLesson(Long curriculumId, int fromSectionIndex, int fromLessonIndex,
    int toSectionIndex, int toLessonIndex) {
    Curriculum curriculum = curriculumFinder.find(curriculumId);
    ...

moveLesson 한 번에 커리큘럼, 섹션 목록, 옮기는 두 섹션의 수업 목록이 따로 조회될 것이다. 섹션 삭제는 옆 섹션의 수업까지 읽는다. 편집 요청 하나에 쿼리가 몇 번 나가는지는 재지 않았다. 편집은 조회보다 드물다는 이유로 넘어갔지만, 통계 테스트 하나면 확인할 수 있는 값이다

매핑을 orm.xml로 옮기자 요소 순서가 스키마에 묶였다

이 프로젝트는 JPA 매핑을 애노테이션 대신 orm.xml에 둔다. 도메인 클래스에서 length, fetch, cascade 같은 DB 관점의 값을 걷어 내려는 결정이다. 배경은 애그리거트 도메인 상태 전이 설계로 잘못된 호출 막기에서 다뤘다. 나는 같은 커밋에서 수강(Enrollment) 매핑도 함께 옮겼다.

옮긴 뒤 도메인 클래스에는 속성 없는 애노테이션만 남았다

@OneToMany
@Getter(AccessLevel.NONE)
private List<Section> sections = new ArrayList<>();

mappedBy도 cascade도 없는데 동작하는 이유는 스펙 12장의 XML 우선 규칙에 있다. xml-mapping-metadata-complete가 없으면 XML이 애노테이션 값을 덮어쓴다. 그리고 one-to-many 요소가 있으면 명시하지 않은 속성에는 기본값이 적용된다. 이 필드의 진짜 매핑은 XML이고, 남은 @OneToMany는 관계 종류를 알려 주는 표시에 가깝다. Curriculum.java에는 이제 쓰이지 않는 CascadeType, FetchType static import도 남아 있다

강의에서는 XML을 옮기다 IntelliJ가 빨간 줄을 그었다. one-to-many를 one-to-one보다 위로 올리니 사라졌다. 강의는 이유를 모르겠다며 넘어갔다. 이유는 스키마다. 이 프로젝트가 쓰는 Hibernate 매핑 스키마 mapping-3.1.0.xsd는 attributes를 순서가 고정된 xsd:sequence로 정의한다

<xsd:sequence>
    <xsd:element name="description" type="xsd:string" minOccurs="0"/>
    <xsd:choice>
        <xsd:element name="id" type="orm:id" minOccurs="0" maxOccurs="unbounded"/>
        <xsd:element name="embedded-id" type="orm:embedded-id" minOccurs="0"/>
    </xsd:choice>
    <xsd:element name="natural-id" type="orm:natural-id" minOccurs="0"/>
    <xsd:element name="basic" type="orm:basic" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="version" type="orm:version" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="many-to-one" type="orm:many-to-one" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="one-to-many" type="orm:one-to-many" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="one-to-one" type="orm:one-to-one" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="many-to-many" type="orm:many-to-many" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="element-collection" type="orm:element-collection" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="embedded" type="orm:embedded" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="transient" type="orm:transient" minOccurs="0" maxOccurs="unbounded"/>
    ...
</xsd:sequence>

sequence는 나열한 순서대로만 요소를 허용한다. basic → many-to-one → one-to-many → one-to-one 순서를 어기면 스키마 위반이다. 표준 스키마인 Jakarta Persistence orm_3_1.xsd도 같은 순서다. IDE는 이 스키마로 파일을 검사해 빨간 줄을 긋는다

그렇다면 순서를 어긴 XML도 실행 중에는 거절되지 않아야 한다. 이 프로젝트의 Spring Boot 3.5.13은 Hibernate 6.6.45.Final을 쓴다. 6.6의 MappingBinder는 설정을 읽어 XML 검증 여부를 정하는데, hibernate.validate_xml 값이 없으면 false를 돌려준다. 런타임은 스키마 순서를 검사하지 않는다는 뜻이다. 설정 상수의 javadoc에는 기본값이 true로 적혀 있어 코드와 다르다. 나는 코드를 따랐다. hibernate.validate_xml=true로 켜고 돌려 보지는 않았다

그 결과 내가 옮긴 Enrollment 블록이 순서를 어긴 채 남아 있다

<entity class="kimspring.splearn.domain.enrollment.Enrollment">
    ...
    <attributes>
        <many-to-one name="member" optional="false" fetch="LAZY"/>
        <many-to-one name="course" optional="false" fetch="LAZY"/>
        <basic name="status">
            ...
        </basic>
        <basic name="enrolledAt">
            ...
        </basic>
    </attributes>
</entity>

basic이 many-to-one보다 뒤에 있다. IDE에서는 빨간 줄이 보일 자리다. 런타임은 위 소스대로라면 검증하지 않으므로 거절하지 않을 것이다. 이 블록으로 테스트를 돌린 결과는 이 글을 쓰며 확인하지 못했다. 두 basic을 위로 올리면 된다. 커리큘럼 블록은 강의가 순서를 맞춘 덕분에 문제가 없다

순서를 지키는 일은 JPA에 맡기되, 순서를 흔드는 동작이 있는 컬렉션에서는 삭제만큼은 직접 해야 한다


출처와 범위

순서 컬럼, 명시적 삭제, 쿼리 수 테스트, EntityGraph 조회는 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2의 흐름을 따랐다. 본문의 SQL 순서와 개수는 강의 화면의 로그다

커리큘럼 애그리거트 시리즈

  1. 커리큘럼 애그리거트는 탐색이 아니라 편집을 기준으로 트리로 설계한다
  2. 리스트 인덱스로 애그리거트 구조를 편집하면 삭제가 인덱스를 먼저 바꾼다
  3. JPA OrderColumn과 orphanRemoval은 수업 이동에서 충돌한다 (이 글)
  4. 도메인에 위임만 하는 애플리케이션 서비스도 테스트해야 버그가 드러난다
  5. DIP로 컴포넌트 순환 의존을 끊으려면 시그니처의 타입까지 옮겨야 한다

참고 자료