다국어 처리를 위한 몇가지 고민 - 1

김나쁜 Kimbad·2023년 3월 24일
0

서비스 확장

목록 보기
1/3

i18n

i18n(Internationalization)은 언어나 지역에 따라 다른 문화와 관습을 고려하여 소프트웨어를 제작하고, 다국어 지원을 위한 기술이다.
웹 어플리케이션을 만들 때 국제화를 고려하지 않으면, 다국어 지원, 시간대(Timezone) 처리 등의 문제가 발생할 수 있다.

현재 프로젝트는 유럽 28개국에 서비스 할 Web을 제작하기 때문에 다국어 지원은 필수적이라고 할 수 있다.
Spring Framework에서는 다국어 지원을 위해 LocaleResolver 인터페이스를 제공한다. 이 인터페이스는 요청(Request)의 Locale 정보를 결정하는 역할을 한다.

LocaleResolver

Spring에서는 i18n을 지원하기 위해 LocaleResolver라는 인터페이스를 제공한다.
LocaleResolver는 클라이언트의 Locale 정보를 파악하는 인터페이스로, Spring에서는 다음과 같은 LocaleResolver를 지원한다.

종류사용
AcceptHeaderLocaleResolver헤더 값을 이용
CookieLocaleResolver쿠키를 이용
FixedLocaleResolver항상 같은 Locale, 즉 서버가 설정한 Locale 정보를 이용
SessionLocaleResolverHTTP Session을 이용

1. AcceptHeaderLocaleResolver

HTTP 요청의 Accept-Language 헤더 값을 이용하여 클라이언트의 언어 정보를 파악한다. Accept-Language 헤더에 ko_KR이라는 값이 있다면, 클라이언트가 한국어를 사용한다는 뜻으로, Spring에서 기본으로 제공하는 LocaleResolver다.

<bean id="localeResolver" class="org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver"/>

2. CookieLocaleResolver

웹 브라우저의 쿠키(Cookie)를 이용하여 클라이언트의 언어 정보를 파악한다. 클라이언트가 선택한 언어 정보를 쿠키에 저장하고, 다음에 클라이언트가 요청할 때 쿠키에서 언어 정보를 읽어온다.

<bean id="localeResolver" class="org.springframework.web.servlet.i18n.CookieLocaleResolver">
    <property name="cookieName" value="lang" />
    <property name="cookieMaxAge" value="86400" />
</bean>

3. FixedLocaleResolver

<bean id="localeResolver" class="org.springframework.web.servlet.i18n.FixedLocaleResolver">
    <!-- 이 값을 고정으로 사용-->
    <property name="defaultLocale" value="ko_KR"/> 
</bean>

4. SessionLocaleResolver

<bean id="localeResolver" class="org.springframework.web.servlet.i18n.SessionLocaleResolver">
    <property name="defaultLocale" value="en_US"/>
    <property name="defaultTimeZone" value="Asia/Seoul"/>
</bean>

SessionLocaleResolver는 LocaleResolver를 상속받은 인터페이스로, defaultTimeZone 속성을 추가로 지원한다. defaultTimeZone 속성을 이용하여, 기본 타임존을 설정할 수 있다.

이 프로젝트의 경우, 여러개의 언어 정보와 타임존 정보가 동시에 필요하기 때문에
SessionLocaleResolver를 사용하기로 했다.

Message Source 설정하기

Spring의 LocaleResolver는 Locale 정보를 세션에서 가져오거나 세션에 저장해 Locale 정보를 유지한다. 이때 Locale 정보는 메시지 프로퍼티 파일에서 메시지를 가져올 때 사용하는 Locale 정보와 동일하다.

<bean id="localeResolver" class="org.springframework.web.servlet.i18n.SessionLocaleResolver">
    <property name="defaultLocale" value="en" />
</bean>


<bean id="messageSource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
    <property name="basenames">
        <list>
            <value>/WEB-INF/messages/messages</value>
        </list>
    </property>
    <property name="cacheSeconds" value="0" />
    <property name="defaultEncoding" value="UTF-8" />
</bean>

위와 같은 설정일 때 SessionLocaleResolver에서 가져온 로케일 정보를 이용해 messageSource에서 "en" 로케일 정보에 해당하는 메시지를 가져온다.

Message Property를 설정할 때는 다음과 같다.

파일 이름: messages_{locale}.properties
{locale} 부분에는 언어 코드가 들어간다. 위의 예시와 같은 경우 messages_en.properties로 작성한다.

파일의 위치는 예시와 같은 경우 resources 디렉토리 아래에 messages 디렉토리를 생성하고 해당 디렉토리 안에 파일을 넣는다.

다국어 메시지

SessionLocaleResolver에 설정된 Locale 정보에 따라 다국어 메시지를 가져와서 View에서 보여준다. 보통 다국어 메시지는 Spring의 MessageSource를 사용하여 구현한다. MessageSource는 메시지 리소스 번들(properties 파일)을 로드하여 특정 언어로 번역된 메시지를 제공한다.

View에서는 메시지를 사용하기 위해 Spring의 태그 라이브러리인 spring:message 태그를 사용한다. spring:message 태그는 MessageSource에서 메시지를 가져와서 View에 출력하는 역할을 한다.

# message_en.properties
greeting=Greetings!

# message_ko.properties
greeting=안녕하세요!
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>

<html>
  <body>
    <h1><spring:message code="greeting" /></h1>
  </body>
</html>

위와 같이 messageSource와 페이지가 작성되어 있을 때,
LocaleResolver의 현재 Locale에 따라 해당 언어로 작성 된 프로퍼티의 값을 보여주게 된다.

경로 변수

첫번째 고민.

요구사항은 다음과 같다.

  1. 국가/언어 코드를 타임존과 다국어 처리에 활용한다.
  2. https://DOMAIN/국가/언어가 기본 경로가 되고, 국가/언어를 PathVariable로 활용할 것
  3. https://DOMAIN/국가로 들어오면 해당 국가의 기본 언어로 설정할 것
  4. https://DOMAIN으로 들어오면 DNS 서버에서 기본 국가를 붙여 리다이렉트를 시켜준다.
  5. 국가는 유럽의 타겟 국가여야 하며 언어는 타겟 국가들의 언어여야 한다.
  6. 따라서 URI로 들어온 /국가/언어에 대해 검증해야 한다.

그렇다면 Controller에서 처리하기 전에 PathVariable로 사용될 URI들에 대한 검증을 끝내야되므로 필터나 인터셉터에서 처리해야 한다.

프로젝트에서 사용되는 Asset들의 요청들은 필터나 인터셉터를 거치면 안되므로
예외 경로를 설정해주어야 하는데
현재 프로젝트가 Spring 4.3.x로 만들어졌기 때문에
filter-mapping에서 exclude-pattern을 작성할 수가 없었다.

그래서 일단은 인터셉터로 처리하기로 했다.

유효성 검증

두번째 고민.

유효한 국가/언어에 대한 검증을 하려면, Validation 처리를 하는 인터셉터 혹은 필터에서 유효 국가/언어 리스트를 가지고 있어야 하는데, 이걸 뭘로 처리하느냐다.

프로퍼티에 작성해놓고 가져오기, DB에서 가져오기, 상수로 하드코딩 하기 등 여러가지 방법을 생각해봤는데 Enum으로 처리하면 될 것 같았다.

국가 코드는 국제 표준인 ISO 3166 Alpha 2, 언어 코드는 ISO 639-1을 사용한다.

해당 국가명과 통화 코드도 추후 사용할 것 같아 Enum에 같이 만들어 두고,
가지고 있는 국가/언어 코드의 검증을 진행하는 부분도 같이 두었다.

public enum CountryEnum {
    AT("Austria", "de", "€", "Europe/Vienna"),
    BE("Belgium", "nl", "€", "Europe/Brussels"),
    BG("Bulgaria", "en", "лв", "Europe/Sofia"),
    CY("Cyprus", "en", "€", "Asia/Nicosia"),
    CZ("Czech Republic", "cs", "Kč", "Europe/Prague"),
    DK("Denmark", "da", "kr", "Europe/Copenhagen"),
    EE("Estonia", "en", "€", "Europe/Tallinn"),
    FI("Finland", "fi", "€", "Europe/Helsinki"),
    FR("France", "fr", "€", "Europe/Paris"),
    DE("Germany", "de", "€", "Europe/Berlin"),
    GR("Greece", "gr", "€", "Europe/Athens"),
    HU("Hungary", "hu", "Ft", "Europe/Budapest"),
    IS("Iceland", "en", "kr", "Atlantic/Reykjavik"),
    IR("Ireland", "en", "€", "Europe/Dublin"),
    IT("Italy", "it", "€", "Europe/Rome"),
    LV("Latvia", "en", "€", "Europe/Riga"),
    LT("Lithuania", "en", "€", "Europe/Vilnius"),
    LU("Luxembourg", "fr", "€", "Europe/Luxembourg"),
    NL("Netherlands", "nl", "€", "Europe/Amsterdam"),
    NO("Norway", "no", "kr", "Europe/Oslo"),
    PL("Poland", "pl", "zł", "Europe/Warsaw"),
    PT("Portugal", "pt", "€", "Europe/Lisbon"),
    RO("Romania", "ro", "lei", "Europe/Bucharest"),
    SK("Slovakia", "sk", "€", "Europe/Bratislava"),
    ES("Spain", "es", "€", "Europe/Madrid"),
    SE("Sweden", "sv", "kr", "Europe/Stockholm"),
    CH("Switzerland", "de", "CHF", "Europe/Zurich"),
    GB("UK", "en", "£", "Europe/London"),
    EU("Europe", "en", "€", "Europe/Paris");

    private final String country;
    private final String language;
    private final String currencySymbol;
    private final TimeZone timeZone;

    CountryEnum(String country, String language, String currencySymbol, String timeZone) {
        this.country = country;
        this.language = language;
        this.currencySymbol = currencySymbol;
        this.timeZone = TimeZone.getTimeZone(timeZone);
    }

    public String getCountry() {
        return country;
    }

    public String getLanguage() {
        return language;
    }

    public String getcurrencySymbol() {
        return currencySymbol;
    }

    public TimeZone getTimeZone() {
        return timeZone;
    }

    public static boolean isValidLanguage(String language) {
        for (CountryEnum code : values()) {
            if (code.language.equalsIgnoreCase(language)) {
                return true;
            }
        }
        return false;
    }

    public static boolean isValidCountry(String country) {
        for (CountryEnum code : values()) {
            if (code.toString().equalsIgnoreCase(country)) {
                return true;
            }
        }
        return false;
    }

    public static String getDefaultLanguage(String country) throws IllegalArgumentException {
        return CountryEnum.valueOf(country.toUpperCase()).getLanguage();
    }
}

Alpha-2 국가 코드가 국가명, 언어, 통화기호, 해당 국가의 타임존을 가리키게 된다.
이 Enum으로 인터셉터에서 처리를 진행한다.

인터셉터

만들고 나니 인터셉터가 뭔가 비효율적으로 느껴졌다.
preHandle만 구현하면 되기 때문에 postHandle과 afterCompletion 메서드는 사용되지 않는데도 빈 메서드로 구현해놓아야 했고 만약 DB에서 유효 국가를 받아오는 로직이라면 매 요청마다 DB에서 유효 국가 리스트를 받아와야한다. 불필요하기 짝이 없다.

public class PathInterceptor implements HandlerInterceptor {

    protected final Logger logger = LoggerFactory.getLogger(PathInterceptor.class);

    // @Autowired
    // private CommonService commonService;
    // DB에서 값을 가져올 시 서비스 레이어를 연결해야 한다.

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o)
            throws Exception {
        // HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE 활용?
        // Request URI에서 PathVariable로 선언된 부분을 추출
        String uri = request.getRequestURI();

        if (isDuplicateSlash(uri)) {
            // 중복 슬래시가 있는 경우 제거 후 리다이렉트
            response.sendRedirect(uri.replaceAll("/{2,}","/"));
        }

        String[] uriParts = uri.split("/");
        String country = ""; // 국가
        String language = ""; // 언어
        HttpSession session = request.getSession();

        if (CountryEnum.isValidCountry(uriParts[1])) { // uriParts[1]이 유효한 국가 코드인지 확인
            country = uriParts[1];
            // 국가 검증 후 세션의 타임존 설정
            session.setAttribute(SessionLocaleResolver.TIME_ZONE_SESSION_ATTRIBUTE_NAME, CountryEnum.valueOf(country.toUpperCase()).getTimeZone());
        } else {
            response.sendError(HttpStatus.SC_NOT_FOUND, "Invalid URL");
            return false;
        }

        if (uriParts.length >= 3 && CountryEnum.isValidLanguage(uriParts[2])) { // Depth가 2 이상 ex) /depth1/depth2 일 경우 uriParts는 3개
            // 언어 검증 후 세션의 로케일 설정
            session.setAttribute(SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME, new Locale(uriParts[2]));
            return true;
        } else { // Depth가 2 이하거나 유효한 언어 코드가 아님
            try {
                language = CountryEnum.getDefaultLanguage(country);
            } catch (IllegalArgumentException e) {
                // 유효한 언어 코드가 아닐 시 에러
                // 사실 윗쪽에서 국가 검증을 하고 넘어온 상태이기 때문에
                // 방어 코드로 작성은 했지만 에러가 발생할 일은 없다
                response.sendError(HttpStatus.SC_NOT_FOUND, "Invalid URL");
            }

            String redirectPath = uriRender(uriParts, country, language); // 위에서 검증된 국가/언어로 URI를 다시 만들어준다
            String queryString = queryStringRender(request); // QueryString이 존재한다면 달아주기

            logger.debug("[preHandle] redirectURI: {}", redirectPath);
            response.sendRedirect(redirectPath + queryString);
            return false;
        }
    }

    @Override
    public void postHandle(HttpServletRequest request,
                           HttpServletResponse response, Object o,
                           ModelAndView mav) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object o, Exception e) throws Exception {

    }

    private String uriRender(String[] uriParts, String country, String language) {
        String redirectPath = "/" + country + "/" + language;
        if (uriParts.length > 2) { // uriParts.length가 2 이하면 국가코드만 존재
            for (int i=2; i<uriParts.length; i++) {
                redirectPath += "/" + uriParts[i];
            }
        }
        return redirectPath;
    }

    private String queryStringRender(HttpServletRequest request) {
        Map<String, String[]> paramMap = request.getParameterMap();
        if (!paramMap.isEmpty()) {
            return "?" + paramMap.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue()[0])
                    .collect(Collectors.joining("&"));
        } else {
            return "";
        }
    }

    private boolean isDuplicateSlash (String uri) {
        return uri.matches(".*/{2,}.*");
    }
}

또한 작성 후 SessionLocaleResolver의 값을 바꿔주는 부분에서, 이게 세션별로 관리가 되는건가 싶은 의문도 생겼다.

SessionLocaleResolver는 싱글턴으로 Spring Application Context에서 관리되며,
로케일 정보가 각 사용자의 세션의 속성으로 저장되기 때문에 각 사용자 별로 서로 다른 로케일을 처리할 수 있다.

그리고 URI와 쿼리스트링을 다시 만들어서 리다이렉트 시키는데,
사용자에게 쿼리 스트링이 노출되는데, 민감한 정보라면 문제가 된다.

그리고 중복 슬래시가 들어오는 경우나 URI를 다시 쌓아서 리다이렉트 처리 하는 부분에서 의도한 대로 동작하지 않는 경우가 있었다.

그래서 검증하기 힘들 것 같은 국가 처리 부분에서
유효한 국가가 아니면 에러 페이지로 이동하도록 처리했다.

  • /eu/fk/test
    존재하지 않는 언어 코드로, EU의 기본 언어 코드 en을 생성 후 리다이렉트,
    /{country}/{language}/fk/test는 Mapping이 안되어있으므로 404 Error
    결과 : /eu/en/fk/test/
  • eo/en/test
    존재하지 않는 국가 코드로 404 Error

또 다른 문제로는 서비스 도중 국가가 추가되는 경우다.
그럴일은 없긴 하지만, 만약 국가가 추가 된다면 DB에서 값을 읽어와서 비교하지 않는 한
현재는 Enum기반으로 비교하고 있으므로 무조건 코드 수정(Enum에 국가 추가)를 해야한다.

혹은 인터셉터가 프로퍼티에서 값을 읽어와서 상수로 가지고 있거나, 인터셉터의 인스턴스가 생성될 때 DB에서 다국어 정보를 1회 가져오는 경우도 생각을 해봤는데,
그 경우(다국어 정보 변경이 일어날 경우) 인터셉터 Bean을 파괴하고 다시 재생성하면 서비스 재기동 없이 가능하다.

@Component
public class BeanUtil {

    private static ApplicationContext applicationContext;

    @Autowired
    private void init(ApplicationContext applicationCtx) {
        applicationContext = applicationCtx;
    }

    private static ApplicationContext getContext() {
        return applicationContext;
    }

    // ...

    public void recreatePathInterceptor() {
        ConfigurableApplicationContext configurableApplicationContext = (ConfigurableApplicationContext) applicationContext;
        configurableApplicationContext.getBeanFactory().destroySingleton("pathInterceptor");

        PathInterceptor pathInterceptor = new PathInterceptor(); // 새로운 인스턴스 생성
        configurableApplicationContext.getBeanFactory().registerSingleton("pathInterceptor", pathInterceptor);
    }
}

어플리케이션 컨텍스트를 가져와 인터셉터 Bean을 파괴하고 재등록하는 방법인데, 좋은 방법은 아니라고 한다.

이 방법은 멀티 스레딩 환경에서 안전하지 않습니다. 새 인스턴스를 생성하고 등록하는 동안 다른 스레드에서 인터셉터를 사용하려고 시도할 수 있으며, 이로 인해 예상치 못한 결과가 발생할 수 있습니다. 이 문제를 해결하려면, recreatePathInterceptor() 메서드에 synchronized 키워드를 추가하여 동기화를 강제하거나, ReadWriteLock 같은 동시성 제어 메커니즘을 사용해야 합니다.

또한, PathInterceptor가 HandlerInterceptor를 구현하는 클래스이므로, 스프링 컨테이너에서 관리되는 빈의 생명 주기에 영향을 줄 수 있습니다. 스프링은 빈 생성 및 파괴에 대한 자체 생명 주기 콜백을 가지고 있으므로, 적절한 관리를 위해 스프링의 기능에 의존해야 합니다.

위와 같은 문제에 대한 해결책은 동시성 제어 메커니즘을 사용하거나, 스프링 컨테이너의 생명 주기 관리에 의존하는 것입니다. 이러한 문제를 피하려면, PathInterceptor에서 변경된 정보를 로드하고 적용하는 방법을 고려해야 합니다. 이렇게 하면 빈을 파괴하고 재생성하지 않고도 정보를 갱신할 수 있습니다.

그냥 서비스 레이어와 연결해서 DB에서 값을 가져오기로 했다..

profile
Bad Language

0개의 댓글