Bean Validation은 @NonNull을 검사하지 않는다

수강 신청 DTO의 두 필드에 @NotNull을 걸고, 애플리케이션 서비스에는 @Validated를 달아 두었다. 그런데 리뷰에서 나는 그 사이에 있어야 할 @Valid가 빠져 있는 것을 발견했다. 그 상태에서는 DTO에 제약을 몇 개 걸어 두어도 Bean Validation 검사가 시작되지 않는다

같은 자리에 함정이 하나 더 겹쳐 있다. null을 막는 애노테이션은 자바 표준 안에도 여러 개 있고 이름이 거의 같다. 그중 Bean Validation이 런타임에 검사하는 것은 하나뿐이다

스위치가 둘이라고 보면 정리가 된다. 제약 애노테이션을 제대로 골랐는가, 그리고 그 제약이 걸린 DTO를 받는 파라미터에 @Valid가 있는가. 둘 중 하나만 켜져 있으면 아무 일도 일어나지 않고, 잘못된 값이 메서드 안까지 들어간다

이 글은 수강 애그리거트 위에 애플리케이션 서비스를 올리면서 포트를 어떻게 나눴는지, 그리고 리뷰에서 이 두 스위치가 어떻게 드러났는지를 정리한 것이다

이 글에서 자주 나오는 용어

  • 포트(Port): 애플리케이션이 외부와 주고받을 기능을 선언한 인터페이스. provided는 외부에 제공하는 기능, required는 외부로부터 제공받아야 하는 기능이다
  • Bean Validation: 필드나 메서드 파라미터에 애노테이션으로 제약을 걸고 런타임에 검사하게 하는 자바 표준. Jakarta Validation이라고도 부른다
  • 제약(Constraint): Bean Validation이 검사 대상으로 인식하는 애노테이션. 아무 애노테이션이나 제약이 되는 것은 아니다
  • 캐스케이드 검증: 파라미터로 받은 객체의 내부 필드까지 따라 들어가 검사하는 것
  • 메서드 검증: 메서드를 호출하는 시점에 파라미터를 검사하는 것. 스프링에서는 AOP 프록시가 호출을 가로채는 방식으로 동작한다
  • 정적 분석: 코드를 실행하지 않고 컴파일 시점이나 IDE에서 문제를 찾아내는 검사
  • JSpecify: null 허용 여부를 코드에 표시하기 위한 애노테이션 표준. 실행되지 않고 검사 도구가 읽는다

Bean Validation이 보는 것은 스펙이 정한 제약뿐이다

헷갈리는 두 애노테이션은 설명이 거의 같다. 사는 패키지가 다르고 철자가 한 글자 다르다

jakarta.annotation.Nonnull
  → "The annotated element must not be null."

jakarta.validation.constraints.NotNull
  → "The annotated element must not be null."

NonnullNotNull이다. 문서만 읽으면 둘 중 무엇을 써도 될 것처럼 보인다. 둘을 가르는 것은 설명이 아니다. Bean Validation 스펙이 제약을 정의하는 조건이 가른다

An annotation is considered a constraint definition
if its retention policy contains RUNTIME
and if the annotation itself is annotated with jakarta.validation.Constraint.

조건이 두 개다. RUNTIME 유지 정책, 그리고 애노테이션 자신에게 @Constraint가 붙어 있어야 한다는 것이다(Jakarta Bean Validation 3.0 스펙). NotNull에는 @Constraint가 붙어 있고 검사기 구현이 지정되어 있다. Nonnull에는 없다. 그래서 Validator는 이 애노테이션을 검사 대상 목록에 넣지도 않는다. 검증이 실패한 것이 아니다. 검증이 아예 시작되지 않는다

이름이 비슷한 애노테이션은 이 둘로 끝나지 않는다

애노테이션패키지@Constraint런타임에 검사되는가
NotNulljakarta.validation.constraints있다검사된다
Nonnulljakarta.annotation없다검사되지 않는다
NonNullorg.springframework.lang없다검사되지 않는다
NonNullorg.jspecify.annotations없다검사되지 않는다

아래 세 줄은 규약을 표시하는 애노테이션이다. “이 자리에 null이 들어오면 안 된다”를 코드에 적어 두는 주석에 가깝고, 그걸 읽는 것은 IDE와 정적 분석 도구다. 실행 중에 값을 검사하는 것은 첫 줄뿐이다. 넷 중 셋이 검사되지 않으니, 이름만 보고 자동 완성이 골라 준 것을 그대로 받으면 3/4의 확률로 동작하지 않는 선언이 된다

jakarta.annotation 쪽으로 손이 가기 쉬운 이유도 있다. 이 프로젝트는 이미 그 패키지의 @Nullable을 여러 곳에서 쓰고 있다

// AbstractEntity
import jakarta.annotation.Nullable;

@Getter(onMethod_ = {@Nullable})
private Long id;

@Nullable을 가져오던 패키지에 Nonnull이 나란히 있으니 반대 방향도 같은 자리에서 꺼내면 된다고 생각하게 된다. 두 애노테이션은 붙어 있지만 하는 일이 다르다. @Nullable은 규약 표시가 목적이므로 그 패키지에 있는 것이 맞다. 런타임 검증이 목적인 것은 jakarta.validation.constraints에서 가져와야 한다

이 프로젝트의 DTO는 처음부터 맞는 쪽으로 들어갔다

public record EnrollRequest(@NotNull Long memberId, @NotNull Long courseId) {
}

importjakarta.validation.constraints.NotNull인지 확인하는 것이 이 한 줄에서 유일하게 중요한 일이다. 파일이 만들어진 첫 커밋부터 이 import였기 때문에, 잘못된 애노테이션을 붙인 상태가 어떤 예외를 내는지는 내 저장소에서 재현해 확인하지 않았다. 위 표는 스펙과 각 애노테이션 문서를 대조해 만든 것이고, 실행해서 얻은 값은 아니다

@Validated와 @Valid는 서로 다른 스위치다

제약을 제대로 골랐어도 검증이 돌지 않을 수 있다. 스프링에서 메서드 검증이 동작하려면 선언이 세 층에 나뉘어 있어야 한다

@Validated   (클래스에)    이 클래스를 메서드 검증 대상으로 만든다
     │                     AOP 프록시가 호출을 가로챈다
     ▼
@Valid       (파라미터에)  그 파라미터 내부 필드까지 따라 들어간다
     │                     캐스케이드 검증
     ▼
@NotNull     (필드에)      실제로 검사되는 제약

위의 둘은 검증을 켜는 스위치이고 맨 아래만 실제 검사 규칙이다. 위 두 층 중 하나가 없으면 맨 아래 층에 제약을 몇 개 걸어 두어도 도달하지 않는다

스프링 문서는 첫 번째 층을 이렇게 설명한다. “스프링이 주도하는 메서드 검증의 대상이 되려면 대상 클래스에 스프링의 @Validated 애노테이션이 붙어 있어야 한다.” 그리고 검증이 실패하면 기본적으로 jakarta.validation.ConstraintViolationException이 던져진다(Spring Framework: Method Validation)

이 프로젝트에서 첫 번째 층은 커스텀 스테레오타입 애노테이션 안에 들어 있다

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ApplicationService   // = @Service + @Transactional
@Validated
public @interface ValidatedApplicationService {
}

@Retention(RetentionPolicy.RUNTIME)이 여기에도 필요하다. 애노테이션의 기본 유지 정책은 CLASS이므로, 이 줄이 없으면 스프링이 런타임에 @Validated를 읽지 못해 스위치가 켜지지 않는다. 앞 절에서 제약 성립 조건으로 나온 RUNTIME과 같은 이유가 여기서도 걸린다

EnrollmentModifyService에는 @ValidatedApplicationService가 붙어 있으므로 첫 번째 층은 켜져 있었다. 빠진 것은 두 번째였고, 그 자리는 포트 인터페이스다

 package kimspring.splearn.application.enrollment.provided;

+import jakarta.validation.Valid;
 import kimspring.splearn.domain.enrollment.Enrollment;

 /**
  * 수강 신청과 관리를 담당
  */
 public interface Enroller {
-    Enrollment enroll(EnrollRequest enrollRequest);
+    Enrollment enroll(@Valid EnrollRequest enrollRequest);

     Enrollment startStudying(Long enrollmentId);

     Enrollment complete(Long enrollmentId);
 }

import 줄이 함께 추가된 것이 이 diff에서 눈여겨볼 부분이다. 앞 절에서 DTO의 import를 확인하라고 한 것과 같은 이유로, 파라미터에 붙이는 @Validjakarta.validation 쪽이어야 한다. 이 수정은 리뷰에서 나온 것들을 한 커밋에 모아 낸 것이고 그중 하나가 이 두 줄이다

선언은 인터페이스에 두고 @Validated는 구현 클래스에 붙어 있다. 인터페이스에 선언한 제약을 검증기가 구현 메서드에 적용하기 때문에 이렇게 나눠 두어도 동작하고, 포트를 쓰는 쪽에서 “이 파라미터는 검증된다”를 인터페이스만 보고 알 수 있다는 이점이 있다

두 층이 다 켜진 상태를 테스트로 고정했다

    @Test
    void enrollFailNullIds() {
        assertThatThrownBy(() -> enroller.enroll(new EnrollRequest(null, null))).isInstanceOf(
            ConstraintViolationException.class);
    }

ConstraintViolationException으로 단정한 것이 핵심이다. 검증이 프록시 단계에서 걸렸다면 이 예외가 나온다. 검증이 돌지 않아 메서드 안까지 들어갔다면 첫 줄인 memberFinder.find(null)이 리포지토리까지 내려가고, 그때 나오는 예외는 Bean Validation의 것이 아니다. 예외의 타입만 보고 검증이 언제 걸렸는지를 가릴 수 있다

로컬 실행에서 EnrollerTest의 테스트 5개가 통과했고, 전체 테스트는 90개가 통과했다. 다만 @Valid를 다시 떼고 이 테스트가 어떤 예외로 바뀌는지까지 돌려 보지는 않았다. 그래서 “@Valid가 없으면 다른 예외가 난다”는 문장은 코드 경로를 읽은 판단이고, 관측한 것은 두 층이 다 켜진 상태에서 ConstraintViolationException이 나온다는 쪽뿐이다

규약 표시용 애노테이션은 스프링 7에서 갈렸다

앞의 표에서 검사되지 않는다고 적은 것 중 org.springframework.lang 계열은 이 프로젝트가 실제로 쓰고 있다. @NonNull이 아니라 같은 패키지의 @NonNullApi이고, domainapplication 두 패키지에 하나씩 있다

@NonNullApi
package kimspring.splearn.domain;

import org.springframework.lang.NonNullApi;

패키지 전체의 기본값을 “null이 아니다”로 선언해 두고, null이 가능한 자리만 @Nullable로 표시하는 방식이다. 이 계열은 스프링 문서가 스스로 목적을 밝힌다. “이 null 안전성 배치의 주된 목표는 빌드 시점 검사를 통해 런타임에 NullPointerException이 던져지는 것을 막는 것”이다. 그리고 그 검사를 수행하는 것은 NullAway 같은 검사기나 JSpecify를 지원하는 IntelliJ IDEA와 Eclipse다(Spring Framework: Null-safety)

그 문서는 계열 자체가 옮겨 갔다는 것도 적는다. “org.springframework.lang 패키지의 스프링 null 안전성 애노테이션 @Nullable, @NonNull, @NonNullApi, @NonNullFields는 JSpecify가 없던 시절인 스프링 프레임워크 5에서 도입되었다. 이들은 JSpecify 애노테이션을 선호하여 스프링 프레임워크 7부터 deprecated 되었다.”

두 계열의 역할은 이렇게 갈린다

목적담당언제 걸리는가
런타임 값 검사Bean Validation (jakarta.validation.constraints)메서드 호출 시점
규약 표시와 정적 검사JSpecify (org.jspecify.annotations)빌드와 IDE

이 프로젝트는 스프링 부트 3.5.13 기준이라 @NonNullApi에 deprecated 경고가 아직 뜨지 않는다. 그 경고는 스프링 프레임워크 7부터 붙고, 스프링 부트 4.0이 스프링 프레임워크 7.0 위에 올라간다. JSpecify로 옮기는 것은 부트를 4로 올릴 때 함께 할 작업이고, 그때도 런타임 검증은 Bean Validation이 계속 맡는다. 두 계열은 대체 관계가 아니다

이 검증이 붙은 포트는 세 개다

여기까지가 검증 이야기이고, 그 선언이 어느 인터페이스에 붙어 있었는지를 보려면 포트부터 봐야 한다. 수강으로 할 일은 신청, 학습 시작, 완료, 그리고 조회다. 이것을 의도에 따라 셋으로 나눴다

/**
 * 수강 신청과 관리를 담당
 */
public interface Enroller {
    Enrollment enroll(@Valid EnrollRequest enrollRequest);

    Enrollment startStudying(Long enrollmentId);

    Enrollment complete(Long enrollmentId);
}

public interface EnrollmentFinder {
    Enrollment find(Long enrollmentId);

    List<Enrollment> findByMember(Long memberId);

    Optional<Enrollment> findByMemberAndCourse(Long memberId, Long courseId);
}

public interface EnrollmentRepository extends Repository<Enrollment, Long> {
    Enrollment save(Enrollment enrollment);

    Optional<Enrollment> findById(Long id);

    List<Enrollment> findByMemberId(Long memberId);

    Optional<Enrollment> findByMemberIdAndCourseId(Long memberId, Long courseId);
}

앞의 두 개는 provided, 마지막 하나는 required다. 변경과 조회를 가르는 기준은 앞 글에서 강의 포트를 나눌 때 쓴 것과 같다. 어떤 의도를 가지고 이 기능에 접근하는가다

이름은 Enroller로 정했다. 수강 신청을 받아 기록하고 관리하는 역할을 가리키는 말이고, 수강을 처리하는 포트에 부여하기에 맞는다. EnrollmentService라는 이름은 무엇을 하는 서비스인지 말해 주지 않는다. 구현 클래스는 EnrollmentModifyServiceEnrollmentQueryService 둘이고, 포트 세 개와 구현 두 개가 일대일로 맞지 않아도 된다. 외부는 인터페이스로만 접근하므로 구현을 나중에 쪼개거나 묶을 수 있다

사용자가 직접 입력하지 않는 포트에는 검증 예외를 선언하지 않았다

Enroller.enroll에는 throws ValidationException 선언이 없다. 강의 쪽 포트에는 붙여 두었다

public interface CourseCreator {
    Course create(@Valid CourseCreateRequest createRequest) throws ValidationException;

    Course updateInfo(Long courseId, @Valid CourseInfoUpdateRequest infoUpdateRequest) throws ValidationException;
}

런타임 예외라 컴파일러가 요구하지 않는데도 적은 이유는, 이 포트를 쓰는 쪽이 검증 실패를 메시지로 바꿔 화면에 보여 줘야 하기 때문이다. 강의 제목과 소개는 강사가 타이핑하는 값이다. 수강 신청에 넘어오는 것은 화면에 뿌려진 목록에서 고른 ID 두 개이므로, 사용자에게 돌려줄 오류 목록을 만들어야 하는 흐름이 아니다

이 규칙이 아직 저장소 전체에 적용되어 있지는 않다. throws ValidationException이 붙은 곳은 CourseCreatorCourseValidator 둘뿐이다. 사용자가 값을 직접 입력하는 MemberRegister.registerInstructorApplication.apply에는 없다. 기준으로는 붙어야 하는 자리가 비어 있는 셈이다

Optional은 없는 것이 정상일 때만 쓴다

EnrollmentFinder의 세 메서드가 반환 타입이 다 다르다. 조회에 실패했을 때 그것을 정상으로 볼지에 따라 갈렸다

메서드반환못 찾으면
find(enrollmentId)Enrollment예외를 던진다
findByMember(memberId)List<Enrollment>빈 리스트
findByMemberAndCourse(memberId, courseId)Optional<Enrollment>값이 없는 Optional

find는 ID를 알고 부르는 조회다. 그 ID를 가진 수강이 없다는 것은 호출하는 쪽이 잘못된 ID를 들고 있다는 뜻이므로 예외가 맞다

    @Override
    public Enrollment find(Long enrollmentId) {
        return enrollmentRepository.findById(enrollmentId)
                                   .orElseThrow(
                                       () -> new IllegalArgumentException("수강을 찾을 수 없습니다. ID: " + enrollmentId));
    }

리포지토리는 Optional을 돌려주는데 포트는 Enrollment를 돌려준다. orElseThrow가 그 사이에서 “여기서 못 찾으면 예외다”라는 규칙을 코드에 고정한다. 이 규칙이 한 자리에 있으므로 이 포트를 쓰는 쪽은 매번 Optional을 풀지 않아도 된다

findByMemberAndCourse는 다르다. “내가 이 강의를 수강하고 있는가”를 확인하려고 부르는 조회이고, 수강하지 않고 있는 것은 완전히 정상이다. 여기서 예외를 던지면 정상 흐름을 예외로 처리하게 된다. 그래서 Optional을 돌려준다

이 값을 클라이언트에게 404로 줄지 별도의 에러 코드로 줄지는 웹 어댑터가 결정한다. 기술과 관련된 선택이므로 애플리케이션 계층이 미리 정할 필요가 없고, 애플리케이션 계층은 “이 조건으로 하나를 못 찾는 경우도 있다”만 타입으로 알려 주면 된다

리스트에는 Optional을 씌우지 않았다. 없으면 빈 리스트를 돌려주면 되고 호출하는 쪽은 그걸로 충분히 판단한다.Optional<List<...>>는 비어 있음을 두 가지 방법으로 표현하게 만든다. 파라미터나 필드에 Optional을 쓰지 않는다는 규칙도 같이 두었다. 값이 있을 수도 없을 수도 있는 반환을 표현하는 자리로만 쓰고, 파라미터의 null 허용 여부는 앞에서 다룬 규약 표시용 애노테이션의 몫으로 남긴다

여기까지의 판단에는 테스트가 없다. EnrollmentFinderTest는 아직 본문이 빈 클래스다. 반환 타입 세 가지와 find의 예외 규칙은 코드를 읽어 정한 것이고 테스트로 고정되어 있지 않다. 리포지토리 쪽은 EnrollmentRepositoryTest가 세 메서드를 다 확인하는데, 그 위에 얹은 포트는 비어 있는 상태다

파라미터 타입이 같으면 이름에 조건을 적는다

findByMemberAndCourse라는 이름은 장황하다. 조회 메서드 이름은 find로 두고 파라미터 타입이 조건을 말해 주게 하는 편이 낫다. find(Course course)라면 강의로 찾는다는 것이 타입에 적혀 있다

여기서는 그게 안 된다. 파라미터가 LongLong이다. 직렬화해서 클라이언트에게 내보냈다가 다시 받는 ID 값만 쓰기 때문이고, ID 두 개를 받는 조회는 앞으로 더 늘어날 수 있다. find(1L, 2L)이라는 호출은 무엇을 찾는지 읽히지 않고, 두 인자의 순서를 바꿔 넣어도 컴파일이 통과한다. 타입으로 구분되지 않는 조건은 이름에 적어야 그 실수가 막힌다. 이름이 길어진 것은 그 대가다

중복은 DB에 닿기 전에 코드에서 막는다

한 회원이 같은 강의를 두 번 신청하는 것은 1편에서 걸어 둔 복합 유니크 제약이 최종적으로 막는다. 그래도 애플리케이션 서비스에서 먼저 확인한다

    @Override
    public Enrollment enroll(EnrollRequest enrollRequest) {
        Member member = memberFinder.find(enrollRequest.memberId());
        Course course = courseFinder.find(enrollRequest.courseId());

        checkDuplication(member, course);

        Enrollment enrollment = Enrollment.enroll(member, course);

        return enrollmentRepository.save(enrollment);
    }

    private void checkDuplication(Member member, Course course) {
        if (enrollmentRepository.findByMemberIdAndCourseId(member.getId(), course.getId()).isPresent()) {
            throw new IllegalArgumentException("이미 수강중인 강의입니다");
        }
    }

DB를 믿고 저장 시점에 제약 위반이 나게 두는 방법도 있다. 코드에서 먼저 확인하면 조회 한 번이 늘어나는 대신 저장 시도와 그로 인한 DB 부하가 줄어든다. 무엇보다 실패 지점이 애플리케이션 안으로 들어와서 어떤 예외를 던질지 고를 수 있게 된다

예외 타입은 IllegalArgumentException으로 골랐다. 회원 가입에서는 이메일 중복에 DuplicateEmailException이라는 전용 예외를 선언했는데 여기서는 그렇게 하지 않았다. 이메일은 사람이 직접 입력하는 값이므로 “이미 사용 중인 이메일입니다”를 화면에 보여 줘야 한다. 수강은 목록에서 버튼을 눌러 신청하고, 이미 수강 중인 강의라면 그 버튼 자체가 나오지 않아야 한다. 신청 요청이 여기까지 왔다면 화면이 잘못 만들어졌거나 API를 잘못 호출한 것이다. 사용자에게 보여 줄 안내가 필요한 상황이 아니고, 개발자가 확인할 신고에 해당한다

이 검사가 동작하는지는 1편에서 다음 편으로 넘긴 항목이었다. 테스트를 붙였다

    @Test
    void enrollFailDuplicate() {
        prepareEnrollment();

        assertThatThrownBy(() -> enroller.enroll(
            new EnrollRequest(enrollment.getMember().getId(), enrollment.getCourse().getId()))).isInstanceOf(
            IllegalArgumentException.class);
    }

이미 만들어진 수강에서 회원과 강의를 꺼내 같은 조합으로 다시 신청한다. checkDuplication이 없으면 이 호출은 저장 단계까지 내려가 DataIntegrityViolationException이 된다. 그래서 IllegalArgumentException으로 단정한 것 자체가 코드 레벨 검사가 먼저 걸렸다는 증거가 된다

DB 제약 쪽은 여전히 위반시켜 보지 않았다. 강의 쪽 CourseRepositoryTest에는 uniqueTitleAndInstructorDataIntegrityViolationException을 확인한다. EnrollmentRepositoryTest에는 대응하는 테스트가 없다. 그리고 checkDuplication은 조회 결과에 의존하므로 동시 요청에서는 완전하지 않다. 같은 조합으로 두 요청이 거의 같은 시각에 들어오면 둘 다 빈 결과를 받고 통과할 수 있고, 그때 최종적으로 막는 것은 유니크 제약이다

권한 검사도 없다. 지금 코드는 enroll을 호출한 주체가 memberId의 당사자인지 확인하지 않는다. 다른 회원의 ID로 수강 신청을 만들 수 있다는 뜻이고, 인증이 붙는 단계에서 다뤄야 한다

애플리케이션 서비스는 도메인이 하는 검증을 다시 하지 않는다

위의 enroll에는 회원이 활성 상태인지, 강의가 공개 상태인지 확인하는 코드가 없다. 빠뜨린 것이 아니다. 그 검사는 도메인이 진입부에서 하고 있다

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

두 검사가 정적 팩토리 메서드의 첫 두 줄에 모여 있다. 애플리케이션 서비스가 같은 조건을 한 번 더 검사하면 규칙이 두 자리에 생기고, 상태가 늘어날 때 한쪽만 고쳐지는 상황이 만들어진다

상태 변경 쪽도 같다. startStudying은 상태가 ENROLLED인지 확인하지 않고 도메인 메서드를 부른다

    @Override
    public Enrollment startStudying(Long enrollmentId) {
        Enrollment enrollment = enrollmentFinder.find(enrollmentId);

        enrollment.startStudying();

        return enrollmentRepository.save(enrollment);
    }

애플리케이션 서비스가 맡아야 하는 검증은 도메인 객체 혼자서는 할 수 없는 것들이다. 두 개 이상의 애그리거트가 엮이거나, 이미 저장된 다른 데이터를 조회해 봐야 알 수 있는 조건이다. 앞의 checkDuplication이 그런 검증이다. 리포지토리를 조회해야 알 수 있으므로 Enrollment가 스스로 판단할 수 없다. 이 기준을 두면 검증을 어느 층에 넣을지 매번 고민하지 않아도 된다. 조회가 필요하면 애플리케이션 서비스, 자기 상태만 보면 되면 도메인이다

수강을 찾을 때 리포지토리를 바로 쓰지 않고 EnrollmentFinder를 거친 것도 같은 맥락이다. 없을 때 예외를 던지는 규칙이 find 안에 이미 있으므로 그 규칙을 여기서 다시 쓰지 않는다

마지막 줄의 save() 호출은 짚어 둘 만하다. 같은 트랜잭션 안에서 영속 상태인 엔티티는 변경만 해도 반영되므로 이 호출은 없어도 동작할 가능성이 높다. 빼고 돌려 보거나 성능 차이를 재 보지는 않았고, 지금은 리포지토리를 거쳐 저장한다는 흐름을 코드에 드러내는 쪽으로 두었다

준비 코드는 베이스 클래스로 올린다

수강 하나를 DB에 저장하려면 준비할 것이 많다. 강의가 있어야 하고, 강의에는 강사가 있어야 하고, 강사는 회원이어야 한다. 강의는 공개 상태여야 한다. 수강할 회원도 있어야 한다

회원(강사가 될) → 강사 → 강의 → 정보 채우기 → 검수 신청 → 공개
                                                ↓
                        회원(수강할) ───────────────→ 수강

수강자를 강사와 다른 회원으로 준비한 것은 테스트의 선택이다. Enrollment.enroll은 회원이 활성 상태인지와 강의가 공개 상태인지만 보고 수강자가 그 강의의 강사인지는 확인하지 않으므로, 도메인이 다른 회원을 요구하는 것은 아니다

준비가 여섯 단계고 검증은 두세 줄이다. 이런 테스트를 리포지토리마다 쓰면 데이터 준비만 수십 줄이 되고 정작 테스트하는 부분이 묻힌다. 그래서 준비 과정을 베이스 클래스로 올렸다

@DataJpaTest
public class BaseRepositoryTest {
    @Autowired
    protected EntityManager entityManager;

    // @Autowired 리포지토리 4개 (member, instructor, course, enrollment) — package-private

    protected Member member;
    protected Instructor instructor;
    protected Course course;
    protected Enrollment enrollment;

    protected Member prepareActiveMember() {
        this.member = memberRepository.save(MemberFixture.createActiveMember());
        return this.member;
    }

    protected Instructor prepareActiveInstructor() {
        prepareActiveMember();
        this.instructor = instructorRepository.save(InstructorFixture.createActiveInstructor(member));
        return this.instructor;
    }

    protected Instructor prepareActiveInstructor(Member member) {
        this.instructor = instructorRepository.save(InstructorFixture.createActiveInstructor(member));
        return this.instructor;
    }

    protected Course prepareCourse(@Nullable Instructor instructor, @Nullable String title) {
        if (instructor == null) {
            prepareActiveInstructor();
        }
        this.course =
            courseRepository.save(CourseFixture.createCourse(instructor == null ? this.instructor : instructor, title));
        this.course.updateInfo(CourseFixture.createCourseInfoUpdateRequest(title).toInfo());
        return this.course;
    }

    protected Course preparePublishedCourse() {
        prepareCourse(null, null);
        this.course.submitForReview();
        this.course.publish();
        return this.course;
    }

    protected Enrollment prepareEnrollment(Member member, Course course) {
        this.enrollment = enrollmentRepository.save(EnrollmentFixture.createEnrollment(member, course));
        return this.enrollment;
    }
}

메서드 이름에 어떤 상태로 준비되는지를 넣었다. prepareMember가 아니라 prepareActiveMember이고, prepareCoursepreparePublishedCourse가 따로 있다. 이름만 보고 호출하면 활성 상태인지 공개 상태인지를 다시 확인하지 않아도 된다. 각 메서드는 만든 것을 필드에 저장하면서 그대로 반환하기도 한다. 받아서 쓸 수도 있고 필드로 꺼내 쓸 수도 있다

prepareCourse가 강사를 인자로 받되 null을 허용하는 것도 봐 둘 부분이다. 인자가 없으면 안에서 강사를 새로 만들고, 있으면 넘어온 강사를 쓴다. 이 분기가 다음 절의 함정이 시작되는 자리다

이걸 깔고 나면 리포지토리 테스트가 짧아진다

    @Test
    void findByMemberId() {
        Member member1 = prepareActiveMember();
        Member member2 = prepareActiveMember();

        Enrollment enrollment1_1 = prepareEnrollment(member1, preparePublishedCourse());
        Enrollment enrollment1_2 = prepareEnrollment(member1, preparePublishedCourse());
        Enrollment enrollment2 = prepareEnrollment(member2, preparePublishedCourse());

        List<Enrollment> enrollments1 = enrollmentRepository.findByMemberId(member1.getId());
        assertThat(enrollments1).hasSize(2).containsExactly(enrollment1_1, enrollment1_2);

        List<Enrollment> enrollments2 = enrollmentRepository.findByMemberId(member2.getId());
        assertThat(enrollments2).hasSize(1).containsExactly(enrollment2);
    }

조회 대상 회원 두 명과 강의 세 개, 수강 세 개를 만드는 준비가 다섯 줄이다. preparePublishedCourse()는 호출마다 강사와 그 강사가 될 회원을 새로 만들기 때문에, 실제로 저장되는 회원은 다섯 명이고 강사는 세 명이다. 그 과정이 다섯 줄 안에 숨는 것이 베이스 클래스의 값이다. hasSize로 개수를, containsExactly로 어느 수강이 돌아왔는지를 확인하니 회원별로 갈라져 나오는지가 검증된다

베이스 클래스의 공유 필드는 함정을 만든다

준비 메서드가 만든 것을 필드에 저장한다는 것은, 그 종류가 하나뿐이라는 전제가 베이스 클래스에 박힌다는 뜻이다. 같은 종류를 두 개 만들어야 하는 테스트에서 이 전제가 깨진다

CourseRepositoryTestfindByInstructor가 그런 테스트다. 강사 두 명과 강사별 강의 하나씩이 필요하다. 베이스 클래스를 여기에 적용하면서 준비 코드를 그대로 prepare 계열로 바꾸면 다음과 같은 상태를 만들 수 있다

    // 필드를 그대로 쓰면 깨지는 형태
    @BeforeEach
    void setUp() {
        member = prepareActiveMember();
        instructor = prepareActiveInstructor(member);      // this.instructor = 강사1
    }

    @Test
    void findByInstructor() {
        var instructor2 = prepareActiveInstructor();       // this.instructor = 강사2 로 덮어쓴다

        var course = prepareCourse(instructor, "Title");   // 필드를 읽으니 강사2의 강의가 된다
        var course2 = prepareCourse(instructor2, "Title2");

        List<Course> courses = courseRepository.findByInstructorId(instructor.getId());
        assertThat(courses).singleElement().isEqualTo(course);   // 강의 두 개가 다 걸려 실패한다

prepareActiveInstructor()는 만든 강사를 this.instructor에 저장한다. 필드가 하나뿐이므로 두 번째 호출이 첫 번째 강사를 덮어쓴다. setUp()에서 준비한 강사를 필드로 읽는 코드는 그 사실을 모른 채 바뀐 값을 보고, 두 강의가 같은 강사에 붙어 singleElement()가 깨진다. 위 코드는 문제를 드러내기 위해 구성한 것이고 저장소에 커밋된 상태는 아니다. 지금 저장소의 테스트는 90개가 전부 통과한다

커밋된 형태는 필드를 아예 쓰지 않는다

    @Test
    void findByInstructor() {
        var instructor1 = prepareActiveInstructor();
        var instructor2 = prepareActiveInstructor();

        var course = prepareCourse(instructor1, null);
        var course2 = prepareCourse(instructor2, null);

        List<Course> courses = courseRepository.findByInstructorId(instructor1.getId());
        assertThat(courses).singleElement().isEqualTo(course);

        List<Course> courses2 = courseRepository.findByInstructorId(instructor2.getId());
        assertThat(courses2).singleElement().isEqualTo(course2);

        List<Course> courses2_1 = courseRepository.findByInstructor(instructor2);
        assertThat(courses2_1).singleElement().isEqualTo(course2);
    }

반환 값을 지역 변수로 받아 그것만 쓴다. 필드는 같은 종류를 하나만 쓰는 테스트에서 편의로 쓰고, 둘 이상이 필요하면 지역 변수로 내려온다. 준비 메서드가 필드에 저장하면서 반환도 하게 만들어 둔 것이 여기서 값을 한다. 제목도 null로 바꿨다. 값이 들어 있으면 그 제목이 테스트에서 쓰이는지 한 번 확인하게 되고, 확인해 보면 쓰이지 않는다

베이스 클래스에 상태를 두면 이런 전제가 하위 테스트로 새어 든다. 지금은 회원, 강사, 강의, 수강 네 개짜리라 감당되지만, 도메인이 늘어나면 상속 대신 준비 전용 컴포넌트를 주입받는 방식이 나을 수 있다

complete() 뒤에 붙을 일들은 아직 비어 있다. 수강이 완료되면 통계를 갱신하고 축하 메일을 보내고 관리자에게 알려야 할 수 있다. 그것을 애플리케이션 서비스 안에 줄줄이 쌓는 대신 완료를 하나의 사건으로 보고 알림을 받아 처리하게 만드는 방법이 있고, 그게 다음 주제다

애노테이션의 의미는 문서에 적혀 있지만, 그 의미를 런타임에 집행하는 주체가 누구인지는 테스트를 한 번 실패시켜 봐야 확인된다


출처와 범위

이 편은 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2의 예제 프로젝트 Splearn을 따라 구현한 코드를 기준으로 썼다. 포트를 나누는 기준과 예외 타입 선택의 근거는 강의를 따랐고, 애노테이션 스펙과 스프링 7 기준은 공식 문서에서 직접 대조해 본문에 링크했다. 인용한 코드는 로컬에서 돌려 통과한 상태이며, 저장소가 비공개라 커밋으로 연결되는 링크는 없다

참고 자료