Files
llm-wiki/wiki/blog/ca-tmpl-boundary-validation-mapping-2026-07-02.md
T

18 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 verified high
blog
ca-tmpl
validation
mapper
archunit
ca-tmpl
2026-07-02
wiki/projects/ca-tmpl/boundary-validation-mapping
backend-engineer ready

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

Layer: wiki/blog/외부 공개용 블로그 글 초안·완성본. canonical (wiki/concepts/ + wiki/projects/) 에서 파생된 산출물. 상태: draft → reviewed → verified → published-ready (외부 게시 가능) status_label: outline | drafting | review | ready | published | retired audience: backend-engineer | senior-engineer | tech-lead | general — 깊이·전문용어 사용량이 달라짐.

Parent / 부모 (필수)

wiki/blog/ 는 derived layer. 반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생. raw 또는 branch에서 직접 파생 금지.

타깃 독자 / Target reader

이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.

  • 독자 profile: Spring Boot API에서 request DTO, validation, mapper, domain model 경계를 어디에 둘지 고민하는 백엔드 엔지니어.
  • 독자가 이미 알고 있을 것이라 가정하는 것: Bean Validation, DTO, controller/service 계층, Jackson, 기본적인 Clean Architecture 용어.
  • 독자가 처음 듣는다고 가정하는 것: PATCH 3-state, mapper 실패와 validation 실패의 분리, ArchUnit으로 boundary rule을 build-time contract로 만드는 방식.

도입 / Hook

왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.

  • 문제 / 궁금증: 입력 검증과 DTO↔domain mapping을 한 계층에 몰아두면 request DTO가 application layer까지 새거나, domain/entity가 response로 silent 직렬화되거나, PATCH가 기존 값을 조용히 덮어쓰는 회귀가 생긴다.
  • 이 글이 답하는 것: ca-tmpl이 입력 syntax, mapper, response shaping, outbound ACL, polymorphic deserialization, envelope error mapping을 어떻게 나누고 어떤 것은 ArchUnit으로 강제했는지 정리한다.
  • 이 글이 답하지 않는 것 (스코프): 운영 트래픽 검증, 실 DB/Testcontainers 통합 검증, 실 외부 HTTP/WireMock 통합, OpenAPI oneOf response shape 명세.

본문 outline / Body outline

글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.

  1. 경계가 흐려질 때 생기는 문제 — request DTO leak, domain/entity response leak, PATCH silent overwrite, raw external response leak.
  2. validation 실패와 mapping 실패를 분리하기 — Spring이 다루는 request parsing/validation은 VALIDATION_FAILED, mapper 내부 의미 실패는 MappingExceptionMAPPING_FAILED.
  3. PATCH 3-state를 JsonNullable<T>에서 Patch<T>로 옮기기 — web adapter의 Jackson-aware 타입을 application-core 밖으로 가두고 absent/null/value를 보존한다.
  4. polymorphic deserialization을 allowlist로 제한하기 — Jackson default typing 위험 API를 ArchUnit rule로 막고, @JsonTypeInfo + subtype allowlist만 허용한다.
  5. DTO/domain/persistence 경계를 ArchUnit fitness function으로 고정하기 — controller 반환 타입, application method parameter, ProblemDetail import, merge-patch media type, @Valid cascade depth, outbound ACL return type을 build-time으로 검증한다.
  6. 검증된 범위와 아직 아닌 범위 — WorkLog wire/unit test, ArchitectureViolationFixtureTest, virtual-thread MDC e2e는 검증됐지만 운영, 실 DB 통합, 실 외부 HTTP 통합, OpenAPI shape 명세는 아니다.

본문 / Body

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

예를 들어 request DTO가 application layer까지 들어가면 application core가 web framework의 모양을 알게 됩니다. 반대로 domain entity나 JPA entity가 controller response로 바로 나가면, 내부 모델이 외부 API contract가 되어 버립니다. PATCH에서는 더 미묘한 문제가 생깁니다. field가 아예 빠진 것인지, 명시적으로 null을 보낸 것인지, 새 값을 보낸 것인지 구분하지 못하면 기존 값을 조용히 덮어쓰는 버그가 생깁니다.

ca-tmpl의 boundary validation/mapping 계약은 이 문제를 “입력 검증을 어디서 하느냐” 하나로 보지 않습니다. request parsing, syntax validation, mapper, domain/application command, response shaping, outbound ACL을 서로 다른 책임으로 나눕니다. 그리고 중요한 경계는 ArchUnit rule과 wire-level test로 고정합니다. 컨벤션 문서에만 적어두는 것이 아니라, 누군가 실수로 깨면 build가 실패하게 만드는 방식입니다.

먼저 validation 실패와 mapping 실패를 분리합니다. Spring MVC가 request body를 읽지 못하거나 Bean Validation을 통과하지 못한 경우는 VALIDATION_FAILED입니다. 예를 들어 MethodArgumentNotValidException, HttpMessageNotReadableException, ConstraintViolationException 계열입니다. 반면 payload는 구조적으로 들어왔지만 mapper가 의미상 domain command로 바꿀 수 없는 경우는 MappingException입니다. ca-tmpl은 이것을 MAPPING_FAILED로 분류합니다.

이 분리가 중요한 이유는 실패의 원인이 다르기 때문입니다. validation failure는 보통 client가 입력 형식을 잘못 보냈다는 뜻입니다. mapping failure는 한 단계 더 안쪽입니다. 예를 들어 link URI가 형식은 문자열이지만 project가 받아들일 수 없는 형태라면, mapper가 그것을 domain command로 바꾸지 못합니다. 둘을 모두 “bad request”로만 뭉개면 운영 분류와 client 디버깅이 어려워집니다.

PATCH 3-state도 이 글의 핵심입니다. 일반 update에서는 null을 “값을 지운다”로 볼 수 있지만, PATCH에서는 field가 빠진 상태와 field가 null인 상태가 다릅니다. 빠졌다는 것은 변경하지 말라는 뜻이고, 명시적 null은 비우라는 뜻일 수 있습니다. ca-tmpl은 web adapter에서 JsonNullable<T>를 받고, application으로 넘기기 전에 Jackson-free 타입인 Patch<T>로 변환합니다.

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

polymorphic deserialization도 경계 문제입니다. Jackson default typing은 임의 subtype을 열어 둘 수 있고, 과거 CVE-2019-14379 같은 gadget chain 위험과 연결됩니다. ca-tmpl은 ObjectMapper.enableDefaultTyping() 호출과 LaissezFaireSubTypeValidator 의존을 ArchUnit으로 막습니다. 대신 @JsonTypeInfo(use = NAME)과 명시적인 @JsonSubTypes allowlist를 사용합니다. 즉 “다형성을 쓰지 않는다”가 아니라, 허용된 이름과 타입만 받게 하는 것입니다.

DTO/domain/persistence 경계는 정적 rule로 고정합니다. controller public method가 domain entity, JPA entity, repository type을 반환하지 못하게 막습니다. application public method가 web DTO를 parameter로 받지 못하게 막습니다. RFC 7807 ProblemDetail import도 막고, application/merge-patch+json media type 문자열도 막습니다. outbound adapter public method가 raw external response type을 밖으로 흘리지 못하게 하는 ACL rule도 있습니다.

여기서 ArchUnit은 “아키텍처 다이어그램 검사기”가 아닙니다. 사람이 리뷰에서 놓치기 쉬운 carrier를 build-time에 잡는 fitness function에 가깝습니다. 특히 ca-tmpl은 violations-as-data 방식을 씁니다. 의도적으로 잘못된 fixture를 만들어 rule이 실제 위반을 잡는지 테스트합니다. 이것은 rule이 아무 것도 검사하지 않는데 green이 되는 vacuous pass를 줄이는 장치입니다.

물론 이 계약이 모든 것을 해결한 것은 아닙니다. 현재 검증 범위는 local/dev입니다. WorkLogControllerWireTest, unit/contract test, virtual-thread MDC test, CleanArchitectureTest와 violation fixture가 있습니다. 하지만 운영 배포는 없고, 실 DB 통합 테스트도 없습니다. outbound ACL도 실 WebClient/RestClient와 WireMock 왕복으로 검증된 것은 아닙니다. OpenAPI oneOf response shape 명세도 아직 planned입니다.

정리하면 ca-tmpl의 boundary validation/mapping 결정은 “controller에 @Valid 붙였다”보다 훨씬 넓은 계약입니다. request DTO가 어디까지 들어갈 수 있는지, mapper 실패는 어떤 error code가 되는지, PATCH의 세 상태를 어떻게 보존하는지, 외부 응답 raw type이 domain으로 어떻게 정리되는지, 그리고 그 규칙을 어떤 ArchUnit rule로 고정하는지를 함께 다룹니다. 좋은 경계 설계는 한 번의 아름다운 mapper가 아니라, 다음 사람이 무심코 깨뜨려도 build가 알려주는 구조에 가깝습니다.

코드 예제 / Code samples (있다면)

가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.

// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/request/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; }
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: sample-portfolio/.../CreateWorkLogRequest.java
@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})
public record CreateWorkLogRequest(
    @NotBlank(groups = Syntax.class) @Size(max = 200, groups = Syntax.class) String title,
    @NotNull(groups = Syntax.class) WorkCategory category,
    @NotNull(groups = Syntax.class) LocalDate periodStart,
    LocalDate periodEnd) {

  @AssertTrue(message = "periodEnd must not be before periodStart", groups = Invariant.class)
  public boolean isPeriodOrdered() { ... }
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: 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());
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: 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 {}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java
public class MappingException extends RuntimeException {
  public MappingException(String message) {
    super(message);
  }

  public MappingException(String message, Throwable cause) {
    super(message, cause);
  }
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/error/GlobalExceptionHandler.java
@ExceptionHandler(MappingException.class)
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
  return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: 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());
}
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS =
    methods()
        .that()
        .areDeclaredInClassesThat()
        .resideInAPackage("..application..")
        .and()
        .arePublic()
        .should()
        .notHaveRawParameterTypes(...);

Sources / 근거 (canonical 인용 필수, derived layer 의무)

모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.

사실 vs 의견 / Fact vs opinion 구분

독자가 자신 있게 인용할 수 있도록.

  • 사실 (검증됨):
  • 내 해석·의견 (검증 안 된 추론):
    • validation/mapping 책임을 하나의 mapper나 controller에 몰지 않고 boundary rule로 쪼개면, skeleton 사용자가 깨뜨리기 쉬운 회귀를 더 빨리 발견할 수 있다.
    • violations-as-data 방식은 ArchUnit rule이 실제 위반을 잡는지 확인하는 데 좋은 학습 소재다.
  • 알지 못하는 것:
    • 실 DB 통합에서 PATCH/mapper 정책이 어떻게 동작하는지는 아직 검증되지 않았다.
    • 실 외부 HTTP + WireMock 기반 outbound ACL 검증은 아직 없다.
    • OpenAPI oneOf response shape 명세는 아직 planned다.

답할 수 있는 범위 / Answer boundary

이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.

  • 자신 있게 답할 수 있는 후속 질문:
    • validation 실패와 mapping 실패를 왜 다른 error category로 나눴는가?
    • PATCH absent/null/value를 왜 구분해야 하는가?
    • Jackson default typing 위험 API를 어떤 ArchUnit rule로 막았는가?
    • controller/application/outbound boundary를 어떤 정적 rule로 강제했는가?
    • violations-as-data fixture가 왜 필요한가?
  • "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
    • 실 DB/Testcontainers 통합 검증.
    • 실 외부 HTTP/WireMock 기반 ACL 검증.
    • OpenAPI response shape oneOf 명세.
    • 운영 트래픽에서 이 계약이 인시던트를 줄였는지에 대한 측정.

게시 체크리스트 / Publish checklist

readypublished 로 올리기 전 확인.

  • 모든 사실 주장에 canonical 링크 있음
  • 사실 vs 의견 분리 명시됨
  • 금지 마케팅 표현 없음
  • 코드 예제 출처 명시
  • 타깃 독자 가정과 톤 일치
  • /lint 통과
  • 게시 URL 기록 (게시 후):