상태를 가진 도메인을 개발할 때 가장 흔한 구조는 엔티티에 status 컬럼을 두고, 서비스 코드에서 if (course.getStatus() == DRAFT) 같은 조건문으로 전이를 검사하는 방식이다. 처음에는 잘 동작한다. 문제는 그 강의를 다루는 코드가 두 곳, 세 곳으로 늘어날 때다. 어느 상태에서 어느 상태로 갈 수 있는지가 서비스 여기저기에 흩어지고, 한 곳에서 검사를 빠뜨리면 검수도 거치지 않은 강의가 공개 상태로 넘어간다
결론부터 말하면 상태를 바꾸는 메서드는 애그리거트 루트에만 두고, 각 메서드가 자기 사전 조건을 스스로 검사하게 해야 한다. 그러면 잘못된 전이는 애그리거트를 통과하지 못한다. 이 글은 온라인 학습 서비스의 강의(Course) 도메인을 새로 만들면서 애그리거트 경계, 식별자, 생성 방식, 상태 전이를 어떤 근거로 결정했는지 정리한 것이다
이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트
Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 설계 판단의 근거와 용어는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다.
이 글에서 자주 나오는 용어
- 애그리거트(Aggregate): 함께 변경되어야 하는 엔티티들의 묶음. 이 경계 안에서는 비즈니스 불변식이 항상 지켜지고, 변경은 하나의 트랜잭션으로 처리된다
- 애그리거트 루트: 애그리거트의 대표 엔티티. 외부는 항상 루트를 통해서만 내부에 접근한다
- 내부 엔티티: 애그리거트 안에서 루트와 함께 동작하지만 외부에 노출되지 않는 엔티티
- 대리키(Surrogate Key): 비즈니스 의미가 없는 인조 식별자. 보통 자동 증가하는
id가 여기 해당한다 - Natural Key: 비즈니스 의미로 행을 구분할 수 있는 값. 대리키가 있어도 별도로 찾아 둘 가치가 있다
- 사전 조건(Precondition): 메서드가 실행되기 전에 반드시 만족해야 하는 조건. 어기면 프로그램의 버그다
- orm.xml: JPA 매핑 정보를 애노테이션 대신 XML로 선언하는 표준 파일. 도메인 클래스에서 DB 관련 정보를 걷어낼 때 쓴다
- Instancio: 객체의 필드를 랜덤 값으로 채워 주는 자바 테스트 데이터 생성 라이브러리
도메인 대화에서 상태가 먼저 나왔다
강의라는 개념을 코드로 옮기기 전에 팀에서 정리한 문장은 다음과 같았다
승인된 강사는 강의를 생성할 수 있다. 생성된 강의는 초안 상태이며, 상세 내용을 등록하거나 수정할 수 있다. 준비가 끝나면 검수를 신청한다. 검수를 통과하면 강의를 공개한다. 공개된 강의는 회원에게 노출되고, 회원은 강의를 수강할 수 있다. 더 이상 공개하지 않기로 하면 강의를 보관한다.
이 문장들에서 명사보다 먼저 눈에 들어오는 것은 동사와 상태다. 생성, 검수 신청, 공개, 보관이라는 네 개의 동사가 있고 그 사이에 네 개의 상태가 있다. 강의는 한 번에 완성되어 바로 열리는 대상이 아니다. 상세 설명과 콘텐츠를 여러 번에 걸쳐 채우고, 준비가 끝났다고 판단되는 시점에 공식적으로 제출하는 흐름을 가진다
new Course() submitForReview() publish() archive()
│ │ │ │
▼ ▼ ▼ ▼
DRAFT ──────────▶ IN_REVIEW ──────────▶ PUBLISHED ─────▶ ARCHIVED
초안 검수 중 공개 보관
전이는 한 방향으로만 흐르고, 각 화살표에는 그 전이를 일으키는 메서드가 하나씩 대응한다. 이 그림이 그대로 코드가 된다는 점이 중요하다. 상태 전이를 표로 정리해 두고 서비스에서 검사하는 방식이 아니라, 전이 자체를 애그리거트의 메서드로 만드는 것이다
용어 하나는 정리했다. 한국어로는 “검수”와 “리뷰”가 섞여 나왔는데, 보편 언어는 검수로 통일하고 코드에서만 review를 쓰기로 했다. 팀 안에서 같은 개념을 두 단어로 부르는 상태를 그대로 두면 문서와 코드가 어긋나기 시작한다
한편 “회원은 강의를 수강할 수 있다”는 문장은 강의 애그리거트 밖의 이야기다. 수강은 별도의 개념이고 이 글에서는 다루지 않는다
애그리거트 경계는 함께 변경되는 범위로 잡는다
강의를 애그리거트로 잡는 것까지는 이견이 없었다. 강의는 회원이나 강사와 같은 이유로 변경되지 않고 독립적인 변경 이유를 가진다. 문제는 그 다음이다. 강의 안에는 상세 설명도 있고, 커리큘럼을 구성하는 섹션과 수업도 있다. 어디까지를 하나의 애그리거트로 묶어야 하는가
판단 기준은 애그리거트의 정의에서 그대로 나온다. 애그리거트는 데이터의 변경을 목적으로 전체를 하나의 단위로 취급하는 객체들의 집합이고, 경계를 긋는 이유는 그 안에서 비즈니스 불변식이 항상 지켜져야 하고 변경이 항상 같은 트랜잭션에서 일어나야 하기 때문이다
[안] Course ──── CourseDetail
강의 제목·강사·상태를 바꾸는 일과
강의 소개를 바꾸는 일은 같은 트랜잭션이어야 한다
[밖] Section, Lesson
커리큘럼을 편집하는 일은
강의 제목을 바꾸는 일과 함께 일어날 필요가 없다
강의 소개는 강의 상태와 함께 검사되어야 한다. 검수 신청 시점에 강의 소개가 비어 있으면 안 되기 때문이다. 이 조건은 두 데이터가 같은 트랜잭션 안에 있어야만 보장할 수 있다. 반면 섹션과 수업은 변경 이유도 시점도 다르다. 강의 준비 기간에 함께 바뀌긴 하지만 그건 목적이 비슷할 뿐 불변식을 공유하는 것은 아니다
그래서 이번 애그리거트는 Course와 CourseDetail 둘로 시작했다. 섹션과 수업을 어떤 애그리거트로 묶을지는 나중에 다시 결정할 문제로 남겨 뒀다. 처음부터 크게 잡고 나중에 쪼개는 것보다, 불변식이 확인된 범위만 묶고 필요할 때 넓히는 편이 되돌리기 쉽다
대리키가 있어도 Natural Key를 찾는다
id는 대리키로 이미 정해져 있다. 그런데도 이 엔티티에서 비즈니스 의미로 행을 구분할 수 있는 값이 무엇인지 따로 확인할 가치가 있다. Natural Key를 찾아 두면 중복 검사 규칙과 URL 설계가 함께 정리되기 때문이다
첫 후보는 제목이었다. 강의 서비스의 상세 페이지 URL이 제목 기반 slug로 만들어지는 경우가 많아서 제목의 고유성은 실제로 의미가 있다. 그런데 제목만으로 전체 고유 제약을 걸면 문제가 생긴다
강사 A: "Spring 7" → 등록됨 강사 B: "Spring 7" → "이미 사용 중인 제목입니다. 다른 이름을 쓰세요"
강사 B 입장에서는 납득하기 어렵다. 다른 사람이 먼저 썼다는 이유로 자기 강의 제목을 바꿔야 할 근거가 없다. 반대로 같은 강사가 같은 제목의 강의를 두 개 만드는 것은 실제로 말이 되지 않는다. 그래서 Natural Key는 강사와 제목의 복합으로 잡았다
Natural Key = (instructor, title) 같은 강사 + 같은 제목 → 금지 다른 강사 + 같은 제목 → 허용
URL에 쓸 slug는 이 결정에서 분리했다. 제목이 서비스 전체에서 중복될 수 있으니 slug는 충돌 시 뒤에 식별자를 붙이는 방식이 필요한데, 그건 이 도메인을 웹에 노출하는 시점에 다시 볼 문제다. 지금 결정하지 않아도 애그리거트 설계는 진행할 수 있다
생성자를 쓸지 팩토리 메서드를 쓸지는 의미로 정한다
이 프로젝트의 앞선 두 애그리거트는 정적 팩토리 메서드로 생성했다. 회원은 register(), 강사는 apply()다. 이름이 생성 과정의 의미를 설명하기 때문이다. 회원은 등록이라는 절차를 거쳐 만들어지고, 강사는 신청이라는 절차를 거쳐 만들어진다
강의는 달랐다. 승인된 강사가 원할 때 언제든 만드는 대상이고, “생성한다” 이상의 의미가 없다. 이런 경우까지 팩토리 메서드로 통일하면 이름이 붙은 만큼의 정보를 주지 못하는 메서드가 늘어난다. 그래서 강의는 생성자를 쓰기로 했다
파라미터 오브젝트도 같은 기준으로 판단했다. 회원 생성에는 MemberRegisterInfo라는 파라미터 오브젝트를 썼는데, 이유는 관련된 값이 여럿 묶여 있고 앞으로 늘어날 가능성이 있어서다. 강의는 생성 시점에 강사, 제목, 강의 소개 세 가지만 받기로 했고 이 목록이 거의 바뀌지 않는다는 데 합의가 됐다. 그래서 파라미터를 그대로 받는다
강의 생성자는 다음과 같다
public Course(Instructor instructor, String title, @Nullable String description) {
instructor.ensureActive();
this.instructor = requireNonNull(instructor);
this.title = requireNonNull(title);
this.status = CourseStatus.DRAFT;
this.detail = new CourseDetail(description);
}
세 가지가 이 짧은 코드 안에 들어 있다. 강사가 승인 상태인지 확인하고, 필수 값이 들어왔는지 확인하고, 상태의 기본값을 DRAFT로 넣는다. 강의 소개는 @Nullable을 붙여 두었다. 실행에 영향을 주는 애노테이션은 아니고, 이 값에는 null이 들어와도 된다는 사실을 이 코드를 읽는 다른 개발자에게 알려 주는 표시다. 빠르게 강의를 만들어 놓고 소개는 나중에 채우는 흐름을 허용하기 위한 결정이다
수정은 반대로 파라미터 오브젝트를 쓴다. 수정 대상은 앞으로 계속 늘어나고 검증도 필요하기 때문이다
public record CourseUpdateInfo(@Size(min = 2, max = 100) String title, @Nullable String description) {
}
@Size는 여기서 직접 검사를 돌리기 위한 것이 아니다. 이 도메인이 어떤 범위의 값을 기대하는지 코드로 남겨 두는 주석 역할이다. 실제 검사는 애플리케이션 서비스 계층의 DTO에서 이루어진다
조건 검사는 대상 객체에게 맡긴다
“승인된 강사만 강의를 만들 수 있다”는 규칙을 코드로 옮길 때 가장 먼저 떠오르는 방식은 이렇다
if (!instructor.isActive()) {
throw new IllegalStateException("승인된 강사가 아닙니다.");
}
동작에는 문제가 없지만 이 코드는 생성자의 본래 목적과 무관한 세 줄을 차지한다. 그래서 강사 애그리거트에 이미 만들어 둔 메서드를 쓴다
instructor.ensureActive();
ensureActive()는 강사가 승인 상태임을 보장하고, 아니면 예외를 던진다. 검사 로직을 조건을 아는 객체 쪽에 두면 호출부에는 의도만 남는다. 읽는 사람이 “이 코드는 강사가 활성 상태여야 진입할 수 있다”만 이해하면 되고, 어떤 조건으로 판단하는지는 필요할 때 들어가서 보면 된다
여기서 한 가지가 겹친다. ensureActive()를 먼저 호출하면 instructor가 null일 때 그 줄에서 이미 NullPointerException이 난다. 그러니 아래의 requireNonNull(instructor)는 실행 관점에서 도달할 수 없는 검사다. 그런데도 남겨 두었다
이유는 코드를 위에서 아래로 읽는 사람 때문이다. title에는 requireNonNull이 붙어 있고 instructor에는 없으면, instructor는 null이어도 되는 값처럼 보인다. 위 호출까지 함께 읽으면 아니라는 걸 알 수 있지만, 그 추론을 독자에게 시킬 이유가 없다. 강의를 초당 수만 건 생성하는 코드가 아닌 이상 이 정도 중복은 주석 값어치를 한다
이 결정에는 부수 효과가 하나 따라왔다. 프로젝트에는 애그리거트 사이의 호출을 조회 계열 메서드로 제한하는 ArchUnit 규칙이 있는데, ensureActive() 호출이 이 규칙에 걸린 것이다
String methodName = call.getTarget().getName();
if (methodName.startsWith("get") || methodName.startsWith("is")
|| methodName.startsWith("ensure")) continue;
ensure로 시작하는 메서드는 상태를 바꾸지 않고 조건만 확인한다. 다른 애그리거트에서 자유롭게 호출해도 경계를 해치지 않으므로 허용 접두사에 추가했다. 이런 규칙은 처음부터 완성해 두는 것이 아니라, 새로운 네이밍 패턴이 등장할 때마다 근거를 확인하고 넓히는 편이 맞다
상태 전이 메서드는 자기 사전 조건을 검사한다
상태 전이 메서드는 셋이고 구조가 모두 같다. 현재 상태를 확인하고, 상태를 바꾸고, 필요하면 내부 엔티티에 위임한다
public void submitForReview() {
state(status == CourseStatus.DRAFT, "DRAFT 상태가 아닙니다.");
state(StringUtils.hasText(detail.getDescription()), "강의 소개가 등록되지 않았습니다");
this.status = CourseStatus.IN_REVIEW;
}
public void publish() {
state(status == CourseStatus.IN_REVIEW, "IN_REVIEW 상태가 아닙니다.");
this.status = CourseStatus.PUBLISHED;
this.detail.publish();
}
public void archive() {
state(status == CourseStatus.PUBLISHED, "PUBLISHED 상태가 아닙니다.");
this.status = CourseStatus.ARCHIVED;
this.detail.archive();
}
state()는 스프링의 Assert.state를 정적 임포트한 것으로, 조건이 거짓이면 IllegalStateException을 던진다. 각 메서드의 첫 줄이 곧 그 전이의 사전 조건이다. PUBLISHED 상태에서 submitForReview()를 호출하면 첫 줄에서 막히고, 상태 필드는 손대지 못한다. 앞의 상태 전이 다이어그램에서 그리지 않은 화살표는 코드에서도 존재하지 않는다
submitForReview()에만 조건이 하나 더 있다. 강의 소개가 채워져 있어야 한다는 조건이다. 생성 시점에는 null을 허용했으니 어딘가에서는 반드시 검사해야 하고, 그 지점이 검수 신청이다. null 검사와 공백 검사를 따로 하는 대신 StringUtils.hasText() 하나로 처리했다
공개 시점에는 이 검사를 다시 하지 않는다. 검수 신청을 통과한 강의만 IN_REVIEW가 될 수 있고, publish()는 IN_REVIEW에서만 호출되기 때문이다. 앞 단계에서 보장된 조건을 뒤에서 반복 검사하면 규칙이 어디서 강제되는지가 흐려진다
조회용 메서드도 두 개 추가했다
public boolean isPublished() {
return status == CourseStatus.PUBLISHED;
}
public void ensurePublished() {
state(status == CourseStatus.PUBLISHED, "PUBLISHED 상태가 아닙니다.");
}
강의가 공개된 상태에서만 가능한 작업, 예를 들어 수강 신청 같은 기능이 나중에 붙을 때 호출부에서 조건문을 다시 쓰지 않게 하려는 준비다. 앞에서 instructor.ensureActive()를 쓴 것과 같은 방식을 강의 쪽에도 만들어 둔 것이다
시각 기록은 내부 엔티티가 맡는다
CourseDetail은 강의 소개와 주요 시점의 일시를 가진다. 외부에 노출되지 않고 Course를 통해서만 호출되어야 한다. 이 조건을 지키는 방법은 접근 제한자다
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseDetail extends AbstractEntity {
private String description;
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
private LocalDateTime archivedAt;
CourseDetail(String description) {
this.description = description;
this.createdAt = LocalDateTime.now();
}
void publish() {
this.publishedAt = LocalDateTime.now();
}
void archive() {
this.archivedAt = LocalDateTime.now();
}
void updateInfo(CourseUpdateInfo updateInfo) {
this.description = updateInfo.description();
}
}
생성자와 상태 변경 메서드가 모두 접근 제한자 없이 선언되어 있다. 자바에서 이건 package-private이고, 같은 패키지의 Course만 호출할 수 있다는 뜻이다. 애플리케이션 서비스나 어댑터에서 courseDetail.publish()를 직접 부르는 코드는 컴파일되지 않는다. 애그리거트 루트를 통해서만 내부에 접근한다는 원칙이 문서가 아니라 컴파일러로 강제된다
주의할 지점은 IDE가 기본값으로 public을 붙인다는 것이다. 이 코드에서도 처음에는 세 메서드가 모두 public으로 만들어져 있었고, 리뷰 과정에서 발견해 내렸다. 내부 엔티티를 만들 때는 생성 직후에 접근 제한자를 한 번 확인하는 편이 낫다
검수 신청 일시는 저장하지 않았다. publishedAt과 archivedAt은 강의 생애 주기의 마일스톤이라 기록할 가치가 있지만, 검수 신청은 그 정도의 의미를 가지지 않는다고 판단했다. 나중에 검수 소요 시간을 측정해야 한다면 그때 추가하면 된다
랜덤 픽스처는 도메인 규칙을 그대로 드러낸다
테스트 픽스처는 Instancio로 만든다. 강의 소개는 실제와 동일하게 null이 될 수 있도록 nullable()을 지정했다
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();
이 설정 때문에 archive 테스트가 간헐적으로 실패했다. 강의 소개가 null로 생성된 실행에서는 submitForReview()가 사전 조건에서 막히고, 그 뒤의 publish()와 archive()까지 연쇄로 실패한다. 여러 번 돌리면 어떤 때는 성공하고 어떤 때는 실패하는 종류의 실패다
해결 방향은 두 가지였다. 픽스처에서 nullable()을 빼거나, 테스트 준비 단계에서 강의 소개를 명시적으로 채우거나. 앞의 방법을 택하면 테스트는 안정되지만 픽스처가 실제 데이터의 모양과 달라진다. null이 들어올 수 있는 필드라면 그 상태에서도 로직이 정상 동작해야 하고, 픽스처가 그 가능성을 재현하는 편이 낫다. 그래서 테스트 쪽을 고쳤다
@BeforeEach
void setUp() {
this.course = CourseFixture.createCourse();
this.course.updateInfo(new CourseUpdateInfo(course.getTitle(), "Description"));
}
제목은 기존 값을 그대로 유지하고 강의 소개만 확실하게 채운다. 상태 전이를 검증하는 테스트들은 이제 검수 신청 조건이 충족된 상태에서 시작한다. 강의 소개가 없을 때 검수 신청이 실패하는지는 픽스처를 쓰지 않고 별도 테스트에서 직접 생성해 확인한다.
@Test
void submitForReviewFail() {
var instructor = InstructorFixture.createActiveInstructor();
Course course = new Course(instructor, "Clean Spring 2", null);
assertThatThrownBy(() -> course.submitForReview()).isInstanceOf(IllegalStateException.class);
}
간헐적으로 실패하는 테스트는 보통 픽스처의 결함으로 취급되지만, 이 경우는 반대였다. 랜덤 값이 도메인 규칙과 테스트 준비 사이의 빠진 연결을 드러낸 것이다.
DB 매핑 정보는 orm.xml로 옮긴다
도메인 엔티티를 JPA 엔티티로 그대로 사용하면 클래스에 DB 관점의 정보가 쌓인다. nullable, length, optional, fetch 같은 값들은 기능에는 문제가 없지만 도메인 코드를 읽을 때 시선을 분산시킨다. 이 프로젝트는 매핑 정보를 orm.xml로 분리하고, 도메인 관점에서 의미가 있는 애노테이션만 클래스에 남긴다
남길 기준은 “이 애노테이션이 도메인 문서로서 의미가 있는가”다
남긴다 @Entity 이것은 엔티티다
@ManyToOne 강사 하나가 강의 여럿을 가진다
@OneToOne 강의 하나에 상세 하나가 붙는다
옮긴다 nullable, length, optional, fetch, cascade, @Enumerated
유니크 제약, 컬럼명, 테이블명
관계의 종류는 DB 이전에 도메인 모델의 사실이다. 반면 지연 로딩 여부나 컬럼 길이는 저장 기술의 선택이다. 이 기준으로 나누면 Course 클래스의 필드 선언이 다음까지 줄어든다
@ManyToOne Instructor instructor; String title; CourseStatus status; @OneToOne CourseDetail detail;
옮겨 간 정보는 orm.xml에 들어간다
<entity class="kimspring.splearn.domain.course.Course">
<table name="course">
<unique-constraint name="UK_INSTRUCTOR_TITLE">
<column-name>instructorId</column-name>
<column-name>title</column-name>
</unique-constraint>
</table>
<attributes>
<basic name="title">
<column name="title" nullable="false" length="100"/>
</basic>
<basic name="status">
<column name="status" nullable="false" length="20"/>
<enumerated>STRING</enumerated>
</basic>
<one-to-one name="detail" fetch="LAZY">
<cascade>
<cascade-all/>
</cascade>
</one-to-one>
</attributes>
</entity>
앞에서 정한 Natural Key가 여기서 UK_INSTRUCTOR_TITLE 복합 유니크 제약으로 나타난다. 애플리케이션 서비스에서도 중복을 검사할 예정이지만, DB 마이그레이션처럼 애플리케이션을 거치지 않는 경로가 있기 때문에 최종 보장은 DB에 두는 편이 안전하다. 이 제약을 실제로 검증하는 테스트는 다음 글의 리포지토리 테스트에서 다룬다
기존 애노테이션을 orm.xml로 옮기는 작업 자체는 기계적이라 AI에 맡길 만하다. 다만 그냥 “전환해 줘”라고 하면 남겨야 할 애노테이션까지 전부 제거하므로, 예외를 명시해야 한다
Instructor와 Course 도메인의 엔티티 애노테이션 정보를 orm.xml로 전환해줘. 단 @Entity, @ManyToOne, @OneToOne 세 가지는 클래스에 그대로 유지해줘.
결과물은 두 가지를 확인해야 한다. 첫째, 기존 테스트가 그대로 통과하는지. 매핑이 잘못 옮겨지면 리포지토리 테스트에서 바로 드러난다. 둘째, XML 스키마가 요구하는 요소 순서다. entity와 embeddable처럼 순서가 정해진 요소를 뒤바꿔 넣는 실수가 있었고, 실행에는 문제가 없었지만 IDE가 스키마 위반으로 표시했다
이 설계가 그대로 통하지 않는 조건
여기까지의 코드는 전체 테스트 67개가 통과하는 상태다. 다만 다루지 않은 조건이 몇 가지 남아 있다
상태 전이가 네 단계 단방향이라는 전제가 크다. 실제 서비스라면 검수 반려로 IN_REVIEW에서 DRAFT로 돌아가는 전이가 필요하고, 그러면 반려 사유를 어디에 저장할지, 반려 이력을 남길지가 따라온다. 전이가 늘어나 조건이 얽히기 시작하면 메서드마다 사전 조건을 적는 방식보다 전이표를 별도로 두는 편이 나은 시점이 온다
일시 기록은 LocalDateTime.now()를 직접 호출한다. 그래서 테스트는 값이 null이 아니라는 것까지만 검증할 수 있다. 정확한 시각을 검증하려면 Clock을 주입하거나 시간 생성을 담당하는 객체를 분리해야 하는데, 지금 단계에서는 비용이 이득보다 크다고 판단했다
섹션과 수업의 애그리거트 경계는 아직 결정되지 않았다. 이 부분이 정해지면 강의 애그리거트가 커리큘럼을 어떻게 참조할지, 강의 공개 조건에 커리큘럼 완성 여부가 들어갈지가 함께 결정된다. 지금의 publish()는 커리큘럼을 전혀 보지 않는다
권한도 다루지 않았다. 지금 코드는 submitForReview()나 publish()를 호출한 주체가 그 강의의 강사인지 확인하지 않는다. 인증과 권한은 웹 계층을 붙이는 단계의 주제다
정리하면, 상태를 가진 도메인에서 지켜야 할 규칙은 상태를 바꾸는 메서드 바로 위에 사전 조건으로 적어야 하고, 그 메서드를 애그리거트 루트 밖에 두는 순간 규칙은 흩어지기 시작한다
출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2