도메인에 위임만 하는 애플리케이션 서비스도 테스트해야 버그가 드러난다

커리큘럼 편집 서비스의 메서드는 모두 세 줄이다. 커리큘럼을 찾고, 도메인 메서드를 부르고, 저장한다. 로직은 전부 도메인에 있고 도메인 테스트는 꼼꼼하다. 강의는 이 서비스가 너무 단순하다며 테스트를 과제로 남겼다. 나는 그 과제를 하기 전에 서비스를 먼저 커밋했는데, 강의 코드에 있던 버그 두 개가 내 코드에도 그대로 들어 있었다. 애플리케이션 서비스 위임 테스트가 잡아야 하는 것은 로직보다 배선이다. 엉뚱한 메서드를 부르거나, 불러야 할 메서드를 빠뜨리는 실수는 위임 코드에서만 생긴다

3편에서 커리큘럼을 저장하는 리포지토리를 만들었다. 이 편은 그 위에 애플리케이션 서비스를 올리고, 과제 테스트를 쓰며 버그를 고친 과정이다

이 글에서 자주 나오는 용어

  • 애플리케이션 서비스: 요청을 받아 도메인 객체를 찾고, 일을 시키고, 저장하는 계층. 비즈니스 규칙은 도메인에 둔다
  • 포트(Port): 애플리케이션이 바깥과 주고받는 기능을 선언한 인터페이스. provided는 바깥에 제공하는 기능, required는 바깥에서 제공받아야 하는 기능이다
  • 위임(Delegation): 받은 요청을 직접 처리하지 않고 다른 객체에 넘기는 것
  • 배선(Wiring): 어떤 메서드가 어떤 메서드를 부르는지의 연결. 이 글에서는 서비스가 도메인 메서드를 올바르게 부르는지를 말한다

포트는 편집과 조회로 나눈다

커리큘럼 컴포넌트의 포트 구성은 이렇다. 포트와 컴포넌트의 개념은 헥사고날 아키텍처 내부는 애플리케이션 컴포넌트로 나눈다에서 정리했다.

application.curriculum
├── provided
│   ├── CurriculumCoordinator   // 편집: 섹션·수업 추가, 제목 수정, 삭제, 이동
│   └── CurriculumFinder        // 조회: find, findWithSections, findByCourse, firstLesson, nextLesson
├── required
│   ├── CurriculumRepository
│   ├── SectionRepository       // delete 하나
│   └── LessonRepository        // delete 하나
├── CurriculumModifyService     // CurriculumCoordinator 구현
└── CurriculumQueryService      // CurriculumFinder 구현

편집 포트의 이름은 CurriculumCoordinator다. 교육 과정을 짜고 관리하는 직무를 실제로 커리큘럼 코디네이터라고 부른다는 데서 가져왔다. 강의에서 정한 이름이다

조회 포트는 메서드마다 결정이 하나씩 있었다

public interface CurriculumFinder {
    Curriculum find(Long curriculumId);

    Curriculum findWithSections(Long curriculumId);

    Curriculum findByCourse(Long courseId);

    Optional<Lesson> firstLesson(Long curriculumId);

    Optional<Lesson> nextLesson(Long curriculumId, Long lessonId);
}

findByCourse는 못 찾으면 Optional이 아니라 예외를 던진다. 강의가 만들어지면 커리큘럼도 반드시 함께 만들어지기 때문이다. 이 규칙은 5편에서 구현한다. 강의는 있는데 커리큘럼이 없다면 데이터가 잘못된 것이다

firstLesson과 nextLesson은 Optional이다. 마지막 수업 다음에는 수업이 없는 것이 정상이다. firstLesson은 예외로 할지 고민했지만, 편집 중인 커리큘럼은 수업이 0개일 수 있으므로 같은 방식으로 맞췄다

nextLesson은 수업 아이디만이 아니라 커리큘럼 아이디도 받는다. 수업 아이디 하나만 받으면 요청한 회원이 수강하지 않은 강의의 수업도 넘겨줄 수 있다. 커리큘럼 아이디가 있으면 적어도 “이 커리큘럼 안의 수업인가”는 확인된다. 다만 “이 회원이 이 강의를 수강 중인가”는 아직 아무 데서도 검사하지 않는다. 진도 기능을 만들 때 붙여야 할 권한 검사다

반환 타입이 Lesson인 점도 짚어 둔다. 애그리거트 안의 엔티티는 바깥에서 루트를 통해서만 다룬다. 그래도 조회 결과로 내부 엔티티를 돌려주는 것은 허용했다. 2편에서 Lesson의 변경 메서드를 패키지 전용으로 막았기 때문에, 받아 간 쪽이 수업을 바꿀 수는 없다. public으로 열려 있는 moveTo 하나를 빼면 그렇다

편집 서비스는 찾고, 시키고, 저장한다

편집 서비스의 메서드는 같은 모양을 반복한다

@Override
public Curriculum updateSectionTitle(Long curriculumId, int sectionIndex, String title) {
    Curriculum curriculum = curriculumFinder.find(curriculumId);

    curriculum.updateSectionTitle(sectionIndex, title);

    return curriculumRepository.save(curriculum);
}

@ApplicationService는 스프링 커스텀 스테레오타입 애노테이션으로 만든 것이고, 안에 @Transactional이 들어 있다. 트랜잭션 안에서 읽은 엔티티는 변경 감지로 저장되므로 save는 없어도 된다. 그래도 이 프로젝트는 변경이 있으면 항상 save를 부르기로 정했다. 나는 이 규칙을, 서비스 코드가 JPA의 변경 감지라는 구현 방식에 기대지 않게 하려는 것으로 이해한다

검증만 하는 메서드는 변경이 없으므로 save를 부르지 않는다. 인덱스 범위, null 제목, 마지막 섹션 삭제 같은 규칙은 도메인이 이미 검사한다. 서비스가 할 일은 정말로 찾고, 시키고, 저장하는 것뿐이다

첫 구현에는 강의의 버그 두 개가 그대로 있었다

@Override
public Curriculum addLesson(Long curriculumId, int sectionIndex, String title) {
    Curriculum curriculum = curriculumFinder.find(curriculumId);

    curriculum.addSection(sectionIndex, title);

    return curriculumRepository.save(curriculum);
}

@Override
public Curriculum removeLesson(Long curriculumId, int sectionIndex, int lessonIndex) {
    Curriculum curriculum = curriculumFinder.find(curriculumId);

    curriculum.removeLesson(sectionIndex, lessonIndex);

    return curriculumRepository.save(curriculum);
}

첫째, addLesson이 addSection을 부른다. 바로 위의 addSection(Long, int, String)을 복사해 붙이고 호출부를 고치지 않았다. 두 메서드는 파라미터가 (Long, int, String)으로 같고 반환 타입도 같다. 컴파일러가 잡을 단서가 없다. 수업을 추가하라는 요청이 섹션을 하나 만든다

둘째, removeLesson이 지운 수업을 저장소에서 지우지 않는다. 3편에서 orphanRemoval을 끄고 “도메인에서 빼고, 리포지토리로 지운다”고 정했다. 리포지토리 테스트에서는 그렇게 했다. 그런데 서비스에서는 빠뜨렸다. removeSection도 같았다. 수업 행과 섹션 행이 DB에 고아로 남는다

두 버그는 강의 코드에도 똑같이 있었다. 강의는 마지막 코드 리뷰에서야 이 둘을 발견하고 “테스트를 안 만들면 이런 문제가 생긴다”고 정리했다.

과제 테스트를 둘 다 고쳤다. 강의에서 리뷰는 DIP 다음 차례이고, 이 커밋은 DIP를 적용한 커밋보다 앞선다. 아래는 서비스 코드 변경분에서 import와 필드 선언을 뺀 부분이다

 	public Curriculum addLesson(Long curriculumId, int sectionIndex, String title) {
 		Curriculum curriculum = curriculumFinder.find(curriculumId);
 
-		curriculum.addSection(sectionIndex, title);
+		curriculum.addLesson(sectionIndex, title);
 
 		return curriculumRepository.save(curriculum);
 	}
@@
 	public Curriculum removeSection(Long curriculumId, int sectionIndex) {
 		Curriculum curriculum = curriculumFinder.find(curriculumId);
 
-		curriculum.removeSection(sectionIndex);
+		Section removed = curriculum.removeSection(sectionIndex);
+		sectionRepository.delete(removed);
 
 		return curriculumRepository.save(curriculum);
 	}
@@
 	public Curriculum removeLesson(Long curriculumId, int sectionIndex, int lessonIndex) {
 		Curriculum curriculum = curriculumFinder.find(curriculumId);
 
-		curriculum.removeLesson(sectionIndex, lessonIndex);
+		Lesson removed = curriculum.removeLesson(sectionIndex, lessonIndex);
+		lessonRepository.delete(removed);
 
 		return curriculumRepository.save(curriculum);
 	}

이 커밋에서 테스트 코드가 482줄 늘었다. 편집 포트 테스트는 1개에서 22개가 됐고, 조회 포트 테스트 12개가 새로 생겼다. 편집 포트 테스트 중 생성 테스트 두 개는 5편에서 빠져 지금은 20개다

두 수정을 테스트가 똑같이 잡은 것은 아니다. addLesson 버그는 아래 테스트가 잡는다

@Test
void addLesson() {
    Curriculum curriculum = saveCurriculum(
        section("S0"),
        section("S1")
    );

    curriculumCoordinator.addLesson(curriculum.getId(), 0, "L0");
    curriculumCoordinator.addLesson(curriculum.getId(), 1, "L1");

    assertThat(sectionContentsAfterReload(curriculum.getId())).containsExactly(
        section("S0", lesson("L0")),
        section("S1", lesson("L1"))
    );
}

수정 전 코드로 이 테스트를 돌려 보지는 않았다. 코드를 따라가면 첫 호출이 인덱스 0에 “L0″이라는 섹션을 끼워 넣는다. 트리가 L0, S0, S1 세 섹션이 되므로 단언이 실패한다

삭제 누락은 이 테스트들로는 잡히지 않는다. removeLesson 테스트도 다시 읽은 트리 모양만 비교한다. 3편에서 본 것처럼 강의 로그에서는 delete를 빼도 트리 모양이 맞게 나왔다. 삭제 수정은 테스트를 쓰는 커밋에 함께 들어갔지만, 그 수정이 맞다고 확인해 주는 테스트는 지금도 없다

위임 코드는 로직이 없어서 테스트가 필요 없어 보인다. 하지만 이번 두 버그는 둘 다 연결의 문제였다. 한 메서드가 이웃 메서드를 잘못 불렀고, 한 메서드가 불러야 할 협력 객체를 빠뜨렸다. 도메인 테스트가 아무리 꼼꼼해도 이 두 가지는 보지 못한다. 서비스를 거쳐 DB까지 다녀온 결과를 비교해야 드러난다

서비스 테스트는 DB를 한 번 거친 트리를 비교한다

테스트 클래스에는 도우미 메서드가 둘 있다

private Curriculum saveCurriculum(SectionContent... sectionContents) {
    var course = prepareCourse();
    Curriculum curriculum = curriculumFinder.findByCourse(course.getId());

    for (SectionContent sectionContent : sectionContents) {
        curriculum.addSection(sectionContent.title());
        sectionContent.lessons().forEach(
            lessonContent -> curriculum.addLesson(curriculum.getSections().size() - 1, lessonContent.title())
        );
    }

    return curriculumRepository.save(curriculum);
}

private List<SectionContent> sectionContentsAfterReload(Long curriculumId) {
    entityManager.flush();
    entityManager.clear();
    return SectionContent.from(curriculumFinder.findWithSections(curriculumId));
}

saveCurriculum은 2편의 스냅샷 레코드를 거꾸로 쓴다. 기대하는 트리 모양을 적으면 그대로 커리큘럼을 만들어 저장한다. 준비 코드와 검증 코드가 같은 표기법을 쓰니 테스트 한 편이 “이런 트리에 이 요청을 보내면 이런 트리가 된다”로 읽힌다

sectionContentsAfterReload는 3편의 리포지토리 테스트와 같은 이유로 flush와 clear를 한다. 메모리의 객체가 아니라 DB에서 다시 읽은 트리를 비교해야 서비스가 저장까지 제대로 했는지 알 수 있다

실패 경우도 서비스 단위로 한 번씩 확인했다

@Test
void addLessonFail() {
    Curriculum curriculum = saveCurriculum(section("S0"));

    assertThatThrownBy(() -> curriculumCoordinator.addLesson(curriculum.getId(), -1, "Fail"))
        .isInstanceOf(IndexOutOfBoundsException.class);
    assertThatThrownBy(() -> curriculumCoordinator.addLesson(curriculum.getId(), 1, "Fail"))
        .isInstanceOf(IndexOutOfBoundsException.class);
    assertThatThrownBy(() -> curriculumCoordinator.addLesson(curriculum.getId(), 0, null))
        .isInstanceOf(NullPointerException.class);
}

@Test
void modifyFailWhenCurriculumDoesNotExist() {
    assertThatThrownBy(() -> curriculumCoordinator.addSection(Long.MAX_VALUE, "Fail"))
        .isInstanceOf(IllegalArgumentException.class);
}

도메인 테스트와 겹쳐 보이지만 확인하는 대상이 다르다. 도메인 예외가 서비스에서 삼켜지거나 다른 예외로 바뀌지 않고 그대로 올라오는지를 본다. 없는 커리큘럼 아이디는 조회 서비스가 IllegalArgumentException으로 바꾼다

삭제 결과와 편집 쿼리 수는 아직 테스트 밖에 있다

과제 테스트를 채웠지만 확인하지 않는 것이 둘 남았다

하나는 앞에서 말한 삭제 결과다. 지운 수업과 섹션을 아이디로 다시 찾아 없는지 보는 단언이 필요하다. removeLesson, removeFirstSection, removeLastSection 테스트에 더해야 한다

다른 하나는 편집 요청의 쿼리 수다. 편집 서비스의 편집 메서드는 모두 find로 커리큘럼을 읽는다. 지연 로딩이라 편집 도중에 섹션과 수업을 따로 조회한다. 3편의 통계 테스트를 서비스 테스트에도 붙이면 요청 하나에 쿼리가 몇 번 나가는지 고정할 수 있다. 아직 붙이지 않았고, 그래서 그 수를 모른다

위임 코드의 테스트는 결과만 확인하면 끝나지 않는다. 불러야 할 협력 객체를 불렀는지까지 확인해야 배선 버그가 드러난다


출처와 범위

포트 이름과 서비스 구조는 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2를 따랐다

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

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

참고 자료