본문으로 건너뛰기

안동민 개발노트

본문 시작

메시지·국제화

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

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

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

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

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

DATA FLOW · LOCALE ALLOWLIST · BUNDLE CONTRACT

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

안정적인 메시지 코드와 타입이 있는 인자는 번역 자원으로 흐르고, 요청 locale은 지원 목록으로 정규화된다. 지원 bundle과 기본 bundle에 코드가 모두 없을 때만 누락 오류로 분기한다.

메시지는 허용된 locale과 완전한 bundle에서 안전한 사용자 문장으로 해석된다 메시지 코드와 인자, 요청 locale이 MessageSource로 들어가 지원 언어 bundle 또는 기본 bundle의 문장으로 해석되는 흐름이다. 양쪽 bundle에도 코드가 없으면 키를 사용자에게 노출하지 않고 누락 오류와 운영 지표로 보낸다. 조회 해석 YES · 출력 언어 선호 ko 또는 en NO YES NO code + args post.saved(title, 42) MessageSource getMessage(code, args, locale) 지원 bundle에 코드가 있는가? 사용자 문장 게시글을 저장했습니다. locale 입력 en-US · fr-FR 지원 목록 정규화 en-US → en · 기타 → ko 기본 bundle에 코드가 있는가? 누락 오류 NoSuchMessageException LOCALE POLICY locale은 표시 언어 선호일 뿐 권한 경계가 아니다. 허용 목록은 language 단위로 en-US를 en으로 축약하고 미지원·잘못된 태그는 항상 ko로 정규화한다. BUILD CONTRACT ko·en bundle은 정확히 같은 6개 코드를 가진다. 영어 의미: Board · General · Question · Notice 누락 코드는 테스트 실패와 운영 지표로 남기고 내부 키와 예외 문구를 사용자 화면에 노출하지 않는다.
SAME FLOW 모바일 의미 순서
  1. 안정적인 코드와 타입 인자

    post.saved와 제목·code point 수를 번역 문장과 분리한다.

  2. locale 허용 목록

    en-USen, 미지원 값은 ko로 정규화한다.

  3. 지원 bundle 우선 조회

    두 bundle의 코드 집합과 Board·General·Question·Notice 의미를 테스트한다.

  4. 기본 bundle 대체

    지원 언어에 코드가 없으면 한국어 기본 bundle을 한 번 확인한다.

  5. 누락은 오류로 관찰

    양쪽에 없으면 예외와 지표로 남기고 내부 키는 화면에 노출하지 않는다.

  • 메시지 해석
  • 문장 출력
  • 누락 오류

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


메시지 번들 계층

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

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

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

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

속성이 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 규칙이나 별도 포매터를 사용합니다.


로케일 신뢰 경계

기본 AcceptHeaderLocaleResolverAccept-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이나 잘못된 태그는 한국어 기본값으로 보냅니다.

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


대체 메시지와 누락 코드

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 기본 로케일을 바꿔도 리졸버의 한국어 기본값이 유지되는지 확인합니다.

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

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