메시지·국제화
게시판 화면 문구를 MessageSource로 분리하고 basename·로케일·인자 탐색 순서, 누락 코드 처리와 Accept-Language 경계를 테스트합니다.
국제화(internationalization)의 핵심은 한국어 문장을 영어로 옮기는 일보다, 코드가 문구를 직접 품지 않게 만드는 데 있습니다. 버튼 레이블과 검증 안내, 상태 이름을 바뀌지 않는 메시지 코드로 참조하면 화면 구조와 번역의 수명 주기를 나눌 수 있습니다. 반대로 번들의 대체 규칙을 이해하지 못하면 운영 환경에서만 엉뚱한 언어나 코드 자체가 노출됩니다.
메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다
지원 locale 선택·bundle/code 탐색·타입 인자 대입·사용자 문장 또는 누락 실패의 해석 과정이다.
DATA FLOW · LOCALE ALLOWLIST · BUNDLE CONTRACT
로케일 선택·번들 탐색·인자 포매팅을 별도로 검증한다
한국어 기본 번들과 영어 번역은 같은 6개 코드를 가집니다. 명시적 locale 선택 뒤 MessageSource가 코드와 인자를 해석하며, 코드 누락은 별도 예외로 발견합니다.
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 탐색·타입 인자 대입·사용자 문장 또는 누락 실패의 해석 과정이다.
DATA FLOW · LOCALE ALLOWLIST · BUNDLE CONTRACT
로케일 선택·번들 탐색·인자 포매팅을 별도로 검증한다
한국어 기본 번들과 영어 번역은 같은 6개 코드를 가집니다. 명시적 locale 선택 뒤 MessageSource가 코드와 인자를 해석하며, 코드 누락은 별도 예외로 발견합니다.
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: 별도 정책 | 해석 결과와 출력 안전 정책을 구분합니다. |
그림은 서로 독립된 두 입력이 MessageSource로 모이는 모습을 보여 줍니다. 하나는 ko와 en만 지원하고 기본값을 ko로 둔 로케일 선택이고, 다른 하나는 post.saved(title, count)처럼 바뀌지 않는 코드와 타입이 있는 인자입니다. MessageSource는 선택된 로케일의 번들, 기본 번들 순서로 코드를 찾고, 어디에도 없으면 기본 문자열이 없는 getMessage에서 NoSuchMessageException을 냅니다. 사용자 화면의 이스케이프와 운영에서 누락을 관찰하는 일은 별도의 정책입니다.
메시지 번들의 계층
Spring Boot는 기본 이름 messages를 찾아, 로케일 후보에 따라 messages.properties, messages_ko.properties, messages_en.properties를 읽습니다. 지원하지 않는 로케일과 번역 누락의 마지막 값을 주기 위해 기본 파일은 반드시 둡니다.
site.title=게시판
post.create=게시글 등록
post.saved=제목이 {0}이고 본문이 {1}자인 게시글을 저장했습니다.
category.GENERAL=일반
category.QUESTION=질문
category.NOTICE=공지site.title=Board
post.create=Create post
post.saved=Saved post {0} with {1} characters.
category.GENERAL=General
category.QUESTION=Question
category.NOTICE=Notice기본 번들의 언어는 제품 정책으로 정합니다. 기본을 한국어로 두었다면, 로케일이 없는 배치 메일과 대체 결과도 한국어라는 뜻입니다. 파일마다 같은 코드를 무조건 복제하기보다 기본값과 실제 번역의 차이를 관리하되, 핵심 사용자 흐름은 지원하는 로케일마다 빠짐없는지 빌드 검사로 보장할 수 있습니다.
먼저 두 리소스 파일을 표시된 경로에 두고 MessageSourceContractTest부터 확인합니다. 이 테스트는 LocaleConfiguration을 직접 만들어 메시지 소스를 얻으므로, Boot 애플리케이션의 패키지 탐색까지 확인하지는 않습니다. 실제 애플리케이션에서도 board.i18n의 설정과 서비스가 탐색되거나 명시적으로 포함되어야 합니다.
앞에서 설명한 Boot의 기본 자동 구성은 클래스 경로 루트의 기본 basename 규칙입니다. 아래 예제는 직접 등록한 messageSource 빈에서 i18n/spring-ch7-messages/messages를 지정하므로, 두 파일만 다른 폴더에 복사하고 이 설정을 빼면 같은 탐색이 되지 않습니다. Boot의 국제화 설정을 기준으로 자동 구성과 직접 빈 등록을 구분합니다.
속성 파일이 UTF-8인지 빌드와 IDE 설정도 확인합니다. Boot의 리소스 번들 인코딩은 UTF-8이지만, 다른 도구가 ISO-8859-1로 읽는 환경과 섞이면 글자가 깨집니다. 저장소 전체의 인코딩을 하나로 정하고 실제 MessageSource의 결과를 테스트합니다.
MessageSource에 넘기는 입력
업무 계층에서 완성된 한국어 문장을 돌려주지 않습니다. 결과 코드와 구조화된 인자를 웹 어댑터가 로케일에 맞는 문장으로 바꿉니다.
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()가 아니라 유니코드 코드 포인트의 수를 뜻합니다. 그래서 보충 평면 문자 하나도 1로 세며, 여러 코드 포인트가 화면의 한 글자로 보이는 grapheme cluster의 수와는 다른 기준입니다.
인자의 순서는 번역에 따라 달라질 수 있으며, 한국어와 영어 예제처럼 자리 표시자의 위치는 번역 파일이 정합니다. 숫자와 날짜를 먼저 toString()으로 바꾸면 로케일에 맞게 포맷할 기회를 잃으므로, 가능하면 타입을 그대로 넘기고 MessageFormat 규칙이나 별도의 포매터를 씁니다.
로케일은 믿을 값이 아닙니다
기본 AcceptHeaderLocaleResolver는 Accept-Language를 보고 로케일을 정합니다. 이것은 표시에 대한 선호일 뿐 사용자의 신원이나 결제 통화를 알려 주지 않습니다. URL 접두사와 회원 설정, 쿠키 가운데 무엇을 제품의 기준 로케일로 쓸지 정하고, 우선순위를 문서로 남깁니다.
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 빈도 두 리소스 파일과 같은 basename을 쓰므로, 애플리케이션과 테스트가 같은 탐색 규칙을 따릅니다. en-US처럼 지원 언어의 지역 변형은 en으로 정규화하고, fr-FR이나 잘못된 태그는 한국어 기본값으로 보냅니다.
예를 들어 Accept-Language: en-US,en;q=0.9는 언어만 등록한 지원 값 en과 맞고, fr-FR,fr;q=0.9는 맞는 지원 값이 없어 기본값 ko가 됩니다. HTTP 헤더 리졸버와 별도의 문자열 헬퍼는 입력 형태가 다른 두 경로이며, 헬퍼에 헤더 전체를 넘겨 품질 값까지 직접 해석하는 예제가 아닙니다. 리졸버 API는 언어만으로 대체 매칭하려면 그 언어를 지원 목록에 명시해야 한다고 설명합니다.
ko가 선택되어도 예제에 messages_ko.properties가 있는 것은 아니며, 이 구성에서는 한국어로 된 기본 파일이 값을 줍니다. 영어 번들에서 어떤 코드가 빠져도 기본 파일이 채워 줄 수 있으므로, 화면에 문장이 나온다는 사실만으로 영어 번역이 빠짐없다고 판단할 수 없습니다. 경로를 리소스 위치에 연결하는 자체 로더는 국제화 기능이 아니라 파일 접근 취약점이 됩니다.
대체 메시지와 누락된 코드
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})}"는 컨트롤러가 같은 코드 포인트 기준으로 계산한 인자를 넘깁니다. 번역 문자열에 HTML 전체를 넣으면 마크업 검토와 이스케이프가 어려워집니다. 문장 안의 링크 위치가 언어마다 크게 다를 때는 검증된 국제화 컴포넌트 전략을 따로 설계합니다.
검증 메시지도 같은 소스를 쓸 수 있지만 코드의 네임스페이스를 분명히 합니다. NotBlank.form.title, NotBlank.title, NotBlank.java.lang.String, NotBlank처럼 구체적인 코드에서 일반적인 코드로 내려가는 계층은 다음 글의 바인딩 오류와 이어집니다. 번역자가 Java 클래스 이름을 직접 다루지 않도록, 바뀌지 않는 애플리케이션 코드를 앞에 둡니다.
| 대상 | 코드 예 | 인자 | 누락되었을 때의 정책 |
|---|---|---|---|
| 화면 제목 | post.create | 없음 | 테스트 실패 |
| 성공 알림 | post.saved | 제목, 유니코드 코드 포인트 수 | 대체한 뒤 기록 |
| 열거형 레이블 | category.NOTICE | 없음 | 지원 값 전체 검사 |
| 검증 | post.content.length | 최소, 최대 | 필드 코드 계층 |
번역 파일은 정렬하고 중복 키를 검사합니다. Java 속성 파일은 같은 키가 두 번 나오면 마지막 값을 쓸 수 있어, 검토할 때 앞의 값을 고쳐도 반영되지 않는 혼란이 생깁니다. 로케일마다 키 집합이 다르다면, 의도한 대체인지 실수인지 구분해 보고서로 남깁니다.
더 깊이: MessageSource가 조용히 적용하는 두 기본값
예제의 messageSource 빈이 setFallbackToSystemLocale(false)를 부르는 데는 이유가 있습니다. AbstractResourceBasedMessageSource API 문서에 따르면 이 설정의 기본값은 true이고, 요청한 로케일의 파일이 없으면 기본 파일로 가기 전에 JVM의 시스템 로케일 파일부터 찾습니다. 대체할 로케일을 따로 정하는 setDefaultLocale도 없다면, 시스템 로케일이 영어인 서버에서 fr 요청을 처리할 때 한국어 기본 파일이 아니라 messages_en.properties의 문장이 나올 수 있습니다. 문서도 이것이 java.util.ResourceBundle의 기본 동작일 뿐, 시스템 로케일이 애플리케이션과 무관한 서버 환경에서는 대개 바람직하지 않으므로 끄라고 권합니다. Boot의 자동 구성도 spring.messages.fallback-to-system-locale의 기본값을 true로 두므로, 자동 구성을 쓸 때도 같은 확인이 필요합니다.
두 번째는 작은따옴표입니다. MessageSource는 {0} 같은 인자를 java.text.MessageFormat으로 채우는데, MessageFormat에서 작은따옴표는 그 사이의 글자를 인용하는 기호이고 작은따옴표 자체는 ''처럼 두 번 써야 합니다. 그래서 영어 번역에 Post '{0}' saved라고 쓰면 {0}이 인용된 글자가 되어 제목 대신 {0}이라는 글자가 그대로 나옵니다. 더 헷갈리는 점은 MessageSourceSupport의 alwaysUseMessageFormat 기본값이 false라서, 인자가 없는 문장은 MessageFormat을 거치지 않고 그대로 돌려준다는 것입니다. 같은 번들 안에서도 인자가 있는 문장만 ''로 써야 하므로, 모든 문장을 같은 규칙으로 쓰고 싶다면 이 설정(Boot에서는 spring.messages.always-use-message-format)을 true로 켜고 모든 작은따옴표를 두 번 씁니다.
정리
- 업무 계층은 완성된 문장 대신 코드와 타입이 있는 인자를 돌려주고, 웹 어댑터가
MessageSource로 로케일에 맞는 문장을 만듭니다. - 기본 번들은 반드시 두며, 기본 번들의 언어는 지원하지 않는 로케일과 번역 누락의 결과가 되므로 제품 정책으로 정합니다.
Accept-Language는 표시 선호일 뿐이므로 지원 목록과 명시적인 기본값으로 좁히고, JVM의 기본 로케일에 기대지 않습니다.- 기본 번들이 빠진 번역을 채워 줄 수 있으므로, 화면에 문장이 나온다고 번역이 빠짐없다고 판단하지 않고 로케일별 키를 따로 검사합니다.
- 코드 자체를 기본 메시지로 주면 누락을 숨기므로, 개발과 테스트에서는 예외로 발견하고 운영에서는 기록을 남깁니다.
연습 문제
1. 주간 요약 문구
게시판의 주간 요약 문구를 한국어와 영어로 제공하려고 합니다. 총 게시글 수와 총 본문 길이를 인자로 받아 0건, 1건, 여러 건에서 자연스러운 문장을 만들되, 영어 단어 뒤에 언제나 s를 붙이는 식으로 처리하지 않아야 합니다. 지원하지 않는 프랑스어 요청이 어떤 값으로 대체되는지도 테스트해야 합니다. 어떻게 구성하나요?
해설 보기
복수형 규칙이 복잡해지면 문장 하나에 삼항 표현식을 넣기보다, 개수 범주별로 코드를 고르거나 ICU MessageFormat을 제공하는 검증된 도구를 검토합니다. 지금처럼 한국어와 영어 두 로케일에 0개, 1개, 여러 개의 단순한 범위라면 애플리케이션이 바뀌지 않는 코드를 고를 수 있습니다.
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의 기본 로케일을 바꿔도 리졸버의 한국어 기본값이 유지되는지 확인합니다. 숫자 형식과 문장 선택은 같은 검증에 담지 말고, 실패의 원인을 나눕니다.
2. 표시 언어와 누락된 번역, 인자의 단위
구성과 두 번들은 그대로 두고 JVM의 기본 언어만 영어로 바꿨다고 가정합니다. A는 프랑스어 요청으로 category.QUESTION을 조회합니다. B는 영어 요청으로 제목 Unicode와 본문 e + 결합 악센트 U+0301 + 😀를 saved()에 넘깁니다. 각각 선택된 로케일과 결과 문장을 정하세요. C는 별도의 리소스 사본에서 영어 번들의 category.QUESTION 한 줄만 지웠습니다. 영어로 조회한 결과가 나오는지, 키 완전성 테스트가 통과하는지 구분하세요. D는 모든 번들에 없는 post.unknown을 기본 메시지 없는 호출과, 코드 자체를 기본 메시지로 주는 호출로 각각 조회합니다. 돌아오는 문자열이나 예외를 설명하세요.
해설 보기
A는 지원 목록과 명시적인 기본 로케일 때문에 ko가 되고 기본 번들의 질문을 돌려줍니다. JVM의 영어 기본값은 이 선택을 바꾸지 않습니다. B는 en이고 코드 포인트 수는 3입니다. 결합 문자도 별도의 코드 포인트이며, 이 문자열의 UTF-16 길이 4나 보통 두 글자로 보이는 것과 다릅니다. 결과는 Saved post Unicode with 3 characters.입니다.
C의 영어 조회는 부모인 기본 번들의 질문으로 채워질 수 있습니다. 그러나 테스트는 영어 파일 자체의 여섯 키를 읽어 비교하므로 실패합니다. 대체로 문장을 보여 주는 일과 지원 언어의 번역 누락을 발견하는 일은 서로 다른 검사입니다. 실제 파일을 고쳤다면 클래스 경로에 다시 복사하고 새 테스트 설정으로 확인해, 캐시된 번들과 섞이지 않게 합니다.
D에서 기본 메시지 없는 getMessage는 NoSuchMessageException을 냅니다. 코드 자체를 기본 메시지로 준 오버로드는 post.unknown을 돌려주지만, 번역을 찾았다는 뜻은 아닙니다. 예외의 기록과 대체 표시, 키 검사를 각각 정해야 합니다. 이 결과들은 코드와 메시지 탐색 규칙에서 끌어낸 예상이므로 테스트를 실행해 확인합니다.
다음 글 바인딩 오류 처리에서는 메시지 코드가 만들어지는 출발점인 BindingResult를 자세히 살펴보고, 바인딩 오류와 검증 오류를 같은 화면에서 정확히 보여 줍니다.