오류 코드와 메시지
FieldError의 후보 코드 순서와 MessageSource 해석을 실행하고, UI 메시지 키와 외부 API 오류 코드를 분리합니다.
검증 오류의 code는 완성된 문장이 아닙니다. Spring은 하나의 오류 의미와 객체·필드·타입 정보를 조합해 구체적인 후보부터 일반 후보까지 배열을 만들고, MessageSource는 현재 로케일의 번들에서 처음 발견한 키를 선택합니다.
FieldError 후보는 구체적인 키부터 메시지를 해석한다
FieldError의 객체/필드/타입/공통 후보를 구체적인 순서대로 탐색해 처음 발견한 메시지로 끝난다.
FLOWCHART · FIELDERROR · MESSAGECODESRESOLVER · MESSAGESOURCE
오류 객체의 후보·인자·기본 문구를 함께 해석한다
후보는 오류 등록 때 생성됩니다. MessageSource는 현재 locale에서 구체 후보부터 조회하고, 첫 일치 문구에 인자를 적용합니다. 모든 후보가 없으면 제공된 기본 문구를 사용합니다.
DEFAULT FIELD CANDIDATES
단순 String 필드의 구체성 순서 4개
post.content.length.postCreate.content → post.content.length.content → post.content.length.java.lang.String → post.content.length. 오류 표시 우선순위가 아니라 객체·필드 문맥을 점차 넓히는 순서입니다.
FIRST MATCH + ARGUMENTS
postCreate는 720 문구 · postEdit은 공통 문구
postCreate.content의 첫 키가 있으면 본문은 720자 이하여야 합니다.입니다. postEdit의 구체 후보가 없으면 공통 키의 허용 범위 안의 값을 입력하세요.가 선택됩니다. 720은 동적 키 조각이 아닌 arguments입니다.
ALL CANDIDATES MISSING
제공된 default message를 마지막에 사용
post.content.unmapped의 네 후보가 모두 없으면 rejectValue에 제공한 등록할 수 없는 본문입니다.를 씁니다. 후보·인자·기본 문구를 가진 오류 객체 전체를 MessageSourceResolvable로 넘깁니다.
OBJECT ERROR / TWO CODES
객체 오류에는 필드·타입 후보가 없다
post.time.order.postCreate → post.time.order의 두 후보입니다. 실제 첫 키는 등록 기간의 시작과 끝을 확인하세요.입니다. FieldError의 네 후보 배열과 구분합니다.
DATE POLICY / FIXED CLOCK
변환 실패 · 누락 · 미래 날짜를 분리
오늘이라는 문자열은 typeMismatch만 남고 날짜 정책을 생략합니다. null은 post.date.required, 고정 Asia/Seoul 시계의 2026-03-10은 통과하고 다음 날은 post.date.future입니다. Clock과 업무 시간대를 함께 고정합니다.
UI RESOLUTION / API CONTRACT
getCode 하나는 전체 후보를 대체하지 않는다
이 fixture의 getCode는 배열 마지막의 공통 코드입니다. 앞의 구체 후보와 인자는 오류 객체 전체에 있습니다. UI 후보 배열을 외부 API 코드로 그대로 노출하지 않고 명시적 별도 계약으로 매핑합니다. 거부된 원문은 오류 코드에 넣지 않습니다.
후보 순서는 메시지의 구체성을 결정한다. 오류 표시 순서나 외부 API 호환성까지 자동으로 결정하지는 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- rejectValue 또는 reject가 기본 resolver로 후보를 만들어 오류 객체에 저장합니다. MessageSource는 codes, arguments, default message를 함께 가진 오류 객체를 받아 현재 locale에서 후보를 순서대로 시도합니다. 단순 String 필드는 객체와 필드, 필드, 타입, 공통 코드 순서이며 객체 오류는 두 후보입니다.
- 원래 4노드와 3관계의 모바일 배치다. rejectValue 또는 reject가 resolver로 후보를 만들고 FieldError 등 오류 객체에 인자와 기본 문구를 함께 저장한다. MessageSource는 오류 객체 전체를 받아 locale별로 첫 일치 후보를 해석하거나 제공된 default를 사용한다. 후보 배열은 여러 오류의 표시 순서나 외부 API 기계 코드가 아니다. 새 실행 결과를 추가한 그림이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | FieldError 후보는 구체적인 키부터 메시지를 해석한다 |
| 17 | 원래 4노드와 3관계의 모바일 배치다. rejectValue 또는 reject가 resolver로 후보를 만들고 FieldError 등 오류 객체에 인자와 기본 문구를 함께 저장한다. MessageSource는 오류 객체 전체를 받아 locale별로 첫 일치 후보를 해석하거나 제공된 default를 사용한다. 후보 배열은 여러 오류의 표시 순서나 외부 API 기계 코드가 아니다. 새 실행 결과를 추가한 그림이 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 오류 후보 생성 rejectValue·reject가 기본 resolver의 codes를 저장합니다. | 입력: FieldError·ObjectError · 산출물: 후보 codes | 오류가 메시지 탐색 계약을 가집니다. |
| 구체 후보부터 탐색 현재 locale에서 객체·필드·타입·공통 후보를 순서대로 시도합니다. | 필드 후보: 구체→공통 · 객체 오류: 두 후보 | 처음 존재하는 메시지까지 탐색을 좁힙니다. |
| 첫 메시지 해석 codes·arguments·default message로 결과를 만듭니다. | 결과: 첫 발견 메시지 · 인자: 오류 객체 arguments | 오류 계약이 사용자 문장으로 바뀝니다. |
FieldError 후보는 구체적인 키부터 메시지를 해석한다
FieldError의 객체/필드/타입/공통 후보를 구체적인 순서대로 탐색해 처음 발견한 메시지로 끝난다.
FLOWCHART · FIELDERROR · MESSAGECODESRESOLVER · MESSAGESOURCE
오류 객체의 후보·인자·기본 문구를 함께 해석한다
후보는 오류 등록 때 생성됩니다. MessageSource는 현재 locale에서 구체 후보부터 조회하고, 첫 일치 문구에 인자를 적용합니다. 모든 후보가 없으면 제공된 기본 문구를 사용합니다.
DEFAULT FIELD CANDIDATES
단순 String 필드의 구체성 순서 4개
post.content.length.postCreate.content → post.content.length.content → post.content.length.java.lang.String → post.content.length. 오류 표시 우선순위가 아니라 객체·필드 문맥을 점차 넓히는 순서입니다.
FIRST MATCH + ARGUMENTS
postCreate는 720 문구 · postEdit은 공통 문구
postCreate.content의 첫 키가 있으면 본문은 720자 이하여야 합니다.입니다. postEdit의 구체 후보가 없으면 공통 키의 허용 범위 안의 값을 입력하세요.가 선택됩니다. 720은 동적 키 조각이 아닌 arguments입니다.
ALL CANDIDATES MISSING
제공된 default message를 마지막에 사용
post.content.unmapped의 네 후보가 모두 없으면 rejectValue에 제공한 등록할 수 없는 본문입니다.를 씁니다. 후보·인자·기본 문구를 가진 오류 객체 전체를 MessageSourceResolvable로 넘깁니다.
OBJECT ERROR / TWO CODES
객체 오류에는 필드·타입 후보가 없다
post.time.order.postCreate → post.time.order의 두 후보입니다. 실제 첫 키는 등록 기간의 시작과 끝을 확인하세요.입니다. FieldError의 네 후보 배열과 구분합니다.
DATE POLICY / FIXED CLOCK
변환 실패 · 누락 · 미래 날짜를 분리
오늘이라는 문자열은 typeMismatch만 남고 날짜 정책을 생략합니다. null은 post.date.required, 고정 Asia/Seoul 시계의 2026-03-10은 통과하고 다음 날은 post.date.future입니다. Clock과 업무 시간대를 함께 고정합니다.
UI RESOLUTION / API CONTRACT
getCode 하나는 전체 후보를 대체하지 않는다
이 fixture의 getCode는 배열 마지막의 공통 코드입니다. 앞의 구체 후보와 인자는 오류 객체 전체에 있습니다. UI 후보 배열을 외부 API 코드로 그대로 노출하지 않고 명시적 별도 계약으로 매핑합니다. 거부된 원문은 오류 코드에 넣지 않습니다.
후보 순서는 메시지의 구체성을 결정한다. 오류 표시 순서나 외부 API 호환성까지 자동으로 결정하지는 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- rejectValue 또는 reject가 기본 resolver로 후보를 만들어 오류 객체에 저장합니다. MessageSource는 codes, arguments, default message를 함께 가진 오류 객체를 받아 현재 locale에서 후보를 순서대로 시도합니다. 단순 String 필드는 객체와 필드, 필드, 타입, 공통 코드 순서이며 객체 오류는 두 후보입니다.
- 원래 4노드와 3관계의 모바일 배치다. rejectValue 또는 reject가 resolver로 후보를 만들고 FieldError 등 오류 객체에 인자와 기본 문구를 함께 저장한다. MessageSource는 오류 객체 전체를 받아 locale별로 첫 일치 후보를 해석하거나 제공된 default를 사용한다. 후보 배열은 여러 오류의 표시 순서나 외부 API 기계 코드가 아니다. 새 실행 결과를 추가한 그림이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | FieldError 후보는 구체적인 키부터 메시지를 해석한다 |
| 17 | 원래 4노드와 3관계의 모바일 배치다. rejectValue 또는 reject가 resolver로 후보를 만들고 FieldError 등 오류 객체에 인자와 기본 문구를 함께 저장한다. MessageSource는 오류 객체 전체를 받아 locale별로 첫 일치 후보를 해석하거나 제공된 default를 사용한다. 후보 배열은 여러 오류의 표시 순서나 외부 API 기계 코드가 아니다. 새 실행 결과를 추가한 그림이 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 오류 후보 생성 rejectValue·reject가 기본 resolver의 codes를 저장합니다. | 입력: FieldError·ObjectError · 산출물: 후보 codes | 오류가 메시지 탐색 계약을 가집니다. |
| 구체 후보부터 탐색 현재 locale에서 객체·필드·타입·공통 후보를 순서대로 시도합니다. | 필드 후보: 구체→공통 · 객체 오류: 두 후보 | 처음 존재하는 메시지까지 탐색을 좁힙니다. |
| 첫 메시지 해석 codes·arguments·default message로 결과를 만듭니다. | 결과: 첫 발견 메시지 · 인자: 오류 객체 arguments | 오류 계약이 사용자 문장으로 바뀝니다. |
그림의 핵심은 FieldError 전체를 해석 경계에 건네는 것입니다. getCode()로 원래 오류 코드만 꺼내 다시 조회하면 다른 메시지 후보와 동적 인자를 잃습니다. 반대로 후보 배열, 인자, 기본 문구를 함께 가진 MessageSourceResolvable을 전달하면 화면별 문구와 공통 대체 문구를 동시에 운영할 수 있습니다.
후보 코드는 구체성 순서다
errors.rejectValue("content", "post.content.length", arguments, defaultMessage)가 객체 이름 postCreate, 필드 타입 String에 적용되면 기본 DefaultMessageCodesResolver는 다음 순서로 후보를 만듭니다.
post.content.length.postCreate.contentpost.content.length.contentpost.content.length.java.lang.Stringpost.content.length
첫 후보는 특정 입력 모델의 특정 필드에만 적용되고 마지막 후보는 같은 규칙 전체의 공통 대체입니다. 이 순서는 “더 중요한 오류부터”가 아니라 더 좁은 문맥부터라는 뜻입니다. 여러 오류의 표시 순서는 별도 UI 정책입니다.
객체 오류에는 필드와 타입이 없으므로 후보가 두 개입니다.
post.time.order.postCreatepost.time.order
후보 생성 결과를 번들 설계에 의존한다면 배열 순서는 업그레이드 회귀 계약이 됩니다. 모든 후보가 같은 문구라면 최종 해석 결과만 검증해도 충분합니다.
실행할 입력 모델과 날짜 정책
이 문서의 파일은 B88 공유 Gradle 그래프 안에서 board.errorcodes 패키지만 사용합니다. 별도 애플리케이션 스캔이나 라우트를 만들지 않고 Spring의 바인딩·오류·메시지 객체를 직접 조립하므로 다른 문서 fixture와 충돌하지 않습니다.
package board.errorcodes;
import java.time.LocalDate;
public final class PostForm {
private String content;
private LocalDate publishedOn;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDate getPublishedOn() {
return publishedOn;
}
public void setPublishedOn(LocalDate publishedOn) {
this.publishedOn = publishedOn;
}
}날짜 정책은 변환 오류가 이미 있으면 미래 날짜 오류를 추가하지 않습니다. publishedOn=오늘처럼 문자열을 LocalDate로 바꾸지 못한 입력은 typeMismatch이고, 정상 변환된 내일 날짜는 post.date.future입니다. 서로 다른 수정 행동을 하나의 오류로 겹치지 않습니다.
package board.errorcodes;
import java.time.Clock;
import java.time.LocalDate;
import java.util.Objects;
import org.springframework.validation.Errors;
public final class PostDatePolicy {
private final Clock clock;
public PostDatePolicy(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
public void validate(PostForm form, Errors errors) {
Objects.requireNonNull(form, "form");
Objects.requireNonNull(errors, "errors");
if (errors.hasFieldErrors("publishedOn")) {
return;
}
if (form.getPublishedOn() == null) {
errors.rejectValue(
"publishedOn",
"post.date.required",
"작성일을 입력하세요.");
return;
}
if (form.getPublishedOn().isAfter(LocalDate.now(clock))) {
errors.rejectValue(
"publishedOn",
"post.date.future",
"작성일은 오늘 이후일 수 없습니다.");
}
}
}Clock을 주입하면 자정과 시간대에 흔들리지 않는 어제·오늘·내일 테스트를 만들 수 있습니다. 실제 애플리케이션에서도 사용자에게 약속한 업무 시간대와 Clock의 zone을 일치시켜야 합니다.
번들은 후보와 인자를 해석한다
메시지 키는 문장 자체가 아니라 안정적인 실패 의미를 이름 짓습니다. 화면 전용 문구는 가장 구체적인 키에, 여러 화면이 공유할 문구는 일반 키에 둡니다.
post.content.length.postCreate.content=본문은 {0}자 이하여야 합니다.
post.content.length=허용 범위 안의 값을 입력하세요.
post.time.order.postCreate=등록 기간의 시작과 끝을 확인하세요.
post.time.order=기간 순서를 확인하세요.
typeMismatch.postCreate.publishedOn=작성일은 연도-월-일 형식으로 입력하세요.
typeMismatch.java.time.LocalDate=날짜는 연도-월-일 형식으로 입력하세요.
typeMismatch=입력 형식을 확인하세요.
post.date.required.postCreate.publishedOn=작성일을 입력하세요.
post.date.required=날짜를 입력하세요.
post.date.future.postCreate.publishedOn=작성일은 오늘 이후일 수 없습니다.
post.date.future=오늘 또는 이전 날짜를 입력하세요.{0} 같은 값은 코드 문자열에 넣지 않고 arguments로 전달합니다. post.content.length.720처럼 동적 키를 만들면 번들 캐시와 운영 메트릭의 카디널리티가 커지고, 제한값 변경도 키 계약 변경이 됩니다.
다음 표는 비슷해 보이는 오류를 발생 경계와 사용자 행동으로 구분합니다.
| 오류 의미 | 첫 발생 경계 | 대표 UI 후보 | 사용자가 고칠 것 |
|---|---|---|---|
| 날짜 문자열 변환 실패 | 데이터 바인딩 | typeMismatch.postCreate.publishedOn | 약속한 날짜 형식 |
| 날짜 누락 | 값 검증 | post.date.required.postCreate.publishedOn | 필수 값 입력 |
| 정상 변환된 미래 날짜 | 업무 날짜 정책 | post.date.future.postCreate.publishedOn | 허용 날짜 범위 |
| 본문 길이 초과 | 값 검증 | post.content.length.postCreate.content | 본문 길이 |
| 두 필드의 순서 위반 | 객체 검증 | post.time.order.postCreate | 값 사이의 관계 |
안내 문구가 연도-월-일을 요구한다면 실제 converter도 그 형식을 받아야 합니다. 메시지가 파서 계약과 다르면 번역 문제가 아니라 입력 계약 결함입니다.
후보 생성과 최종 선택을 함께 테스트한다
테스트는 Spring 기본 후보 순서, 가장 구체적인 번들 선택, 공통 대체, 모든 후보가 없을 때의 기본 문구, 객체 오류, 타입 변환 실패, 날짜 누락과 고정 시계 날짜 정책을 한 격리된 suite에서 실행합니다.
package board.errorcodes;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.DataBinder;
import org.springframework.validation.DefaultMessageCodesResolver;
class ErrorMessageResolutionTest {
private static final Locale KOREAN = Locale.KOREAN;
private static final Clock FIXED_CLOCK = Clock.fixed(
Instant.parse("2026-03-10T00:00:00Z"),
ZoneId.of("Asia/Seoul"));
@Test
void fieldErrorCandidatesRunFromObjectFieldToCommonRule() {
var resolver = new DefaultMessageCodesResolver();
var codes = resolver.resolveMessageCodes(
"post.content.length",
"postCreate",
"content",
String.class);
assertThat(codes).containsExactly(
"post.content.length.postCreate.content",
"post.content.length.content",
"post.content.length.java.lang.String",
"post.content.length");
}
@Test
void messageSourceSelectsTheMostSpecificFieldCodeAndFormatsArguments() {
var result = bindingResult("postCreate");
result.rejectValue(
"content",
"post.content.length",
new Object[]{720},
"본문 길이 오류");
var error = result.getFieldError("content");
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"post.content.length.postCreate.content",
"post.content.length.content",
"post.content.length.java.lang.String",
"post.content.length");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("본문은 720자 이하여야 합니다.");
}
@Test
void aDifferentObjectFallsBackToTheCommonRule() {
var result = bindingResult("postEdit");
result.rejectValue(
"content",
"post.content.length",
new Object[]{720},
"본문 길이 오류");
assertThat(messageSource().getMessage(
result.getFieldError("content"), KOREAN))
.isEqualTo("허용 범위 안의 값을 입력하세요.");
}
@Test
void allCandidatesMissingUseTheDefaultMessage() {
var result = bindingResult("postCreate");
result.rejectValue(
"content",
"post.content.unmapped",
"등록할 수 없는 본문입니다.");
var error = result.getFieldError("content");
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"post.content.unmapped.postCreate.content",
"post.content.unmapped.content",
"post.content.unmapped.java.lang.String",
"post.content.unmapped");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("등록할 수 없는 본문입니다.");
}
@Test
void objectErrorUsesObjectSpecificThenCommonCandidates() {
var result = bindingResult("postCreate");
result.reject(
"post.time.order",
new Object[]{"publishedOn", "archivedOn"},
"기간 순서 오류");
var error = result.getGlobalError();
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"post.time.order.postCreate",
"post.time.order");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("등록 기간의 시작과 끝을 확인하세요.");
}
@Test
void bindingFailureKeepsTypeMismatchAndSkipsTheDatePolicy() {
var form = new PostForm();
var binder = new DataBinder(form, "postCreate");
binder.setConversionService(new DefaultFormattingConversionService());
binder.bind(new MutablePropertyValues(Map.of(
"publishedOn", "오늘")));
var errors = binder.getBindingResult();
var error = errors.getFieldError("publishedOn");
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"typeMismatch.postCreate.publishedOn",
"typeMismatch.publishedOn",
"typeMismatch.java.time.LocalDate",
"typeMismatch");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("작성일은 연도-월-일 형식으로 입력하세요.");
new PostDatePolicy(FIXED_CLOCK).validate(form, errors);
assertThat(errors.getFieldErrors("publishedOn")).hasSize(1);
assertThat(errors.getFieldError("publishedOn").getCode())
.isEqualTo("typeMismatch");
}
@Test
void missingDateUsesRequiredCandidatesInsteadOfFutureCandidates() {
var form = new PostForm();
var errors = new BeanPropertyBindingResult(form, "postCreate");
new PostDatePolicy(FIXED_CLOCK).validate(form, errors);
var error = errors.getFieldError("publishedOn");
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"post.date.required.postCreate.publishedOn",
"post.date.required.publishedOn",
"post.date.required.java.time.LocalDate",
"post.date.required");
assertThat(error.getCode()).isEqualTo("post.date.required");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("작성일을 입력하세요.");
}
@Test
void fixedClockSeparatesTodayFromTomorrow() {
var policy = new PostDatePolicy(FIXED_CLOCK);
var today = LocalDate.of(2026, 3, 10);
var accepted = new PostForm();
accepted.setPublishedOn(today);
var acceptedErrors = new BeanPropertyBindingResult(
accepted, "postCreate");
policy.validate(accepted, acceptedErrors);
assertThat(acceptedErrors.hasErrors()).isFalse();
var rejected = new PostForm();
rejected.setPublishedOn(today.plusDays(1));
var rejectedErrors = new BeanPropertyBindingResult(
rejected, "postCreate");
policy.validate(rejected, rejectedErrors);
var error = rejectedErrors.getFieldError("publishedOn");
assertThat(error).isNotNull();
assertThat(error.getCodes()).containsExactly(
"post.date.future.postCreate.publishedOn",
"post.date.future.publishedOn",
"post.date.future.java.time.LocalDate",
"post.date.future");
assertThat(messageSource().getMessage(error, KOREAN))
.isEqualTo("작성일은 오늘 이후일 수 없습니다.");
}
private static BeanPropertyBindingResult bindingResult(
String objectName) {
return new BeanPropertyBindingResult(new PostForm(), objectName);
}
private static ResourceBundleMessageSource messageSource() {
var source = new ResourceBundleMessageSource();
source.setBasename("errorcodes/messages");
source.setDefaultEncoding(StandardCharsets.UTF_8.name());
return source;
}
}FieldError#getCode()는 후보 배열의 첫 요소가 아니라 원래 등록한 짧은 오류 코드인 typeMismatch를 반환합니다. 최종 문구 해석에는 getCodes() 전체를 가진 오류 객체를 사용해야 한다는 점을 테스트가 의도적으로 드러냅니다.
UI 메시지 키와 API 오류 코드는 소유자가 다르다
Thymeleaf th:errors 같은 UI 경계는 객체·필드·로케일 문맥을 활용할수록 유용합니다. 반면 외부 API의 기계 판독 코드는 클라이언트 호환성이 우선입니다.
| 계약 | 예시 | 변경 기준 | 소비자 |
|---|---|---|---|
| UI 후보 키 | post.content.length.postCreate.content | 화면·필드 문맥과 번역 정책 | 서버 렌더링 뷰 |
| UI 일반 키 | post.content.length | 여러 화면의 공통 문구 | MessageSource |
| API 전체 문제 코드 | request.validation.failed | 외부 API 버전 계약 | API 클라이언트 |
| API 필드 코드 | post.content.length | 클라이언트 수정 행동 | 폼·SDK |
| 동적 인자 | 720 | 정책 값 | 문구 포매터 |
Spring이 만든 NotBlank, Size, typeMismatch 같은 내부 코드를 그대로 외부 API 계약으로 선언하면 프레임워크 구성 변경이 클라이언트 변경으로 번집니다. 다음 문서에서는 이 내부 오류를 명시적인 ProblemDetail 필드 코드로 매핑합니다.
오류 코드에는 거부된 원문 값을 넣지 않습니다. 비밀번호·토큰·개인정보일 수 있는 rejectedValue도 응답이나 고카디널리티 메트릭에 기본 포함하지 않습니다.
연습 문제
postCreate에는 날짜 형식을 구체적으로 안내하고 postEdit에는 LocalDate 공통 문구로 대체되게 번들과 테스트를 확장하세요. 이어서 객체 오류의 구체 키를 제거했을 때 post.time.order가 선택되는지 검증하세요.
완료 조건은 다음과 같습니다.
- 잘못된 날짜 문자열은
typeMismatch후보만 남고 날짜 정책 오류가 추가되지 않습니다. - 오늘은 통과하고 내일은
post.date.future후보를 만듭니다. - 구체 키가 없을 때만 일반 키가 선택됩니다.
- API 응답에는 이 후보 배열을 그대로 노출하지 않습니다.
다음 문서에서는 JSON 읽기 실패와 Bean Validation 실패를 분리하고, 생성·수정 DTO와 command·domain·DB 경계를 실행합니다.