Spring 의존성을 격리한 다국어 처리 클래스 설계

img_1.png

1. 서론

회사 제품을 개발, 유지보수하는 과정에서, 기존에 하드코딩되어 있던 문자열을 중앙화하고 다국어(i18n)를 지원해야 하는 업무를 맡게 되었다.

이 제품은 수년간 Spring 기반으로 개발되어 왔지만, 장기적으로는 특정 프레임워크에 종속되지 않는 구조를 목표로 하고 있었다. 이 목표에 따라 문자열 처리와 같은 공통적으로 타 프로젝트에서도 쓰일수 있는 기능은 Spring에 최대한 독립적으로 유지해서 사내 다른 프로젝트에서도 쓸 수 있도록 common-sdk화 하자는 방향성이 있었다.

Spring이 제공하는 MessageSource 빈을 주입받아 활용하면 비교적 편하게 다국어를 적용할 수 있다. 그러나 이 방식은 메시지 조회하는 모든 객체가 Spring IoC 컨테이너에 의존하게 되어, Enum이나 DTO처럼 컨테이너 관리 볌위 밖에 존재하는 객체에서는 MessageSource를 주입받기 어렵다는 한계가 있다.

이 글에서는 Spring이 제공하는 편리함(LocaleContextHolder)과 Java 표준 API(ResourceBundle)를 적절히 조합하여, 프레임워크와의 결합도를 최소화하면서도 어디서든 접근 가능한 다국어 처리 클래스를 설계하고 구현한 과정을 정리해본다.

2. 문제 분석

2.1 Spring MessageSource 초기화

Spring의 다국어 처리는 기본적으로 ApplicationContextMessageSource 인터페이스를 상속받아 구현하는 구조다.

spring frameworkspring boot문서 문서 참고

아래의 AbstractApplicationContext#initMessageSource 코드를 보면, Spring은 컨텍스트 초기화 시점에 다음 중 하나를 수행한다.

  • 사용자 정의 messageSource Bean이 있으면 이를 사용
  • 없다면 DelegatingMessageSource를 기본 등록

즉, MessageSource는 전략 패턴을 기반으로 한 빈이며, ResourceBundleMessageSource, ReloadableResourceBundleMessageSource 같은 구현체가 Spring IoC 컨테이너에 의해 관리되는 구조인 것이다.

protected void initMessageSource() {
    ConfigurableListableBeanFactory beanFactory = getBeanFactory();
    if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
        this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
        // Make MessageSource aware of parent MessageSource.
        if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource hms &&
                hms.getParentMessageSource() == null) {
            // Only set parent context as parent MessageSource if no parent MessageSource
            // registered already.
            hms.setParentMessageSource(getInternalParentMessageSource());
        }
        if (logger.isTraceEnabled()) {
            logger.trace("Using MessageSource [" + this.messageSource + "]");
        }
    }
    else {
        // Use empty MessageSource to be able to accept getMessage calls.
        DelegatingMessageSource dms = new DelegatingMessageSource();
        dms.setParentMessageSource(getInternalParentMessageSource());
        this.messageSource = dms;
        beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource);
        if (logger.isTraceEnabled()) {
            logger.trace("No '" + MESSAGE_SOURCE_BEAN_NAME + "' bean, using [" + this.messageSource + "]");
        }
    }
}

공식 문서에서는 MessageSource는 다음과 같이 주입받아 사용하는 예시를 제시한다.

public class Example {
 
	private MessageSource messages;
 
	public void setMessages(MessageSource messages) {
		this.messages = messages;
	}
 
	public void execute() {
		String message = this.messages.getMessage("argument.required",
			new Object [] {"userDao"}, "Required", Locale.ENGLISH);
		System.out.println(message);
	}
}

2.2. 핵심 문제: 생명주기의 불일치

하지만 Enum에 다국어를 적용하려고 할 때 문제가 발생했다

Enum은 Java 클래스 로더에 의해 클래스가 로딩되는 시점에 생성되고 메모리에 고정된다(static final). 반면, MessageSource와 같은 Spring Bean은 그 이후 Spring 컨테이너가 초기화되는 런타임 시점에 생성된다.

이러한 생명주기의 차이 인해 Enum내부에서는 Spring Bean을 직접 주입받을 수 없다. 물론 몇 가지 방법(ApplicationContext로 빈 주입, Setter 활용한 PostConstruct 시점에 주입 등)이 존재하지만, 이는 데이터를 표현해야 할 순수 객체(Enum)가 Spring에 강하게 결합되는 결과를 가져온다.

이는 미래의 탈 Spring을 위한 "프레임워크 독립적인 다국어 처리"라는 우리 팀의 방향성과도 배치되는 방식이었다.

3. 설계: 의존성 격리

설계의 핵심은 "무엇을 Spring에 맡기고, 무엇을 격리할 것인가"였다. 나는 다음과 같이 역할을 분리했다.

  • Locale 결정: Spring의 LocaleContextHolder 활용
  • 메시지 조회: Java 표준 라이브러리 ResourceBundle 활용
  • 조합 객체: 이 둘을 연결하는 유틸리티 클래스를 두어 의존성을 한 곳으로 격리

결론적으로, Spring에 의존하는 코드를 I18nMessage라는 유틸리티 클래스 단 한 곳으로 몰아넣는 전략을 취했다.

img.png

이렇게 설계하면 향후 탈 Spring 진행 시, 비즈니스 코드는 수정할 필요 없이 I18nMessage 내부의 LocaleContextHolder 호출부만 Java ThreadLocal을 활용한 자체 LocaleContextHodler로 교체하면 마이그레이션이 완료된다.

4 구현

설계에 따라 구현한 유틸리티 클래스다. Spring 라이브러리는 LocaleContextHolder 단 하나만 import 하며, 나머지는 모두 Java 표준 라이브러리다.

import java.util.ResourceBundle;
 
class I18nMessage {
    private static final String BUNDLE_NAME = "messages"; 
    
    public static String getMessage(String code, Object... args) {
        return MessageFormat.format(ResourceBundle.getBundle(BUNDLE_NAME, LocaleContextHolder.getLocale()).getString(code), args);
    }
 
}

위와 같은 간단한 클래스를 만들었고 전역에 흩어진 모든 문자열들을 message_ko.propeties, message_en.properties에 중앙 관리 되도록 리팩토링을 실시했다. 다음 코드는 적용된 모습의 예시이다.

이제 Enum은 Spring을 전혀 모르는 상태로 다국어 메시지를 제공할 수 있게 되었다.

public enum ErrorCode {
    INVALID_INPUT("error.invalid.input"),
    USER_NOT_FOUND("error.user.not.found");
 
    private final String messageKey;
 
    ErrorCode(String messageKey) {
        this.messageKey = messageKey;
    }
 
    public String getMessage(Object... args) {
        return I18nMessage.getMessage(this.messageKey, args);
    }
}

5. 결론 및 배운 점

5.1 전략적 타협

LocaleContextHolder를 사용했기에 100% 순수 자바 코드는 아니다. 하지만 의존성을 I18nMessage 클래스 하나로 제한함으로써, 나머지 영역은 Spring의 변경이나 제거로부터 독립적 일 수 있게 되었다.

언젠가 Spring을 걷어 낼때 다국어 관련 비즈니스로직은 건드릴 필요가 없으니 미래의 변화에 유연히 대응가능한 구조를 확립했다.

5.2 배운 점

  • Enum과 Bean의 생성 시점 차이를 이해하고, 이를 우회하기 위해 정적 유틸 클래스 구현하여 활용하는 법을 익힘
  • Spring의 MessageSource 초기화, 동작 코드를 살펴봄으로써 Spring의 동작 원리를 좀 더 깊이 이해할 수 있었음
  • Spring 기능의 내부 동작을 분석한 뒤 필요한 부분만 선택적으로 재구성해보는 경험
  • 무조건적으로 Spring 기능을 사용하기보다, "이 코드가 미래의 변경에 유연하게 대응할 수 있는가?"를 고민해보는 태도 생김

6. 참고자료