입력 값 검증을 애플리케이션 서비스 메서드 안에 직접 넣으면 첫 번째 오류를 만나는 순간 예외를 던지게 된다. 입력 항목이 열 개고 그중 다섯 개가 잘못됐다면 사용자는 다섯 번 제출하고 다섯 번 오류 메시지를 본다. 두 번째 제출에서야 두 번째 오류를 알게 되는 구조다
결론부터 말하면 사용자 입력 검증은 별도 컴포넌트로 분리하고, 예외는 오류 하나가 아니라 오류 목록을 담아야 한다. 그리고 이 검증은 프로그램의 버그를 잡는 불변식 검사와 다른 예외로 표현해야 한다. 복구 가능성이 다르기 때문이다
이 글은 앞 글 “강의 도메인의 상태 전이는 애그리거트 안에서 강제한다”에서 만든 강의 도메인 위에 애플리케이션 서비스를 올리면서 포트를 어떻게 나눴는지, 검증을 왜 분리했는지, 그리고 생성할 때와 수정할 때의 중복 검사가 왜 달라야 하는지를 정리한 것이다
이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트
Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 설계 판단의 근거와 용어는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다.
이 글에서 자주 나오는 용어
- 포트(Port): 애플리케이션이 외부와 주고받을 기능을 선언한 인터페이스.
provided는 외부에 제공하는 기능,required는 외부로부터 제공받아야 하는 기능이다 - 애플리케이션 서비스: 포트의 구현체. 도메인 객체와 리포지토리를 조합해 하나의 작업 흐름을 처리한다
- 불변식(Invariant): 도메인이 항상 만족해야 하는 조건. 어기면 프로그램에 버그가 있다는 뜻이다
- 준영속 상태(Detached): 한 번 영속화되어 식별자를 가졌지만 지금은 영속성 컨텍스트에 속하지 않은 엔티티의 상태
- 픽스처(Fixture): 테스트에 필요한 객체를 미리 만들어 주는 코드
- Pull Members Up: 하위 클래스의 필드나 메서드를 상위 클래스로 끌어올리는 IDE 리팩터링
포트는 사용 의도로 나눈다
강의로 할 수 있는 작업은 다섯 가지다. 생성, 정보 수정, 검수 신청, 공개, 보관. 이걸 인터페이스 하나에 다 넣을 수도 있고 다섯 개로 쪼갤 수도 있다. 기준은 “어떤 의도를 가지고 이 기능에 접근하는가”다
강의를 준비하는 의도 강의를 공개하는 의도 강의를 찾는 의도
create() submitForReview() find()
updateInfo() publish() findByTitle()
archive() findByInstructor()
→ CourseCreator → CoursePublisher → CourseFinder
수정이 CourseCreator에 들어간 것이 어색해 보일 수 있다. 이름은 수정이지만 실제로는 생성을 여러 번에 나눠서 하는 작업이기 때문이다. 강의는 생성 시점에 강사, 제목, 강의 소개만 받고 나머지 정보는 나중에 채운다. 강의 소개조차 null을 허용하니 사실상 강사와 제목만으로 시작할 수 있다. 그 “나중”은 대부분 강의를 준비하는 초반에 몰려 있다. 시간이 한참 지난 뒤 정보를 고치는 일반적인 수정과는 성격이 다르다. 그래서 생성이라는 큰 작업 안에 포함시켰다
/**
* 강의를 준비하는 작업
*/
public interface CourseCreator {
Course create(@Valid CourseCreateRequest createRequest) throws ValidationException;
Course updateInfo(Long courseId, @Valid CourseInfoUpdateRequest infoUpdateRequest) throws ValidationException;
}
throws ValidationException은 런타임 예외라 컴파일러가 요구하지 않는데도 붙였다. 이 포트를 쓰는 쪽에서 검증 실패가 발생할 수 있다는 사실을 알고 써야 하기 때문이다. 그래야 호출부에서 예외를 잡아 메시지로 변환하는 후처리를 준비할 수 있다. 사용자가 직접 값을 입력하는 기능에는 이 선언을 계속 붙여 나갈 계획이다
구현은 인터페이스 개수와 일치하지 않아도 된다. CourseModifyService 하나가 CourseCreator와 CoursePublisher를 함께 구현하고, 조회는 CourseQueryService가 맡는다
@ValidatedApplicationService
@RequiredArgsConstructor
public class CourseModifyService implements CourseCreator, CoursePublisher {
외부에서는 항상 인터페이스로만 접근하기 때문에 구현 클래스를 나중에 자유롭게 쪼갤 수 있다. 규모가 커지거나 내부에서 쓰는 의존성이 제각각이 되는 시점에 분리하면 된다. 클래스를 먼저 나누고 인터페이스를 맞추는 순서가 아니라, 인터페이스를 의도대로 나눠 두고 구현은 편의에 따라 묶는 순서다
대상을 고르는 값과 바꿀 값은 분리한다
updateInfo()의 파라미터가 두 개라는 점을 짚고 넘어갈 만하다. 강의 ID와 수정 요청 DTO다. ID를 DTO 안에 넣지 않은 데는 이유가 있다
courseId → 어떤 것을 수정할 것인가 (대상 선택) CourseInfoUpdateRequest → 무엇으로 바꿀 것인가 (수정 내용)
성격이 다른 두 정보다. REST API로 노출하면 이 구분이 그대로 드러난다. 강의 ID는 경로에 들어가고 수정 내용은 요청 본문에 들어간다. 지금 분리해 두면 웹 어댑터를 붙일 때 DTO를 다시 쪼갤 일이 없다
public record CourseCreateRequest(
@NotNull Long instructorId,
@Size(min = 2, max = 100) String title,
@Size(max = 500) String description
) {
}
public record CourseInfoUpdateRequest(
@Size(min = 2, max = 100) String title,
@Size(max = 500) String description
) {
public CourseUpdateInfo toInfo() {
return new CourseUpdateInfo(title, description);
}
}
생성 요청에는 강사 ID가 있지만 수정 요청에는 없다. 강의의 강사는 바뀌지 않기 때문이다. toInfo()는 애플리케이션 계층의 DTO를 도메인의 값 객체로 변환한다. 도메인이 애플리케이션 계층의 DTO를 알지 못하게 하려면 이 변환이 어딘가에 있어야 하고, 요청 객체 자신이 맡는 것이 자연스럽다
조회 의도는 메서드 이름에서 드러나야 한다
리포지토리는 스프링 데이터의 쿼리 메서드로 구성한다
public interface CourseRepository extends Repository<Course, Long> {
Course save(Course course);
Optional<Course> findById(Long id);
List<Course> findByTitleContaining(String keyword);
default List<Course> findByInstructor(Instructor instructor) {
return findByInstructorId(instructor.getId());
}
List<Course> findByInstructorId(Long instructorId);
Optional<Course> findByInstructorAndTitle(Instructor instructor, String title);
}
Containing은 스프링 데이터가 제공하는 키워드로, like %keyword% 형태의 쿼리를 만든다. 이름을 findByTitle로 두면 제목이 정확히 일치하는 것을 찾아오는 메서드처럼 보인다. 실제 동작이 부분 일치라면 이름에서 그 사실이 드러나야 한다. 같은 이유로 포트 쪽 메서드는 파라미터 이름을 title이 아니라 keyword로 잡았다
findByInstructor는 default 메서드다. 엔티티를 이미 손에 들고 있는데 매번 getId()를 꺼내는 것이 번거로워서, 실제 쿼리를 만드는 findByInstructorId에 위임하는 편의 메서드를 하나 뒀다. 쿼리 메서드 이름 규칙에는 findByInstructor도 유효하지만, 그러면 파라미터 타입에 따라 어떤 쿼리가 만들어지는지가 덜 명확해진다
복합 유니크 제약은 DB가 최종 보장한다
같은 강사가 같은 제목의 강의를 두 개 가질 수 없다는 규칙은 애플리케이션 서비스에서도 검사한다. 그런데 DB 제약을 함께 거는 이유는 애플리케이션을 거치지 않는 경로가 있기 때문이다. 데이터 마이그레이션, 운영 중 직접 실행하는 SQL, 배치 작업이 그렇다. 최종 방어선은 DB에 두는 편이 안전하다
제약은 orm.xml에 선언했다
<table name="course">
<unique-constraint name="UK_INSTRUCTOR_TITLE">
<column-name>instructorId</column-name>
<column-name>title</column-name>
</unique-constraint>
</table>
column-name을 두 개 나열하면 두 컬럼을 묶은 복합 유니크 인덱스가 만들어진다
여기 적은 instructorId는 자바 필드명 표기다. 실제 DB 컬럼은 instructor_id인데도 동작하는 이유는 하이버네이트가 이 이름을 물리 네이밍 전략에 통과시키기 때문이다. 스프링 부트의 기본 전략인 CamelCaseToUnderscoresNamingStrategy가 카멜 케이스를 스네이크 케이스로 바꾼다. 즉 instructorId와 instructor_id 둘 다 같은 결과가 된다. 다만 이 파일 안의 다른 제약은 email_address처럼 스네이크 케이스로 적혀 있어 표기가 섞인 상태다. 동작에는 문제가 없지만 읽는 사람이 어느 쪽 기준인지 매번 확인해야 하므로 하나로 통일하는 편이 낫다. 네이밍 전략을 기본값에서 바꿔 쓰고 있다면 실제 생성되는 컬럼명을 확인하고 맞춰야 한다
검증하는 테스트는 다음과 같다
@Test
void uniqueTitleAndInstructor() {
courseRepository.save(CourseFixture.createCourse(instructor, "Title"));
assertThatThrownBy(() -> courseRepository.save(CourseFixture.createCourse(instructor, "Title"))).isInstanceOf(
DataIntegrityViolationException.class);
}
이 테스트를 만들 때는 순서가 있었다. 제약을 걸기 전에 테스트를 먼저 쓰면 어떤 예외가 날지 모르기 때문에 일단 RuntimeException으로 넓게 잡아 두고, 제약을 건 뒤 실제로 발생하는 예외를 확인해 좁혔다. 결과는 스프링의 DataIntegrityViolationException이다. 예외 타입을 짐작해서 적기보다 한 번 실패시켜 확인하는 편이 정확하다
Detached Entity 오류는 픽스처가 원인이었다
리포지토리 테스트를 처음 실행했을 때 저장 단계에서 CourseDetail이 준영속 상태라는 오류가 났다. 저장한 적이 없는 새 객체인데 왜 준영속으로 인식됐는지가 문제였다
원인은 픽스처였다. Instancio는 지정하지 않은 필드를 전부 랜덤 값으로 채우는데, Course에는 id를 무시하도록 지정했지만 CourseDetail에는 그 지정이 빠져 있었다. 그래서 CourseDetail에 임의의 id 값이 들어갔고, JPA는 식별자를 가진 엔티티를 한 번 저장됐다 떨어져 나온 객체로 판단했다
CourseDetail detail = Instancio.of(CourseDetail.class)
.ignore(field(CourseDetail::getId))
.generate(field(CourseDetail::getDescription),
gen -> gen.string().maxLength(500).nullable())
.set(field(CourseDetail::getCreatedAt), LocalDateTime.now())
.create();
ignore(field(CourseDetail::getId)) 한 줄을 추가해 해결했다. 랜덤 데이터 생성기를 엔티티에 쓸 때는 식별자 필드를 무시 대상으로 지정했는지가 첫 번째 점검 항목이다
저장과 조회를 한 테스트에서 검증할 때 또 하나 필요한 것이 영속성 컨텍스트 초기화다
entityManager.flush(); entityManager.clear(); Course found = courseRepository.findById(course.getId()).orElseThrow();
clear() 없이 findById()를 호출하면 영속성 컨텍스트에 남아 있는 객체가 그대로 반환되고 DB까지 가지 않는다. 매핑이 잘못돼도 테스트는 통과한다. flush()로 쓰기를 반영하고 clear()로 컨텍스트를 비워야 실제 쿼리가 나간다
애플리케이션 서비스에는 절차만 남긴다
애플리케이션 서비스는 하나의 요청을 절차적으로 처리하는 코드다. 그래서 구현하기 전에 절차를 먼저 적어 두면 구조가 잡힌다
// 1. instructor 찾기 // 2. validate // 3. save
주석을 하나씩 지우면서 코드로 바꾸는 방식이다. create 정도로 단순한 작업에는 과하지만, 단계가 대여섯 개로 늘어나는 기능에서는 이 방법이 절차를 놓치지 않게 해 준다. 완성된 코드는 다음과 같다
@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());
return courseRepository.save(course);
}
네 줄이 곧 네 단계다. 강사를 찾고, 검증하고, 도메인 객체를 만들고, 저장한다. 검증 규칙이 몇 개인지, 중복 검사가 어떻게 이루어지는지는 이 코드에 없다. 그 상세는 CourseValidator의 구현체인 CourseValidationService 안에 있다
이렇게 최상위 레벨을 추상화된 형태로 유지하는 이유는 시간이 지난 뒤에 드러난다. 검증 로직과 조회 로직이 이 메서드 안에 늘어서 있으면 스무 줄, 서른 줄이 되고, 그때는 이 기능이 무슨 일을 하는지 읽어내는 데 시간이 걸린다. 강의가 처음 생성된 뒤 이메일 발송이나 통계 처리 같은 후처리가 붙어도 이 구조라면 한 줄이 늘어날 뿐이다
검증 실패와 불변식 위반은 다른 예외다
검증을 분리하기 전에 정리해야 할 것이 있다. 무엇을 검증이라고 부를 것인가다. 앞 글에서 도메인은 이미 여러 조건을 검사하고 있었다. IllegalStateException을 던지는 그 검사들과 지금 만들려는 검증은 다른 종류다
불변식 위반 사용자 입력 검증 실패 ─────────────────────────── ───────────────────────────── 승인되지 않은 강사로 강의 생성 이미 사용 중인 제목 DRAFT가 아닌데 검수 신청 금지된 단어가 포함된 소개 원인: 이 코드를 호출한 쪽의 버그 원인: 사용자가 입력한 값 복구: 불가능. 프로그램을 고쳐야 함 복구: 가능. 값을 고쳐 다시 제출 처리: 발견 즉시 예외를 던진다 처리: 전부 모아서 한 번에 던진다
구분 기준은 복구 가능성이다. 검증되지 않은 회원으로 강사를 신청하는 상황은 클라이언트 프로그램 자체에 문제가 있는 것이고, 사용자가 무엇을 고쳐도 해결되지 않는다. 그 자리에서 바로 예외를 던지면 된다. 반면 제목 중복은 사용자가 다른 제목을 입력하면 해결된다. 그렇다면 예외에는 무엇을 고쳐야 하는지에 대한 정보가 담겨야 한다
그래서 오류 목록을 담는 예외를 따로 만들었다
@Getter
public class ValidationException extends RuntimeException {
private final List<String> errors;
public ValidationException(List<String> errors) {
this.errors = errors;
}
}
이 예외는 애플리케이션 서비스 전용이 아니다. 도메인이나 어댑터에서도 쓸 수 있어야 하므로 특정 계층 패키지가 아니라 루트의 support.exception에 뒀다. 계층을 가로질러 쓰이는 타입을 어느 한 계층에 두면 의존 방향이 어긋나기 시작한다
오류는 모아서 한 번에 돌려준다
검증 구현체는 오류 메시지를 담을 리스트를 먼저 만들고, 개별 검사는 예외를 던지는 대신 이 리스트에 메시지를 추가한다. 모든 검사가 끝난 뒤 리스트가 비어 있지 않으면 그때 예외를 던진다
@Override
public void validateForCreate(Instructor instructor, CourseCreateRequest createRequest) throws ValidationException {
instructor.ensureActive();
List<String> errors = new ArrayList<>();
checkTitleDuplicationForCreate(instructor, createRequest.title(), errors);
checkBannedWords(createRequest.title(), errors);
checkBannedWords(createRequest.description(), errors);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
개별 검사 메서드는 조건을 확인하고 문제가 있으면 errors에 메시지를 추가한 뒤 정상 반환한다. 이 구조여야 세 검사가 모두 실행되고 사용자가 세 가지 문제를 한 번에 볼 수 있다. 중간에 예외를 던지는 검사가 하나라도 섞이면 그 뒤의 검사는 실행되지 않는다
첫 줄의 instructor.ensureActive()는 이 분류로는 검증이 아니라 불변식 검사다. 원래 애플리케이션 서비스에 있어야 할 코드인데, 확인하는 코드를 한곳에 모으려고 검증 서비스 앞에 두었다. 예외 타입이 다르므로 호출부에서 구분하는 데는 문제가 없다
@Valid 애노테이션으로 처리되는 검사와도 역할이 갈린다. @NotNull, @Size 같은 Bean Validation 애노테이션은 DTO에 붙어 있고 AOP가 메서드 호출 시점에 검사한다. 형식에 대한 검사는 거기서 끝난다. 값이 형식은 맞지만 다른 데이터와 비교해야 판단할 수 있는 조건, 예를 들어 중복 여부 같은 것이 이 검증 서비스의 몫이다
수정할 때의 중복 검사는 생성과 다르다
같은 강사에게 같은 제목이 있으면 안 된다는 규칙 하나인데, 생성과 수정에서 검사 방식이 달라야 한다. 생성할 때는 “DB에 없어야 한다”로 충분하다. 수정할 때 같은 조건을 쓰면 항상 실패한다
강사 A의 강의 "Clean Spring"의 소개만 고치려고 한다 → 제목은 그대로 "Clean Spring" → DB에서 (강사 A, "Clean Spring")을 찾으면 존재한다 → 생성 기준으로 검사하면 중복이므로 실패 → 그런데 그 존재하는 강의가 바로 지금 수정하려는 강의다
찾은 결과가 있다는 사실만으로는 판단할 수 없고, 찾은 것이 지금 수정 중인 강의인지 다른 강의인지를 한 번 더 확인해야 한다. 그래서 메서드를 둘로 나눴다
private void checkTitleDuplicationForCreate(Instructor instructor, String title, List<String> errors) {
if (courseRepository.findByInstructorAndTitle(instructor, title).isPresent()) {
errors.add("이미 사용중인 강의 제목입니다. " + title);
}
}
private void checkTitleDuplicationForUpdate(Course course, String title, List<String> errors) {
courseRepository.findByInstructorAndTitle(course.getInstructor(), title).ifPresent(found -> {
if (!found.equals(course)) {
errors.add("이미 사용중인 강의 제목입니다. " + title);
}
});
}
수정 쪽은 파라미터로 강의를 통째로 받는다. 강사를 꺼내는 데도 쓰지만, 찾아낸 강의와 비교하는 데 필요하기 때문이다. 제목을 그대로 두고 소개만 바꾸는 흔한 경우가 이 비교 하나로 통과한다
검증할 시나리오는 세 가지이고 한 테스트 안에 주석과 함께 넣었다. 준비 과정이 길어 테스트를 셋으로 나누면 오히려 읽기 어려워지는 경우다
@Test
void titleDuplicationForCreate() {
var instructor1 = prepareInstructor();
var instructor2 = prepareInstructor();
courseRepository.save(CourseFixture.createCourse(instructor1, "Clean Spring"));
courseRepository.save(CourseFixture.createCourse(instructor2, "Clean Code"));
// instructor1, 중복되지 않는 제목 - OK
courseValidator.validateForCreate(instructor1, new CourseCreateRequest(instructor1.getId(), "Spring 7", null));
// instructor1, 중복 제목 - FAIL
assertThatThrownBy(() -> courseValidator.validateForCreate(instructor1,
new CourseCreateRequest(instructor1.getId(), "Clean Spring", null))).isInstanceOfSatisfying(
ValidationException.class, e -> assertThat(e.getErrors()).hasSize(1));
// instructor2, 1과 중복되는 제목 - OK
courseValidator.validateForCreate(instructor2,
new CourseCreateRequest(instructor2.getId(), "Clean Spring", null));
}
isInstanceOfSatisfying은 예외 타입 확인과 예외 내부 검증을 한 번에 한다. 예외가 발생했다는 사실만 확인하고 넘어가면, 의도한 검사가 아닌 다른 이유로 예외가 나도 테스트는 통과한다. 오류가 정확히 하나인지까지 확인하면 그 구멍이 막힌다. 메시지 내용까지 비교하지 않은 것은 메시지가 자주 바뀌기 때문이다. 메시지를 검증하려면 키 값으로 비교하는 구조가 먼저 필요하다
테스트 준비 코드는 베이스 클래스로 끌어올린다
강의 하나를 테스트하려면 회원을 만들고 활성화하고, 강사를 신청하고 승인하고, 그다음에야 강의를 만들 수 있다. 이 준비 과정이 테스트마다 반복되면 정작 검증하려는 코드가 준비 코드에 묻힌다
그래서 준비 메서드를 추출해 상위 클래스로 올렸다. IDE의 Pull Members Up을 쓰면 메서드가 참조하는 의존성까지 함께 올릴 수 있다
@ApplicationServiceTest
public class BaseApplicationServiceTest {
@Autowired
MemberRegister memberRegister;
@Autowired
InstructorApplication instructorApplication;
@Autowired
CourseCreator courseCreator;
protected Member member;
protected Instructor instructor;
protected Course course;
protected Instructor prepareInstructor() {
prepareMember();
this.instructor = instructorApplication.apply(InstructorFixture.createApplyRequest(member));
this.instructor.approve();
return this.instructor;
}
protected Member prepareMember() {
this.member = memberRegister.register(MemberFixture.createMemberRegisterRequest());
this.member.activate();
return this.member;
}
protected Course prepareCourse() {
prepareInstructor();
this.course = courseCreator.create(CourseFixture.createCourseCreateRequest(instructor.getId(), null));
this.course.updateInfo(CourseFixture.createCourseInfoUpdateRequest(null).toInfo());
return this.course;
}
}
두 가지 결정이 들어 있다. 첫째, 의존성 주입을 생성자가 아니라 @Autowired 필드로 받는다. 이 프로젝트의 테스트는 @RequiredArgsConstructor와 final 필드 조합으로 생성자 주입을 쓰는데, 상위 클래스가 생성자 주입을 쓰면 하위 클래스마다 슈퍼 생성자를 호출해 줘야 한다. 상속 구조에서는 필드 주입이 더 간결하다
둘째, 준비 메서드는 만든 객체를 반환하면서 동시에 protected 필드에도 넣는다. 두 개 이상이 필요한 테스트는 반환값을 받아 쓰고, 하나만 필요한 테스트는 prepare 호출 후 상위 클래스의 필드를 그대로 쓰면 된다. 두 사용 방식을 한 메서드가 지원한다
결과는 테스트 코드에서 바로 드러난다
@ApplicationServiceTest
@RequiredArgsConstructor
class CoursePublisherTest extends BaseApplicationServiceTest {
final CoursePublisher coursePublisher;
@BeforeEach
void setUp() {
prepareCourse();
}
@Test
void publish() {
coursePublisher.submitForReview(course.getId());
var courseForPublish = coursePublisher.publish(course.getId());
assertThat(courseForPublish.getStatus()).isEqualTo(CourseStatus.PUBLISHED);
}
}
회원, 강사, 강의를 준비하는 코드가 prepareCourse() 한 줄로 줄었다. publish 테스트에 남은 것은 컨텍스트를 만드는 한 줄, 검증 대상을 실행하는 한 줄, 결과를 확인하는 한 줄뿐이다
이 베이스 클래스를 만든 뒤 이미 작성해 둔 강사 테스트와 조회 테스트에도 적용했다. 강사를 테스트하는데 회원 등록과 활성화 코드가 잔뜩 따라붙던 부분이 전부 사라졌다. 픽스처와 베이스 클래스를 잘 만들어 두면 이후 테스트를 작성하는 속도가 눈에 띄게 달라진다
이 방식이 그대로 통하지 않는 조건
여기까지의 코드는 전체 테스트 78개가 통과하는 상태다. 다만 다루지 않은 조건이 있다
검수, 공개, 보관 시점의 검증은 인터페이스와 빈 구현만 만들어 두고 내용은 비워 뒀다. 금지 단어 검사도 마찬가지다. 금지어는 별도 파일이나 저장소에서 목록을 읽어 비교하는 구조가 필요하고, 그건 검증 로직 분리와는 다른 주제다. 틀만 잡아 두면 규칙이 정해질 때 그 자리에 채우면 된다
오류 메시지는 문자열 그대로 코드에 들어 있다. 실무에서는 메시지 파일로 분리하고 예외에는 키 값만 담는 편이 일반적이다. 다국어 지원이 필요하거나 메시지 문구가 자주 바뀌는 서비스라면 처음부터 키 기반으로 시작하는 편이 낫다
중복 검사는 DB 조회에 의존하므로 동시 요청에서는 완전하지 않다. 두 요청이 거의 같은 시각에 같은 제목을 검사하면 둘 다 통과할 수 있다. 이 구간을 최종적으로 막는 것은 앞에서 건 복합 유니크 제약이고, 그때 발생하는 DataIntegrityViolationException을 사용자에게 어떻게 보여줄지는 별도의 처리가 필요하다
베이스 클래스는 편리한 만큼 결합을 만든다. 지금은 회원, 강사, 강의 세 가지 준비 메서드지만 도메인이 늘어날수록 커지고, 특정 준비 과정이 필요 없는 테스트까지 그 의존성을 상속받는다. 어느 시점부터는 상속 대신 조합, 예를 들어 준비 전용 컴포넌트를 주입받는 방식이 나을 수 있다
권한 검사는 여전히 없다. 지금 코드는 강의를 공개하려는 사람이 그 강의의 강사인지 확인하지 않는다. 인증이 붙는 단계에서 다뤄야 할 부분이다
정리하면, 사용자가 고칠 수 있는 문제는 모아서 한 번에 돌려주고 프로그램이 고쳐야 할 문제는 즉시 던지는 것, 이 구분이 검증 코드를 어디에 둘지까지 결정한다
출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2