256 lines
18 KiB
Markdown
256 lines
18 KiB
Markdown
---
|
|
title: 입력 경계에서 검증과 매핑 책임을 분리하기
|
|
source_type: blog
|
|
status: verified
|
|
confidence: high
|
|
tags: [blog, ca-tmpl, validation, mapper, archunit]
|
|
related_projects: [ca-tmpl]
|
|
last_reviewed: 2026-07-02
|
|
canonical_sources:
|
|
- wiki/projects/ca-tmpl/boundary-validation-mapping
|
|
audience: backend-engineer
|
|
target_publish:
|
|
status_label: 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에서 직접 파생 금지.
|
|
|
|
- 핵심 canonical:
|
|
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — ca-tmpl 입력 경계 검증, DTO↔도메인 매핑, ArchUnit 정적 강제 구현·검증 범위.
|
|
- 관련 개념 문서:
|
|
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation과 DTO/domain mapping 일반 개념. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
|
- 영감 출처:
|
|
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper 책임 분리 글감.
|
|
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API 정적 차단 글감.
|
|
|
|
## 타깃 독자 / 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 내부 의미 실패는 `MappingException`→`MAPPING_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·커밋 명시.
|
|
|
|
```java
|
|
// 출처: [[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; }
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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() { ... }
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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());
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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 {}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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);
|
|
}
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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);
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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());
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.
|
|
|
|
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 이 글의 1차 canonical. `verified`, `actually-implemented`, `locally-verified` 범위와 planned 항목을 따른다.
|
|
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation, DTO/domain mapping, mapper responsibility의 관련 개념 문서. 현재 `draft`이므로 project 구현 사실의 출처로 쓰지 않는다.
|
|
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — feature branch 결정, Decision Evidence Map, 구현 결과.
|
|
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper responsibility map 블로그 글감 raw seed.
|
|
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API static block 블로그 글감 raw seed.
|
|
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — `@GroupSequence`, `@Valid` cascade 근거.
|
|
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC validation/deserialization exception 처리 근거.
|
|
- [[raw/official-docs/patch-json-merge-rfc7396]] — JSON Merge Patch null semantics와 미채택 근거.
|
|
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 위험과 allowlist API 근거.
|
|
|
|
## 사실 vs 의견 / Fact vs opinion 구분
|
|
|
|
> 독자가 자신 있게 인용할 수 있도록.
|
|
|
|
- **사실 (검증됨)**:
|
|
- `Patch<T>`, `MappingException`, `GlobalExceptionHandler`, `EnvelopeBodyAdvice`, WorkLog request/mapper, outbound ACL mapper, boundary ArchUnit rules는 ca-tmpl 코드에 존재한다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
|
- `WorkLogControllerWireTest`, unit/contract tests, virtual-thread MDC tests, `CleanArchitectureTest` + violation fixtures가 로컬 검증 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
|
- 운영 배포와 prod 검증은 없다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
|
- **내 해석·의견 (검증 안 된 추론)**:
|
|
- 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
|
|
|
|
`ready` → `published` 로 올리기 전 확인.
|
|
|
|
- [x] 모든 사실 주장에 canonical 링크 있음
|
|
- [x] 사실 vs 의견 분리 명시됨
|
|
- [x] 금지 마케팅 표현 없음
|
|
- [x] 코드 예제 출처 명시
|
|
- [x] 타깃 독자 가정과 톤 일치
|
|
- [x] `/lint` 통과
|
|
- [ ] 게시 URL 기록 (게시 후):
|
|
|
|
## Related / 관련
|
|
|
|
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
|
- 영감을 받은 raw 자료: [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]], [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]]
|