Spring/Spring Boot

[Spring Boot][KoLiving] 2. i18n

noahkim_ 2023. 9. 18. 22:18

안녕하세요!

이번에는 메시지 국제화를 살펴보고 프로젝트에 적용한 것들을 포스팅 하겠습니다.

 

1. i18n 이란?

  • "Internationalization"의 축약형이며 단어의 뜻은 "국제화" 입니다.
  • 이는 "특정 언어나 지역에 종속되지 않고 다양한 지역, 언어에서 정상 동작하도록 고려하는 개발"을 말합니다
  • 서버에서 클라이언트의 응답을 내려줄 때, 응답내용이 특정 지역과 언어에 종속되지 않도록 개발하는 것을 목표로 두었습니다.

 

2. 필요성

메일 전송

  • 프로젝트내에서 사용자에게 메일을 전송하는 기능이 있습니다. (인증 이메일, 비밀번호 재설정 등)
  • 메일은 클라이언트의 모국어로 만들어진 메일로 전송되어야 합니다.
  • 내국인과 외국인에게 전송되어야 하는 메일 언어가 다릅니다.

 

예외 메시지

  • 로그인 중 잘못된 비밀번호로 로그인 시도를 하였다면, 400 응답과 함께 이에 적절한 메시지를 내려주어야 합니다.
  • 이때 클라이언트의 언어로 내려주어야 합니다.

 

example
  • "이메일 혹은 비밀번호가 유효하지 않습니다.
  • "The email or password you requested is invalid"

 

3. Spring Boot에서 구현하기

MessageSource

  • 다국어로 작성된 메시지 리소스 파일을 읽어 웹 브라우저의 로케일에 따라 각 언어별 메시지로 설정할 수 있는 컴포넌트입니다.
  • 메시지 파일
    • key-value 형식의 메시지
      • email.notempty=Please provide valid email id.
      • email.notempty=Veuillez fournir un identifiant de messagerie valide.
    • src/main/resourcesmessage_${locale}.properties

 

4. Database의 Table을 사용한 메시지 번들링

MessageBundle 구현체

ResourceBundleMessageSource
  • 자바의 ResourceBundle을 사용해 메시지를 읽습니다.

 

ReloadableResourceBundleMessageSource
  • 자바의 ResourceBundle을 사용해 메시지를 읽습니다.
  • 메시지 소스 파일에서 주기적으로 메시지를 다시 읽어 들일 수 있습니다.

 

DataBase를 선택한 이유

장점
  • 다른 팀원들이 메시지 파일을 접근하는데 어려움이 있을꺼라 생각했습니다.
    • 그에 비해 DB는 쉽게 접근이 가능합니다.
  • 메시지 내용을 추가하거나 수정하려면 서버를 재기동해야 하는 부담이 Database로 관리하는 것보다 크다 생각했습니다.
    • ReloadableResourceBundleMessageSource 구현체를 사용할 수 있었으나, 주기적으로 메시지를 다시 읽는 것도 비용이라 생각했습니다.
  • 메시지를 가공하고 다른 툴과 연동하는 것은 database의 툴을 사용하는 것이 효과적이라 생각했습니다.

 

단점
  • 쿼리 질의로 인한 오버헤드 발생
  • 데이터베이스 형상관리 툴 사용 필요

 

현재 구동중인 레디스나 JPA 캐싱 기능을 통해 극복 가능하다 생각이 들었고

데이터베이스 형상관리 툴도 서비스가 확장될수록 필수적이라 생각해서

 

이러한 결정을 내렸습니다.

 

Language Table

LOCALE
  • locale 정보에 대한 컬럼입니다.

 

MESSAGE_KEY
  • 애플리케이션에서 메시지의 키 값에 대한 컬럼입니다.

 

MESSAGE_PATTERN
  • 메시지에 대한 패턴을 저장한 컬럼입니다.
  • 파라미터를 받아 메시지를 완성하도록 하기 위해 구성하였습니다.

 

Unique Key

  •  LOCALE, MESSAGE_KEY
    • LOCALE과 MESSAGE_KEY가 같은 행은 오직 하나여야 합니다.
  • 백엔드 개발자 뿐만 아니라 다른 팀원들도 추가할 수 있으므로 제약을 거는 것이 안전하다 생각했습니다.

 

5. 어려웠던 점

내부 모듈의 의존

스프링 시큐리티나 스프링 validation 모듈의 예외 메시지 접근
  • 예외가 발생하면 내부적으로 예외에 대한 메시지 접근을 MessageSource에게 위임합니다.
  • 사용자 빈 MessageSource가 모듈의 에러메시지는 메시지 번들러를 찾아가 요청하도록 구현해야 합니다.

 

구현 방법
  • 다국어 메시지 정보가 들어있는 Language 테이블을 접근하는 사용자 빈 MessageSource
    • 기본적으로 스프링에서 빈으로 사용하였던 ResourceBundleMessageSource를 의존합니다.
    • 커스터마이징한 MessageSource에서 분기처리하여 구현체를 바꾸어 구현하였습니다.
@Configuration
public class MessageSourceConfig {

    @Bean
    public ResourceBundleMessageSource resourceBundleMessageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        
        // hibernate : jsr-303 어노테이션 에러 메시지 파일 클래스패스 등록
        messageSource.setBasename("org/hibernate/validator/ValidationMessages"); 

	// spring security : 인증, 인가 에러 메시지 파일 클래스패스 등록
	messageSource.addBasenames("org/springframework/security/messages");	 

        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }
}
@Component
@RequiredArgsConstructor
public class MessageSource extends AbstractMessageSource {

    private final ResourceBundleMessageSource resourceBundleMessageSource;
    private final LanguageRepository languageRepository;

    @Override
    protected MessageFormat resolveCode(String key, Locale locale) {
        String messagePattern = null;

        try {
            messagePattern = resourceBundleMessageSource.getMessage(key, null, locale);
        } catch (NoSuchMessageException e) {
            messagePattern = languageRepository.findByLocaleAndMessageKey(locale.toString(), key)
                    .map(Language::getMessagePattern)
                    .orElseThrow(() -> new IllegalStateException("Could not find message pattern"));
        }

        return new MessageFormat(messagePattern, locale);
    }
}
  • 먼저 ResourceBundleMessageSource으로 메시지를 조회합니다.
  • 존재하지 않을 경우, Language 테이블에서 조회합니다.

 

6. 사용 예

사용자 예외 처리에 대한 응답 메시지

@ExceptionHandler(value = IllegalArgumentException.class)
public ResponseEntity<ResponseDto<String>> handleIllegalArgumentException(IllegalArgumentException e, Locale locale) {
    locale = httpUtils.getLocaleForLanguage(locale);

    String messageKey = e.getMessage();
    String errorMessage = messageSource.getMessage(messageKey, null, locale);

    return httpUtils.createResponseEntity(
            httpUtils.createFailureResponse(errorMessage, badRequest.value())
    );
}

 

로그인 실패 응답 메시지

@Component
@RequiredArgsConstructor
public class LoginFailureHandler implements AuthenticationFailureHandler {

    private final HttpUtils httpUtils;
    private final MessageSource messageSource;
    private final LocaleResolver customLocaleResolver;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        Locale locale = customLocaleResolver.resolveLocale(request);
        String failureMessage = messageSource.getMessage("login_exception", null, locale);

        httpUtils.setResponseWithRedirect(
                response,
                httpUtils.createFailureResponse(failureMessage, HttpStatus.UNAUTHORIZED.value()),
                httpUtils.getCurrentVersionPath("login")
        );
    }
}

 

JSR-303 유효성 실패 응답 메시지

@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<ResponseDto<ValidationResult>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    ValidationResult errors = ValidationResult.of(e);

    return httpUtils.createResponseEntity(
            httpUtils.createFailureResponse(errors, badRequest.value())
    );
}

 

 

출처