본문 바로가기
Java/Spring

messages.properties 숫자 대신 문자 인덱스 사용하기

by 서사주 2022. 6. 27.

국제화를 진행할 때 messages.properties는 빼놓을 수 없다.

로케일 설정에 따라 같은 코드에 다른 메세지를 보여줄 수 있다는 점에서 굉장히 훌륭하지만, 메세지를 관리할 때 한 가지 문제가 있었다.

 

# messages.properties
test.age=나는 {0}살입니다.
test.introduce=제 이름은 {0}이고, 나이는 {1}살입니다.

 

인덱스가 숫자로 되어 있어 messages.properties만 봤을 때 어떤 값이 들어가야 하는지 문맥을 보고 유추를 해야하는 점이다.

그나마 파라미터가 1개일 때는 괜찮은데, 2개 이상으로 넘어가면 알아보기가 너무 어렵다. 지금은 한국어로 예시를 작성해서 알아보기가 편하지만, 한국어 이외의 다른 외국어를 사용할 때는 어떤 값이 들어가는지 몰라 앞이 깜깜해진다.

 

나는 messages.properties를 이렇게 바꿔보고 싶었다.

 

test.age=나는 {age}살입니다.
test.introduce=제 이름은 {name}이고, 나이는 {age}살입니다.

 

 

우선, 무작정 위 코드처럼 messages.properties를 만들고 컨트롤러를 만들어 테스트를 해보았다.

 

@RestController
@RequiredArgsConstructor
public class MessageController {

    private final MessageSource messageSource;

    @GetMapping("message")
    public String message(String name, String age) {
        return messageSource.getMessage("test.introduce", new Object[]{name, age}, Locale.getDefault());
    }
}

 

당연히 실패했다.

 

2022-01-19 12:51:40.592 ERROR 24062 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalArgumentException: can't parse argument number: name] with root cause

java.lang.NumberFormatException: For input string: "name"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) ~[na:na]
	at java.base/java.lang.Integer.parseInt(Integer.java:652) ~[na:na]
	at java.base/java.lang.Integer.parseInt(Integer.java:770) ~[na:na]
	at java.base/java.text.MessageFormat.makeFormat(MessageFormat.java:1449) ~[na:na]
	at java.base/java.text.MessageFormat.applyPattern(MessageFormat.java:491) ~[na:na]
	at java.base/java.text.MessageFormat.<init>(MessageFormat.java:390) ~[na:na]
	...

 

MessageFormat에서 예외가 호출되는 것을 확인할 수 있었다.

 

 

디버깅 결과, segments[SEG_INDEX] 값이 “name”인 것을 확인할 수 있었고, 이를 Integer로 파싱하는 과정에서 NumberFormatExcpetion이 발생한 것이었다.

{name}이 아닌 {0}이었다면 처음에 넘겨준 매개변수 배열의 0번째 값으로 변환하는 과정을 거쳤겠지만, name번이라는건 없다.


해결책

  1. messages.properties에 저장된 텍스트를 그대로 불러온다.
  2. {name}, {age} 처럼 중괄호로 묶인 부분을 직접 변환한다.

Spring은 MessageSource를 쉽게 사용하기 위한 MessageSourceAccessor라는 헬퍼 클래스를 제공하고 있다.

이 MessageSourceAccessor를 재구성하여 문자 인덱스를 사용할 수 있게끔 입맛대로 바꾸는게 목표이다.

 

import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.lang.Nullable;

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

public class MessageAccessor {

    private final MessageSource messageSource;

    @Nullable
    private final Locale defaultLocale;

    public MessageAccessor(MessageSource messageSource) {
        this.messageSource = messageSource;
        this.defaultLocale = null;
    }

    public MessageAccessor(MessageSource messageSource, Locale defaultLocale) {
        this.messageSource = messageSource;
        this.defaultLocale = defaultLocale;
    }

    protected Locale getDefaultLocale() {
        return this.defaultLocale != null ? this.defaultLocale : LocaleContextHolder.getLocale();
    }

    public String getMessage(String code, Map<String, Object> params) {
        String message = this.messageSource.getMessage(code, null, getDefaultLocale());

        for (Map.Entry<String, Object> entry : params.entrySet()) {
            if (entry.getValue() != null) {
                message = resolveMessage(message, entry.getKey(), entry.getValue().toString());
            }
        }

        return message;
    }

    private String resolveMessage(String message, String key, String value) {
        String target = "{" + key + "}";
        return message.replace(target, value);
    }
}

 

생성자와 getDefaultLocale 메소드는 MessageSourceAccessor 클래스에서 가져왔다.

getMessage와 resolveMessage 메소드에서 큰 차이가 있다. getMessage를 컨트롤러의 매개변수로 넘어온 값들을 Map 컬렉션으로 받아 처리하게끔 변경하였다. 그리고 반드시 중괄호로 묶인 문자 인덱스에 한해서만 변환하도록 했다.

 

@Configuration
@RequiredArgsConstructor
public class AppConfig {

    private final MessageSource messageSource;

    @Bean
    public MessageAccessor messageAccessor() {
        return new MessageAccessor(messageSource);
    }
}

 

@RestController
@RequiredArgsConstructor
public class MessageController {

    private final MessageSource messageSource;
    private final MessageAccessor messageAccessor;

    @GetMapping("message")
    public String message(String name, String age) {
        return messageSource.getMessage("test.introduce", new Object[]{name, age}, Locale.getDefault());
    }

    @GetMapping("new-message")
    public String newMessage(String name, String age) {
        Map<String, Object> params = new HashMap<>();
        params.put("name", name);
        params.put("age", age);

        return messageAccessor.getMessage("test.introduce", params);
    }
}

 

/new-message를 동일한 파라미터를 받아 새로 만든 MessageAccesor를 사용하여 결과를 보면 성공적으로 출력이 되는 것을 확인할 수 있다.

 

사실 27살이다.


한계점

문자 인덱스로 넘긴 값이 어떤 것인지 알기 위해 {name} = {name}이라는 텍스트를 작성했다고 가정해보자.

원하는 결과는 {name} = 석상준이지만, 실제 실행 결과는 석상준 = 석상준으로 출력이 된다.

지금은 변환 로직을 간단히 했지만, 상황에 맞추어 변화를 주어야 할 것 같다.