메시지·국제화
게시판 화면 문구를 MessageSource로 분리하고 basename·로케일·인자 탐색 순서, 누락 코드 처리와 수락-언어 경계를 테스트합니다.
국제화의 핵심은 한국어 문장을 영어로 번역하는 작업보다 코드가 문구를 직접 소유하지 않게 만드는 데 있습니다. 버튼 레이블, 검증 안내, 상태 이름을 안정적인 메시지 코드로 참조하면 화면 구조와 번역 수명주기를 분리할 수 있습니다. 반대로 번들 대체를 이해하지 못하면 운영에서만 엉뚱한 언어나 코드 자체가 노출됩니다.
메시지 번들 계층
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=게시판
post.create=Create post
post.saved=Saved post {0} with {1} characters.
category.GENERAL=Low
category.QUESTION=Normal
category.NOTICE=High기본 번들의 언어는 제품 정책으로 정합니다. 기본을 한국어로 두었다면 로케일 없는 배치 메일과 대체도 한국어라는 뜻입니다. 파일마다 같은 코드를 무조건 복제하기보다 기본값과 실제 번역 차이를 관리하되, 핵심 사용자 흐름은 지원 로케일별 완전성을 빌드 검사로 보장할 수 있습니다.
속성이 UTF-8인지 빌드와 IDE 설정을 확인합니다.
Boot의 리소스 번들 인코딩은 UTF-8을 사용하지만 다른 도구가 ISO-8859-1로 읽는 환경과 섞이면 글자가 깨집니다.
저장소 전체 인코딩을 하나로 고정하고 실제 MessageSource 결과를 테스트합니다.
MessageSource 입력
업무 계층에서 완성된 한국어 문장을 반환하지 않습니다. 결과 코드와 구조화된 인자를 웹 어댑터가 로케일에 맞춰 문장으로 바꿉니다.
package board.web;
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
) {
return messages.getMessage(
"post.saved",
new Object[]{title, content.length()},
locale);
}
public String categoryLabel(PostCategory level, Locale locale) {
return messages.getMessage(
"category." + level.name(),
null,
locale);
}
public enum PostCategory {
GENERAL, QUESTION, NOTICE
}
}인자 순서는 번역에 따라 달라질 수 있습니다.
한국어와 영어 예제처럼 자리표시자 위치를 번역 파일이 정합니다.
숫자·날짜를 단순 toString()으로 먼저 바꾸면 로케일 포매팅 기회를 잃으므로 가능한 한 타입을 그대로 넘기고 MessageFormat 규칙이나 별도 포매터를 사용합니다.
로케일 신뢰 경계
기본 AcceptHeaderLocaleResolver는 Accept-Language를 바탕으로 로케일을 결정합니다.
이것은 표시 선호이지 사용자 신원이나 결제 통화를 증명하지 않습니다.
URL 접두사, 회원 설정, 쿠키 중 어느 것을 제품의 정규 로케일로 쓸지 정하고 우선순위를 문서화합니다.
package board.web;
import java.util.List;
import java.util.Locale;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
@Configuration
public class LocaleConfiguration {
@Bean
LocaleResolver localeResolver() {
var resolver = new AcceptHeaderLocaleResolver();
resolver.setSupportedLocales(List.of(
Locale.KOREAN,
Locale.ENGLISH));
resolver.setDefaultLocale(Locale.KOREAN);
return resolver;
}
}지원 로케일을 제한하지 않으면 en-US, en-GB, fr-CA 같은 수많은 조합이 들어옵니다.
ResourceBundle은 더 구체적인 후보에서 언어·기본 파일로 내려가지만, 선택 정책과 JVM 기본 로케일까지 개입하면 결과를 예상하기 어렵습니다.
리졸버의 지원 목록과 명시적 기본값을 두면 배포 서버 로케일에 따른 차이를 줄입니다.
사용자가 기본 이름이나 파일 경로를 고르게 해서는 안 됩니다.
로케일 태그도 Locale.forLanguageTag로 파싱한 뒤 허용 목록에 대조합니다.
경로를 리소스 위치에 연결하는 자체 로더는 국제화 기능이 아니라 파일 접근 취약점이 됩니다.
대체 메시지와 누락 코드
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Locale;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.support.ResourceBundleMessageSource;
class MessageSourceContractTest {
private ResourceBundleMessageSource messages;
@BeforeEach
void setUp() {
messages = new ResourceBundleMessageSource();
messages.setBasename("messages");
messages.setDefaultEncoding("UTF-8");
messages.setFallbackToSystemLocale(false);
}
@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는_조용히_code를_노출하지_않는다() {
assertThatThrownBy(() -> messages.getMessage(
"post.unknown",
null,
Locale.KOREAN))
.isInstanceOf(NoSuchMessageException.class);
}
}MessageSourceContractTest
> 영어_bundle은_argument_순서를_문장에_맞게_배치한다() PASSED
> 존재하지_않는_code는_조용히_code를_노출하지_않는다() PASSED
en = Saved post Spring with 45 characters.
missing code policy = exception
system locale fallback = disabledgetMessage(code, args, code, locale)처럼 코드 자체를 기본값 메시지로 주면 장애는 줄어 보이지만 번역 누락을 숨깁니다.
개발·테스트에서는 예외로 빠르게 발견하고, 운영의 비핵심 문구만 관찰 가능한 자리표시자로 대체하는 식으로 정책을 나눌 수 있습니다.
핵심은 누락이 로그와 메트릭에 남아야 한다는 점입니다.
Thymeleaf 메시지 조합
th:text="#{post.create}"는 메시지 표현식이고 th:text="#{post.saved(${title}, ${content.length()})}"는 인자를 전달합니다.
HTML 전체를 번역 문자열에 넣으면 마크업 검토와 이스케이프가 어려워집니다.
문장 안 링크 위치가 언어마다 크게 다를 때는 검증된 i18n 컴포넌트 전략을 따로 설계합니다.
검증 메시지도 동일한 소스를 사용할 수 있지만 코드 네임스페이스를 분명히 합니다.
NotBlank.form.title, NotBlank.title, NotBlank.java.lang.String, NotBlank처럼 구체적인 코드에서 일반 코드로 내려가는 계층은 다음 문서의 바인딩 오류와 연결됩니다.
번역자가 Java 클래스 이름을 직접 다루지 않게 안정적인 애플리케이션 코드를 앞에 둡니다.
| 대상 | 코드 예시 | 인자 | 누락 시 정책 |
|---|---|---|---|
| 화면 제목 | post.create | 없음 | 테스트 실패 |
| 성공 알림 | post.saved | 제목, 글자 수 | 대체 후 관찰 |
| 열거형 레이블 | category.NOTICE | 없음 | 지원 값 전체 검사 |
| 검증 | post.content.length | 최소, 최대 | 필드 코드 계층 |
번역 파일을 정렬하고 중복 키를 검사합니다. Java 속성은 같은 키가 두 번 나오면 마지막 값을 쓸 수 있어 검토에서 앞 값을 수정해도 반영되지 않는 혼란이 생깁니다. 로케일별 키 집합 차이도 의도한 대체인지 실수인지 구분해 보고서로 남깁니다.
연습 문제
게시판의 주간 요약 문구를 한국어와 영어로 제공하세요.
총 게시글 수와 총 본문 길이를 인자로 받고 0건, 1건, 여러 건에서 자연스러운 문장을 만들되, 단순히 영어 단어 뒤에 항상 s를 붙이지 마세요.
지원하지 않는 프랑스어 요청이 어떤 값으로 대체하는지도 테스트로 확인합니다.
해설 보기
복수형 규칙이 복잡해지면 문장 하나에 삼항 표현식을 넣기보다 개수 범주별 코드를 선택하거나 ICU MessageFormat을 제공하는 검증된 도구를 평가합니다. 현재 한국어·영어 두 로케일과 단순한 0개·1개·여러 개 범위라면 애플리케이션이 안정적인 코드를 고를 수 있습니다.
package board.web;
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 기본 로케일을 바꿔도 리졸버의 한국어 기본값이 유지되는지 확인합니다. 숫자 형식과 문장 선택을 같은 검증에 담지 말고 실패 원인을 나눕니다.
다음 문서에서는 메시지 코드가 만들어지는 출발점인 BindingResult를 해부하고 바인딩 오류와 검증 오류를 같은 화면에서 정확히 보여 줍니다.