[Spring Boot] 도메인 검증(Validation)의 계층별 책임과 구현 전략
📝 DDD 찍먹해봤다가 온갖 물음표들이 머릿속에 휘몰아 치는 중이라, 정리해둘 겸 작성한 포스팅입니다. 정답은 저도 모르겠습니다.
📕 목차
1. Introduction
2. Is validation in both the controller and domain layers redundant?
3. When and where should we validate within the domain layer
1. Introduction
📌 the reason for writing this post
DDD 찍먹..진짜 재밌었고, 새로운 시야가 보이게 된 거 같아서 좋긴 한데.
못 보던 것들을 갑자기 마주하게 되니, 온갖 혼란과 기존 코드의 더러움이 거슬려서 미칠 것 같은 상태...
그렇다고 죄다 리팩토링할 시간적 여유가 없으니 애써 무시하려 했지만, 새로운 기능을 구현하다가 문득 몇 가지 의문점들이 생겼다.
📌 컨트롤러의 검증과 도메인의 검증은 중복인가?
public final class ChatRoomReq {
@Schema(title = "채팅방 생성 요청 DTO")
public record Create(
@NotBlank
@Size(min = 1, max = 50)
@Schema(description = "채팅방 제목. NULL 혹은 공백은 허용하지 않으며, 1~50자 이내의 문자열이어야 한다.", example = "페니웨이")
String title,
...
) { ... }
}
채팅방 데이터를 생성하기 위해, 제목에 대한 검증을 @Validate 어노테이션으로 검증해주고 있다고 가정하자.
@Entity
@Table(name = "chat_room")
public class ChatRoom extends DateAuditable {
@Id
private Long id;
private String title;
@Builder
public ChatRoom(String title) {
validate(title);
this.title = title;
}
private void validate(String title) {
if (!StringUtils.hasText(title) || title.length() > 50) {
throw new IllegalArgumentException("제목은 null이거나 빈 문자열이 될 수 없으며, 50자 이하로 제한됩니다.");
}
}
...
}
그러나 데이터 신뢰성을 위해 entity의 생성자에서 한 번 더 검증 로직을 수행하고 있다면, 이는 중복인가?
사실 당연히 해야 하는 게 맞다고 생각하고 있었으나, 예전에 팀원이 "컨트롤러에서 검증하는데, 굳이 한 번 더 할 필요가 있나요?"라는 질문을 한 적이 있었다.
우리 서비스는 멀티 모듈 구조를 가지고 있기 때문에, domain 모듈을 참조하는 하위 모듈이 무엇이 될 지 모르기 때문에 검증 하는 것이 옳다고 이야기하긴 했었다.
그렇다고 컨트롤러에서 검증을 수행하지 않는 것은 불필요하게 domain 영역까지 내려와서 로직을 수행해봐야 하는데, 이는 적절하지 않다고 생각했기 때문이다. (빠른 입구컷이 가능한데 굳이?)
그러나, 한 번은 도메인 규칙이 수정되었을 때 컨트롤러의 검증 로직을 수정하지 않아 에러가 난 적도 있었다.
그렇다면 컨트롤러의 로직은 관리해야 할 영역만 늘리는 꼴이었던 건 아니었을까?
컨트롤러의 검증은 정말 필요한 것이었을까?
📌 도메인 영역의 어디서, 언제 검증을 수행해야 하는가?
public class ChatRoom extends DateAuditable {
@Id
private Long id;
private String title;
private String description;
private String backgroundImageUrl;
private Integer password;
@ColumnDefault("NULL")
private LocalDateTime deletedAt;
@Builder
public ChatRoom(Long id, String title, String description, String backgroundImageUrl, Integer password) {
validate(id, title, description, password);
this.id = id;
this.title = title;
this.description = description;
this.backgroundImageUrl = backgroundImageUrl;
this.password = password;
}
...
}
일반적으로 ChatRoom을 생성할 때 검증이 수행되어야 하며, 이는 도메인 생성 시 개발자가 혹시라도 검증을 놓친 경우를 우려했기 때문이었다.
하지만 돌이켜 봤을 때, 이러한 전제는 엄연히 잘못되었다.
애초에 도메인 규칙을 실수할 정도의 개발자였다면, 검증 로직을 어디서 수행하든 오류는 발생했을 것이다.
이러한 문제는 단위 테스트로 검증했어야 옳았다.
물론, 다른 이유도 존재하긴 했었다.
검증 로직을 service에 구현해두었는데, 개발자가 이를 무시하고 repository에 바로 entity를 삽입하여 데이터를 생성한 경우 또한 방지하고자 했다.
그러나 이것도 코드 리뷰를 통해 잡아냈어야 할 문제지, 팀원의 실수를 예방한답시고 entity에 무지성으로 검증 로직을 넣는 것은 좋은 해결책은 아니었다.
(작은 PR룰과 커밋 룰을 도입한 이유도 이런 문제였다. 코드 리뷰에서 잡아냈어야 할 문제를, 팀원들이 잡아내어 시스템에 문제를 발생시키는 것을 미연에 방지하고자 함이었다.)
Entity의 생성자에 검증 로직을 넣는 것이 잘못된 것은 아니지 않은데, 굳이 고민할 것이 뭐가 있나 싶을 수 있다.
하지만 일반적으로 비즈니스 규칙에 따른 검증이 필요한 것은 데이터를 생성 혹은 수정해야 하는 경우만 해당된다.
왜냐하면, 이러한 경우는 데이터 신뢰성을 위해, 엄격하게 검증이 필요한 경우기 때문이다.
반면 DB에 이미 저장된 데이터는 이미 검증된 데이터들이다.
따라서 이러한 데이터에 검증 로직을 수행하는 것은 오버헤드고, 만약 여기서 예외가 발생한 거면 이건 비즈니스 로직의 문제가 아니다. 그냥 시스템에 심각한 결함이 발생했다는 신호일 뿐이다.
그런데 생성자에 검증 로직을 포함하면, 이미 신뢰하는 데이터들을 entity에 매핑하는 과정에서 또 한 번 검증 로직을 수행한다.
만약 검증 로직이 복잡하고, 대규모 데이터를 처리해야 하는 경우라면, 이는 성능 문제로 번질 수도 있다.
아니 애초에 여기서 검증을 하는 것이 옳다고 볼 수 있는가?
공부를 너무 많이 해서 잠시 미쳤었던 것 같다.
내가 정의한 생성자의 유효성 검사 로직은 DB -> Java 단으로 변환될 때 실행되지 않는다.
왜냐면, JPA는 reflection이라는 기능으로 Entity의 기본 생성자를 호출해 필드 주입 방법을 사용하기 때문이다.
내가 만든 생성자를 안 쓰는데, 어떻게 검증 로직이 돌아가나.
돌아버렸던 건 나였다.
놀랍게도 포스팅 완료 누르기 직전에 정신 차리고, 급하게 내용 수정을 하는 중이다.
논제를 다시 잡자.
유효성 검사를 Entity에서 처리해야 하는가, Domain Service 로직에서 처리해야 하는가?
2. Is validation in both the controller and domain layers redundant?
📌 Is validation within the domain layer necessary?
도메인에서의 검증은 필수적인가?
사실 이건 누가봐도 자명한 사실이다.
또한, DDD에서도 도메인 규칙은 가능한 한 도메인 계층에서 보장되어야 한다고 이야기 한다.
그럼 그 다음 의문은 "Controller 계층에서의 검증은 필요한가?"
📌 Roles of each layer
컨트롤러와 도메인 계층의 검증이 모두 필요하다고 이야기 하려면, 둘은 중복이 아님을 증명하면 된다.
1️⃣ 검증 시점과 컨텍스트 차이
💡 검증하려는 대상이 다르다.
// Controller - HTTP 요청 컨텍스트
@PostMapping("/chat-rooms/{roomId}/members")
public void joinRoom(@RequestBody JoinRequest request) {
// 1. API 스펙 검증
@NotBlank(message = "닉네임은 필수입니다") // 필수값 검증
@Pattern(regexp = "^[a-zA-Z0-9]{1,50}$", message = "닉네임은 50자 이내의 영문/숫자만 가능합니다") // 형식 검증
private String nickname;
}
// Domain - 비즈니스 컨텍스트
class ChatMember {
public void updateNickname(String nickname) {
// 2. 비즈니스 규칙 검증
if (isRestrictedNickname(nickname)) {
throw new RestrictedNicknameException();
}
if (isDuplicateNickname(nickname)) {
throw new DuplicateNicknameException();
}
}
}
- 컨트롤러 계층
- 외부 세계와의 통신 규약을 준수하기 위한 검증
- 애플리케이션 진입 전의 early validation
- 잘못된 요청을 원천 차단하고, HTTP 명세나 API 스펙의 준수 여부를 검증
- @NotBlank, @Pattern 등은 API 명세의 제약 사항을 표현
- 도메인 계층
- 비즈니스 규칙의 무결성을 지키기 위한 검증
- 실제 도메인 로직이 실행되는 시점의 validation
- 비즈니스 규칙 위반 방지가 목적
- 도메인 전문가가 정의한 규칙을 검증
- isRestrictedNickname()은 실제 업무 규칙을 표현
2️⃣ 검증 실패의 응답 차이
💡 검증 이후의 예외 처리 플로우가 다르다.
// Controller - 사용자 친화적 응답
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
return ResponseEntity.badRequest().body( // HTTP에 종속적인 400 에러 코드 반환
new ErrorResponse(
"INVALID_INPUT",
"닉네임은 50자 이내의 영문/숫자만 가능합니다",
ex.getBindingResult().getFieldErrors()
)
);
}
// Domain - 비즈니스 예외
class RestrictedNicknameException extends BusinessException {
public RestrictedNicknameException() {
super(
"RESTRICTED_NICKNAME",
"금지된 닉네임입니다",
"e-123456" // 비즈니스 규칙에 따른 예외 코드
);
}
}
- 컨트롤러 계층
- HTTP 명세에 따른 응답을 반환
- 클라이언트가 이해하기 쉬운 에러 메시지를 포함
- 잘못된 입력값에 대한 구체적인 피드백
- 도메인 계층
- 비즈니스적 의미를 담은 예외를 발생
- 도메인 용어를 사용한 예외 메시지
- 비즈니스 규칙 위반 상황을 명확히 표현
- 기술적 맥락이 아니라 업무적인 맥락의 예외
3️⃣ 검증 범위의 차이
💡 검증 가능한 수준의 범위가 다르다.
// Controller - 단일 필드 수준의 검증
class JoinRequest {
@NotBlank
private String nickname;
@Min(1) @Max(100)
private Integer age;
}
// Domain - 객체 전체 수준의 검증
class ChatMember {
public void join(ChatRoom room) {
// 복합 규칙 검증
if (room.isAgeRestricted() && this.age < room.getMinAge()) {
throw new AgeRestrictedException();
}
if (room.isMembersFull()) {
throw new RoomFullException();
}
}
}
- 컨트롤러 계층
- 단일 필드나 단순히 request dto 필드의 관계에 대한 검증 (요청 DTO 내부의 제한된 범위)
- 기술적인 제약 조건에 대한 검증
- ex. 채팅방 가입을 위한, 아이디 입력이 유효한가?
- 도메인 계층
- 여러 객체 간의 복합적인 규칙 검증
- 도메인 모델 전체에 걸친 광범위한 검증
- ex. 가입하려는 채팅방의 인원수 제한을 초과하지 않았는가?
4️⃣ 검증 데이터 접근 범위의 차이
💡 검증 가능한 데이터의 범위가 다르다.
// Controller - 요청 데이터만 접근 가능
@PostMapping("/chat-rooms/{roomId}/members")
public void joinRoom(@RequestBody JoinRequest request) {
// HTTP 요청 범위 내 데이터만 검증 가능
@Future(message = "예약 시간은 미래 시간만 가능합니다")
private LocalDateTime reservationTime;
}
// Domain - 영속성 컨텍스트 전체 접근
class ChatMember {
public void join(ChatRoom room) {
// 다른 엔티티와의 관계, DB 데이터 참조 필요
if (hasJoinHistory(room)) {
throw new AlreadyJoinedException();
}
if (room.hasBanned(this)) {
throw new BannedMemberException();
}
}
}
- 컨트롤러 계층
- HTTP 요청에 포함된 데이터까지만 접근
- 네트워크 요청의 무결성 검증에 초점
- 도메인 계층
- 영속성 컨텍스트의 모든 데이터에 접근
- 연관된 Entity나 값에 대한 참조가 가능
5️⃣ 재사용성과 결합도의 차이
💡 종속되는 규칙이 다르다.
// Controller - API 스펙에 종속적
@PostMapping(value = "/api/v1/chat-rooms/{roomId}/members") // API 버전에 종속
public void joinRoom(@RequestBody JoinRequestV1 request) { // DTO 버전에 종속
// API 스펙이 변경되면 검증 로직도 변경 필요
}
// Domain - 비즈니스 규칙에만 종속적
class ChatMember {
public void join(ChatRoom room) {
// API 변경과 무관하게 비즈니스 규칙은 유지
// 다양한 클라이언트(웹, 앱, 배치)에서 재사용 가능
validateJoinRules(room);
}
}
- 컨트롤러 계층
- HTTP(혹은 API) 버전에 종속적
- 특정한 presentation 계층에 결합
- API 스펙 변경 시 함께 변경
- 클라이언트의 요구 사항에 따라 변경
- 도메인 계층
- 순수한 비즈니스 규칙에만 의존
- presentation 계층에 독립적이며, API 변경과 무관하게 유지
- 다양한 client(ex. 외부 api, 내부 api, socket 등)에서 재사용이 가능
📌 Conclusion
Controller 계층의 검증과 Domain 계층의 검증은 중복이 아님을 다음과 같이 증명할 수 있게 되었다.
- 관심사
- Controller: 외부 세계와의 통신 규약 검증
- Domain: 비즈니스 규칙의 무결성 보장
- 책임
- Controller: 클라이언트의 오기입 조기 차단
- Domain: 비즈니스 규칙 위반을 방지
- 변경의 이유
- Controller: API 스펙 변경 시 수정
- Domain: 비즈니스 규칙 변경 시 수정
각 계층의 검증은 규칙이 단순한 경우 서로 같거나 중복으로 보일 수 있지만, 서로 다른 책임과 역할을 지니고 있다.
따라서 이는 우발적 중복이며, 두 검증 로직 모두 수행되는 것이 옳다고 판단했다.
📌 Is using `@PreAuthorize` for authorization incorrect?
위 내용을 작성하다가 떠오른 또 다른 문제점
@Override
@DeleteMapping("/{categoryId}")
@PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(principal.userId, #categoryId)")
public ResponseEntity<?> deleteSpendingCategory(@PathVariable Long categoryId) {
spendingCategoryUseCase.deleteSpendingCategory(categoryId);
return ResponseEntity.ok(SuccessResponse.noContent());
}
@Slf4j
@Component("spendingCategoryManager")
@RequiredArgsConstructor
public class SpendingCategoryManager {
private final SpendingCustomCategoryService spendingCustomCategoryService;
@Transactional(readOnly = true)
public boolean hasPermission(...) {...}
}
Spring Security가 제공하는 @PreAuthorize 어노테이션은 SpEL 문법을 사용해 bean을 주입할 수 있다.
나는 여기서 사용자의 자원 접근 검사(ex. 사용자가 게시물을 삭제하려는 작성자가 맞는지?)를 수행하고, 그에 따른 예외 처리를 하고 있었다.
그러나 위에서 정리한 바대로라면, controller 계층의 검증은 범위가 요청 dto에 한정적이므로 context가 다르다고 주장한 것과는 달리, 이 또한 영속성 컨텍스트에 접근이 가능하다는 논리적 오류가 발생하게 된다.
그렇다면, 내 추론이 틀렸던 것일까?
아니면, 자원 접근 검사를 컨트롤러에서 수행한다는 생각이 문제였던 걸까?
결론부터 이야기하자면, 모르겠다!!!!!!!!!!! (아니, 진짜 모르겠음.)
일단 위 방식이 옳다고 생각하는 이유와 틀렸다고 생각하는 이유, 나름의 결론까지 모두 작성해두려 한다.
🙆♂️ 위 처리 방식은 왜 옳다고 보는가?
💡 인가(Authorization) 처리는 보안 계층의 책임이다.
"자원을 수정/삭제하려는 사용자는 자원을 소유한 사용자여야 한다."라는 비즈니스 규칙은 엄연히 따지자면, 도메인 계층이 아닌 보안 계층의 영역이다.
즉, 컨트롤러 계층과 도메인 계층 2개가 아닌, 보안 계층을 하나 더 추가한 것이다.
- 컨트롤러 계층
- API 스펙 검증
- 요청/응답 형식 처리
- 클라이언트 측에게 피드백
- 보안 계층
- 자원 접근 권한 검증
- 인증된 사용자 검증
- 도메인 계층
- 비즈니스 규칙 검증
- 도메인 무결성 보장
- 상태 변경 규칙
// Before: 도메인 계층에서 보안 검증을 함께 처리
class SpendingCategory {
public void delete(Long userId) {
// 보안 검증 - 부적절
if (!this.userId.equals(userId)) {
throw new UnauthorizedException();
}
// 비즈니스 규칙 검증
if (hasLinkedTransactions()) {
throw new CategoryInUseException();
}
// ...
}
}
// After: 관심사가 잘 분리됨
@PreAuthorize("@spendingCategoryManager.hasPermission(#userId, #categoryId)")
public void deleteCategory(Long categoryId) {
SpendingCategory category = repository.findById(categoryId);
category.delete(); // 순수하게 비즈니스 로직만 처리
}
이렇게 보면 계층 별로 관심사를 잘 분리했기 때문에, 매우 흡족스럽다고 볼 수도 있을 것이다.
물론, 한 가지 수정이 필요하다고 보는 부분이 있는데, 이 내용은 접은 글로 달아놓았다.
💡 보안 검증은 비즈니스 로직과 다른 관심사를 갖는다.
// 현재 구조
Controller -> Security (@PreAuthorize) -> SpendingCategoryManager -> DomainService -> Repository
코드를 확인해보면, 인가 처리를 위한 SpendingCategoryManager는 Repository가 아닌 Domain Service 빈을 주입받고 있다.
이렇게 된 이유가 존재하는데, 가장 큰 이유는 멀티 모듈 설계를 할 때, "도메인은 가장 보호받아야 하는 영역"에 대한 정의를 잘못 받아들였었기 때문이 컸다.
그 당시엔 "보호"라는 키워드에 집중해서, "db 일관성을 지키라는 거구나!" 싶었었다.
그래서 Domain Service 외엔, 어디에서도 repository 빈을 직접 주입받지 못 하게 막아버렸었다.
하지만 나중에 알게 된 "보호"의 정의는 외부 변화에 전파받지 않아야 한다라는 말에 가까웠다. (아니, 처음부터 그렇게 이야기 해달라고 😂)
이 규칙의 문제점은 동일한 메서드(ex. exists())의 사용 목적이 다른 경우가 허다하다는 것이었다.
예를 들어, soft delete 정책을 사용하고 있고, 보안 계층과 도메인 계층 모두 exists()를 호출해야 한다고 가정하자.
전자는 일반적으로 삭제되지 않은 데이터가 존재하기만 하면 되는 경우가 많아서, "SELECT 1 FROM entity e WHERE e.user_id = ?" 이런 쿼리를 필요로 한다.
그러나 후자는 삭제된 데이터도 함께 조회해야 하는 경우가 있다. (예를 들어, 채팅 멤버 데이터가 soft delete 되었는데, 삭제된 이유가 자진해서 나간 건지, ban을 당한 건지 파악)
그럼 쿼리가 "SELECT 1 FROM entity e WHERE e.user_id = ? AND deleted_at IS NULL" 이렇게 나가야 하거나, 아예 entity를 전부 불러와서 애플리케이션 단의 필터링을 사용하는 경우도 있다.
위 로직은 같은 결과를 반환할 수도 있지만, 우발적 중복일 확률이 크다.
그렇단 말은 보안 계층을 위한 쿼리를 합치는 것도 안 되지만, 애초에 둘은 SRP 원칙에 의해 같은 클래스에 정의되어선 안 된다는 말이 된다.
나처럼 뻘짓하지 말고 repository 빈을 주입받도록 하자...
🙅♂️ 왜 잘못되었다고 보는가?
💡 소유권 검증이 "보안 계층의 책임"이라고 단순화 하는 것은 잘못되었다.
하지만 다르게 생각해보면 뭔가 이상하다.
"자원을 수정/삭제하려는 사용자는 자원을 소유한 사용자거나, 관리자여야 한다."라는 비즈니스 규칙이 존재하는데, 소유자 검증만 똑 떼어버리는 게 맞을까?
domain 계층은 여러 client를 하위에 둘 수 있다.
그런데 소유자 검증을 없애버림으로써, 이 부분에 대한 제약을 더 이상 둘 수 없게 되었다.
이러한 경우 비즈니스 규칙이 제대로 이행되지 않다고 볼 수도 있지 않을까?
🤷♂️ 나름대로의 결론
💡 같은 소유권 검증이라도, 실행 컨텍스트, 검증의 목적, 규칙의 성격에 따라 처리 계층이 달라질 수 있다.
단순히 사용자 아이디를 기반으로 자원 접근 검사만 고려한 게 문제였던 것 같아서, "소유권" 규칙을 좀 더 추가했다.
다음과 같은 비즈니스 규칙을 추가했다고 가정하자.
- 사용자는 자신이 생성한 카테고리만 수정할 수 있다.
- 공유된 카테고리는 공유 받은 사용자도 수정 가능하다.
- 카테고리 삭제는 소유자만 가능하다. (시스템 카테고리는 누구도 삭제할 수 없다)
- 프리미엄 사용자는 공유 멤버의 동의 없이, 공유한 카테고리를 삭제할 수 있다.
- 공유 카테고리를 삭제하려는 경우, 모든 공유 멤버의 동의가 필요하다.
- 프리미엄 사용자는 카테고리를 무제한 생성 가능하다.
- 무료 사용자는 최대 5개까지만 카테고리를 생성할 수 있다.
이제 문제가 상당히 복잡해졌다.
카테고리의 소유자가 아님에도 다른 사용자에게 공유해줄 수 있으며, 소유자임에도 삭제를 마음대로 하지 못 할 수도 있다.
그럼 이 모든 소유권 규칙들을 보안 계층이나, 도메인 계층 한 곳에 몰아 넣는 것이 옳은 결정일까?
우선, 다음 단계로 진행하기 전에, 내 잘못된 전제를 하나 좀 깨부술 필요가 있다.
내 고질적인 문제는 팀원이 실수할 경우를 과하게 우려한다는 점이다.
서버 외부의 client는 분명히 경계의 대상이 맞다.
괜히 의심증, 강박증 심한 개발자가 좋은 서버 개발자라는 농담이 나오는 게 아니니까.
하지만 내가 만든 domain 모듈을 의존하는 모듈을 추가해서 client 모듈을 생성하는 서버 개발자를 신뢰하지 못 하는 것은 문제가 있다.
위에서도 이야기 했듯, 이런 문제는 테스트 케이스와 코드 리뷰로 완화해야 할 문제지, 설계를 위해 고려할 사항은 아니다.
@Entity
public class SpendingCategory {
private Long ownerId;
private Set<Long> sharedUserIds;
// Domain Layer의 책임
public boolean canBeDeletedBy(Long userId) { ... }
}
도메인 계층에서는 위와 같이 검증을 수행한다고 치자.
만약 @PreAuthorize의 반대파라면, "요청자는 자원의 소유자여야 한다"라는 비즈니스 규칙 또한 이 안에서 이루어져야 한다.
아직, 뭐가 옳은지 모르겠으니, 여러가지 domain 계층의 client를 추가해보자.
// HTTP API 컨텍스트
@RestController
class CategoryController {
@DeleteMapping("/{id}")
@PreAuthorize("@categoryManager.hasPermission(...)") // HTTP 인증 컨텍스트
public void delete(@PathVariable Long id) {
categoryService.delete(id);
}
}
// Batch Job 컨텍스트
@Component
class CategoryCleanupJob {
public void cleanup() {
// 배치 작업에서는 HTTP 인증이 아닌
// 비즈니스 규칙으로서의 소유권만 검증
categories.stream()
.filter(category -> category.canBeDeletedBy(executorId))
.forEach(Category::delete);
}
}
// Admin 툴 컨텍스트 (SSR일 수도 있고, 자체 제작 콘솔일 수도 있고..)
@Controller
class AdminConsole {
public void forceDelete(Long categoryId) {
// 관리자 도구에서는 또 다른 보안 컨텍스트
category.adminDelete(adminId);
}
}
여기서 뭔가 이상한 냄새가 난다.
"요청자는 자원의 소유자여야 한다"라는 규칙은 "요청자"가 사용자, 즉 인프라에 구성된 Actor가 아니라, 실제 우리 서비스를 사용하고 있는 사용자를 의미한다.
이는 외부 세계에서 어떤 비정상적인 접근이 들어올지 모르니,
인증 컨텍스트에서 세션 혹은 jwt 등을 사용하여 사용자를 식별하고 검사를 하기 위함이지,
시스템 내부에 속해있는 batch에 대한 비즈니스 규칙이 되어서는 안 된다.
즉, 코드는 다음과 같이 구성되었어야 하는 것이 옳았다는 이야기가 아닐까?
// Security Layer의 책임
@PreAuthorize("@categoryManager.hasPermission(principal.userId, #categoryId)")
public void deleteCategory(Long categoryId) {
// 여기서의 검증은 "HTTP 요청자가 이 자원에 접근할 자격이 있는가?"
}
@Entity
public class SpendingCategory {
private Long ownerId;
private Set<Long> sharedUserIds;
// Domain Layer의 책임
public boolean canBeDeletedBy(Long userId) {
// 비즈니스 규칙으로서의 소유권
// - "프리미엄 사용자는 공유 받은 카테고리도 삭제할 수 있다"
// - "기본 카테고리는 소유자도 삭제할 수 없다"
// - "공유 카테고리는 모든 공유자의 동의가 필요하다"
return isOwner(userId) && !isDefaultCategory();
}
}
내가 위 컨텍스트를 나눈 건 다음과 같은 기준을 세웠기 때문에 가능했다.
- 인증 컨텍스트에서의 소유권 검증
- "이 요청이 허용되어야 하는가?"
- 클라이언트/프로토콜이 무엇인지에 따라 처리방식이 다름.
- 비즈니스 규칙에서의 소유권 검증
- "이 작업이 비즈니스적으로 허용되는가?"
- 실행 컨텍스트와 무관하게 지켜져야 할 규칙
여기까지 써놓고도, 여전히 저 비즈니스란 단어를 확실하게 정의하기가 어렵다.
그리고 이 예시도 써놓고 나니 role과 authority를 세분화하거나, 혹은 API GW 같은 추가 인프라 장치들이 등장하면 처리하는 위치가 또 달라질 수 있다는 점이다.
(이래서 DDD가 어렵다고 하는 거구나 싶기도 하고.)
여튼 여기까지가 내가 생각해볼 수 있는 결론이었다.
이 이상은 DDD 제대로 배우지도 않은 내가 판단하기엔 너무 먼 영역이다.
3. When and where should we validate within the domian layer?
위 게시물의 상위 3개 댓글에 대해 먼저 분석하고, 나만의 절충안을 세워보자.
그런데 내가 잘 모르는 내용들이라, 잘못 이해했을 수도 있으니 원문을 읽고 나름대로의 판단을 세워보는 것이 더 좋은 경험이 될 것 같다.
📌 API 계층 중심 접근
- 장점
- 잘못된 입력을 초기에 차단해야 한다.
- 명확한 계층 구분
- CQRS 패턴과 잘 어울림
- 단점
- 도메인 규칙이 Controller 계층에 누출될 수 있음
- 중복 검증 가능성
- 도메인 모델의 무결성 보장이 어렵지 않을까?
📌 도메인 모델 중심 접근
- 장점
- 도메인 무결성 보장
- 비즈니스 규칙 캡슐화
- 재사용성 높음
- 단점
- 늦은 검증으로 인한 성능 저하
- 성능 저하로 인한 사용자 경험 저하
- 계층간 결합도 증가 우려
나는 단점이라고 써놨지만, 작성자가 주장하는 바는 이렇다.
"어차피 도메인에서 검증할 거 뭐하러 한 번 더 검증하나요? 도메인 레이어까지 내려가야 하는 건 사실이지만, 오히려 그게 더 빠르게 만들 겁니다. 도메인 모델은 상위 계층이 검증을 수행했다고 의존하고나 가정해서는 안 됩니다."
나랑 완전히 상반된 의견 ㅎㅎ;
📌 입력 소스 최접점 검증
상남자 입력 검증 방법.."유효하지 않은 데이터가 4개 계층 통과하는 걸 기다리지 않겠다. 가능한 한 빨리, 예를 들어 클라이언트 측 자바 스크립트에서 수행한다."
개인적으로 클라이언트는 믿지 않는다는 주의기 때문에 분석이고 뭐고 간에 바로 컷!
📌 Command 검증
스택 오버 플로우 댓글이 생각보다 마음에 안 들어서, 좀 더 구글링을 하다보니 재밌는 의견을 발견했다.
일단 내용을 조금 정리해두자.
(모르는 언어로 써놓은 걸, 나름대로 java로 변환해두긴 했는데 틀렸을 수도 있습니다.)
일단 글쓴이의 처음 시작은 이렇다.
"DDD를 하면, Entity 내부에 검증을 넣고 싶을 수도 있다. 하지만 Entity의 책임 일부로서 검증을 하는 것은 잘 맞지 않는다는 것을 알게 되었다."
@Entity
public class Customer {
@NotNull // ❌ 엔티티가 invalid 상태가 될 수 있음
private String firstName;
public void setFirstName(String firstName) {
this.firstName = firstName; // 검증 전 상태 변경
}
}
우선, 주석을 이용해서 필드가 필수 임을 알리는 방법은 두 가지 문제가 있다.
- 유효성 검사 전에 상태를 변경하므로, Entity가 잘못된 상태에 있을 수 있다.
- 사용자가 무엇을 하려고 했는 지에 대한 맥락이 존재하지 않는다.
public class Customer {
public void changeName(String firstName, String lastName) {
if (firstName == null)
throw new ArgumentNullException(); // ❌ 예외로 검증 처리
this.firstName = firstName;
}
}
그래서 DDD를 따르는 대부분은 위와 같은 방법을 사용할 것이다.
하지만 이건 약간만 나아졌을 뿐, "검증 오류"를 표현할 유일한 방법은 예외 뿐이다.
public class Customer {
public CommandResult changeName(ChangeNameCommand command) {
if (command.getFirstName() == null)
return CommandResult.fail("First name required"); // ❌ 단일 에러만 반환
if (command.getLastName() == null)
return CommandResult.fail("Last name required")
this.firstName = command.getFirstName();
this.lastName = command.getLastName();
return CommandResult.success();
}
}
예외를 사용하지 않고, 어떤 종류의 명령 결과(CommandResult)를 사용할 수도 있다.
하지만 이 또한 한 번에 하나의 검증 오류만 반환하므로, 최종 사용자에게 명시적으로 이유를 알려주기가 어렵다.
이 말은 즉, Entity는 명령 검증에 서투르다는 것을 의미한다.
물론 일괄 처리하려면 할 수는 있지만, 화면의 필드 이름과 어떻게 상관 관계를 맺을 것인가?
(여기서 이 사람이 프론트 개발자라는 걸 눈치 챘다. ㅋㅋㅋ)
💡 Entity는 검증 라이브러리가 아니다. 불변식(invariant) 보장에만 집중해야 한다.
// Command에서 검증
public class ChangeNameCommand {
@NotNull
@Length(min = 3, max = 50)
private String firstName;
@NotNull
@Length(min = 3, max = 50)
private String lastName;
}
// Entity는 상태 변경에만 집중
public class Customer {
public void changeName(ChangeNameCommand command) {
// command는 이미 검증됨
this.firstName = command.getFirstName();
this.lastName = command.getLastName();
}
}
- Entity는 불변성만 관리하면 된다.
- 불변성은 부분적으로가 아니라, 한 상태에서 다음 상태로 완전히 전환할 수 있는 지를 확인하는 것이 핵심이다.
- 즉, Entity는 요청을 검증하는 게 아니라, 상태 전환을 수행하는 역할만을 담당해야 한다.
- 검증에 대한 작업은 Command가 수행한다.
- 검증 속성은 Command 자체에 있으며, 유효할 때만 상태 전환을 위해 Entity에게 전달한다.
- Entity 내부에서 ChangeNameCommand를 성공적으로 수락하고, 상태 전환을 수행하여 불변성을 충족하는 지 확인해야 한다.
- 중요한 차이점은 Entity가 아니라, 명령을 검증한다는 것에 있다.
- Entity 자체는 검증 라이브러리가 아니기 때문에, Command에서 검증하는 것이 훨씬 깔끔하다.
물론 여기에 대한 호기심, 걱정, 반대하는 의견도 적지 않아 있긴 한데..
자세하게 다루는 것은 의미가 없는 거 같기도 하고, 너무 프론트 관점이라 나름의 절충안이 필요하긴 하다.
예를 들어, Entity의 검증을 예외로 표현하는 것은 문제가 있다는 내용은 흥미롭지만, 단일 에러만 클라이언트에게 명시적으로 알릴 수 있다는 건 딱히 어려운 일이 아니라고 생각하기 때문...
그리고 Entity가 언제나 상위 계층(Command)을 신뢰해야 하는 방식은 한계가 있다고 생각하기 때문.
📌 절충안: 계층별 책임에 따른 점진적 검증
우선 각 계층의 검증 역할을 다음과 같이 구상해볼 수 있다.
- 컨트롤러 계층: 입력값 검증
- 보안 계층: 인증/인가 검증
- 도메인 서비스 계층
- 비즈니스 규칙 검증
- 트랜잭션 수준의 일관성이 필요한 규칙
- 동시성 제어가 필요한 규칙
- 여러 Entity 간의 관계를 검증해야 하는 규
- 도메인 계층
- 불변성, 상태 변경 규칙, 비즈니스 제약 조건 검증
- 다른 Entity나 외부 상태와 무관한 규칙
- 단일 Entity 상태만으로 검증 가능한 규칙
- Enity의 life-cycle 동안 항상 지켜져야 하는 규칙
하지만 위에서 계속 이야기했던 중복 검증 문제는 어떻게 해야 할까?
// API 계층
@PostMapping("/categories")
public ResponseEntity<?> create(@Valid @RequestBody CategoryRequest request) {
validateRequest(request); // ✅ API 검증
categoryService.create(request.toCommand());
}
// Application 계층
@Service
class CategoryService {
public void create(CreateCategoryCommand command) {
validateCommand(command); // ✅ 중복 검증
Category category = new Category(command);
}
}
// Domain 계층
class Category {
public Category(CreateCategoryCommand command) {
validateBusinessRules(command); // ✅ 또 검증
// ...
}
}
이는 빠른 실패를 감지하고 domain 영역을 안전하게 만들 수는 있으나,
중복 검사로 인해 성공 시나리오에 대한 성능은 떨어진다는 문제가 있다.
만약, 이걸 어떻게든 처리하고 싶다면 모놀리식 애플리케이션에 한하여, Validation 책임 연쇄 패턴 등을 도입해볼 수도 있을 것이다.
예시는 너무 길어질 거 같으니까, 접은 글로 표시
책임 연쇄 패턴은 Handler들을 Linked List 형식으로 묶고, 다음 Handler를 호출할 지 말지를 이전 Handler에서 결정하는 방법이다.
전역 예외 처리기를 이미 만들었다면, 검증 실패 시 바로 예외를 던져 버리도록 만들 수도 있다.
1. RequestValidationHandler (API 계층)
- 기본적인 입력값 검증
- 실패 → 즉시 에러 반환
- 성공 → chain.next() 호출
↓
2. ApplicationValidationHandler (Application 계층)
- 유일성, 존재성 등 검증
- 실패 → 즉시 에러 반환
- 성공 → chain.next() 호출
↓
3. DomainValidationHandler (Domain 계층)
- 비즈니스 규칙 검증
- 실패 → 즉시 에러 반환
- 성공 → chain.next() 호출
그리고 이 패턴의 가장 매력적인 점은 각 계층 별로 준수해야 할 비즈니스 규칙을 중앙 관리할 수 있다는 점이다.
위의 예시처럼 각 계층에서 검증하고 싶은 내용들을 정의하고, 각 Handler를 구현하는 것이다.
만약 Handler를 실행 후 이상이 없다면, 해당 계층에서 수행되었어야 할 테스트는 통과했음을 보장한다.
예를 들어, 다음과 같은 ValidationHandler와 Handler를 List로 갖는 ValidationChain이 있다고 치자.
// 1. 검증 체인 정의
public interface ValidationHandler<T> {
ValidationResult handle(T command, ValidationChain<T> chain);
}
public class ValidationChain<T> {
private final List<ValidationHandler<T>> handlers;
private int currentIndex = 0;
public ValidationResult next(T command) {
if (currentIndex < handlers.size()) {
return handlers.get(currentIndex++).handle(command, this);
}
return ValidationResult.success();
}
}
그 다음은 각 계층 별로 검증해야 할 Handler를 정의한다.
// API 계층 검증
@Component
class RequestValidationHandler implements ValidationHandler<CreateCategoryCommand> {
@Override
public ValidationResult handle(CreateCategoryCommand command, ValidationChain<CreateCategoryCommand> chain) {
// 기본적인 입력값 검증
if (command.getName() == null || command.getName().isBlank()) {
return ValidationResult.fail("이름은 필수입니다"); // 혹은 예외
}
// 다음 핸들러로 전달
return chain.next(command);
}
}
// Application 계층 검증
@Component
class ApplicationValidationHandler implements ValidationHandler<CreateCategoryCommand> {
private final CategoryRepository repository;
@Override
public ValidationResult handle(CreateCategoryCommand command, ValidationChain<CreateCategoryCommand> chain) {
// 유일성, 존재성 검증
if (repository.existsByName(command.getName())) {
return ValidationResult.fail("이미 존재하는 이름입니다"); // 혹은 예외
}
return chain.next(command);
}
}
// Domain 계층 검증
@Component
class DomainValidationHandler implements ValidationHandler<CreateCategoryCommand> {
@Override
public ValidationResult handle(CreateCategoryCommand command, ValidationChain<CreateCategoryCommand> chain) {
// 비즈니스 규칙 검증
if (command.getType() == CategoryType.DEFAULT && command.isDeletionRequested()) {
return ValidationResult.fail("기본 카테고리는 삭제할 수 없습니다"); // 혹은 예외
}
return chain.next(command);
}
}
@Bean으로 등록하고 싶다면 다음과 같이 Configuration을 정의하면 된다.
@Configuration
public class ValidationConfig {
@Bean
public ValidationChain<CreateCategoryCommand> createCategoryValidationChain(
RequestValidationHandler requestHandler,
ApplicationValidationHandler applicationHandler,
DomainValidationHandler domainHandler
) {
return new ValidationChain<>(List.of(
requestHandler, // 1번: API 계층 검증
applicationHandler, // 2번: Application 계층 검증
domainHandler // 3번: Domain 계층 검증
));
}
}
그리고 각 계층에서 다음과 같이 사용하면 된다.
@RestController
@RequiredArgsConstructor
public class CategoryController {
private final ValidationChain<CreateCategoryCommand> validationChain;
private final CategoryService categoryService;
@PostMapping("/categories")
public ResponseEntity<?> create(@RequestBody CreateCategoryRequest request) {
CreateCategoryCommand command = request.toCommand();
// 전체 검증 체인 실행
ValidationResult result = validationChain.next(command);
if (!result.isValid()) {
return ResponseEntity.badRequest().body(result.getErrors());
}
// 검증된 커맨드로 처리
return ResponseEntity.ok(categoryService.create(command));
}
}
디게 재밌어 보이는 방법이라, 다음 프로젝트에 써먹어 볼까 고민 중 ㅎ
하지만 이것도 결국 Entity가 상위 계층에 종속된다는 문제가 존재하진 않을까 😂
참 정답이 없는 주제에 대해 하루 종일 고민하느라 너무 피곤해져서, 일단 이번 포스팅은 여기서 끝내야겠다.
DDD 괜히 찍먹했나...재밌긴 한데, 사람 미치게 만드네.