오류 코드와 메시지
FieldError의 후보 코드 순서와 MessageSource 해석을 실행하고, UI 메시지 키와 외부 API 오류 코드를 분리합니다.
검증 오류의 code는 완성된 문장이 아닙니다. Spring은 하나의 오류 의미와 객체·필드·타입 정보를 조합해 구체적인 후보부터 일반 후보까지 배열을 만들고, MessageSource는 현재 로케일의 번들에서 처음 발견한 키를 선택합니다.
FLOWCHART · FIELDERROR · MESSAGECODESRESOLVER · MESSAGESOURCE
FieldError 후보는 구체적인 키부터 메시지를 해석한다
오류 의미와 객체·필드·타입으로 만든 후보를 앞에서부터 조회한다. 일치한 번들 키에는 arguments를 적용하고, 모든 후보가 없을 때만 default message를 사용한다.
-
FIELDERROR
오류 의미와 객체·필드·타입, arguments, default를 보관한다
첫 코드 하나가 아니라 전체
MessageSourceResolvable계약을 전달합니다. -
CANDIDATE ORDER
객체+필드 → 필드 → Java 타입 → 공통 규칙 순으로 확장한다
post.content.length.postCreate.content부터post.content.length까지 문맥 범위를 넓힙니다. -
MESSAGE SOURCE
현재 locale 번들에서 후보를 앞에서부터 조회한다
없는 키는 건너뛰고 다음 일반 후보로 이동합니다.
-
FIRST MATCH
처음 발견한 문구에 arguments를 적용한다
예를 들어
720은 코드가 아니라 문구 포매팅 인자입니다. -
EXHAUSTED
모든 후보가 없을 때만 default message를 사용한다
외부 API용 기계 코드는 이 UI 후보 배열과 별도의 명시적 계약으로 매핑합니다.
후보 순서는 메시지의 구체성을 결정한다. 오류 표시 순서나 외부 API 호환성까지 자동으로 결정하지는 않는다.
그림의 핵심은 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 경계를 실행합니다.