87 lines
10 KiB
Markdown
87 lines
10 KiB
Markdown
---
|
|
title: 경계 검증과 DTO↔도메인 매핑 (Bean Validation · MapStruct vs 수기 mapper · Patch partial-update)
|
|
source_type: llm-generated
|
|
status: draft
|
|
confidence: medium
|
|
tags: [backend, validation, mapper, dto, bean-validation, boundary]
|
|
related_projects: [ca-skeleton, ca-tmpl]
|
|
last_reviewed: 2026-06-04
|
|
---
|
|
|
|
# 경계 검증과 DTO↔도메인 매핑
|
|
|
|
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 [[wiki/projects/ca-tmpl/boundary-validation-mapping]] 참조.
|
|
|
|
## Summary
|
|
|
|
웹 애플리케이션의 **입력 경계**(request boundary)에서는 두 가지 책임이 동시에 생긴다: (1) 들어온 데이터가 형식적으로 올바른지 **검증**하고, (2) 외부 표현(DTO)을 내부 모델(domain / command)로 **변환(mapping)** 하는 것이다.
|
|
|
|
- **Bean Validation (Jakarta Validation, JSR 380 / 3.0)**: `@NotNull`, `@Size`, `@Valid` 같은 선언적 제약을 DTO 필드/메서드에 붙여 프레임워크가 자동 검증하게 하는 표준. Spring MVC 는 컨트롤러 파라미터에 `@Valid`/`@Validated` 가 붙으면 본문 바인딩 직후 검증을 수행하고, 실패 시 `MethodArgumentNotValidException` 을 던진다.
|
|
- **validation-at-boundary 원칙**: 검증은 가능한 한 *입력 경계 한 곳* 에서 fail-fast 로 끝내고, 안쪽 레이어(application/domain)는 이미 검증된 값만 받는다는 설계. 단, 형식(syntax) 검증과 도메인 불변식(invariant) 검증은 책임이 다르므로 같은 어노테이션 한 줄로 뭉뚱그리지 않는다.
|
|
- **DTO↔domain mapping**: 외부에 노출되는 DTO 와 내부 도메인 객체를 분리하고 그 사이를 변환하는 코드. 변환 도구는 **수기(manual) mapper** 와 **MapStruct 같은 코드 생성기(generator)** 두 갈래가 있다.
|
|
- **partial-update (PATCH) semantics**: PATCH 요청에서 "필드 없음(absent) / 명시적 null / 값 있음" 세 상태를 구분해야 silent overwrite 를 막을 수 있다.
|
|
|
|
## Standard (공식 정의)
|
|
|
|
- **Jakarta Bean Validation 3.0** (official-standard): class-level constraint 는 "한 클래스의 여러 property 를 동시에 보는 상태 검증"을 위한 것이고(JBV-3.0-C1), `ConstraintValidator` 는 클래스 인스턴스를 받아 여러 필드에 동시 접근할 수 있다(JBV-3.0-C2). `@GroupSequence` 를 쓰면 group 을 순서대로 실행하다 한 group 이 실패하면 **다음 group 을 건너뛴다(short-circuit)** — syntax 검증을 먼저 통과해야 invariant 검증이 돈다는 패턴의 normative 근거(JBV-3.0-C3). `@Valid` 는 중첩 객체로 검증을 **cascade(전파)** 시킨다(JBV-3.0-C4). 단, Bean Validation 자체는 syntax/invariant 라는 **레이어 이름을 정의하지 않는다** — 그 분류는 애플리케이션 설계 결정이다.
|
|
- **Spring MVC REST exception handling** (official-vendor-doc): `HttpMessageNotReadableException`(JSON 파싱 실패) 과 `MethodArgumentNotValidException`(Bean Validation 실패) 은 모두 Spring 내장 `ErrorResponse` 구현체이고 `ResponseEntityExceptionHandler` 가 normative 하게 처리한다(SPRING-MVC-EXC-C1/C2/C4/C5). 즉 이 두 예외는 표준적으로 검증 실패(400) 카테고리로 분류된다.
|
|
- **RFC 7396 (JSON Merge Patch)** (official-standard): merge patch 에서 `null` 값은 "해당 필드 삭제"를 의미한다(RFC7396-C2). 따라서 "명시적 null" 을 다른 의미로 쓰려는 API 는 RFC 7396 merge patch 를 그대로 채택하면 충돌한다(RFC7396-C3). 배열 부분 수정도 불가하다(RFC7396-C4).
|
|
- **MapStruct** (도구): 컴파일 타임에 mapper 구현 코드를 생성하는 어노테이션 프로세서. 리플렉션 없이 동작하지만, 생성된 코드가 architecture 규칙(예: 도메인 직접 접근 금지)을 우회할 수 있어 별도 exemption 관리가 필요하다. (※ MapStruct 도구 선택 자체는 공식 표준이 권고하는 사항이 아니라 프로젝트 trade-off 결정이다.)
|
|
|
|
## 한계 / 주의점
|
|
|
|
- **4-layer validation 분류(syntax / policy / invariant / persistence integrity)는 표준이 아니다.** Bean Validation spec 은 이런 taxonomy 를 정의하지 않는다. 레이어를 나누는 것은 설계 결정이며, 잘못 나누면 같은 검증이 두 곳에서 중복되거나 빠진다.
|
|
- **MapStruct vs 수기 mapper 는 정답이 없는 trade-off.** 수기 mapper 는 boilerplate 가 많지만 동작이 투명하다. MapStruct 는 코드량을 줄이지만 generated code 가 architecture 경계를 silent 하게 leak 할 수 있고, 매핑 누락이 컴파일 시점에 드러나지 않을 수 있다.
|
|
- **PATCH 의 null/absent 혼동**은 흔한 버그다. Java record 의 기본 매핑으로 PATCH 를 구현하면 요청에 없던 필드가 `null` 로 들어와 기존 값을 덮어쓰는 silent overwrite 가 발생한다. `Optional<T>` 또는 `JsonNullable<T>`(openapi-generator) 같은 3-state wrapper 가 필요하다.
|
|
- **검증을 경계에서만 한다고 도메인 불변식이 보장되지는 않는다.** 형식 검증(DTO)과 도메인 불변식(application/domain)은 별개다. DTO 검증만으로 "도메인이 안전하다"고 말하면 안 된다.
|
|
- **`@Valid` cascade 의 무한/깊은 재귀**는 DoS 표면이 될 수 있다. 중첩 깊이에 상한을 두는 것은 spec 이 아니라 운영적 방어 결정이다.
|
|
|
|
## Project Application
|
|
|
|
- ca-tmpl 은 입력 경계의 검증/매핑 책임을 명시적으로 고정하고, 일부 정책을 ArchUnit fitness function 으로 정적 강제했다. 구체적 구현 사실·검증 등급은 [[wiki/projects/ca-tmpl/boundary-validation-mapping]] 참조.
|
|
- 관련 트랜잭션 경계 추상화는 [[wiki/concepts/transaction-boundary-abstraction]] / [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]].
|
|
|
|
## Claim-backed Knowledge
|
|
|
|
> 아래는 본 개념을 뒷받침하는 raw official-doc claim 인용. company-tech-blog 는 사례일 뿐 공식 best practice 로 격상하지 않는다.
|
|
|
|
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
|
|---|---|---|---|
|
|
| class-level constraint 는 한 클래스의 여러 property 상태를 함께 검증한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C1 | high | constraint 의 *목적* 근거. syntax/invariant 레이어 이름은 spec 미규정 |
|
|
| `@GroupSequence` 는 group 을 순차 실행하다 실패 시 후속 group 을 short-circuit 한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C3 | high | syntax→invariant 단계 분리 패턴의 normative 근거 |
|
|
| `@Valid` 는 중첩 객체로 검증을 cascade 한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C4 | high | cascade *메커니즘* 근거. depth 상한은 설계 결정 (spec 미규정) |
|
|
| `HttpMessageNotReadableException` 은 Spring 이 normative 하게 처리하는 내장 예외 | [[raw/official-docs/spring-mvc-rest-exception-handling]] SPRING-MVC-EXC-C4 | high | JSON 파싱 실패 → 검증(400) 분류 근거 |
|
|
| `MethodArgumentNotValidException` 은 field error 를 담아 normative 처리된다 | [[raw/official-docs/spring-mvc-rest-exception-handling]] SPRING-MVC-EXC-C5 | high | Bean Validation 실패 → 검증(400) + field error shape 근거 |
|
|
| JSON Merge Patch 의 `null` 은 필드 삭제를 의미한다 | [[raw/official-docs/patch-json-merge-rfc7396]] RFC7396-C2 | high | PATCH 에서 null/absent 구분이 필요한 이유. ca-tmpl 은 merge patch *미채택* |
|
|
|
|
## 내가 설명할 수 있어야 하는 것
|
|
|
|
- Bean Validation 의 `@Valid`/`@Validated`/`@GroupSequence` 가 각각 무엇이고, syntax 검증과 도메인 invariant 검증을 왜 분리하는가.
|
|
- `MethodArgumentNotValidException` 과 `HttpMessageNotReadableException` 이 왜 둘 다 "검증 실패(400)" 로 분류되는가, mapper 내부 예외는 왜 별도 카테고리가 필요한가.
|
|
- DTO↔domain mapping 에서 MapStruct 와 수기 mapper 의 trade-off (boilerplate vs architecture leak / 컴파일 안전성).
|
|
- PATCH 의 absent / explicit-null / value 3-state 를 구분하지 않으면 어떤 버그(silent overwrite)가 생기는가, `Optional`/`JsonNullable` 로 어떻게 구분하는가.
|
|
- RFC 7396 merge patch 의 null=deletion semantics 와, 이를 채택하지 않는 API 가 왜 `application/merge-patch+json` content type 을 쓰면 안 되는가.
|
|
|
|
## Interview Questions
|
|
|
|
- "request 검증을 어디서 하나요? 컨트롤러? 서비스? 도메인?" → 형식 검증은 경계(DTO), 도메인 불변식은 application/domain. 한 줄 어노테이션으로 다 끝낸다는 답은 위험.
|
|
- "`@Valid` 와 `@Validated` 차이는?" → `@Validated` 는 Spring 의 group 지원 + 메서드 레벨 검증, `@Valid` 는 표준 cascade.
|
|
- "PATCH 에서 어떤 필드만 바꾸고 싶을 때 null 을 어떻게 처리하나요?" → absent vs explicit-null 구분, 3-state wrapper.
|
|
- "DTO 와 도메인 객체를 왜 분리하나요? MapStruct 와 수기 매핑 중 무엇을 쓰나요?" → 노출 경계 분리 + 도구 trade-off.
|
|
|
|
## Do Not Overclaim
|
|
|
|
- **"Bean Validation 이 syntax/invariant 를 알아서 나눠준다" → 금지.** spec 은 레이어를 정의하지 않는다. `@GroupSequence` 로 *순서* 는 줄 수 있지만 분류는 설계자가 한다.
|
|
- **"MapStruct 가 수기 mapper 보다 우월하다" → 금지.** generated code 의 architecture leak / 매핑 누락 trade-off 가 있다.
|
|
- **"DTO 검증을 했으니 도메인이 안전하다" → 금지.** 형식 검증과 도메인 불변식은 별개.
|
|
- **"PATCH 의 null 은 항상 삭제다(RFC 7396)" → 단정 금지.** RFC 7396 의 정의일 뿐, 이를 채택하지 않는 API 도 많다.
|
|
|
|
## Sources
|
|
|
|
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — Jakarta Bean Validation 3.0 normative (class-level constraint, group sequence, `@Valid` cascade)
|
|
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC `ResponseEntityExceptionHandler` 처리 예외 목록 (`HttpMessageNotReadableException` / `MethodArgumentNotValidException`)
|
|
- [[raw/official-docs/patch-json-merge-rfc7396]] — RFC 7396 JSON Merge Patch (null=deletion semantics)
|
|
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 보안 지침 (allowlist, CVE-2019-14379) — 경계 역직렬화 보안 맥락
|
|
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 본 개념을 도출한 ca-tmpl 경계 검증/매핑 계약 branch
|
|
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 내 프로젝트 적용 사실
|