본문 바로가기

메시지·국제화

게시판 화면 문구를 MessageSource로 분리하고 basename·로케일·인자 탐색 순서, 누락 코드 처리와 Accept-Language 경계를 테스트합니다.

국제화의 핵심은 한국어 문장을 영어로 번역하는 작업보다 코드가 문구를 직접 소유하지 않게 만드는 데 있습니다.

버튼 레이블, 검증 안내, 상태 이름을 안정적인 메시지 코드로 참조하면 화면 구조와 번역 수명 주기를 분리할 수 있습니다.

반대로 번들 대체를 이해하지 못하면 운영에서만 엉뚱한 언어나 코드 자체가 노출됩니다.

메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다

지원 locale 선택·bundle/code 탐색·타입 인자 대입·사용자 문장 또는 누락 실패의 해석 과정이다.

locale 선택은 명시적 ko/en 지원 목록과 기본 ko를 쓰고 메시지 코드는 고정 basename의 한국어 기본 messages와 영어 messages_en에서 찾습니다. title과 code point 수를 타입 인자로 전달하며 A😀는 2개입니다. 없는 post.unknown은 기본 문자열 없는 getMessage에서 NoSuchMessageException입니다. 두 파일의 6개 키와 번역 의미는 단위 테스트로 검증하며 사용자 화면 출력과 운영 지표는 별도 정책입니다.

DATA FLOW · LOCALE ALLOWLIST · BUNDLE CONTRACT

로케일 선택·번들 탐색·인자 포매팅을 별도로 검증한다

한국어 기본 번들과 영어 번역은 같은 6개 코드를 가집니다. 명시적 locale 선택 뒤 MessageSource가 코드와 인자를 해석하며, 코드 누락은 별도 예외로 발견합니다.

로케일 선택과 메시지 해석의 입력명시적ko/en지원목록과ko기본값으로locale을선택하고안정적code와타입인자를MessageSource에넘깁니다. 실제자원은한국어기본messages와영어messages_en입니다. locale자원과기본자원에서code를못찾는getMessage호출은NoSuchMessageException입니다. 사용자화면의escape와운영missing관찰은별도정책입니다.locale 선택지원 ko/en · 기본 ko안정적 code + 타입 인자post.saved(title, count)MessageSource(code, args, locale)locale bundle → 기본 bundle → 누락 예외
로케일 선택과 메시지 해석의 독립 입력원 데스크톱과 같은 두 독립 입력이다. 선택된locale과 안정적code/타입인자는MessageSource에따로전달한다. 중간에놓인인자상자는locale의출력이아니며순차흐름이아니다. locale전달은오른쪽외곽선을따라최종상자의top160에,인자는top96에연결된다. 번들대체와없는code예외는원비교설명에분리한다.localecode · argslocale 선택지원 ko/en기본 ko안정적 codetitle · code point 수MessageSourcecode · args · locale번들 탐색·포매팅

LOCALE SELECTION

표시 언어를 명시적 ko/en 목록으로

Accept-Language fixture의 en-US,en;q=0.9는 en, fr-FR,fr;q=0.9는 기본 ko입니다. 별도 문자열 helper도 en-US→en, fr-FR·%%%·null→ko를 확인합니다. 언어 선호는 권한이나 결제 통화를 증명하지 않습니다.

FIXED RESOURCE PATH

한국어 기본 + 영어 번역의 6개 코드

basename은 i18n/spring-ch7-messages/messages로 서버가 고정합니다. 실제 파일은 messages.properties와 messages_en.properties입니다. UTF-8과 명시적 기본 ko를 쓰고 JVM 기본 locale 대체를 끕니다.

CODE + TYPED ARGUMENTS

번역 파일이 인자의 문장 위치를 정한다

post.saved + title=Spring, count=45, locale=en → Saved post Spring with 45 characters.입니다. 제목과 숫자를 Java에서 완성된 문장으로 합치지 않습니다.

DISPLAY UNIT

A😀는 이 정책에서 code point 2개

service가 content.codePointCount로 계산하므로 A😀의 영문 메시지는 2 characters입니다. UTF-16 코드 단위 수나 사용자 인식 문자소 수와 동일하다고 주장하지 않습니다.

MISSING CODE · UNIT TEST

없는 post.unknown은 예외로 발견

default message 없는 getMessage는 NoSuchMessageException입니다. 번들에서 찾지 못하는 코드와 지원하지 않는 locale의 기본값 선택은 다른 실패 경계입니다.

BUNDLE CONTRACT / OUTPUT POLICY

키 완전성과 번역 의미를 따로 확인

단위 테스트는 두 파일의 정확한 6개 키와 Board·General·Question·Notice 의미를 확인합니다. 사용자에게 키를 노출하지 않는 대체·운영 지표·Thymeleaf 최종 출력 검사는 추가 정책이며 이 suite가 실제 MVC 화면까지 렌더링하지는 않습니다.

locale 선택과 메시지 코드 완전성은 서로 다른 계약이다. 전자는 허용 목록으로 정규화하고, 후자는 모든 지원 bundle의 키·의미 테스트로 고정한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 명시적 ko/en 지원 목록과 ko 기본값으로 locale을 선택하고 안정적인 code와 타입 인자를 MessageSource에 넘깁니다. 실제 자원은 한국어 기본 messages와 영어 messages_en입니다. locale 자원과 기본 자원에서 code를 못 찾는 getMessage 호출은 NoSuchMessageException입니다. 사용자 화면의 escape와 운영 missing 관찰은 별도 정책입니다.
  • 원 데스크톱과 같은 두 독립 입력이다. 선택된 locale과 안정적인 code/타입 인자는 MessageSource에 따로 전달한다. 중간에 놓인 인자 상자는 locale의 출력이 아니며 순차 흐름이 아니다. locale 전달은 오른쪽 외곽선을 따라 최종 상자의 top 160에, 인자는 top 96에 연결된다. 번들 대체와 없는 code의 예외는 원래 비교 설명에 분리한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다
7명시적 ko/en 지원 목록과 ko 기본값으로 locale을 선택하고 안정적인 code와 타입 인자를 MessageSource에 넘깁니다. 실제 자원은 한국어 기본 messages와 영어 messages_en입니다. locale 자원과 기본 자원에서 code를 못 찾는 getMessage 호출은 NoSuchMessageException입니다. 사용자 화면의 escape와 운영 missing 관찰은 별도 정책입니다.
15원 데스크톱과 같은 두 독립 입력이다. 선택된 locale과 안정적인 code/타입 인자는 MessageSource에 따로 전달한다. 중간에 놓인 인자 상자는 locale의 출력이 아니며 순차 흐름이 아니다. locale 전달은 오른쪽 외곽선을 따라 최종 상자의 top 160에, 인자는 top 96에 연결된다. 번들 대체와 없는 code의 예외는 원래 비교 설명에 분리한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
지원 locale 선택

ko·en 목록과 ko 기본값으로 locale을 정합니다.

지원: ko·en · 기본: ko허용 범위의 locale이 해석 입력이 됩니다.
bundle·code 탐색

안정 code와 타입 인자를 MessageSource에 넘깁니다.

자원: messages·messages_en · 입력: code·arguments선택 locale과 기본 자원에서 문장을 찾습니다.
문장 또는 누락 실패

찾은 메시지에 인자를 적용하고 없으면 NoSuchMessageException입니다.

성공: 사용자 문장 · 누락: NoSuchMessageException · escape: 별도 정책해석 결과와 출력 안전 정책을 구분합니다.

메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다

지원 locale 선택·bundle/code 탐색·타입 인자 대입·사용자 문장 또는 누락 실패의 해석 과정이다.

locale 선택은 명시적 ko/en 지원 목록과 기본 ko를 쓰고 메시지 코드는 고정 basename의 한국어 기본 messages와 영어 messages_en에서 찾습니다. title과 code point 수를 타입 인자로 전달하며 A😀는 2개입니다. 없는 post.unknown은 기본 문자열 없는 getMessage에서 NoSuchMessageException입니다. 두 파일의 6개 키와 번역 의미는 단위 테스트로 검증하며 사용자 화면 출력과 운영 지표는 별도 정책입니다.

DATA FLOW · LOCALE ALLOWLIST · BUNDLE CONTRACT

로케일 선택·번들 탐색·인자 포매팅을 별도로 검증한다

한국어 기본 번들과 영어 번역은 같은 6개 코드를 가집니다. 명시적 locale 선택 뒤 MessageSource가 코드와 인자를 해석하며, 코드 누락은 별도 예외로 발견합니다.

로케일 선택과 메시지 해석의 입력명시적ko/en지원목록과ko기본값으로locale을선택하고안정적code와타입인자를MessageSource에넘깁니다. 실제자원은한국어기본messages와영어messages_en입니다. locale자원과기본자원에서code를못찾는getMessage호출은NoSuchMessageException입니다. 사용자화면의escape와운영missing관찰은별도정책입니다.locale 선택지원 ko/en · 기본 ko안정적 code + 타입 인자post.saved(title, count)MessageSource(code, args, locale)locale bundle → 기본 bundle → 누락 예외
로케일 선택과 메시지 해석의 독립 입력원 데스크톱과 같은 두 독립 입력이다. 선택된locale과 안정적code/타입인자는MessageSource에따로전달한다. 중간에놓인인자상자는locale의출력이아니며순차흐름이아니다. locale전달은오른쪽외곽선을따라최종상자의top160에,인자는top96에연결된다. 번들대체와없는code예외는원비교설명에분리한다.localecode · argslocale 선택지원 ko/en기본 ko안정적 codetitle · code point 수MessageSourcecode · args · locale번들 탐색·포매팅

LOCALE SELECTION

표시 언어를 명시적 ko/en 목록으로

Accept-Language fixture의 en-US,en;q=0.9는 en, fr-FR,fr;q=0.9는 기본 ko입니다. 별도 문자열 helper도 en-US→en, fr-FR·%%%·null→ko를 확인합니다. 언어 선호는 권한이나 결제 통화를 증명하지 않습니다.

FIXED RESOURCE PATH

한국어 기본 + 영어 번역의 6개 코드

basename은 i18n/spring-ch7-messages/messages로 서버가 고정합니다. 실제 파일은 messages.properties와 messages_en.properties입니다. UTF-8과 명시적 기본 ko를 쓰고 JVM 기본 locale 대체를 끕니다.

CODE + TYPED ARGUMENTS

번역 파일이 인자의 문장 위치를 정한다

post.saved + title=Spring, count=45, locale=en → Saved post Spring with 45 characters.입니다. 제목과 숫자를 Java에서 완성된 문장으로 합치지 않습니다.

DISPLAY UNIT

A😀는 이 정책에서 code point 2개

service가 content.codePointCount로 계산하므로 A😀의 영문 메시지는 2 characters입니다. UTF-16 코드 단위 수나 사용자 인식 문자소 수와 동일하다고 주장하지 않습니다.

MISSING CODE · UNIT TEST

없는 post.unknown은 예외로 발견

default message 없는 getMessage는 NoSuchMessageException입니다. 번들에서 찾지 못하는 코드와 지원하지 않는 locale의 기본값 선택은 다른 실패 경계입니다.

BUNDLE CONTRACT / OUTPUT POLICY

키 완전성과 번역 의미를 따로 확인

단위 테스트는 두 파일의 정확한 6개 키와 Board·General·Question·Notice 의미를 확인합니다. 사용자에게 키를 노출하지 않는 대체·운영 지표·Thymeleaf 최종 출력 검사는 추가 정책이며 이 suite가 실제 MVC 화면까지 렌더링하지는 않습니다.

locale 선택과 메시지 코드 완전성은 서로 다른 계약이다. 전자는 허용 목록으로 정규화하고, 후자는 모든 지원 bundle의 키·의미 테스트로 고정한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 명시적 ko/en 지원 목록과 ko 기본값으로 locale을 선택하고 안정적인 code와 타입 인자를 MessageSource에 넘깁니다. 실제 자원은 한국어 기본 messages와 영어 messages_en입니다. locale 자원과 기본 자원에서 code를 못 찾는 getMessage 호출은 NoSuchMessageException입니다. 사용자 화면의 escape와 운영 missing 관찰은 별도 정책입니다.
  • 원 데스크톱과 같은 두 독립 입력이다. 선택된 locale과 안정적인 code/타입 인자는 MessageSource에 따로 전달한다. 중간에 놓인 인자 상자는 locale의 출력이 아니며 순차 흐름이 아니다. locale 전달은 오른쪽 외곽선을 따라 최종 상자의 top 160에, 인자는 top 96에 연결된다. 번들 대체와 없는 code의 예외는 원래 비교 설명에 분리한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다
7명시적 ko/en 지원 목록과 ko 기본값으로 locale을 선택하고 안정적인 code와 타입 인자를 MessageSource에 넘깁니다. 실제 자원은 한국어 기본 messages와 영어 messages_en입니다. locale 자원과 기본 자원에서 code를 못 찾는 getMessage 호출은 NoSuchMessageException입니다. 사용자 화면의 escape와 운영 missing 관찰은 별도 정책입니다.
15원 데스크톱과 같은 두 독립 입력이다. 선택된 locale과 안정적인 code/타입 인자는 MessageSource에 따로 전달한다. 중간에 놓인 인자 상자는 locale의 출력이 아니며 순차 흐름이 아니다. locale 전달은 오른쪽 외곽선을 따라 최종 상자의 top 160에, 인자는 top 96에 연결된다. 번들 대체와 없는 code의 예외는 원래 비교 설명에 분리한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
지원 locale 선택

ko·en 목록과 ko 기본값으로 locale을 정합니다.

지원: ko·en · 기본: ko허용 범위의 locale이 해석 입력이 됩니다.
bundle·code 탐색

안정 code와 타입 인자를 MessageSource에 넘깁니다.

자원: messages·messages_en · 입력: code·arguments선택 locale과 기본 자원에서 문장을 찾습니다.
문장 또는 누락 실패

찾은 메시지에 인자를 적용하고 없으면 NoSuchMessageException입니다.

성공: 사용자 문장 · 누락: NoSuchMessageException · escape: 별도 정책해석 결과와 출력 안전 정책을 구분합니다.

메시지 번들 계층

Spring Boot는 기본 이름 messages를 찾고 messages.properties, messages_ko.properties, messages_en.properties를 로케일 후보에 따라 읽습니다.

기본 파일을 반드시 두어 지원하지 않는 로케일과 번역 누락의 마지막 값을 제공합니다.

src/main/resources/i18n/spring-ch7-messages/messages.properties
site.title=게시판
post.create=게시글 등록
post.saved=제목이 {0}이고 본문이 {1}자인 게시글을 저장했습니다.
category.GENERAL=일반
category.QUESTION=질문
category.NOTICE=공지
src/main/resources/i18n/spring-ch7-messages/messages_en.properties
site.title=Board
post.create=Create post
post.saved=Saved post {0} with {1} characters.
category.GENERAL=General
category.QUESTION=Question
category.NOTICE=Notice

기본 번들의 언어는 제품 정책으로 정합니다.

기본을 한국어로 두었다면 로케일 없는 배치 메일과 대체도 한국어라는 뜻입니다.

파일마다 같은 코드를 무조건 복제하기보다 기본값과 실제 번역 차이를 관리하되, 핵심 사용자 흐름은 지원 로케일별 완전성을 빌드 검사로 보장할 수 있습니다.

따라 하기

두 resource 파일을 표시된 경로에 두고 원 MessageSourceContractTest부터 확인합니다. 이 테스트는 LocaleConfiguration을 직접 만들어 메시지 소스를 얻으므로 Boot 애플리케이션의 패키지 탐색까지 검증하지는 않습니다. 실제 애플리케이션에서도 board.i18n의 설정·서비스가 탐색되거나 명시적으로 포함되어야 합니다.

위에서 설명한 Boot 기본 자동 설정은 classpath 루트의 기본 basename 규칙입니다. 아래 예제는 직접 등록한 messageSource Bean에서 i18n/spring-ch7-messages/messages를 지정합니다. 두 파일만 다른 폴더에 복사하고 이 설정을 빼면 같은 탐색이 되지 않습니다. Boot의 국제화 설정을 기준으로 자동 설정과 직접 Bean 등록을 구분하세요.

속성이 UTF-8인지 빌드와 IDE 설정을 확인합니다.

Boot의 리소스 번들 인코딩은 UTF-8을 사용하지만 다른 도구가 ISO-8859-1로 읽는 환경과 섞이면 글자가 깨집니다.

저장소 전체 인코딩을 하나로 고정하고 실제 MessageSource 결과를 테스트합니다.


MessageSource 입력

업무 계층에서 완성된 한국어 문장을 반환하지 않습니다.

결과 코드와 구조화된 인자를 웹 어댑터가 로케일에 맞춰 문장으로 바꿉니다.

src/main/java/board/i18n/PostMessageService.java
package board.i18n;

import java.util.Locale;

import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;

@Component
public final class PostMessageService {
    private final MessageSource messages;

    public PostMessageService(MessageSource messages) {
        this.messages = messages;
    }

    public String saved(
            String title,
            String content,
            Locale locale
    ) {
        int characterCount = content.codePointCount(0, content.length());
        return messages.getMessage(
                "post.saved",
                new Object[]{title, characterCount},
                locale);
    }

    public String categoryLabel(PostCategory category, Locale locale) {
        return messages.getMessage(
                "category." + category.name(),
                null,
                locale);
    }

    public enum PostCategory {
        GENERAL, QUESTION, NOTICE
    }
}

이 장에서 “글자 수”는 Java의 UTF-16 코드 단위인 String.length()가 아니라 Unicode code point 수를 뜻합니다.

따라서 보조 평면 문자 하나도 1로 세며, 여러 code point가 한 화면 글자로 보이는 grapheme cluster 수와는 다른 정책입니다.

인자 순서는 번역에 따라 달라질 수 있습니다.

한국어와 영어 예제처럼 자리표시자 위치를 번역 파일이 정합니다.

숫자·날짜를 단순 toString()으로 먼저 바꾸면 로케일 포매팅 기회를 잃으므로 가능한 한 타입을 그대로 넘기고 MessageFormat 규칙이나 별도 포매터를 사용합니다.


로케일 신뢰 경계

기본 AcceptHeaderLocaleResolver는 Accept-Language를 바탕으로 로케일을 결정합니다.

이것은 표시 선호이지 사용자 신원이나 결제 통화를 증명하지 않습니다.

URL 접두사, 회원 설정, 쿠키 중 어느 것을 제품의 정규 로케일로 쓸지 정하고 우선순위를 문서화합니다.

src/main/java/board/i18n/LocaleConfiguration.java
package board.i18n;

import java.util.List;
import java.util.Locale;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;

@Configuration(proxyBeanMethods = false)
public class LocaleConfiguration {
    static final Locale DEFAULT_LOCALE = Locale.KOREAN;
    static final List<Locale> SUPPORTED_LOCALES = List.of(
            Locale.KOREAN,
            Locale.ENGLISH);

    @Bean
    ResourceBundleMessageSource messageSource() {
        var source = new ResourceBundleMessageSource();
        source.setBasename("i18n/spring-ch7-messages/messages");
        source.setDefaultEncoding("UTF-8");
        source.setFallbackToSystemLocale(false);
        source.setDefaultLocale(DEFAULT_LOCALE);
        return source;
    }

    @Bean
    LocaleResolver localeResolver() {
        var resolver = new AcceptHeaderLocaleResolver();
        resolver.setSupportedLocales(SUPPORTED_LOCALES);
        resolver.setDefaultLocale(DEFAULT_LOCALE);
        return resolver;
    }

    static Locale resolveSupportedLocaleTag(String languageTag) {
        if (languageTag == null || languageTag.isBlank()) {
            return DEFAULT_LOCALE;
        }
        Locale requested = Locale.forLanguageTag(languageTag);
        return SUPPORTED_LOCALES.stream()
                .filter(locale -> locale.getLanguage()
                        .equals(requested.getLanguage()))
                .findFirst()
                .orElse(DEFAULT_LOCALE);
    }
}

지원 로케일을 제한하지 않으면 en-US, en-GB, fr-CA 같은 수많은 조합이 들어옵니다.

ResourceBundle은 더 구체적인 후보에서 언어·기본 파일로 내려가지만, 선택 정책과 JVM 기본 로케일까지 개입하면 결과를 예상하기 어렵습니다.

리졸버의 지원 목록과 명시적 기본값을 두면 배포 서버 로케일에 따른 차이를 줄입니다.

사용자가 기본 이름이나 파일 경로를 고르게 해서는 안 됩니다.

URL·쿠키처럼 문자열로 받은 로케일 태그는 Locale.forLanguageTag로 파싱한 뒤 같은 허용 목록의 언어에 대조합니다. 예제의 messageSource Bean 역시 두 resource fence와 같은 basename을 사용하므로 애플리케이션과 테스트가 동일한 탐색 계약을 공유합니다.

en-US처럼 지원 언어의 지역 변형은 en으로 정규화하고 fr-FR이나 잘못된 태그는 한국어 기본값으로 보냅니다.

예시 Accept-Language: en-US,en;q=0.9는 언어만 등록한 지원 값 en과 맞고, fr-FR,fr;q=0.9는 지원 값이 없어 기본 ko가 됩니다. 여기서 HTTP 헤더 리졸버와 별도 문자열 helper는 입력 모양이 다른 두 경로입니다. helper에 헤더 전체를 넘겨 품질값까지 직접 해석하는 예제가 아닙니다. 리졸버 API는 언어만 대체 매칭하려면 그 언어를 지원 목록에 명시해야 한다고 설명합니다.

ko가 선택돼도 예제에 messages_ko.properties가 있는 것은 아닙니다. 이 자원 구성에서는 한국어 기본 파일이 값을 제공합니다. 영어 번들의 특정 코드가 빠져도 기본 파일이 채울 수 있어, 화면에 문장이 나온다는 사실만으로 영어 번역이 완전하다고 판정할 수 없습니다.

경로를 리소스 위치에 연결하는 자체 로더는 국제화 기능이 아니라 파일 접근 취약점이 됩니다.


대체 메시지와 누락 코드

src/test/java/board/i18n/MessageSourceContractTest.java
package board.i18n;

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

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;

class MessageSourceContractTest {
    private static final Set<String> REQUIRED_CODES = Set.of(
            "site.title",
            "post.create",
            "post.saved",
            "category.GENERAL",
            "category.QUESTION",
            "category.NOTICE");

    private ResourceBundleMessageSource messages;

    @BeforeEach
    void setUp() {
        messages = new LocaleConfiguration().messageSource();
    }

    @Test
    void 영어_bundle은_argument_순서를_문장에_맞게_배치한다() {
        var result = messages.getMessage(
                "post.saved",
                new Object[]{"Spring", 45},
                Locale.ENGLISH);

        assertThat(result)
                .isEqualTo("Saved post Spring with 45 characters.");
    }

    @Test
    void 저장_메시지는_보조_평면_문자를_code_point_하나로_센다() {
        var service = new PostMessageService(messages);

        assertThat(service.saved("Unicode", "A😀", Locale.ENGLISH))
                .isEqualTo("Saved post Unicode with 2 characters.");
    }

    @Test
    void 영어_bundle은_분류의_업무_의미를_보존한다() {
        assertThat(messages.getMessage(
                "site.title", null, Locale.ENGLISH))
                .isEqualTo("Board");
        assertThat(messages.getMessage(
                "category.GENERAL", null, Locale.ENGLISH))
                .isEqualTo("General");
        assertThat(messages.getMessage(
                "category.QUESTION", null, Locale.ENGLISH))
                .isEqualTo("Question");
        assertThat(messages.getMessage(
                "category.NOTICE", null, Locale.ENGLISH))
                .isEqualTo("Notice");
    }

    @Test
    void 지원_locale_bundle은_필수_code를_빠짐없이_가진다()
            throws Exception {
        assertThat(bundleKeys("messages.properties"))
                .containsExactlyInAnyOrderElementsOf(REQUIRED_CODES);
        assertThat(bundleKeys("messages_en.properties"))
                .containsExactlyInAnyOrderElementsOf(REQUIRED_CODES);
    }

    @Test
    void accept_language는_지원_언어로_좁히고_나머지는_기본값을_쓴다() {
        var resolver = new LocaleConfiguration().localeResolver();
        var english = new MockHttpServletRequest();
        english.addHeader("Accept-Language", "en-US,en;q=0.9");
        var unsupported = new MockHttpServletRequest();
        unsupported.addHeader("Accept-Language", "fr-FR,fr;q=0.9");

        assertThat(resolver.resolveLocale(english))
                .isEqualTo(Locale.ENGLISH);
        assertThat(resolver.resolveLocale(unsupported))
                .isEqualTo(Locale.KOREAN);
        assertThat(LocaleConfiguration.resolveSupportedLocaleTag("en-US"))
                .isEqualTo(Locale.ENGLISH);
        assertThat(LocaleConfiguration.resolveSupportedLocaleTag("fr-FR"))
                .isEqualTo(Locale.KOREAN);
        assertThat(LocaleConfiguration.resolveSupportedLocaleTag("%%%"))
                .isEqualTo(Locale.KOREAN);
        assertThat(LocaleConfiguration.resolveSupportedLocaleTag(null))
                .isEqualTo(Locale.KOREAN);
    }

    @Test
    void 존재하지_않는_code는_기본_문자열_대신_예외로_발견한다() {
        assertThatThrownBy(() -> messages.getMessage(
                "post.unknown",
                null,
                Locale.KOREAN))
                .isInstanceOf(NoSuchMessageException.class);
    }

    private List<String> bundleKeys(String fileName) throws Exception {
        String path = "/i18n/spring-ch7-messages/" + fileName;
        try (var input = MessageSourceContractTest.class
                .getResourceAsStream(path)) {
            assertThat(input).as(path).isNotNull();
            var reader = new BufferedReader(new InputStreamReader(
                    input, StandardCharsets.UTF_8));
            return reader.lines()
                    .filter(line -> !line.isBlank())
                    .filter(line -> !line.startsWith("#"))
                    .map(line -> line.substring(0, line.indexOf('=')))
                    .toList();
        }
    }
}

getMessage(code, args, code, locale)처럼 코드 자체를 기본값 메시지로 주면 장애는 줄어 보이지만 번역 누락을 숨깁니다.

개발·테스트에서는 예외로 빠르게 발견하고, 운영의 비핵심 문구만 관찰 가능한 자리표시자로 대체하는 식으로 정책을 나눌 수 있습니다.

핵심은 누락이 로그와 메트릭에 남아야 한다는 점입니다.


Thymeleaf 메시지 조합

th:text="#{post.create}"는 메시지 표현식이고 th:text="#{post.saved(${title}, ${contentCharacterCount})}"는 컨트롤러가 같은 code point 정책으로 계산한 인자를 전달합니다.

HTML 전체를 번역 문자열에 넣으면 마크업 검토와 이스케이프가 어려워집니다.

문장 안의 링크 위치가 언어마다 크게 다를 때는 검증된 i18n 컴포넌트 전략을 따로 설계합니다.

검증 메시지도 동일한 소스를 사용할 수 있지만 코드 네임스페이스를 분명히 합니다.

NotBlank.form.title, NotBlank.title, NotBlank.java.lang.String, NotBlank처럼 구체적인 코드에서 일반 코드로 내려가는 계층은 다음 문서의 바인딩 오류와 연결됩니다.

번역자가 Java 클래스 이름을 직접 다루지 않게 안정적인 애플리케이션 코드를 앞에 둡니다.

대상코드 예시인자누락 시 정책
화면 제목post.create없음테스트 실패
성공 알림post.saved제목, Unicode code point 수대체 후 관찰
열거형 레이블category.NOTICE없음지원 값 전체 검사
검증post.content.length최소, 최대필드 코드 계층

번역 파일을 정렬하고 중복 키를 검사합니다.

Java 속성은 같은 키가 두 번 나오면 마지막 값을 쓸 수 있어 검토에서 앞 값을 수정해도 반영되지 않는 혼란이 생깁니다.

로케일별 키 집합 차이도 의도한 대체인지 실수인지 구분해 보고서로 남깁니다.


연습 문제

게시판의 주간 요약 문구를 한국어와 영어로 제공하세요.

총 게시글 수와 총 본문 길이를 인자로 받고 0건, 1건, 여러 건에서 자연스러운 문장을 만들되, 단순히 영어 단어 뒤에 항상 s를 붙이지 마세요.

지원하지 않는 프랑스어 요청이 어떤 값으로 대체되는지도 테스트로 확인합니다.

해설 보기

복수형 규칙이 복잡해지면 문장 하나에 삼항 표현식을 넣기보다 개수 범주별 코드를 선택하거나 ICU MessageFormat을 제공하는 검증된 도구를 평가합니다.

현재 한국어·영어 두 로케일과 단순한 0개·1개·여러 개 범위라면 애플리케이션이 안정적인 코드를 고를 수 있습니다.

src/main/java/board/i18n/SummaryMessageCode.java
package board.i18n;

public final class SummaryMessageCode {
    public String posts(int count) {
        if (count == 0) {
            return "summary.posts.zero";
        }
        if (count == 1) {
            return "summary.posts.one";
        }
        return "summary.posts.many";
    }
}

각 로케일의 세 코드가 모두 존재하는지 파라미터화된 테스트로 순회합니다.

대체 테스트에서는 JVM 기본 로케일을 바꿔도 리졸버의 한국어 기본값이 유지되는지 확인합니다.

숫자 형식과 문장 선택을 같은 검증에 담지 말고 실패 원인을 나눕니다.

독립 확인

원 구성과 두 번들을 유지하고 JVM 기본 언어만 영어로 바꿨다고 가정합니다. A는 프랑스어 요청으로 category.QUESTION을 조회합니다. B는 영어 요청으로 제목 Unicode, 본문 e+결합 악센트(U+0301)+😀를 saved()에 넘깁니다. 선택된 로케일과 결과 문장을 정하세요.

C는 별도 자원 사본에서 영어 번들의 category.QUESTION 한 줄만 삭제했습니다. 영어 조회 결과가 나오는지, 원 키 완전성 테스트가 통과하는지 구분하세요. D는 모든 번들에 없는 post.unknown을 원 호출과 코드 자체를 기본 메시지로 주는 호출로 각각 조회합니다. 반환 문자열 또는 예외를 설명하세요.

표시 언어·누락 번역·인자 단위를 분리하는 풀이

A는 지원 목록과 명시적 기본 로케일 때문에 ko입니다. 기본 번들의 질문을 반환하며 JVM의 영어 기본값이 그 선택을 바꾸지 않습니다. B는 en이고 code point 수는 3입니다. 결합 문자도 별도 code point이며 이 문자열의 UTF-16 길이 4나 통상 두 문자소와 다릅니다. 결과는 Saved post Unicode with 3 characters.입니다.

C의 영어 조회는 부모 기본 번들의 질문으로 채워질 수 있습니다. 그러나 원 테스트는 영어 파일 자체의 여섯 키를 읽어 비교하므로 실패합니다. 대체로 문장을 제공하는 일과 지원 언어의 번역 누락을 발견하는 일은 다른 계약입니다. 실제 파일을 수정했다면 classpath 재복사와 새 테스트 fixture로 확인해 캐시된 번들과 섞지 않습니다.

D의 기본 메시지 없는 원 getMessage는 NoSuchMessageException입니다. 코드 자체를 기본 메시지로 준 오버로드는 post.unknown을 반환하지만, 이것은 번역을 찾았다는 뜻이 아닙니다. 예외 관찰·대체 표시·키 검사를 각각 정해야 합니다. 위 입력 결과는 원 코드와 메시지 탐색 규칙에서 도출한 예상이며 새 MVC·템플릿 실행 기록은 아닙니다.

다음 문서에서는 메시지 코드가 만들어지는 출발점인 BindingResult를 해부하고 바인딩 오류와 검증 오류를 같은 화면에서 정확히 보여 줍니다.