현재 개발 중인 어드민 사이트는 다국어 지원이 되어야 한다. 

내가 맡은 부분은 서버 즉 백엔드 부분이므로, 설정한 언어에 맞게 Error Message를 클라이언트로 전달해야 했다.

 

기존 구 어드민 사이트에서는 .properties 파일에 Message들을 저장했지만 나는 DB에 저장하여 관리키로 함.

전략이라 할 것도 없지만.. 어쨌든 내가 가고자 하는 방향에 맞는 전략은 아래와 같다.

(내 전략의 방향이 맞는지, 그리고 이것을 구현한 방법이 맞는지 아직도 의문이다. 따라서 혹시라도 이것을 보시고 더 좋은 방법이나 틀린 부분이 있다고 느끼시는 분들이 있다면 Comment로 남겨주시면 제가 그것을 감사히 읽어보고 수정하도록 하겠습니다.)

 

- 에러 메시지는 DB에 저장

- 에러 메시지를 클라이언트로 전달할 때는 Caching되어진 데이터를 전달하고, 데이터는 locale과 messageKey 값으로 찾음 

- Exception은 @RestControllerAdvice로 지정한 class에서 처리

 

@RestContorllerAdvice로 설정된 전역 ExceptionHandler가 클라이언트에서 전달한 Accept-Language Header값에 맞는 Exception Message로 전달하는 역할을 담당한다. 각 메소드는 Exception에 대한 MessageKey를 가지고 있어 Caching되어진 Exception Data에서 MessageKey와 Locale 정보에 맞는 Exception Message를 불러와서 Client로 전달한다. 단, 어떤 Exception 은 구체화된 에러 메시지를 내려줄 필요성이 있기에 이런 경우에는 exception을 throw할 때 overriding한 CustomException의 messageKey를 set해서 전달한다. 아래 로직은 @ExceptionHandler가 Exception을 Client에 전달하기 전에 수행하는 작업에 대한 내용이다. 

@RestControllerAdvice
@Slf4j
public class AdminExceptionHandler {
    @ExceptionHandler(ForbiddenRequestException.class)
    public ResponseEntity<?> handleForbiddenRequestException(ForbiddenRequestException e, Locale locale) {
        log.error(ExceptionUtils.getExceptionMessage(e));
        String messageKey;
        if (e.getMsgKey() != null) {
        	messageKey = e.getMsgKey();
        } else {
            	messageKey = "forbidden.request";
        }
        return ApiMessage.builder().errorMessage(resource.getMessage(CacheKeys.builder().messageKey(messageKey).locale(locale.toString()).build())).status(e.getStatus()).build().toEntity();
    }
}

 

그리고 아래 부분은 DefaultLocale 값을 설정하고, Accept-Language 값을 Locale로 설정하기 위해 구현한 내용이다. 

나는 CookieLocaleResolver를 사용했다. 아래 설정을 마치면 @ExceptionHandler로 지정한 메소드로 전달한 Locale값이 Accept-Language 헤더값으로 잘 전달된다. 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Arrays;
import java.util.Locale;

@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }


    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver localeResolver = new CookieLocaleResolver();
        localeResolver.setDefaultLocale(Locale.KOREA);
        return localeResolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
        lci.setParamName("lang");
        return lci;
    }
}

 

다국어 데이터를 Caching 하여 필요할 때 꺼내쓰려면 messageKey와 locale 두개의 값이 Caching 데이터의 Key가 되어야 했고 이를 위해 아래 CacheKeys라는 아래 class를 만들었다. 

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;
import java.util.Objects;

@Getter
@Setter
@Builder
public class CacheKeys implements Serializable {
    private String messageKey;
    private String locale;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CacheKeys cacheKeys = (CacheKeys) o;
        return Objects.equals(messageKey, cacheKeys.messageKey) &&
                Objects.equals(locale, cacheKeys.locale);
    }

    @Override
    public int hashCode() {
        return Objects.hash(messageKey, locale);
    }
}

 

getMessage 메소드가 캐싱하는 대상 메소드이다.

보시다시피 key는 위에 정의한 CacheKey 클래스의 messageKey와 locale 값이다. 

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import java.util.List;

@Slf4j
@Component
public class ErrorMessageComponent {

    @Autowired
    ErrorMsgI18nRepository errorMsgI18nRepository;

    @Cacheable(value = "errorMsgCache", key = "#cacheKeys")
    public String getMessage(CacheKeys cacheKeys) {
        log.debug("by getMessage method -> not by cache");
        return errorMsgI18nRepository.findByMsgKeyAndLocale(cacheKeys.getMessageKey(), cacheKeys.getLocale()).orElse("CACHE KEYS ERROR");
    }
}

 

class가 key로 되게 하기 위해서는 우선 내가 사용한 ehcache의 xml파일에 정의를 해주어야 한다.

key가 바로 CacheKeys라는 것을 명시해주고, value는 String임을 정의해준다.

<expiry>를 보면 서비스가 run하는 중에는 만료되지 않게 하기 위해 <none/>를 해준 것을 알 수 있다. 

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.ehcache.org/v3"
        xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
        xsi:schemaLocation="
            http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd
            http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

    <cache alias="errorMsgCache"> 
        <key-type>com.test.entity.CacheKeys</key-type> 
        <value-type>java.lang.String</value-type>
        <expiry>
            <none/>
        </expiry>

        <resources>
            <heap unit="entries">10</heap> 
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>
</config>

 

 

  • Ehcache
    • Hibernate와 캐시 제공자 사이의 다리 역할
    • redis / memchached와 달리 별도 서버를 가지지 않으며, Spring 내부적으로 동작하여 캐싱함
    • 캐시하고 싶은 메소드에 @Cacheable(value = "xxx")를 붙여주면 된다. 
      • Second-Level Cache란?
        • 세션 단위의 1차 레벨 캐시라는 개념을 적용한게 Hibernate / 세션이 종료되면 First-Level Cache도 종료
        • Second-Level Cache는 SessionFactory 범위이므로 모든 세션에서 같은 SessionFactory를 공유한다. 
        • 엔티티가 Second-Level Cahing 활성화 상태가 되면? 
          • 인스턴스가 First-Level-Cache에 있는 경우엔 First-Level-Cache에서 반환된다.
          • First-Level-Cache에서 인스턴스를 찾을 수 없고 해당 인스턴스 상태가 Second-Level에 Cache된 경우 거기서 데이터를 가져온다. 
          • 그렇지 않으면 필요한 데이터가 데이터베이스에서 로드되고 Instance가 assemble되고 반환된다.

 

 

일정에 쫓기어 글을 완성도 있게 마무리 하고 싶었지만 우선 이정도로 마무리.. 

언제쯤이면 일도 척척 하고 포스팅도 여유롭게 할 수 있는 경지에 다다를 수 있을런지

얼른 끝내고 다시 돌아오겠습니다~

 

 

 

 

 

참고.

gs.saro.me/dev?tn=488

+ Recent posts