본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : View·폼·메시지·검증

오류 코드와 메시지

MessageCodesResolver가 만드는 객체·필드·타입별 후보와 typeMismatch 우선순위를 확인하고 게시판 검증 코드를 안정적인 번역 계약으로 설계합니다.

같은 “필수 입력”이라도 제목에는 “제목을 입력하세요”, 일반 텍스트 필드에는 “값을 입력하세요”가 더 적절할 수 있습니다. Spring은 오류 하나에 코드 하나만 저장하지 않고 구체적인 객체·필드 조합에서 일반 제약 조건까지 후보 배열을 만듭니다. MessageSource는 앞에서부터 존재하는 문구를 골라 화면별 재사용과 세밀한 수정을 함께 지원합니다.


rejectValue 코드 계층

검증기가 errors.rejectValue("content", "length", args, defaultMessage)를 호출하면 length 문자열 하나만 끝나지 않습니다. 기본 DefaultMessageCodesResolver는 객체 이름이 form이고 필드 타입이 String일 때 대략 다음 순서의 후보를 만듭니다.

content field의 code 후보
length.form.content
length.content
length.java.lang.String
length

첫 후보는 특정 화면의 특정 필드에 가장 구체적이고, 마지막 후보는 같은 규칙 전체에 공통입니다. length.form.content만 번들에 두면 다른 폼의 문구를 나눌 수 있고, length만 두면 여러 문자열 필드가 공통 대체를 씁니다.

애플리케이션 고유 코드를 post.content.length처럼 이미 충분히 설명적으로 정했다면 리졸버가 붙이는 후보도 길어집니다. 이름 길이 자체보다 코드의 소유권과 안정성이 중요합니다. Java 메서드 이름이나 현재 화면 제목처럼 자주 바뀌는 표현은 코드에 넣지 않습니다.

메시지 번들 우선순위

src/test/java/board/web/MessageCodeHierarchyTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;

class MessageCodeHierarchyTest {
    @Test
    void field_error는_object_field_type_common_순서로_내려간다() {
        var resolver = new DefaultMessageCodesResolver();

        String[] codes = resolver.resolveMessageCodes(
                "post.content.length",
                "form",
                "content",
                String.class);

        assertThat(codes).containsExactly(
                "post.content.length.form.content",
                "post.content.length.content",
                "post.content.length.java.lang.String",
                "post.content.length");
    }
}
실행 결과
MessageCodeHierarchyTest
  > field_error는_object_field_type_common_순서로_내려간다() PASSED
candidate[0] = post.content.length.form.content
candidate[1] = post.content.length.content
candidate[2] = post.content.length.java.lang.String
candidate[3] = post.content.length

테스트가 Spring의 기본 전략에 너무 강하게 결합되는지 검토할 수 있습니다. 이 순서를 이용해 번역을 설계하고 있다면 업그레이드 시 변화가 사용자 문구를 바꾸므로 계약 테스트 가치가 있습니다. 반대로 어느 후보가 선택돼도 같은 문구라면 배열 전체 검증보다 최종 메시지 결과만 검증해도 됩니다.

typeMismatch 안내

publishedOn=오늘은 날짜 검증기가 만든 미래 날짜 오류가 아니라 바인더가 StringLocalDate로 바꾸지 못한 변환 오류입니다. 기본 코드 계층은 typeMismatch.form.publishedOn, typeMismatch.publishedOn, typeMismatch.java.time.LocalDate, typeMismatch로 내려갑니다. 번들은 가장 구체적인 행동 지침부터 일반 대체까지 배치합니다.

src/main/resources/messages.properties
typeMismatch.form.publishedOn=작성일은 연도-월-일 형식으로 입력하세요.
typeMismatch.java.time.LocalDate=날짜는 연도-월-일 형식으로 입력하세요.
typeMismatch=입력 형식을 확인하세요.

post.title.required.form.title=제목을 입력하세요.
post.title.required=필수 값을 입력하세요.
post.content.length.form.content=본문은 {0}자 이하여야 합니다.
post.content.length=허용 범위 안의 값을 입력하세요.
post.publicVisible.minimum=공개 게시글의 본문은 {0}자 이상이어야 합니다.

typeMismatch.java.time.LocalDate는 모든 날짜 필드에 쓸 수 있지만 화면마다 레이블과 허용 범위가 다를 수 있습니다. typeMismatch.form.publishedOn이 있으면 구체 문구를 선택하고 없는 필드는 타입 문구로 대체합니다. 내부 타입 이름은 후보를 만드는 데만 쓰이며 최종 사용자에게 노출하지 않습니다.

날짜 형식을 로케일별로 다르게 허용한다면 컨버터와 안내 문구가 반드시 일치해야 합니다. 안내는 YYYY-MM-DD인데 파서가 한국어 날짜만 받는 식의 불일치는 메시지 문제가 아니라 입력 계약 결함입니다.

ObjectError 코드 탐색

errors.reject("post.time.order", args, default)처럼 필드가 없는 오류는 post.time.order.form, post.time.order 후보를 갖습니다. 등록 폼과 수정 폼의 관계 규칙 문구를 다르게 해야 한다면 객체별 코드를 두고, 같다면 공통 코드 하나로 충분합니다.

src/test/java/board/web/ResolvedErrorMessageTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Locale;

import org.junit.jupiter.api.Test;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.validation.BeanPropertyBindingResult;

class ResolvedErrorMessageTest {
    @Test
    void 가장_구체적인_field_message와_argument를_사용한다() {
        var form = new ContentForm();
        var result = new BeanPropertyBindingResult(form, "form");
        result.rejectValue(
                "content",
                "post.content.length",
                new Object[]{720},
                "길이 오류");

        var source = new StaticMessageSource();
        source.addMessage(
                "post.content.length",
                Locale.KOREAN,
                "허용 범위 안의 값을 입력하세요.");
        source.addMessage(
                "post.content.length.form.content",
                Locale.KOREAN,
                "본문은 {0}자 이하여야 합니다.");

        String message = source.getMessage(
                result.getFieldError("content"),
                Locale.KOREAN);

        assertThat(message)
                .isEqualTo("본문은 720자 이하여야 합니다.");
    }

    static final class ContentForm {
        private String content;

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }
    }
}
메시지 해석 결과
selected code = post.content.length.form.content
arguments = [720]
message = 본문은 720자 이하여야 합니다.
fallback code was not selected

FieldError 자체가 MessageSourceResolvable이므로 코드 배열과 인자를 함께 넘깁니다. 컨트롤러가 getCode() 하나만 꺼내 직접 조회하면 뒤의 대체 후보를 잃습니다. Thymeleaf th:errors와 Spring 폼 태그는 이 구조를 이용합니다.

오류 코드 분류 체계

코드를 error1, invalid2처럼 정하면 번역과 메트릭 모두 의미를 잃습니다. 반대로 문장 전체를 코드로 쓰면 문구 수정이 계약 변경이 됩니다. post.content.length, post.title.required, post.time.order처럼 대상과 실패 의미를 점으로 나눈 안정적인 이름이 적절합니다.

코드 범위사용 시점사용자 행동운영 집계
typeMismatch문자열 변환 실패형식 수정클라이언트 입력 형식
post.*.required빈 값 검증값 입력필수 항목 이탈
post.*.length길이 위반글자 수 조정문자열 길이 이탈
post.time.order필드 관계 위반두 값 비교화면 흐름 결함

HTTP API의 기계-읽을 수 있는 오류 코드와 HTML 메시지 코드를 무조건 같은 문자열로 만들 필요는 없습니다. API 코드는 클라이언트 호환성과 버전 관리를, UI 메시지 코드는 로케일과 필드 컨텍스트를 우선합니다. 한 도메인 실패가 어댑터별 안정적인 코드로 매핑되는 지점을 명확히 두면 됩니다.

메시지 코드에 거부된 값을 포함하지 않습니다. content.999999처럼 동적 코드를 만들면 번들 캐시와 메트릭 카디널리티가 폭발합니다. 값은 인자나 별도 필드에 두고 민감 정보는 마스킹합니다.

연습 문제

등록 폼의 publishedOn에 잘못된 날짜가 들어왔을 때 “날짜 형식” 문구를, 미래 날짜가 정상 변환된 뒤 검증기에서 거부될 때 “오늘 이전 날짜” 문구를 보여 주세요. 등록 화면만 더 구체적인 문구를 쓰고 다른 날짜 필드는 공통 타입 문구로 대체하도록 번들과 테스트를 작성합니다.

해설 보기

변환 오류는 typeMismatch.form.publishedOn, 규칙 오류는 post.date.future.form.publishedOn으로 시작합니다. 두 오류를 동시에 만들지 않도록 검증기가 hasFieldErrors("publishedOn")을 먼저 확인합니다.

package board.web;

import java.time.Clock;
import java.time.LocalDate;

import org.springframework.validation.Errors;

public final class PostDatePolicy {
    private final Clock clock;

    public PostDatePolicy(Clock clock) {
        this.clock = clock;
    }

    public void validate(LocalDate publishedOn, Errors errors) {
        if (errors.hasFieldErrors("publishedOn") || publishedOn == null) {
            return;
        }
        if (publishedOn.isAfter(LocalDate.now(clock))) {
            errors.rejectValue(
                    "publishedOn",
                    "post.date.future",
                    "작성일은 오늘 이후일 수 없습니다.");
        }
    }
}

고정 Clock으로 오늘 경계를 재현하고 어제·오늘·내일을 테스트합니다. 타입 불일치 테스트는 검증기 직접 호출이 아니라 바인더를 거쳐 코드 후보와 거부된 값을 함께 확인합니다.

다음 문서에서는 수동 검증기와 같은 구조를 Jakarta Bean 검증 애노테이션으로 옮기되, DTO 분리와 서비스 불변식을 잃지 않는 방법을 정리합니다.