본문 바로가기
Spring/Common

[Spring] 스프링 메세지와 국제화 정리

by coding_whale 2026. 5. 24.
반응형

1. 도입부 (Introduction)

웹 애플리케이션을 개발하고 운영하다 보면, 화면에 노출되는 수많은 텍스트(라벨, 메시지, 오류 문구 등)를 관리해야 하는 시점이 찾아온다. 처음에는 HTML 파일 곳곳에 직접 "상품명", "가격"과 같은 단어들을 직접 타이핑하곤 하지만, 요구사항이 변경되어 "상품명"을 "아이템 이름"으로 한 번에 변경해야 한다면 어떨까? 수십, 수백 개의 HTML 템플릿을 일일이 열어서 수정해야 하는 비효율적인 상황, 즉 '유지보수의 재앙'을 마주하게 된다.

이러한 문제를 깔끔하게 해결하기 위해 스프링이 지원하는 무기가 바로 메시지(Message) 기능국제화(Internationalization, i18n) 기능이다. 공통 텍스트를 파일 하나로 격리하여 중앙 집중식으로 관리하고, 나아가 접속하는 사용자 국가(Locale)에 맞추어 언어를 자동으로 번역하여 서비스하는 메커니즘을 학습해 본다.

 

 

2. 주요 특징 및 핵심 로직 (Main Features & Logic)

스프링 메시지 및 국제화 시스템의 핵심은 텍스트 리소스의 외부화클라이언트 세션 분석을 통한 동적 바인딩에 있다. 이 아키텍처가 실제로 어떻게 협업하는지 전체 흐름을 한눈에 파악해 본다.

이 시스템을 구성하는 주요 컴포넌트는 크게 세 가지 축으로 나뉜다.

  1. MessageSource: 공통 리소스 파일(*.properties)을 읽어 자바 객체 혹은 템플릿으로 메시지를 꺼내 주는 인터페이스다.
  2. ResourceBundle: 언어와 지역 정보에 따라 그룹화된 텍스트 자원들의 집합이다.
  3. LocaleResolver: 사용자의 요청 정보(헤더, 쿠키, 세션 등)를 바탕으로 현재 접속 지역이 어디인지 식별하는 결정기다.

 

 

3. 상세 가이드 및 심층 분석 (Detailed Guide)

5.1 스프링 메시지 소스(MessageSource) 설정 방법

스프링 프레임워크가 제공하는 메시지 관리 기능을 사용하기 위해서는 MessageSource를 스프링 컨테이너에 빈으로 등록해야 한다. 과거 스프링 부트 이전의 방식과 부트의 자동 설정을 비교하여 차이점을 확실히 인지하자.

① 직접 빈 등록하기 (수동 설정 방식)

설정 클래스를 작성하여 ResourceBundleMessageSource 인스턴스를 수동 빈으로 등록하는 정석적인 구조다.

@Configuration
public class MessageConfig {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        // 읽어들일 설정 파일의 기본 이름(basename)을 복수로 지정한다.
        messageSource.setBasenames("messages", "errors");
        // 한글이나 특수문자 깨짐을 원천적으로 막기 위해 UTF-8 인코딩을 적용한다.
        messageSource.setDefaultEncoding("utf-8");
        
        return messageSource;
    }
}
  • setBasenames: 리소스 폴더의 어떤 파일을 기준으로 메시지를 파싱할지 결정한다. messages로 설정하면 프로젝트의 src/main/resources/messages.properties 파일을 조회하며, 끝에 국가 코드를 붙여 국제화에 매핑할 수 있다.
  • setDefaultEncoding: 인코딩을 명시한다. 현대 웹 개발에서 UTF-8은 선택이 아닌 필수다.

 

② 스프링 부트의 자동 설정 (Auto Configuration)

스프링 부트를 사용하면 위와 같은 수동 정의 빈 설정 코드는 완전히 생략해도 무방하다. 스프링 부트는 우리가 별도의 메시지 소스 설정을 선언하지 않으면 messages라는 basename으로 MessageSource 빈을 자동으로 컨텍스트에 등록한다.

만약 읽고 싶은 파일의 위치나 이름을 다변화하고 싶다면 application.properties에 아래 한 줄만 추가하면 끝난다.

# application.properties 설정 예시 (여러 개의 파일 지정 시 쉼표로 나열한다)
spring.messages.basename=messages,config.i18n.messages
spring.messages.encoding=UTF-8

 

5.2 테스트 코드로 검증하는 MessageSource

MessageSource 인터페이스의 실제 작동 방식과 예외가 발생하는 모서리 케이스(Edge Case)들을 JUnit5 통합 테스트를 통해 빈틈없이 검증해 보자.

우선 검증에 앞서 src/main/resources/messages.properties를 루트 경로에 생성하고 테스트 데이터를 작성한다.

# messages.properties
hello=안녕
hello.name=안녕 {0}

이후 MessageSource 빈을 자동 주입(Autowired)받아 다음과 같이 통합 테스트를 실행한다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;

import java.util.Locale;

import static org.assertj.core.api.Assertions.*;

@SpringBootTest
public class MessageSourceTest {

    @Autowired
    MessageSource ms;

    @Test
    @DisplayName("Locale 정보가 없는 경우 디폴트 파일에서 메시지를 성공적으로 추출한다")
    void helloMessage() {
        // Locale이 null이면 지정된 디폴트 파일인 messages.properties에서 키를 조회한다.
        String result = ms.getMessage("hello", null, null);
        assertThat(result).isEqualTo("안녕");
    }

    @Test
    @DisplayName("존재하지 않는 메시지 코드를 조회하는 경우 NoSuchMessageException이 발생한다")
    void notFoundMessageCode() {
        assertThatThrownBy(() -> ms.getMessage("no_code", null, null))
                .isInstanceOf(NoSuchMessageException.class);
    }

    @Test
    @DisplayName("코드가 없더라도 defaultMessage 매개변수를 넘겨주면 해당 기본 메시지를 반환한다")
    void notFoundMessageCodeDefaultMessage() {
        String result = ms.getMessage("no_code", null, "기본 메시지", null);
        assertThat(result).isEqualTo("기본 메시지");
    }

    @Test
    @DisplayName("중괄호 표기법이 적용된 템플릿 메시지에 동적 매개변수를 전달해 문자열을 치환한다")
    void argumentMessage() {
        // hello.name=안녕 {0} 위치에 Object 배열로 건넨 "Spring" 단어가 정상 바인딩된다.
        String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
        assertThat(result).isEqualTo("안녕 Spring");
    }

    @Test
    @DisplayName("구체적인 Locale 언어 파일이 존재하지 않는 경우 디폴트 언어로 자동 폴백(Fallback)한다")
    void defaultLang() {
        // ko_KR 파일이 물리적으로 매핑되지 않았기에 디폴트 설정(messages.properties)을 정상 선택한다.
        assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
    }
}

 

5.3 웹 애플리케이션의 국제화 실무 적용

실제 프로젝트에서 한글 서비스와 영문 다국어 서비스를 완벽히 분리하고 화면에 연동하는 과정을 차례로 구성해 보자.

① 리소스 번들 파일 준비

한국인과 미국인 접속자 모두를 만족시킬 수 있는 프로퍼티 구조를 생성한다.

src/main/resources/messages.properties (디폴트 한글 파일)

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

src/main/resources/messages_en.properties (영어 대응 국제화 파일)

label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity

page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update

button.save=Save
button.cancel=Cancel

 

② 타임리프(Thymeleaf) 연동 뷰 구성

타임리프는 스프링의 MessageSource 구현체와 완벽하게 추상화 통합이 이루어져 있다. HTML 템플릿 파일 내부에서 #{...} 문법을 활용하면 메시지 코드에 직접 접근할 수 있다.

<!-- Thymeleaf 템플릿 파일 일부 -->
<h2 th:text="#{page.addItem}">상품 등록 (서버 미기동 시 보여지는 가짜 라벨)</h2>
<form action="" method="post">
    <div>
        <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
        <input type="text" id="itemName" name="itemName">
    </div>
    <div>
        <label for="price" th:text="#{label.item.price}">가격</label>
        <input type="text" id="price" name="price">
    </div>
    
    <button type="submit" th:text="#{button.save}">저장</button>
    <button type="button" th:text="#{button.cancel}">취소</button>
</form>

 

 

6. 실무 적용 팁 및 주의사항 (Actionable Tips & Warnings)

6.1 Accept-Language 분석의 명암

웹 브라우저는 현재 클라이언트 컴퓨터 OS 언어 설정에 기인하여 HTTP 요청 메시지를 쏠 때 헤더에 Accept-Language 값을 알아서 설정하여 전달한다.

하지만, 실무 환경에서는 브라우저 기본 언어 감지만으로 다국어를 결정하기엔 한계가 존재한다. 공용 PC나 사내 가상 사설망(VPN) 환경, 혹은 해외 사용자가 국내 출장을 와서 PC를 사용하는 등 '인위적인 강제 다국어 번역' 요구사항이 빈번하게 들어오기 때문이다.

 

6.2 다국어 수동 변경을 위한 LocaleResolver 커스터마이징

이러한 경우 브라우저 요청의 헤더를 넘어, 수동으로 언어 선택 버튼("KOR | ENG")을 명시적으로 클릭했을 때 세션이나 쿠키 기반으로 Locale 정보를 서버가 영구적으로 기억하도록 커스터마이징을 수행해야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public LocaleResolver localeResolver() {
        // 브라우저의 Accept-Language 헤더가 아닌 WAS의 세션에 Locale 정보를 계속 유지하게 한다.
        SessionLocaleResolver localeResolver = new SessionLocaleResolver();
        localeResolver.setDefaultLocale(Locale.KOREA);
        return localeResolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        // 인터셉터가 파라미터 감시자로 동작하여 ?lang=en 과 같이 특정 파라미터 유무를 감지해낸다.
        LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
        lci.setParamName("lang");
        return lci;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 스프링 MVC 인프라에 감시 장치인 인터셉터를 등록한다.
        registry.addInterceptor(localeChangeInterceptor());
    }
}
  • 동작 매커니즘: 위 설정을 가동하는 순간, 사용자가 한 번이라도 http://localhost:8080/items?lang=en 주소로 접속하면, LocaleChangeInterceptor가 파라미터를 읽어 해당 사용자의 세션 내 Locale 데이터를 즉시 Locale.ENGLISH로 갱신해 준다. 이후에는 주소창에 파라미터를 떼어내고 다녀도 세션이 유지되는 한 영문으로 번역된 화면이 깔끔하게 응답된다.

 

 

7. 결론 및 복습 질문 (Conclusion & Q&A)

스프링 프레임워크가 제공하는 메시지와 국제화 인프라를 잘 세팅해 두면, 하드코딩이 존재하지 않는 극강의 확장성 높은 뷰 코드를 만들 수 있다. 더 나아가 유효성 검증(Validation) 에러 처리가 유입될 때 예외 클래스의 코드값과 MessageSource를 긴밀히 조율하면 비즈니스 알림 레이어를 빌드하는 토대가 되어준다.

 

2. 관련 태그 (10가지)

#Spring #SpringBoot #MessageSource #i18n #국제화 #다국어지원 #LocaleResolver #Thymeleaf #AcceptLanguage #백엔드개발

반응형