Files
llm-wiki/wiki/publish-blog/boundary-validation-mapping-blog.md
T

10 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
입력 경계에서 검증과 매핑 책임을 분리하기 blog draft unknown
blog
ca-tmpl
validation
mapper
bean-validation
anti-corruption-layer
ca-tmpl
backend-engineer draft

입력 경계에서 검증과 매핑 책임을 분리하기

@Valid 하나로 끝날 것 같던 입력 검증이, 실제로는 최소 세 개의 서로 다른 책임으로 쪼개져야 하는 이유를 정리한 글입니다.

TL;DR

  • Request DTO가 application layer까지 새어 들어가거나, domain/JPA entity가 response로 그대로 나가거나, PATCH가 기존 값을 조용히 덮어쓰는 문제는 전부 입력 경계가 흐려질 때 생깁니다.
  • ca-tmpl은 이 경계를 request parsing, mapper, PATCH 3-state, polymorphic deserialization, DTO/domain/persistence 분리, 이렇게 다섯 개 책임으로 나눴습니다.
  • 중요한 규칙은 컨벤션 문서가 아니라 ArchUnit rule로 build-time에 강제했습니다. 사람이 리뷰에서 놓쳐도 빌드가 잡아냅니다.
  • 검증 범위는 로컬/개발 환경까지입니다. 운영 배포, 실 DB 통합, 실 외부 HTTP 통합은 아직입니다.

1. 경계가 흐려지면 생기는 문제들

입력 검증은 처음엔 controller에 @Valid 하나 붙이면 끝나는 문제처럼 보입니다. Request body를 DTO로 받고, Bean Validation으로 검사하고, service로 넘기면 충분해 보이죠. 그런데 프로젝트가 커질수록 이 경계는 생각보다 쉽게 무너집니다.

  • Request DTO가 application layer까지 새어 들어가면, application core가 web framework의 모양을 알게 됩니다.
  • Domain entity나 JPA entity가 controller response로 그대로 나가면, 내부 모델이 그 자체로 외부 API 계약이 되어버립니다.
  • PATCH에서는 더 미묘한 문제가 생깁니다. 필드가 아예 빠진 건지, 명시적으로 null을 보낸 건지, 새 값을 보낸 건지 구분하지 못하면 기존 값을 조용히 덮어쓰는 버그로 이어집니다.

ca-tmpl은 이 문제를 "입력 검증을 어디서 하느냐" 하나로 뭉치지 않았습니다. request parsing, syntax validation, mapper, domain/application command, response shaping, outbound ACL을 서로 다른 책임으로 나누고, 그중 중요한 경계는 ArchUnit rule과 wire-level 테스트로 고정했습니다.


2. Validation 실패와 Mapping 실패는 다른 문제다

가장 먼저 나눈 것은 **"형식이 틀렸다"**와 **"의미가 성립하지 않는다"**의 구분입니다.

Spring MVC가 request body를 파싱하지 못하거나 Bean Validation을 통과하지 못하면 VALIDATION_FAILED입니다. MethodArgumentNotValidException, HttpMessageNotReadableException, ConstraintViolationException 계열이 여기 속합니다.

반면 payload는 구조적으로 잘 들어왔는데, mapper가 의미상 domain command로 바꿀 수 없는 경우는 다릅니다.

// shared-contract/.../MappingException.java
public class MappingException extends RuntimeException {
  public MappingException(String message) {
    super(message);
  }

  public MappingException(String message, Throwable cause) {
    super(message, cause);
  }
}
// adapter-web/.../GlobalExceptionHandler.java
@ExceptionHandler(MappingException.class)
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
  return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
}

이 둘을 분리하는 이유는 실패의 원인이 다르기 때문입니다. validation 실패는 보통 client가 입력 형식을 잘못 보냈다는 뜻입니다. mapping 실패는 한 단계 더 안쪽입니다. 예를 들어 링크 URI가 문자열 형식은 맞지만 프로젝트가 받아들일 수 없는 형태라면, mapper가 그걸 domain command로 바꾸지 못합니다. 두 경우를 전부 "bad request"로 뭉개면 운영 분류와 client 디버깅이 모두 어려워집니다.


3. PATCH의 세 번째 상태 — absent, null, value

PATCH를 다뤄본 사람이라면 한 번쯤 겪는 문제가 있습니다. 일반 update에서는 null을 "값을 지운다"로 볼 수 있지만, PATCH에서는 필드가 빠진 상태필드가 명시적으로 null인 상태가 다릅니다. 빠졌다는 건 "건드리지 마"라는 뜻이고, 명시적 null은 "비워달라"는 뜻일 수 있습니다.

ca-tmpl은 web adapter에서 Jackson의 JsonNullable<T>을 받고, application으로 넘기기 전에 **Jackson을 전혀 모르는 타입인 Patch<T>**로 변환합니다.

// shared-contract/.../Patch.java
public final class Patch<T> {
  public static <T> Patch<T> absent() { ... }
  public static <T> Patch<T> ofNull() { ... }
  public static <T> Patch<T> of(T value) { ... }

  public boolean isAbsent() { return !present; }
  public boolean isExplicitNull() { return present && value == null; }
  public boolean hasValue() { return present && value != null; }
}
// sample-portfolio/.../UpdateWorkLogRequest.java
private static <T> Patch<T> toPatch(JsonNullable<T> field) {
  if (field == null || !field.isPresent()) {
    return Patch.absent();
  }
  return field.get() == null ? Patch.ofNull() : Patch.of(field.get());
}

이렇게 나누면 application-core는 Jackson의 존재 자체를 모릅니다. application은 Patch.absent(), Patch.ofNull(), Patch.of(value) 세 가지만 보고 의도를 판단하면 됩니다. web adapter는 wire 표현을 domain/application이 이해할 수 있는 작은 값 객체로 번역하는 역할만 맡습니다. 이게 정확히 DTO와 command 사이 mapper의 책임입니다.


4. Polymorphic Deserialization은 allowlist로만

다형성 역직렬화도 경계 문제입니다. Jackson의 default typing은 임의의 subtype을 받아들일 수 있는데, 이건 과거 CVE-2019-14379 같은 gadget chain 취약점과 연결된 전례가 있습니다.

ca-tmpl은 ObjectMapper.enableDefaultTyping() 호출과 LaissezFaireSubTypeValidator 의존을 ArchUnit으로 원천 차단합니다. 대신 명시적인 이름 기반 매핑만 허용합니다.

// sample-portfolio/.../SamplePolymorphicRequest.java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
  @JsonSubTypes.Type(value = SamplePolymorphicRequest.Text.class, name = "text"),
  @JsonSubTypes.Type(value = SamplePolymorphicRequest.Image.class, name = "image")
})
public sealed interface SamplePolymorphicRequest
    permits SamplePolymorphicRequest.Text, SamplePolymorphicRequest.Image {}

즉 "다형성을 아예 쓰지 않는다"가 아니라, 허용된 이름과 타입만 받게 하는 것입니다.


5. 경계를 문서가 아니라 빌드로 강제하기

DTO/domain/persistence 사이의 경계는 정적 규칙으로 고정했습니다.

  • controller의 public 메서드가 domain entity, JPA entity, repository 타입을 반환하지 못하게 막고
  • application의 public 메서드가 web DTO를 파라미터로 받지 못하게 막고
  • RFC 7807 ProblemDetail import를 막고 (이전 글에서 다룬 것과 같은 이유입니다)
  • application/merge-patch+json media type 문자열 사용도 막고
  • outbound adapter의 public 메서드가 raw external response 타입을 밖으로 흘리지 못하게 하는 ACL 규칙도 둡니다
// app-bootstrap/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS =
    methods()
        .that()
        .areDeclaredInClassesThat()
        .resideInAPackage("..application..")
        .and()
        .arePublic()
        .should()
        .notHaveRawParameterTypes(...);

여기서 ArchUnit은 단순한 "아키텍처 다이어그램 검사기"가 아닙니다. 사람이 코드 리뷰에서 놓치기 쉬운 경계 위반을 빌드 타임에 잡아내는 fitness function에 가깝습니다.

특히 ca-tmpl은 violations-as-data 방식을 씁니다. 의도적으로 잘못된 fixture 코드를 만들어서, 규칙이 실제로 그 위반을 잡아내는지 테스트합니다. 이건 "규칙은 있는데 사실 아무것도 검사하지 않아서 항상 통과하는" vacuous pass를 줄이는 장치입니다.

Outbound 방향의 예시도 하나 보겠습니다. 외부 응답 raw 타입을 도메인으로 정리하는 mapper는 이렇게 생겼습니다.

// sample-portfolio/.../RepoStatsAclMapper.java
static RepoStats toDomain(RawRepoStatsResponse raw) {
  if (raw == null || raw.fullName() == null || raw.fullName().isBlank()) {
    throw new MappingException("repo provider: missing 'fullName'");
  }
  return new RepoStats(
      raw.fullName().toLowerCase(), Math.max(0, raw.stargazers()), raw.pushedAt());
}

외부 서비스가 이상한 응답을 주더라도, 그 raw 타입이 domain까지 새어 들어가지 않고 이 지점에서 정리되거나 MappingException으로 명확하게 실패합니다.


6. 지금까지 검증된 것, 아직인 것

이 계약이 모든 걸 해결한 건 아닙니다. 현재 검증 범위는 로컬/개발 환경입니다.

검증된 것

  • WorkLogControllerWireTest, unit/contract 테스트
  • virtual-thread MDC 테스트
  • CleanArchitectureTest와 violation fixture

아직 아닌 것

  • 운영 배포 검증
  • 실 DB(Testcontainers 등) 통합 테스트
  • 실 외부 HTTP(WireMock 등)를 통한 outbound ACL 검증
  • OpenAPI oneOf response shape 명세

마무리

ca-tmpl의 boundary validation/mapping 결정은 "controller에 @Valid 붙였다"보다 훨씬 넓은 계약입니다. request DTO가 어디까지 들어갈 수 있는지, mapper 실패는 어떤 error code가 되는지, PATCH의 세 상태를 어떻게 보존하는지, 외부 응답의 raw 타입이 domain으로 어떻게 정리되는지, 그리고 그 규칙을 어떤 ArchUnit rule로 고정하는지를 함께 다룹니다.

좋은 경계 설계는 한 번의 아름다운 mapper 코드가 아니라, 다음 사람이 무심코 경계를 깨뜨려도 빌드가 알려주는 구조에 가깝습니다.