리스트 인덱스로 애그리거트 구조를 편집하면 삭제가 인덱스를 먼저 바꾼다

커리큘럼 편집 요청은 전부 숫자로 온다. “섹션 0번의 수업 2번을 섹션 1번의 0번 자리로 옮겨라” 같은 식이다. 리스트 인덱스로 애그리거트 구조 편집을 구현하면 코드는 짧다. 대신 remove가 한 번 일어나면 그 뒤 원소의 인덱스가 하나씩 당겨진다는 사실을 계속 의식해야 한다. 이 글의 버그 후보는 모두 그 자리에서 나왔다

1편에서 커리큘럼을 섹션 → 수업의 트리로 설계했다. 이 편은 그 트리를 TDD로 편집 가능하게 만든 과정이다. 삽입 인덱스의 뜻, 섹션 삭제 규칙, 수업 이동의 인덱스 기준, 트리 전체를 한 번에 검증하는 테스트 방식을 다룬다

이 글에서 자주 나오는 용어

  • 삽입 인덱스: 새 원소가 들어간 뒤 차지할 자리. List.add(index, e)의 index다
  • TDD(Test-Driven Development): 실패하는 테스트를 먼저 쓰고, 통과시키는 코드를 나중에 쓰는 개발 방식
  • 학습 테스트(Learning Test): 언어나 라이브러리의 동작을 확인하려고 쓰는 테스트. 제품 코드는 검증하지 않는다
  • 스냅샷: 테스트에서 비교하기 쉽도록 현재 객체 구조의 필요한 값만 뽑아 만든 복사본

모든 편집은 루트에 인덱스로 요청한다

화면에서 강사는 섹션과 수업을 드래그한다. 드래그의 결과는 위치다. 그래서 커리큘럼의 편집 메서드는 아이디가 아니라 인덱스를 받는다

public Section addSection(String title)
public Section addSection(int sectionIndex, String title)
public Lesson addLesson(int sectionIndex, String title)
public Section updateSectionTitle(int sectionIndex, String title)
public void updateLessonTitle(int sectionIndex, int lessonIndex, String title)
public Lesson removeLesson(int sectionIndex, int lessonIndex)
public Section removeSection(int sectionIndex)
public void moveLesson(int fromSectionIndex, int fromLessonIndex, int toSectionIndex, int toLessonIndex)

모두 Curriculum의 public 메서드다. Section과 Lesson의 변경 메서드는 패키지 전용(package-private)으로 두었다. 바깥에서는 섹션 제목 하나를 바꿀 때도 루트를 거쳐야 한다. 섹션 제목을 section.title = ...처럼 직접 바꾸지 않고, 그 엔티티의 updateTitle()을 통해서만 바꾼다

파라미터 이름도 규칙이 있다. 처음에는 index라고 썼다가 sectionIndex로 바꿨다. 곧 sectionIndex와 lessonIndex가 한 메서드에 같이 나오기 때문이다. 이동에는 인덱스가 네 개 나오므로 from과 to까지 붙였다

삽입 인덱스는 “이 원소 앞”이라는 뜻이다

섹션이 S1, S2 두 개 있을 때 둘 사이에 새 섹션을 넣으려면 인덱스로 무엇을 줘야 할까. 0일까 1일까

위치:   0     1     2
       ▼     ▼     ▼
        [S1]  [S2]

삽입 인덱스는 “새 원소가 들어간 뒤의 자리”다. 달리 말하면 “지금 이 인덱스에 있는 원소 앞”이다. 맨 앞은 0, S1과 S2 사이는 1, 맨 뒤는 2다. 원소가 두 개면 넣을 수 있는 자리는 세 개다. 맨 앞을 음수로 표현할 수 없으니 이 규칙이 자연스럽다

자바 List.add(int, E)가 정확히 이렇게 정의되어 있다. Java 21 List 문서는 index < 0 || index > size()일 때 IndexOutOfBoundsException을 던진다고 적는다. index == size()는 맨 뒤 삽입으로 허용된다

그래서 구현은 리스트에 맡긴다

public Section addSection(int sectionIndex, String title) {
    Section section = new Section(this, title);

    this.sections.add(sectionIndex, section);

    return section;
}

범위를 벗어난 인덱스는 add가 알아서 거절한다. 테스트는 그 동작을 단언한다

Section s1 = curriculum.addSection("S1");
Section s2 = curriculum.addSection("S2");

Section s1_1 = curriculum.addSection(1, "S1_1");
assertThat(curriculum.getSections()).containsExactly(s1, s1_1, s2);

Section s3 = curriculum.addSection(3, "S3");
assertThat(curriculum.getSections()).containsExactly(s1, s1_1, s2, s3);

assertThatThrownBy(() -> curriculum.addSection(5, "Fail")).isInstanceOf(IndexOutOfBoundsException.class);

원소가 네 개일 때 쓸 수 있는 자리는 0부터 4까지 다섯 개다. 5는 범위 밖이다

리스트에 넣기 전에 먼저 검사하고 싶다면 Objects.checkIndex가 있다. Java 9에 들어온 메서드다. 두 번째 인자 length는 배타적 상한이므로 삽입 검사에는 checkIndex(index, size() + 1)로 써야 한다. size()를 그대로 넘기면 맨 뒤 삽입이 거절된다. 이 코드는 Section을 만드는 일 말고 부수 효과가 없어서 검사를 앞당기지 않았다

List 동작은 학습 테스트로 박아 둔다

인덱스 편집을 하려면 리스트가 수정 가능해야 한다. 테스트 데이터를 만들 때 자주 쓰는 팩토리 메서드들은 수정 가능 여부가 서로 다르다. 강의를 따라 이 차이를 학습 테스트로 남겼다

@Test
void init() {
    var list1 = List.of(1, 2, 3, 4);
    assertThatThrownBy(() -> list1.set(0, 0)).isInstanceOf(UnsupportedOperationException.class);
    assertThatThrownBy(() -> list1.add(5)).isInstanceOf(UnsupportedOperationException.class);

    var list2 = Arrays.asList(1, 2, 3, 4);
    list2.set(0, 0);
    assertThatThrownBy(() -> list2.add(5)).isInstanceOf(UnsupportedOperationException.class);

    var list3 = new ArrayList<>(List.of(1, 2, 3, 4));
    list3.set(0, 0);
    list3.add(5);
}

세 줄이 세 가지 동작을 고정한다

생성 방법setadd/remove근거
List.of(...)불가불가List — Unmodifiable Lists
Arrays.asList(...)가능불가Arrays.asList
new ArrayList<>(List.of(...))가능가능새로 만든 ArrayList

Arrays.asList는 배열을 감싼 고정 크기 리스트다. 원소를 바꾸면 배열이 바뀐다. 크기를 바꾸는 연산만 막힌다. 셋째 줄은 편리한 List.of로 값을 나열하고, 그 값을 복사해 수정 가능한 ArrayList를 만든다

강의에서는 List.of가 Java 9인지 11인지 헷갈려했다. JEP 269로 Java 9에 들어왔다. 강의는 이 리스트를 “불변 리스트”라고 불렀는데, Java 문서의 용어는 수정 불가(unmodifiable)다. 리스트에 넣은 원소가 가변 객체라면 원소의 상태는 바뀔 수 있다. 문서도 이 점을 따로 적어 둔다. 이 구분은 뒤의 “수정 불가 뷰”에서 다시 쓰인다

섹션 삭제는 규칙 세 개를 인덱스 하나로 처리한다

섹션 삭제 규칙은 1편의 설계 문서에 적은 그대로다

  1. 마지막 남은 섹션은 지울 수 없다
  2. 섹션을 지우면 그 안의 수업은 앞 섹션의 맨 뒤로 옮긴다
  3. 첫 섹션을 지우면 수업은 다음 섹션의 맨 앞으로 옮긴다. 전체 순서는 그대로다

구현은 이렇다

public Section removeSection(int sectionIndex) {
    state(this.sections.size() > 1, "마지막 남은 섹션은 삭제할 수 없습니다");

    Section removed = this.sections.remove(sectionIndex);

    if (sectionIndex == 0) {
        Section next = this.sections.get(0);
        removed.moveAllLessonsTo(next, 0);
    } else {
        Section previous = this.sections.get(sectionIndex - 1);
        removed.moveAllLessonsTo(previous, previous.getLessons().size());
    }

    return removed;
}

첫 줄이 규칙 1이다. 스프링의 Assert.state는 조건이 거짓이면 IllegalStateException을 던진다. 이 줄이 없어도 섹션이 하나일 때 어딘가에서 예외는 난다. 하지만 IndexOutOfBoundsException은 “왜 안 되는가”를 말해 주지 않는다. 규칙을 코드 맨 앞에 두면 예외 메시지가 규칙을 설명하고, 코드를 읽는 사람도 규칙을 먼저 본다

if (sectionIndex == 0) 안의 get(0)이 이 편의 제목이 말하는 자리다. 첫 섹션을 지우면 다음 섹션은 인덱스 1에 있어야 할 것 같다. 하지만 remove가 이미 일어났다

remove 전:  [S0] [S1] [S2]        sectionIndex = 0
             0    1    2
remove 후:  [S1] [S2]             다음 섹션 S1은 이제 0번
             0    1

강의에서도 처음에 sectionIndex + 1을 가져오게 짰다. 테스트를 돌리자 섹션이 두 개여야 할 자리에서 하나만 남았다. 디버거로 따라가 보니 remove가 먼저 실행되어 인덱스가 당겨져 있었다. get(0)으로 고치고 테스트가 통과했다

반대로 else 쪽의 sectionIndex - 1은 remove의 영향을 받지 않는다. 지운 자리보다 앞의 원소는 인덱스가 그대로이기 때문이다. 삭제 이후의 인덱스 계산은 “지운 자리 앞인가 뒤인가”만 보면 된다. 뒤는 하나씩 당겨지고, 앞은 그대로다

수업 전체를 옮기는 쪽은 Section이 맡는다

void moveAllLessonsTo(Section target, int insertIndex) {
    while(!this.lessons.isEmpty()) {
        target.addLesson(insertIndex++, this.lessons.getFirst());
        this.lessons.removeFirst();
    }
}

void addLesson(int insertIndex, Lesson lesson) {
    lesson.moveTo(this);
    this.lessons.add(insertIndex, lesson);
}

insertIndex++가 순서를 지킨다. 다음 섹션 맨 앞(0)에 L0, L1을 넣을 때 둘 다 0에 넣으면 L1, L0 순서로 뒤집힌다. 0, 1, 2로 자리를 늘려 가며 넣어야 원래 순서가 유지된다

getFirst()와 removeFirst()는 Java 21에서 List에 추가된 메서드다. Java 21 List 문서의 @since가 21이다. 이 프로젝트는 툴체인이 Java 21이라 그대로 썼다. 그보다 낮은 버전이라면 get(0)과 remove(0)으로 바꿔야 한다

테스트에는 세 규칙을 주석으로 붙였다. 주석의 오타는 옮기면서 고쳤다

// 섹션을 삭제하면 수업은 앞 섹션 수업의 뒤에 추가된다
curriculum.removeSection(2);
assertThat(SectionContent.from(curriculum)).containsExactly(
    section("S0", lesson("L0"), lesson("L1")),
    section("S1", lesson("L2"), lesson("L3"), lesson("L4"), lesson("L5"))
);

// 단, 첫번째 섹션을 삭제하면 수업은 다음 섹션 앞부분으로 추가된다
curriculum.removeSection(0);
assertThat(SectionContent.from(curriculum)).containsExactly(
    section("S1", lesson("L0"), lesson("L1"), lesson("L2"), lesson("L3"),
        lesson("L4"), lesson("L5"))
);

// 하나 남은 섹션은 삭제할 수 없다
assertThatThrownBy(() -> curriculum.removeSection(0))
    .isInstanceOf(IllegalStateException.class);

두 번의 삭제가 끝나도 L0부터 L5까지의 순서는 한 번도 바뀌지 않는다. 바뀐 것은 수업이 어느 섹션에 속하는가뿐이다. SectionContent는 뒤에서 설명한다

수업 이동은 빼고 난 뒤의 인덱스로 센다

수업 이동의 인덱스는 네 개다. 어느 섹션의 몇 번째를, 어느 섹션의 몇 번째 자리로. 여기서 다시 삽입 인덱스 질문이 나온다. 같은 섹션의 L0, L1, L2에서 L0을 L1 뒤로 옮기려면 toLessonIndex는 무엇일까

원래:        [L0] [L1] [L2]
L0을 뺀 뒤:  [L1] [L2]            ← 여기서 삽입 인덱스 1
결과:        [L1] [L0] [L2]

두 가지 답이 가능하다. 옮기기 전 기준으로 “L1과 L2 사이”는 2다. 빼고 난 뒤 기준으로는 1이다. 강의는 빼고 난 뒤를 택했다. 이동은 삭제 다음 삽입이므로, 삽입할 때의 인덱스를 주는 것이 동작 순서와 맞는다. 결과적으로 toLessonIndex는 “이동이 끝난 뒤 그 수업이 있을 자리”가 된다

이 기준이면 구현은 한 줄이다

public void moveLesson(int fromSectionIndex, int fromLessonIndex, int toSectionIndex, int toLessonIndex) {
    Section from = this.sections.get(fromSectionIndex);
    Section to = this.sections.get(toSectionIndex);

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

List.remove(int)는 지운 원소를 돌려준다. 그 원소를 대상 섹션에 넣는다. 같은 섹션이든 다른 섹션이든 코드는 같다. 1편에서 트리 모델을 고른 이유가 이 한 줄이다

테스트는 네 경우를 차례로 적용하며 매번 트리 전체를 비교한다. 아래는 이동 호출과 섹션 확인만 뽑은 것이고, 주석은 이 글에서 붙였다

curriculum.moveLesson(0, 0, 0, 1);   // 같은 섹션, 뒤로
curriculum.moveLesson(0, 2, 0, 0);   // 같은 섹션, 맨 앞으로
curriculum.moveLesson(0, 1, 1, 2);   // 다른 섹션, 맨 뒤로
assertThat(l1.getSection()).isEqualTo(s1);
curriculum.moveLesson(2, 1, 0, 1);   // 뒤 섹션에서 앞 섹션 중간으로
assertThat(l6.getSection()).isEqualTo(s0);

섹션이 바뀌는 이동 뒤에는 수업이 가리키는 섹션도 확인한다. 리스트만 보면 수업이 옮겨 갔지만, Lesson.section 필드가 옛 섹션을 가리키고 있을 수 있다. addLesson 안의 lesson.moveTo(this)가 그 필드를 맞춘다

이 확인은 테스트용 세부 사항이 아니다. Hibernate 6.6 사용자 가이드는 @OneToMany(mappedBy = "...") 리스트의 순서를 유지하려면 @OrderColumn이 필요하다고 적는다. 그리고 양쪽 관계를 맞춰 두지 않으면 원소의 위치가 제대로 갱신되지 않는다고 경고한다. DB에서 수업이 어느 섹션 소속인지 정하는 외래 키는 Lesson 쪽에 있다. 3편에서 이 매핑을 다룬다

트리 전체는 레코드 스냅샷으로 비교한다

섹션 삭제를 검증하려고 하면 1편의 allLessons()로는 부족하다. 삭제 전후로 평탄화한 수업 순서가 같기 때문이다. 어느 섹션 밑에 무엇이 있는지를 비교해야 한다

엔티티를 하나씩 꺼내 단언하면 테스트가 길어진다. 그래서 트리를 테스트용 레코드로 바꾸는 스냅샷을 만들었다

public record SectionContent(String title, List<LessonContent> lessons) {
    public static List<SectionContent> from(Curriculum curriculum) {
        return curriculum.getSections().stream().map(
            section -> new SectionContent(
                section.getTitle(),
                section.getLessons().stream()
                    .map(lesson -> new LessonContent(lesson.getTitle()))
                    .toList()
            )
        ).toList();
    }

    public static SectionContent section(String title, LessonContent... lessonContents) {
        return new SectionContent(title, List.of(lessonContents));
    }
}

public record LessonContent(String title) {
    public static LessonContent lesson(String title) {
        return new LessonContent(title);
    }
}

레코드는 필드 값으로 equals를 만든다. 그래서 containsExactly가 구조와 제목을 한 번에 비교한다. 정적 팩토리 section, lesson을 static import하고 가변 인자로 받으면, 기대값이 트리 모양 그대로 읽힌다

containsExactly는 원소와 순서가 모두 같아야 통과한다. 순서를 바꿔 적으면 실패한다. 순서가 핵심인 이 도메인에 맞는 단언이다

이 스냅샷은 테스트 소스에만 있다. 제품 코드에 테스트 전용 메서드를 넣지 않았다. 3편과 4편의 리포지토리 테스트와 서비스 테스트도 같은 레코드를 재사용한다

양방향 관계는 toString에서 끊는다

Curriculum이 sections를 출력하고, Section이 다시 curriculum을 출력하면 서로를 끝없이 부른다. Lombok @ToString의 exclude로 각 엔티티에서 상대를 가리키는 필드를 뺐다

@ToString(callSuper = true, exclude = {"sections"})           // Curriculum
@ToString(callSuper = true, exclude = {"curriculum", "lessons"})  // Section
@ToString(callSuper = true, exclude = {"section"})            // Lesson

이 문법은 강의와 저장소가 쓰는 방식이지만 최신 권장 방식은 아니다. Lombok @ToString 문서는 lombok 1.16.22부터 필드에 @ToString.Exclude를 붙이는 방식을 안내한다. exclude 파라미터는 아직 지원되지만 앞으로 deprecated될 예정이라고 적혀 있다. 문자열로 필드 이름을 적는 방식은 필드 이름을 바꿔도 컴파일러가 잡아 주지 못한다. Curriculum의 exclude에는 course도 빠져 있었다

인자 문제는 IllegalArgumentException으로 통일한다

nextLesson(Lesson)에서 커리큘럼에 없는 수업이 들어오면 예외를 던진다. 강의를 따라 처음에 state()로 썼다. 같은 상황을 다루는 nextLesson(Long)은 IllegalArgumentException을 던지고 있었다. 한 메서드는 상태 문제, 다른 메서드는 인자 문제로 보고 있던 것이다.

리뷰에서 인자 문제로 통일했다. 커리큘럼의 상태에는 문제가 없고, 호출한 쪽이 엉뚱한 수업을 넘긴 것이기 때문이다. Assert.isTrue는 IllegalArgumentException을 던진다.

isTrue(index >= 0, "커리큘럼에 포함된 수업이 아닙니다");

처음 코드가 리뷰에서 뒤집힌 자리지만, 강의 코드를 그대로 따른 것이라 내 판단이 뒤집혔다고 하기는 어렵다. 이 편에서 내가 따로 판단했다가 검증에서 뒤집힌 자리는 없다. 예외를 고르는 기준은 단순하다. 객체의 현재 상태 때문에 안 되면 state, 넘어온 값 때문에 안 되면 isTrue다

컬렉션 게터는 수정 불가 뷰로 내보낸다

Lombok @Getter가 만든 getSections()는 내부 리스트를 그대로 돌려준다. 바깥 코드가 그 리스트에 add를 하면 루트를 거치지 않고 구조가 바뀐다. 게터를 직접 정의해 막았다

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

public List<Section> getSections() {
    return Collections.unmodifiableList(sections);
}

@Getter(AccessLevel.NONE)은 이 필드에 대해서만 Lombok 게터를 끈다. Section.getLessons()도 같은 방식이다. 막혔는지는 테스트로 확인했다

assertThatThrownBy(() -> curriculum.getSections().add(new Section(curriculum, "Fail")))
    .isInstanceOf(UnsupportedOperationException.class);

Collections.unmodifiableList가 돌려주는 것은 복사본이 아니라 뷰다. 조회는 원본 리스트를 그대로 읽고, 수정 시도는 UnsupportedOperationException이 된다. 루트가 섹션을 추가하면 이미 받아 둔 뷰에도 바로 보인다

막히는 것은 리스트 연산뿐이다. 앞에서 본 수정 불가 리스트의 성질 그대로, 원소 객체는 여전히 바뀔 수 있다. Section과 Lesson의 변경 메서드가 패키지 전용이라 대부분 막혀 있다. 그런데 하나가 열려 있다

public void moveTo(Section section) {
    this.section = section;
}

설계 문서에는 Lesson의 행위를 “package-private”로 적었다. 그런데 moveTo는 public으로 들어간 뒤 그대로다. 바깥 코드가 getSections().get(0).getLessons().get(0).moveTo(다른섹션)을 부르면 수업의 섹션 필드만 바뀐다. 두 섹션의 리스트는 그대로다. 리스트와 외래 키가 서로 다른 말을 하는 상태가 된다. moveTo를 부르는 곳은 같은 패키지의 Section.addLesson뿐이므로 접근 제한자를 지우면 된다. 아직 고치지 않았다

인덱스 기반 편집에서는 remove 뒤에 오는 모든 인덱스 계산에 “지운 자리보다 뒤인가”를 한 번씩 물으면 된다


출처와 범위

인덱스 규칙과 섹션 삭제 동작, 스냅샷 레코드를 쓰는 테스트 방식은 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2에서 배웠다

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

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

참고 자료