Skip to main content

Spring Boot i18n: 국제화 설정 튜토리얼

MessageSource를 구성하고, 로케일별 properties 파일을 만들며, 로케일을 결정하고, 다국어 Thymeleaf 템플릿을 렌더링한 다음 AI로 번역을 자동화해요.

1

종속성 추가

Spring Boot Starter Web에는 MessageSource 자동 구성이 기본으로 포함되어 있어요. 서버에서 렌더링하는 i18n 템플릿을 위해 Thymeleaf를 추가하고, 현지화된 오류 메시지를 위해 유효성 검사 스타터를 추가해요.

Spring Boot는 클래스패스의 messages.properties를 읽는 MessageSource 빈을 자동으로 구성해요. basename, 인코딩 또는 캐싱 동작을 맞춤 설정할 때만 명시적으로 구성하면 돼요.
pom.xml
<!-- pom.xml — Spring Boot Starter Web includes MessageSource auto-config -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Thymeleaf for server-side rendered templates with i18n -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- Validation (for localized error messages) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2

MessageSource 및 LocaleResolver 구성

Spring의 MessageSource는 basename 규칙에 따라 .properties 파일에서 번역을 불러와요: messages.properties(기본값), messages_de.properties(독일어), messages_ja.properties(일본어). 요청마다 사용할 로케일을 결정하도록 LocaleResolver를 구성해요.

번역 파일

messages.properties
# src/main/resources/messages.properties (default / English)
nav.home=Home
nav.about=About
nav.settings=Settings

greeting=Hello, {0}!
cart.itemCount={0,choice,0#No items|1#1 item|1<{0,number} items}

error.notFound=Page not found
error.serverError=Something went wrong. Please try again.

MessageSource 구성

I18nConfig.java
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class I18nConfig {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource source =
            new ReloadableResourceBundleMessageSource();
        source.setBasename("classpath:messages");
        source.setDefaultEncoding("UTF-8");
        source.setCacheSeconds(3600); // reload interval in dev
        return source;
    }

    // Wire MessageSource into Bean Validation
    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource);
        return bean;
    }
}
번역된 텍스트 대신 키 이름이 반환된다면 잘못된 basename이 가장 흔한 원인이에요. 기본값은 'messages'이며 클래스패스의 messages.properties에 매핑돼요. 파일 이름이 다르거나 하위 디렉터리에 있다면 spring.messages.basename을 명시적으로 설정하세요.

로케일 결정

Spring이 각 요청의 활성 로케일을 결정하는 방식을 구성해요. CookieLocaleResolver는 세션이 바뀌어도 사용자의 선택을 유지해요. LocaleChangeInterceptor를 사용하면 ?lang=de 같은 쿼리 매개변수로 로케일을 전환할 수 있어요.

LocaleConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

import java.util.Locale;

@Configuration
public class LocaleConfig implements WebMvcConfigurer {

    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
        resolver.setDefaultLocale(Locale.ENGLISH);
        resolver.setCookieMaxAge(3600 * 24 * 365); // 1 year
        return resolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
        interceptor.setParamName("lang"); // ?lang=de switches locale
        return interceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }
}
3

코드에서 번역 사용

컨트롤러에서는 MessageSource 주입으로, Thymeleaf 템플릿에서는 #{...} 구문으로 번역된 메시지에 접근해요. REST API에서는 자동으로 결정된 Locale 매개변수를 사용해요.

MessageSource를 사용하는 컨트롤러

HomeController.java
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.Locale;

@Controller
public class HomeController {

    private final MessageSource messageSource;

    public HomeController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping("/")
    public String home(Model model, Locale locale) {
        // Spring injects the resolved Locale automatically
        String greeting = messageSource.getMessage(
            "greeting",
            new Object[]{"World"},
            locale
        );
        model.addAttribute("greeting", greeting);
        return "home";
    }
}

Thymeleaf 템플릿

Thymeleaf의 #{...} 표현식은 .properties 파일에서 메시지 키를 자동으로 찾아요. 매개변수는 #{key(arg0, arg1)} 구문으로 전달해요. 템플릿은 LocaleResolver가 결정한 로케일을 사용해요.

home.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="#{nav.home}">Home</title>
</head>
<body>
    <!-- Simple message lookup -->
    <h1 th:text="#{greeting('World')}">Hello, World!</h1>

    <!-- Navigation with i18n -->
    <nav>
        <a href="/" th:text="#{nav.home}">Home</a>
        <a href="/about" th:text="#{nav.about}">About</a>
        <a href="/settings" th:text="#{nav.settings}">Settings</a>
    </nav>

    <!-- Parameterized messages -->
    <p th:text="#{cart.itemCount(3)}">3 items</p>

    <!-- Language switcher -->
    <div>
        <a th:href="@{/(lang=en)}">English</a>
        <a th:href="@{/(lang=de)}">Deutsch</a>
        <a th:href="@{/(lang=ja)}">日本語</a>
    </div>

    <!-- Conditional text based on locale -->
    <p th:if="${#locale.language == 'ja'}"
       th:text="#{greeting('ユーザー')}">
        こんにちは、ユーザーさん!
    </p>
</body>
</html>
#{greeting('World')} 같은 Thymeleaf 표현식은 MessageFormat에 인수를 전달해요. HTML 태그 안의 정적 텍스트는 Spring 없이 템플릿을 볼 때 폴백으로 사용되므로 템플릿을 직접 작업하는 디자이너에게 유용해요.

REST API 현지화

REST API에서 Spring은 Accept-Language 헤더로부터 Locale을 자동으로 결정해요. 이를 메서드 매개변수로 주입하여 MessageSource에 전달해요. 클라이언트는 서로 다른 Accept-Language 헤더를 전송하여 언어를 전환해요.

ApiController.java
import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Locale;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class ApiController {

    private final MessageSource messageSource;

    public ApiController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping("/greeting/{name}")
    public ResponseEntity<Map<String, String>> greeting(
            @PathVariable String name,
            Locale locale) {  // Resolved from Accept-Language header
        String msg = messageSource.getMessage(
            "greeting", new Object[]{name}, locale
        );
        return ResponseEntity.ok(Map.of("message", msg));
    }

    // curl -H "Accept-Language: de" localhost:8080/api/greeting/Max
    // → {"message": "Hallo, Max!"}
}
REST API는 일반적으로 헤더 기반인 AcceptHeaderLocaleResolver를 사용하고, 웹 앱은 쿠키 기반인 CookieLocaleResolver를 사용해요. 하나의 앱에서 둘 다 제공한다면 쿠키를 먼저 확인한 후 Accept-Language 헤더로 폴백하는 사용자 정의 LocaleResolver를 고려해 보세요.

Bean Validation 메시지

Spring은 MessageSource에서 유효성 검사 제약 조건 메시지를 자동으로 찾아요. 제약 조건 애너테이션에 {validation.name.required} 같은 중괄호 플레이스홀더를 사용하고 .properties 파일에 번역을 정의해요.

Bean Validation i18n
import jakarta.validation.constraints.*;

public class CreateUserRequest {

    @NotBlank(message = "{validation.name.required}")
    @Size(min = 2, max = 50, message = "{validation.name.size}")
    private String name;

    @Email(message = "{validation.email.invalid}")
    private String email;
}

// In messages.properties:
// validation.name.required=Name is required
// validation.name.size=Name must be between {min} and {max} characters
// validation.email.invalid=Please enter a valid email address
//
// In messages_de.properties:
// validation.name.required=Name ist erforderlich
// validation.name.size=Name muss zwischen {min} und {max} Zeichen lang sein
// validation.email.invalid=Bitte geben Sie eine gültige E-Mail-Adresse ein
4

복수형 및 변수 처리

Spring은 보간과 복수형 처리에 java.text.MessageFormat을 사용해요. ChoiceFormat 패턴은 기본적인 복수형 규칙을 처리하지만, ICU 복수형 전체를 지원하려면(아랍어 6가지 형식, 러시아어 3가지 형식) ICU4J 라이브러리를 추가하세요.

MessageFormat Plurals
# MessageFormat plural syntax in messages.properties
# Uses java.text.ChoiceFormat — NOT ICU plural rules
cart.itemCount={0,choice,0#No items|1#1 item|1<{0,number} items}

# For more complex plurals, use ICU4J:
# 1. Add dependency: com.ibm.icu:icu4j
# 2. Use ICUMessageSource instead of ResourceBundleMessageSource
#
# Then you can write ICU-style plurals:
# cart.items={count, plural, one {# item} other {# items}}

# Variables with MessageFormat:
welcome.message=Welcome, {0}! You have {1,number} new {1,choice,1#notification|1<notifications}.
order.total=Order total: {0,number,currency}
event.date=Event date: {0,date,long}
ChoiceFormat은 ICU 복수형 규칙과 달라요. CLDR 범주(zero, one, two, few, many, other)가 아니라 숫자 범위(0#, 1#, 1<)를 사용해요. 아랍어, 폴란드어, 러시아어처럼 복수형 규칙이 복잡한 언어에는 ChoiceFormat만으로 부족하므로 ICU4J의 MessageFormat을 사용하세요.

번역 자동화

i18n 설정을 마쳤다면 AI로 .properties 파일을 번역해요. IDE에서 AI 어시스턴트에게 원본 파일 번역을 요청하거나 CI/CD 파이프라인에서 i18n Agent CLI를 사용해요.

Terminal
# Translate your .properties files with AI
# In your IDE, ask your AI assistant:
> Translate src/main/resources/messages.properties to German, Japanese, and Spanish

✓ messages_de.properties created (1.2s)
✓ messages_ja.properties created (1.5s)
✓ messages_es.properties created (1.1s)

# Or use the CLI in CI/CD:
npx i18n-agent translate src/main/resources/messages.properties --lang de,ja,es
점진적으로 번역하세요. messages.properties에 새 키를 추가할 때 모든 로케일 파일을 다시 생성하지 말고 새 키만 번역해요. 그러면 기존 파일에서 사람이 검토한 번역을 보존할 수 있어요.

번역 품질 자동화

i18n-validate로 출시 전에 누락된 키와 손상된 플레이스홀더를 찾아요. 실제 번역이 준비되기 전에 i18n-pseudo의 의사 번역으로 UI를 테스트해요.

spring-locale-chain으로 구성 없이 시작

spring-locale-chain은 종속성 하나만으로 LocaleResolver, LocaleChangeInterceptor, 지원 로케일 유효성 검사를 자동 구성하는 오픈 소스 Spring Boot 스타터예요. application.yml에 지원할 로케일을 정의하면 나머지는 라이브러리가 처리해요.

pom.xml
<!-- Add spring-locale-chain for zero-config locale resolution -->
<dependency>
    <groupId>io.github.i18n-agent</groupId>
    <artifactId>spring-locale-chain</artifactId>
    <version>1.0.0</version>
</dependency>

권장 파일 구조

Project Structure
my-spring-app/
├── src/main/
│   ├── java/com/example/
│   │   ├── config/
│   │   │   ├── I18nConfig.java          # MessageSource bean
│   │   │   └── LocaleConfig.java        # LocaleResolver + interceptor
│   │   ├── controller/
│   │   │   └── HomeController.java      # Uses MessageSource
│   │   └── MyApplication.java
│   └── resources/
│       ├── messages.properties          # Default (English)
│       ├── messages_de.properties       # German
│       ├── messages_ja.properties       # Japanese
│       ├── messages_es.properties       # Spanish
│       ├── application.yml              # Spring config
│       └── templates/
│           └── home.html                # Thymeleaf with #{...}
├── pom.xml
└── build.gradle

흔히 발생하는 문제

Non-ASCII 문자가 깨져 표시됨

Java .properties 파일의 기본 인코딩은 UTF-8이 아니라 ISO-8859-1이에요. 움라우트(ü)나 CJK 문자 같은 문자가 깨져 보여요. 해결 방법: application.yml에서 spring.messages.encoding=UTF-8로 설정하거나 .properties 파일에서 \u00FC 같은 유니코드 이스케이프를 사용하세요. Spring Boot의 ReloadableResourceBundleMessageSource는 기본적으로 UTF-8을 사용하지만 ResourceBundleMessageSource는 그렇지 않아요.

영어 이외의 복수형에서 작동하지 않는 ChoiceFormat

Java의 ChoiceFormat 패턴({0,choice,0#|1#|1<})은 숫자 범위만 지원하며 'few'나 'many' 같은 CLDR 복수형 범주를 표현할 수 없어요. 아랍어(6가지 형식), 폴란드어(3가지 형식), 러시아어(3가지 형식)의 복수형을 올바르게 처리하려면 ICU4J가 필요해요. ChoiceFormat이 모든 언어를 처리한다고 가정하지 마세요.

번역 변경 사항이 반영되지 않음

ResourceBundleMessageSource는 기본적으로 번들을 무기한 캐시해요. 개발 중에는 cacheSeconds=0으로 설정한 ReloadableResourceBundleMessageSource를 사용하면 재시작하지 않고 변경 사항을 확인할 수 있어요. 프로덕션에서는 성능과 업데이트 속도의 균형을 위해 적절한 캐시 기간(예: 3600초)을 설정하세요.

예기치 않은 JVM 로케일 폴백

기본적으로 Spring은 messages.properties 파일이 아니라 JVM의 기본 로케일(Locale.getDefault())로 폴백해요. 항상 기본 번들을 사용하려면 application.yml에서 spring.messages.fallback-to-system-locale=false로 설정하세요. 그렇지 않으면 요청된 로케일에 키가 없을 때 JVM 로케일이 'fr'로 설정된 서버에서는 영어 대신 프랑스어가 표시돼요.

지금 i18n Agent 사용해 보기

번역 파일을 여기에 드롭

JSON, YAML, PO, XML, CSV, Markdown, Properties

또는 클릭하여 파일 선택

대상 언어

가입 불필요즉시 견적

spring-locale-chain을 활용한 로케일 폴백

pt-BR 같은 지역 로케일에 번역 키가 없으면 Spring Boot는 상위 로케일 pt를 먼저 확인하지 않고 기본 로케일로 바로 이동해요.

Terminal
<!-- Maven -->
<dependency>
  <groupId>ai.i18nagent</groupId>
  <artifactId>spring-locale-chain</artifactId>
</dependency>
Configuration
# application.yml
locale-chain:
  fallbacks:
    pt-BR:
      - pt
      - en
    zh-Hant-HK:
      - zh-Hant
      - zh
      - en

지원되는 프레임워크 전체 목록과 75개의 기본 제공 체인은 로케일 폴백 가이드에서 확인하세요. Learn more →

자주 묻는 질문