DIP로 컴포넌트 순환 의존을 끊으려면 시그니처의 타입까지 옮겨야 한다

강의를 만들면 빈 커리큘럼도 함께 생기게 하고 싶었다. 강의 생성 서비스에서 커리큘럼 생성 포트를 한 줄 부르자 테스트 두 개가 깨졌다. 하나는 DB의 유니크 제약이고, 하나는 아키텍처 테스트의 순환 금지 규칙이다. 순환은 의존성 역전 원칙(DIP)으로 풀었다. 인터페이스를 쓰는 쪽 패키지로 옮기는 방법이다. 그런데 같은 방법으로 붙인 검수 검증에서는 인터페이스는 옮겼지만 그 시그니처의 예외 타입이 커리큘럼 쪽에 남았다. DIP 컴포넌트 순환 의존 문제는 인터페이스 파일 하나가 아니라 그 인터페이스가 끌고 오는 타입 전부의 위치로 판단해야 한다

4편까지 커리큘럼 컴포넌트를 혼자 완성했다. 이 편은 커리큘럼을 강의 컴포넌트와 연결한다

이 글에서 자주 나오는 용어

  • 컴포넌트: 애그리거트 하나와 그것을 감싼 애플리케이션 서비스를 묶은 단위. 여기서는 domain.course + application.course가 강의 컴포넌트다
  • DIP(Dependency Inversion Principle): 상위 모듈과 하위 모듈이 모두 추상화에 의존해야 한다는 원칙. SOLID의 D다
  • DI(Dependency Injection): 쓸 객체를 바깥에서 넣어 주는 기법. 이름이 비슷하지만 DIP와 다른 개념이다
  • Separated Interface: 인터페이스를 구현 클래스와 다른 패키지, 보통 인터페이스를 쓰는 쪽 패키지에 두는 패턴
  • ArchUnit 슬라이스: 패키지 이름 패턴으로 클래스를 묶은 단위. 슬라이스 사이의 순환을 테스트로 금지할 수 있다

강의를 만들 때 커리큘럼도 만들고 싶었다

지금까지는 클라이언트가 두 번 요청해야 했다. 강의를 만들고, 받은 아이디로 커리큘럼을 만든다. 두 애그리거트는 따로지만 강의가 생기면 빈 커리큘럼이 준비되어 있는 편이 자연스럽다. 강의 없는 커리큘럼도, 커리큘럼 없는 강의도 쓸모가 없다

강의에서 처음 시도한 코드는 강의 생성 서비스 안에서 커리큘럼 편집 포트를 부르는 것이었다

Course savedCourse = courseRepository.save(course);

curriculumCoordinator.create(savedCourse.getId());   // CurriculumCoordinator는 application.curriculum.provided

한 줄을 넣고 전체 테스트를 돌리자 두 개가 실패했다

깨진 테스트 하나는 1:1을 지키는 유니크 제약이었다

첫째는 커리큘럼 생성 테스트였다. 테스트 준비 단계의 prepareCourse()는 강의 생성 서비스로 강의를 만든다. 이제 그 안에서 커리큘럼이 이미 생긴다. 그 뒤 테스트가 같은 강의로 커리큘럼을 한 번 더 만들자 유니크 인덱스 위반이 났다

유니크 제약은 내가 선언한 것이 아니다. Curriculum.course는 @OneToOne이다. Hibernate 6.2 마이그레이션 가이드에 따르면 6.2부터 논리적 일대일 연관의 외래 키에 UNIQUE 제약을 만든다. 모델링 관점에서 일대일의 외래 키는 유일해야 하기 때문이다. 이 프로젝트는 Spring Boot 3.5.13으로 Hibernate 6.6을 쓴다. 강의 하나에 커리큘럼 하나라는 규칙을 DB가 지키고 있었던 셈이다.

 	@Test
 	void findByCourseFail() {
-		var courseWithoutCurriculum = prepareCourse();
-
-		assertThatThrownBy(() -> curriculumFinder.findByCourse(courseWithoutCurriculum.getId()))
+		assertThatThrownBy(() -> curriculumFinder.findByCourse(Long.MAX_VALUE))
 			.isInstanceOf(IllegalArgumentException.class);
 	}

나는 “커리큘럼이 없는 강의”를 만들어 실패 경우를 테스트했었다. 강의 생성이 커리큘럼 생성을 포함하자 그런 강의는 서비스로는 만들 수 없게 됐다. 없는 강의 아이디로 바꿨다. 편집 포트의 생성 테스트 두 개는 지웠고, 테스트용 커리큘럼은 새로 만들지 않고 강의에 딸려 생긴 것을 찾아 쓰게 바꿨다

다른 하나는 컴포넌트 사이의 순환이었다

둘째 실패는 아키텍처 테스트였다. 애플리케이션 컴포넌트 사이의 의존은 한 방향이어야 한다는 규칙을 ArchUnit으로 걸어 두었다. 이 규칙은 ArchUnit 슬라이스 순환 의존성 검증으로 아키텍처를 코드로 강제한다에서 만들었다

@ArchTest
void applicationServiceFreeOfCycles(JavaClasses classes) {
    SlicesRuleDefinition.slices()
        .matching("kimspring.splearn.application.(*)..")
        .should().beFreeOfCycles()
        .check(classes);
}

application.(*)의 괄호가 패키지 한 단계를 잡아 슬라이스 이름으로 쓴다. ArchUnit 사용자 가이드의 Slices 절에 나오는 문법이다. application.course와 application.curriculum이 각각 슬라이스가 된다

원래 의존은 한 방향이었다. 커리큘럼 서비스는 강의를 찾으려고 CourseFinder를 썼다. 방금 넣은 한 줄이 반대 방향을 만들었다

                    CourseFinder
application.curriculum ─────────────▶ application.course
          ▲                                    │
          └────────────────────────────────────┘
                 CurriculumCoordinator  (방금 추가)

해법 후보는 몇 개 있었다. 강의가 커리큘럼을 참조하게 뒤집으면, 커리큘럼을 먼저 만든 뒤 그걸로 강의를 만들어야 한다. 강의 없는 커리큘럼이 먼저 생기는 것은 어색하다. 커리큘럼이 강의를 가리키는 방향이 자연스럽다. 기능을 포기하는 방법도 있다. 강의에서는 셋째 방법인 DIP를 택했다

인터페이스를 쓴다고 역전이 되지는 않는다

DIP의 정의는 두 문장이다. 상위 모듈은 하위 모듈에 의존하면 안 되고 둘 다 추상화에 의존해야 한다. 추상화는 세부 사항에 의존하면 안 되고 세부 사항이 추상화에 의존해야 한다. Wikipedia의 DIP 항목이 로버트 C. 마틴의 원래 표현을 옮겨 두었다

이 정의를 “인터페이스를 만들어 인터페이스에 의존하라”로 줄여 기억하면 이번 순환이 설명되지 않는다. 강의 서비스는 이미 인터페이스인 CurriculumCoordinator에 의존하고 있었다. 구현 클래스를 직접 쓴 적이 없다. 그런데도 순환이 났다

빠진 조건은 인터페이스의 위치다. 인터페이스를 도입해도 그 인터페이스가 하위 모듈 패키지에 있으면, 상위 모듈은 여전히 하위 모듈 패키지를 import한다. 인터페이스가 바뀌는 것은 하위 모듈이 바뀌는 것이다. 역전이 되려면 인터페이스의 소유권도 상위 모듈로 옮겨야 한다. 마틴 파울러는 이 배치를 Separated Interface라고 부른다

헥사고날 아키텍처는 이미 이렇게 되어 있다.

인터페이스가 하위 모듈에 있을 때               인터페이스를 쓰는 쪽이 가질 때

application                                application
  OrderService ─────┐                        OrderService ──▶ OrderRepository (required)
                    ▼                                               ▲
adapter                                    adapter                  │ implements
  OrderRepository ◀── JpaOrderRepository     JpaOrderRepository ────┘
  (여전히 아래를 import)                      (아래가 위를 import)

OrderRepository가 애플리케이션 계층의 required 패키지에 있다. 어댑터가 그 인터페이스를 구현한다. 코드 의존이 아래에서 위로 향한다. 이것이 “의존성의 역전”이다

같은 모양을 컴포넌트 사이에 적용하면 된다. 강의 컴포넌트가 커리큘럼의 기능을 쓰고 싶다면, 그 기능의 인터페이스를 강의 컴포넌트가 가진다. 커리큘럼 컴포넌트는 그 인터페이스를 구현한다

쓰는 쪽이 필요한 만큼만 선언한다

강의 컴포넌트의 required 패키지에 커리큘럼 생성만 담은 인터페이스를 만들었다

package kimspring.splearn.application.course.required;

import kimspring.splearn.domain.course.Course;

public interface CurriculumCreator {
    Long createCurriculum(Course course);
}

시그니처에 나오는 타입은 Long과 Course뿐이다. 둘 다 강의 컴포넌트 안에 있거나 자바 기본 타입이다. 반환을 Curriculum으로 하면 강의 쪽이 커리큘럼 도메인을 import하게 되어 순환이 되살아난다. 그래서 아이디만 돌려준다. 강의 쪽은 생성된 커리큘럼으로 할 일이 없으니 아이디로 충분하다

파라미터를 아이디가 아니라 Course로 받은 것도 이유가 있다. 호출하는 쪽이 방금 저장한 Course를 들고 있다. 구현하는 쪽은 강의를 다시 조회할 필요가 없어져서 CourseFinder 의존이 하나 줄었다

강의 생성 서비스는 이 인터페이스만 안다

@Override
public Course create(CourseCreateRequest createRequest) throws ValidationException {
    Instructor instructor = instructorFinder.find(createRequest.instructorId());

    courseValidator.validateForCreate(instructor, createRequest);

    Course course = new Course(instructor, createRequest.title(), createRequest.description());

    Course savedCourse = courseRepository.save(course);

    curriculumCreator.createCurriculum(savedCourse);

    return savedCourse;
}

구현은 커리큘럼 쪽이 한다. 커리큘럼 편집 포트가 이 인터페이스를 상속하고, 편집 서비스가 구현한다

public interface CurriculumCoordinator extends CurriculumCreator, CurriculumValidator { ... }

@ApplicationService
@RequiredArgsConstructor
public class CurriculumModifyService implements CurriculumCoordinator, CurriculumCreator {
    ...
    @Override
    public Long createCurriculum(Course course) {
        Curriculum curriculum = new Curriculum(course);

        return curriculumRepository.save(curriculum).getId();
    }

편집 포트에 있던 create(Long courseId)는 지웠다. 커리큘럼은 이제 강의와 함께만 생긴다

강의 화면에서는 이 변경 뒤 아키텍처 테스트를 포함한 114개가 통과했다. 강의 생성 테스트에는 커리큘럼이 생겼는지 확인하는 단언을 더했다

Course course = courseCreator.create(CourseFixture.createCourseCreateRequest(instructor.getId(), null));
Curriculum curriculum = curriculumFinder.findByCourse(course.getId());

assertThat(curriculum.getId()).isNotNull();
assertThat(curriculum.getCourse()).isEqualTo(course);

테스트 코드는 강의 쪽 테스트가 CurriculumFinder를 써도 된다. ArchUnit 규칙은 ImportOption.DoNotIncludeTests로 테스트를 검사 대상에서 뺐다. 검증을 위해 양쪽을 다 아는 것은 테스트의 역할이다

한계도 하나 생겼다. 두 생성이 같은 트랜잭션에서 동기 호출로 묶였다. 커리큘럼 생성이 실패하면 강의 생성도 롤백된다. 지금은 원하는 동작이다. 이 결합을 느슨하게 하려면 도메인 이벤트를 써야 한다. 그때도 이벤트 타입을 어느 컴포넌트가 가지는가라는 같은 질문이 돌아온다

검수 신청 검증에도 같은 방법을 썼다

강의에는 검수 신청(submitForReview)과 공개(publish) 전에 도는 검증이 있다. 강의 컴포넌트를 만들 때는 비워 두었다. 커리큘럼의 validate()가 생겼으니 이제 채울 수 있다

package kimspring.splearn.application.course.required;

import kimspring.splearn.domain.curriculum.InvalidCurriculumException;

public interface CurriculumValidator {
    void validate(Long courseId) throws InvalidCurriculumException;
}

강의 검증 서비스는 커리큘럼 검증에서 난 예외를 잡아 오류 목록에 담는다

@Override
public void validateForReview(Course course) throws ValidationException {
    List<String> errors = new ArrayList<>();

    checkCurriculum(course, errors);

    if (!errors.isEmpty()) {
        throw new ValidationException(errors);
    }
}

private void checkCurriculum(Course course, List<String> errors) {
    try {
        curriculumValidator.validate(course.getId());
    } catch (InvalidCurriculumException e) {
        errors.add(e.getMessage());
    }
}

검증 오류를 목록에 모아 한 번에 던지는 방식은 애플리케이션 서비스 사용자 입력 검증 분리하고 오류 모으기에서 정했다. 금칙어 검사처럼 앞으로 늘어날 항목도 같은 목록에 쌓인다. 공개 검증도 같은 checkCurriculum을 쓴다

테스트에는 정상 커리큘럼을 준비하는 도우미를 기반 클래스에 두었다

protected Curriculum prepareCurriculumSectionsAndLessons(Course course) {
    Curriculum curriculum = curriculumFinder.findByCourse(course.getId());

    curriculum.addSection("S1");
    curriculum.addLesson(0, "L1");
    curriculum.addLesson(0, "L2");
    curriculum.addSection("S2");
    curriculum.addLesson(1, "L3");
    curriculum.addLesson(1, "L4");
    curriculum.addSection("S3");
    curriculum.addLesson(2, "L5");

    this.curriculum = curriculum;

    return this.curriculum;
}

실패 테스트는 이 커리큘럼을 일부러 망가뜨린다

@Test
void submitForReviewFailInvalidCurriculum() {
    Course course = prepareCourse();
    Curriculum curriculum = prepareCurriculumSectionsAndLessons(course);
    curriculum.removeLesson(2, 0);

    assertThatThrownBy(() -> coursePublisher.submitForReview(course.getId()))
        .isInstanceOf(ValidationException.class);
}

S3의 유일한 수업 L5를 지우면 수업 없는 섹션이 생기고, 검수 신청이 거절된다. 서비스를 거치지 않고 관리 중인 엔티티를 직접 바꿨다. 테스트 트랜잭션 안이라 같은 영속성 컨텍스트의 객체가 검증에 쓰인다. 기존 공개 테스트는 커리큘럼이 비어 있으면 이제 실패하므로 준비 단계를 prepareCourseWithCurriculum()으로 바꿨다

예외 타입 하나가 순환을 되살렸다

CurriculumValidator의 import를 다시 보자

import kimspring.splearn.domain.curriculum.InvalidCurriculumException;

인터페이스는 강의 컴포넌트에 있다. 하지만 그 시그니처의 예외 타입은 커리큘럼 도메인에 있다. 인터페이스를 쓰는 CourseValidationService도 이 예외를 잡으려고 같은 import를 한다

CurriculumCreator를 만든 뒤 강의는 이렇게 설명했다. “여기에 어떠한 코드도 커리큘럼이라는 애플리케이션 서비스 혹은 도메인 쪽에 있는 거를 직접 사용하는 건 없어요.” 강의 쪽 코드를 두고 한 말이고, 그 시점에는 맞았다. CurriculumValidator가 그 조건을 깬다

이 글을 쓰면서 src/main/java의 import 문을 짧은 파이썬 스크립트로 모아 봤다. kimspring.splearn 다음다음 패키지 이름을 컴포넌트 이름으로 보고, 다른 컴포넌트를 가리키는 import만 셌다. 아래는 그 출력에서 두 컴포넌트에 해당하는 줄이다. AbstractEntity는 domain 바로 아래 클래스라 컴포넌트처럼 찍힌 것이다

course -> ['AbstractEntity', 'curriculum', 'exception', 'instructor', 'stereotype']
curriculum -> ['AbstractEntity', 'course', 'stereotype']
('course', 'curriculum')
   application/course/CourseValidationService.java -> import kimspring.splearn.domain.curriculum.InvalidCurriculumException;
   application/course/required/CurriculumValidator.java -> import kimspring.splearn.domain.curriculum.InvalidCurriculumException;
('curriculum', 'course')
   application/curriculum/CurriculumModifyService.java -> import kimspring.splearn.application.course.required.CurriculumCreator;
   application/curriculum/CurriculumModifyService.java -> import kimspring.splearn.domain.course.Course;
   application/curriculum/provided/CurriculumCoordinator.java -> import kimspring.splearn.application.course.required.CurriculumCreator;
   application/curriculum/provided/CurriculumCoordinator.java -> import kimspring.splearn.application.course.required.CurriculumValidator;
   domain/curriculum/Curriculum.java -> import kimspring.splearn.domain.course.Course;

강의 컴포넌트가 커리큘럼 컴포넌트를, 커리큘럼 컴포넌트가 강의 컴포넌트를 의존한다. 컴포넌트 수준의 순환이다. import 문을 스크립트로 모은 결과이고, ArchUnit으로 검사한 결과는 아니다

그런데 아키텍처 테스트는 통과한다. 두 슬라이스 규칙의 범위를 보면 이유가 보인다

규칙슬라이스이번 의존이 보이는가
aggregateFreeOfCyclesdomain.(*)domain.course는 domain.curriculum을 import하지 않는다
applicationServiceFreeOfCyclesapplication.(*)application.course가 import한 것은 application.curriculum이 아니라 domain.curriculum이다

두 규칙 모두 같은 계층 안의 순환만 본다. 문제의 의존은 application.course에서 domain.curriculum으로, 계층을 가로지른다. 어느 규칙에도 걸리지 않는다. 강의에서 순환을 잡아 준 테스트가 이번에는 침묵한다

예외도 쓰는 쪽이 가져야 한다

고치는 방법은 DIP를 끝까지 적용하는 것이다. 강의 쪽 인터페이스의 시그니처에서 커리큘럼 타입을 없앤다. 강의 쪽은 결국 오류 메시지 목록이 필요하므로 그걸 돌려받으면 된다

package kimspring.splearn.application.course.required;

public interface CurriculumValidator {
    List<String> validate(Long courseId);   // 문제가 없으면 빈 목록
}

커리큘럼 쪽 구현이 도메인 예외를 잡아 메시지로 바꾼다. 강의 쪽 checkCurriculum은 errors.addAll(curriculumValidator.validate(course.getId())) 한 줄이 된다. 강의 컴포넌트에서 domain.curriculum import가 사라진다. 이 변경은 제안이고 저장소에는 아직 반영하지 않았다

예외를 support 같은 공용 패키지로 옮기는 방법도 있다. 순환은 사라지지만 커리큘럼 규칙의 예외가 모두가 쓰는 공용 코드가 된다. 공용 패키지가 커질수록 컴포넌트 경계가 흐려지므로 택하지 않았다

이런 순환을 테스트로 잡으려면 계층이 아니라 컴포넌트를 슬라이스로 삼아야 한다

@ArchTest
void componentFreeOfCycles(JavaClasses classes) {
    SlicesRuleDefinition.slices()
        .matching("kimspring.splearn.*.(*)..")
        .should().beFreeOfCycles()
        .check(classes);
}

*는 패키지 한 단계와 맞는다. application.course와 domain.course가 모두 course 슬라이스에 들어간다. ArchUnit 문법으로는 이렇게 되지만, 나는 이 규칙을 돌려 보지 않았다. adapter.webapi나 support.stereotype도 각각 슬라이스가 되므로, 돌려 보면 예상하지 못한 순환이 더 보이거나 제외 규칙이 필요할 수 있다. 지금의 저장소에 넣으면 위의 예외 import 때문에 실패해야 정상이다. 실패하는 것을 먼저 보고 나서 인터페이스를 고칠 생각이다

DIP로 순환을 끊었는지는 인터페이스를 쓰는 쪽 파일의 import 목록으로 확인한다


출처와 범위

강의-커리큘럼 연결과 두 인터페이스 설계, DIP 도식은 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2에서 따라 했다. 예외 타입이 만든 순환과 컴포넌트 단위 ArchUnit 규칙은 이 글을 쓰며 import를 분석해 얻은 결론이고, 규칙은 실행하지 않았다

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

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

참고 자료