“Clean Architecture”가 그렇게 깨끗하지 않을 때: 도메인에서 검증 재고

발행: (2026년 2월 6일 오전 12:12 GMT+9)
6 min read
원문: Dev.to

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:

CampCharacteristics
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:

다른 분들은 어떻게 접근하고 있는지 궁금합니다.

Back to Blog

관련 글

더 보기 »

Garbage Collection 심층 분석 (JAVA)

이 댓글을 숨기시겠습니까? 게시물에서는 숨겨지지만 댓글의 퍼머링크를 통해 여전히 볼 수 있습니다. 하위 댓글도 숨깁니다.

Java 기능::

Java 프로그래밍 언어는 처음에 임베디드 시스템, 셋톱 박스, 그리고 텔레비전에서 작동하도록 개발되었습니다. 요구 사항에 따라, 다양한 플랫폼에서 실행되도록 설계되었습니다.