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

194 lines
10 KiB
Markdown

---
title: 입력 경계에서 검증과 매핑 책임을 분리하기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, validation, mapper, bean-validation, anti-corruption-layer]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: 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로 바꿀 수 없는 경우는 다릅니다.
```java
// shared-contract/.../MappingException.java
public class MappingException extends RuntimeException {
public MappingException(String message) {
super(message);
}
public MappingException(String message, Throwable cause) {
super(message, cause);
}
}
```
```java
// 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>`**로 변환합니다.
```java
// 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; }
}
```
```java
// 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으로 원천 차단합니다. 대신 명시적인 이름 기반 매핑만 허용합니다.
```java
// 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 규칙도 둡니다
```java
// 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는 이렇게 생겼습니다.
```java
// 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 코드가 아니라, **다음 사람이 무심코 경계를 깨뜨려도 빌드가 알려주는 구조**에 가깝습니다.