“Clean Architecture”가 그렇게 깨끗하지 않을 때: 도메인에서 검증 재고
Source: Dev.to
얼마 전, 나는 클린 아키텍처를 기반으로 구축된 것으로 알려진 Java 애플리케이션을 유지보수해 달라는 요청을 받았다. 실제로는… 그리 그렇지 않았다.
거의 모든 엔티티가 다음 중 하나에 해당했다:
- Jakarta 애노테이션이 여기저기 흩어져 있음 → 프레임워크가 도메인으로 직접 누수
- Guava
Preconditions.checkArgument()로 가득함 → 침해적인 유틸리티 의존성 - 임시(null) 검사로 오염됨 → 일관성 없고 반복적이며 거의 테스트되지 않음
결국 나는 다음과 같은 생성자를 발견했다 (익명 처리했지만 매우 실제적인 사례):
public Invoice(String customerEmail, BigDecimal amount, List items, LocalDate dueDate) {
this.customerEmail = Preconditions.checkNotNull(customerEmail, "email required");
Preconditions.checkArgument(customerEmail.contains("@"), "bad email");
Preconditions.checkArgument(customerEmail.length() 0, "amount positive");
this.items = Preconditions.checkNotNull(items);
Preconditions.checkArgument(!items.isEmpty(), "items empty");
Preconditions.checkArgument(items.stream().allMatch(Objects::nonNull), "null item");
this.dueDate = Preconditions.checkNotNull(dueDate);
Preconditions.checkArgument(dueDate.isAfter(LocalDate.now()), "future date");
}
작동은 했지만, 물론.
더 나쁜 점은: 모든 실패가 IllegalArgumentException으로 통합되어, 프로덕션에 도달했을 때 잘못된 사용자 입력과 개발자 실수를 구분할 수 없게 만들었다.
기존 검증 시도
처음에 흔히 쓰이는 방법들을 실험해 보았습니다:
- Jakarta Bean Validation
- Apache Commons Validate
두 방법 모두 내가 받아들일 수 없는 trade‑off가 있었습니다:
- 외부 프레임워크에 대한 강한 결합
- 복잡한 규칙에 잘 조합되지 않는 어노테이션 중심 API
- 모든 검증 실패를 동일한 버킷에 넣는 일반적인 예외
Bean Validation 3.0 + 레코드가 상황을 개선하지만, 여전히 어노테이션 기반이며 비즈니스 규칙은 선언형 어노테이션에 잘 맞지 않는 경우가 많습니다.
도메인‑네이티브 검증 접근법
만약 도메인 레이어에 어떤 의존성을 도입한다면, 그것은 다음 조건을 만족해야 합니다:
- 전이 의존성이 전혀 없어야 함 (단일 JAR,
java.base만 사용) - 타입이 명확하고 의미 있는 예외를 던져야 함 (
IllegalArgumentException이 아니라) - 유창하고 가독성 높은 체이닝을 지원해야 함
- 모든 것을 람다 스프가 아니라 커스텀 프레디케이트를 허용해야 함
같은 생성자는 이제 다음과 같이 보입니다:
public Invoice(String email, BigDecimal amount, List items, LocalDate dueDate) {
this.email = Assert.field("email", email)
.notBlank()
.email()
.maxLength(100)
.value();
this.amount = Assert.field("amount", amount)
.positive()
.value();
this.items = Assert.field("items", items)
.notEmpty()
.noNullElement()
.value();
this.dueDate = Assert.field("dueDate", dueDate)
.inFuture()
.value();
}
동일한 규칙.
의도는 비즈니스 로직처럼 읽힙니다.
일반적인 IllegalArgumentException: bad email 대신 이제는 다음과 같은 예외가 발생합니다:
EmailFormatInvalidException {
fieldName = "email",
invalidValue = "...",
constraint = "EMAIL_FORMAT"
}
장점
-
명확한 모니터링 – 자동으로 다음을 구분할 수 있습니다
- 사용자 입력 오류 → HTTP 400
- 프로그래밍 오류 → HTTP 500
불안정한 문자열 매칭이나 추측이 필요 없습니다.
-
명시적인 커스텀 프레디케이트
Assert.field("iban", iban) .notBlank() .satisfies(this::isValidIBANChecksum, "Checksum failed"); -
어노테이션 없음 – Hibernate‑first 패턴을 피하는 팀에도 적합합니다 (
@NotNull을 JPA 엔티티에 붙이는 것이 큰 가치를 제공하지 않음). -
AOP 매직 없음 – 의도적인, 생성자 수준 방어.
The bigger picture
After this experience, I see validation libraries falling into two camps:
| Camp | Characteristics |
|---|---|
| Framework validation (Jakarta, Spring) | HTTP/바인딩 레이어에서는 뛰어나지만 도메인 순수성에는 형편없음 |
| Utility validation (Guava, Apache Commons) | 재사용 가능하지만 표현력과 오류 의미론이 약함 |
There’s a missing middle ground: domain‑native validation that reads like business rules and fails with typed domain events. That’s the gap I was trying to fill.
커뮤니티를 위한 열린 질문
- 도메인을 오염시키지 않으면서 검증을 어떻게 처리하시나요?
- 엔티티에서 Jakarta 애노테이션을 허용하시나요?
- 방어적 코드를 직접 작성하시나요?
- 아니면 전혀 다른 접근 방식을 찾으셨나요?
그리고 솔직한 질문 하나: 타입이 지정된 검증 예외(e.g. NumberValueTooLowException)가 실제 운영 환경에서 도움이 되나요—아니면 단순히 과도한 설계인가요?
Project:
다른 분들은 어떻게 접근하고 있는지 궁금합니다.