오류 코드와 메시지
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 | 오류 계약이 사용자 문장으로 바뀝니다. |
그림은 rejectValue나 reject가 기본 리졸버로 후보 코드를 만들어 FieldError에 인자와 기본 문구와 함께 저장하고, MessageSource가 그 오류 객체 전체를 받아 현재 로케일에서 처음 일치하는 후보를 해석하는 과정을 보여 줍니다. 모든 후보가 없으면 제공한 기본 문구를 씁니다. 핵심은 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
번들 설계가 후보 생성 결과에 기대고 있다면, 배열의 순서는 업그레이드 때 확인해야 할 회귀 테스트의 대상이 됩니다. 모든 후보의 문구가 같다면 최종 해석 결과만 검증해도 충분합니다.
더 깊이: 컬렉션 필드와 형식 설정이 바꾸는 후보
필드가 배열이나 List, Map의 원소라면 후보는 네 개보다 많아집니다. DefaultMessageCodesResolver API 문서는 특정 원소를 가리키는 코드와 컬렉션 전체를 가리키는 코드를 함께 만든다고 설명합니다. 예를 들어 객체 user의 배열 groups 안에 있는 name 필드라면 typeMismatch.user.groups[0].name, typeMismatch.user.groups.name, typeMismatch.groups[0].name, typeMismatch.groups.name, typeMismatch.name, typeMismatch.java.lang.String, typeMismatch의 일곱 후보를 차례로 시도합니다. 그래서 번들에는 인덱스 없는 키 하나만 두어도 모든 원소의 오류에 같은 문구를 줄 수 있습니다.
후보의 모양 자체도 설정으로 바뀝니다. 기본 형식인 PREFIX_ERROR_CODE는 오류 코드를 맨 앞에 두지만, POSTFIX_ERROR_CODE로 바꾸면 postCreate.content.post.content.length처럼 객체 이름과 필드 뒤에 오류 코드가 붙어 번들에서 화면별로 키를 모으기 쉬워집니다. setPrefix("validation.")처럼 접두사를 주면 모든 후보 앞에 그 문자열이 붙습니다. 어느 쪽이든 번들의 모든 키가 함께 바뀌므로, 형식을 바꿀 때는 이 글의 후보 순서 테스트가 먼저 실패하게 두고 번들을 한 번에 옮깁니다.
실행할 입력 모델과 날짜 정책
이 글의 파일은 Thymeleaf 표현식에서 정한 공유 Gradle 그래프 안에서 board.errorcodes 패키지만 씁니다. 별도의 애플리케이션 스캔이나 라우트를 만들지 않고 Spring의 바인딩과 오류, 메시지 객체를 직접 조립하므로, 다른 글의 예제와 충돌하지 않습니다.
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 | 값 사이의 관계 |
안내 문구가 연도-월-일 형식을 요구한다면 실제 컨버터도 그 형식을 받아야 합니다. 메시지가 파서의 규칙과 다르다면 번역의 문제가 아니라 입력 규칙의 결함입니다.
후보 생성과 최종 선택을 함께 테스트합니다
테스트는 Spring의 기본 후보 순서, 가장 구체적인 번들의 선택, 공통 대체, 모든 후보가 없을 때의 기본 문구, 객체 오류, 타입 변환 실패, 날짜 누락과 고정 시계의 날짜 정책을 한 격리된 테스트 묶음에서 실행합니다. 애플리케이션 컨텍스트는 띄우지 않고 DataBinder, DefaultMessageCodesResolver, ResourceBundleMessageSource를 직접 만들어 연결합니다. 그래서 테스트가 실패하면 후보 코드를 만드는 단계와 번들에서 문구를 고르는 단계 가운데 어디가 틀렸는지를 이 세 객체 안에서 좁힐 수 있습니다.
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도 기본적으로 응답이나 고카디널리티 메트릭에 넣지 않습니다.
정리
DefaultMessageCodesResolver는 필드 오류에 객체와 필드, 필드, 타입, 공통 코드 순서로 네 후보를, 객체 오류에는 두 후보를 만듭니다.- 후보의 순서는 문맥이 좁은 것부터라는 뜻이며, 여러 오류를 보여 주는 순서와는 무관합니다.
MessageSource에는 후보와 인자, 기본 문구를 모두 가진 오류 객체를 넘기고,getCode()로 꺼낸 코드 하나로 다시 조회하지 않습니다.- 제한값 같은 동적인 값은 키가 아니라 인자로 넘기고, 변환 실패와 누락, 업무 정책의 오류는 서로 겹치지 않게 나눕니다.
- UI의 메시지 후보와 외부 API의 오류 코드는 쓰는 쪽과 바뀌는 기준이 다르므로 명시적으로 따로 매핑합니다.
연습 문제
1. 화면마다 다른 날짜 안내
postCreate에는 날짜 형식을 구체적으로 안내하고, postEdit에는 LocalDate 공통 문구로 대체되도록 번들과 테스트를 넓히려고 합니다. 이어서 객체 오류의 구체적인 키를 지웠을 때 post.time.order가 선택되는지도 확인합니다. 잘못된 날짜 문자열에는 typeMismatch 후보만 남고 날짜 정책 오류가 더해지지 않아야 하며, 오늘은 통과하고 내일은 post.date.future 후보를 만들고, 구체적인 키가 없을 때만 일반 키가 선택되며, API 응답에는 이 후보 배열을 그대로 노출하지 않아야 합니다. 번들의 키와 테스트를 어떻게 구성하나요?
해설 보기
번들에는 typeMismatch.postCreate.publishedOn에 형식을 알려 주는 문구를 두고, postEdit용 구체 키는 두지 않은 채 typeMismatch.java.time.LocalDate에 공통 문구를 둡니다. 그러면 postCreate는 첫 후보에서, postEdit는 세 번째 후보에서 문구를 찾습니다. 객체 오류는 post.time.order.postCreate를 지운 번들로 해석해, 두 번째 후보인 post.time.order의 문구가 나오는지 확인합니다.
테스트는 DataBinder로 publishedOn=오늘을 바인딩한 뒤 날짜 정책을 실행해, 그 필드의 오류가 typeMismatch 하나뿐인지 봅니다. 날짜 정책에는 고정된 Clock을 넣어 오늘은 오류가 없고 내일은 post.date.future 후보가 생기는지 확인합니다. 두 객체 이름 postCreate와 postEdit로 같은 오류를 만들어 해석된 문구가 각각 다른지도 확인합니다. API 응답에는 후보 배열 대신 명시적으로 정한 필드 코드만 넣고, 응답 본문에 typeMismatch.로 시작하는 문자열이 없는지 검사합니다.
2. 후보 배열과 getCode
errors.rejectValue("title", "post.title.required")를 객체 이름 postEdit, 필드 타입 String에 적용했습니다. 만들어지는 후보 네 개를 순서대로 적고, getCode()가 무엇을 돌려주는지 답하세요. 번들에 post.title.required.title과 post.title.required만 있다면 어떤 문구가 선택되나요?
해설 보기
후보는 post.title.required.postEdit.title, post.title.required.title, post.title.required.java.lang.String, post.title.required 순서입니다. getCode()는 처음 등록한 post.title.required를 돌려줍니다. 번들에 첫 후보가 없으므로 두 번째 후보인 post.title.required.title의 문구가 선택되고, 마지막 공통 키는 쓰이지 않습니다. getCode()의 값으로 다시 조회했다면 공통 키의 문구가 나와 필드 전용 문구를 놓쳤을 것입니다.
다음 글 Bean 검증과 DTO에서는 JSON 읽기 실패와 Bean Validation 실패를 나누고, 생성·수정 DTO와 명령, 도메인, DB의 검증 단계를 실행합니다.