들어가기
현재 진행하는 프로젝트는 한국인만이 아니라 외국인 대상으로도 출시를 목표로 하고 있다. 그렇기에 평소에 하던 것처럼 한국어로만 앱을 구성하는 것이 아닌, 다국어 버전으로 작성해야 한다.
에러 메시지를 사용자에게 전달할 때, 언어에 따라 분기점을 만드는 것을 생각했지만, 이는 복잡성을 증가시킬 것이다. 이런 경우를 위해 Spring boot에서는 'MessageSource' 객체와 'Locale' 객체를 활용한 다국어 기능을 지원해 준다.
MessageSource는 Spring 프레임워크에서 다국어 지원을 위한 메시지 처리를 담당하는 인터페이스이며, 이를 통해 다양한 언어에 맞는 메시지를 쉽게 관리하고, 사용자의 Locale에 해당하는 메시지를 반환해준다.
Locale은 특정 언어나 국가, 지역에 대한 정보를 나타내며 Spring에만 국한되는 용어는 아니다. 다국어 및 국가, 지역 관련 컨텐츠를 지원하는 데 주로 사용되는데, Spring에서도 Locale 정보를 객체로서 쉽게 활용할 수 있다.
이 글에서는 MessageSource와 Locale를 활용해 다국어별 에러메시지를 반환하는 과정을 설명할 것이다.
목차는 아래와 같다.
3. 트러블 슈팅(LocaleContextHolder 사용 시 주의사항)
본론
1. MessageSource, Locale 설정
위에서 언급했다시피 MessageSoruce는 메시지 처리를 위한 객체로서, 이를 위해선 다국어별 작성된 메시지 파일이 필요하다.
다국어별 메시지 파일은 resources 폴더 내 아래와 같이 작성할 수 있다.
- message_en.properties
- message_ko.properties
- message_ja.properties
만약 언어별 뿐만 아니라, 국가별로 추가 구분을 원한다면 아래와 같이 작성할 수 있다.
- message_en_UK.properties
- message_en_US.properties
혹시 resources 폴더 내 추가 폴더, 다른 파일 명으로 지정하고 싶다면 아래와 같이 커스텀하여 사용할 수 있다.
@Component
public class MessageSourceConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasenames(
"classpath:messages/error"
);
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.toString());
return messageSource;
}
}
이제 MessageSource 객체를 통해 Locale에 따라 다른 파일 내의 설정된 값을 불러올 수 있다.
파일 내부 설정 값은 일반적인 properties 파일을 작성할 때처럼 작성하면 된다. 아래는 예시 코드이다.
//error_ja.properties 내부
test1=japanese
test2={0} japanese
...
//error_ja_JP.properties 내부
test1=japanese and japan
test2={0} japanese and japan
{0} 부분은 동적으로 데이터를 삽입할 수 있도록 만든 것으로 0부터 1, 2... 순서로 대입할 수 있다.
만약 intellij에서 사용 중 한국어, 일본어 사용 시, 다국어가 깨졌다면 file encoding에서 UTF-8을 설정해야 한다.
이렇게 설정을 하면 MessageSource를 의존성 주입 한 후 getMessage()를 통해 다국어별 파일에 설정된 값을 가져올 수 있다. 첫 번째 값은 설정 변수 이름, 두 번째 값은 동적으로 할당할 변수 리스트, 세 번째 값은 Locale 값을 넣는다.
@Autowired
private MessageSource messageSource;
...
System.out.println(messageSource.getMessage("test1", null, Locale.KOREA));
System.out.println(messageSource.getMessage("test2", new Object[]{"한국"}, Locale.KOREAN));
System.out.println(messageSource.getMessage("test1", null, Locale.JAPAN));
System.out.println(messageSource.getMessage("test2", new Object[]{"일본"}, Locale.JAPANESE));
...
Locale.KOREA는 error_ko_KR.properties에,
Locale.KOREAN는 error_ko.properties에,
Locale.JAPAN는 error_ja_JP.properties에,
Locale.JAPANESE는 error_ja.properties에 각각 매핑된 것을 확인할 수 있다.
Spring Boot에서 Locale의 값은 기본적으로 ISO 언어 코드와 ISO 국가 코드로 표현된다.
- en_US(미국 영어)
- en_UK(영국 영어)
- ko_KR(한국어)
- ja_JP(일본어)
언어만을 표현할 수도 있고, 국가만을 표현할 수가 있다. 기본적으로 설정된 Locale 값의 종류는 아래와 같다.
public static final Locale ENGLISH; // 영어(언어 코드: en)
public static final Locale FRENCH; // 프랑스어(언어 코드: fr)
public static final Locale GERMAN; // 독일어(언어 코드: de)
public static final Locale ITALIAN; // 이탈리아어(언어 코드: it)
public static final Locale JAPANESE; // 일본어(언어 코드: ja)
public static final Locale KOREAN; // 한국어(언어 코드: ko)
public static final Locale CHINESE; // 중국어(언어 코드: zh)
public static final Locale SIMPLIFIED_CHINESE; // 간체 중국어(언어 코드: zh, 문자 코드: Hans)
public static final Locale TRADITIONAL_CHINESE; // 번체 중국어(언어 코드: zh, 문자 코드: Hant)
public static final Locale FRANCE; // 프랑스 국가(언어 코드 : fr, 국가 코드: FR)
public static final Locale GERMANY; // 독일 국가(언어 코드 : de, 국가 코드: DE)
public static final Locale ITALY; // 이탈리아 국가(언어 코드 : it, 국가 코드: IT)
public static final Locale JAPAN; // 일본 국가(언어 코드 : ja, 국가 코드: JP)
public static final Locale KOREA; // 한국 국가(언어 코드 : ko, 국가 코드: KR)
public static final Locale UK; // 영국 국가(언어 코드 : en, 국가 코드: GB)
public static final Locale US; // 미국 국가(언어 코드 : en, 국가 코드: US)
public static final Locale CANADA; // 캐나다 국가(언어 코드 : en, 국가 코드: CA)
public static final Locale CANADA_FRENCH; // 캐나다의 프랑스어 사용 지역(언어 코드 : fr, 국가 코드: CA)
public static final Locale ROOT; // 기본값으로 사용되는 '루트' Locale. 언어 및 국가 코드가 정의되지 않은 Locale을 의미,
// 언어 및 지역 설정이 전혀 없는 상황에서 사용
물론 위의 값들만 사용할 수 있는 것은 아니고, 임의의 언어와 국가 값으로 Locale 객체를 지정하여 사용할 수 있다.
Locale.ENGLISH처럼 직접 호출하지 않고, 미리 지정한 Locale을 호출할 수 있다.
LocaleContextHolder의 getLocale()를 통해 가져올 수 있다.
Locale locale = LocaleContextHolder.getLocale();
LocaleContextHolder는 현재 스레드에 Locale을 저장하고 조회할 수 있는 객체인데, JVM의 기본 Locale은 운영체제의 설정에 따라 결정(한국은 ko_KR)되거나, JVM이 시작될 때 명시적으로 설정할 수 있다.
-Duser.language=en -Duser.country=US
위와 같이 시작 옵션에서 'user.language'와 'user.country'를 설정하면 JVM의 기본 Locale을 변경할 수 있다.
혹은 요청에 따라 LocaleResolver 인터페이스를 통해서 Locale를 설정할 수 있으며, 아래의 4가지 구현체를 확인할 수 있다.
AcceptHeaderLocaleResolver: 클라이언트의 HTTP 요청 헤더에서 로케일을 결정(Accept-Language).
SessionLocaleResolver: 사용자 세션에 로케일을 저장해 세션이 유지되는 동안 로케일을 지속.
CookieLocaleResolver: 쿠키에 로케일을 저장해 브라우저를 닫아도 로케일을 유지.
FixedLocaleResolver: 항상 고정된 로케일을 사용.
기본적으로 AcceptHeaderLocaleResolver가 적용되어 있고 다른 방식을 원할 시 아래와 같이 설정할 수 있다.
@Configuration
public class WebConfig {
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.US); // 기본 값을 Locale.US로 설정
// SessionLocaleResolver를 사용하도록 설정
return localeResolver;
}
}
위 4가지 구현체에 대해 자세한 설명은 https://terry9611.tistory.com/304를 참고하길 바란다.
혹은 아래와 같이 CustomLocaleResolver()를 설정할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 회원 정보를 담은 Member 엔티티의 Repository
private final MemberRepository memberRepository;
public WebConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public LocaleResolver localeResolver() {
return new CustomLocaleResolver(memberDetailRepository);
}
}
@Slf4j
public class CustomLocaleResolver implements LocaleResolver {
public CustomLocaleResolver(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public Locale resolveLocale(HttpServletRequest request) {
try {
// Accept-Language 헤더 확인
String locale = request.getHeader("Accept-Language");
if (locale != null && !locale.isBlank()) {
// 미리 설정된 Enum타입 LanguageCode. 세부 코드는 아래 확인
return LanguageCode.fromCode(locale).getLocale();
}
// Authorization 헤더 확인(JWT 관련 내용으로 자세한 설명은 생략)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.getPrincipal() instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
// 회원의 언어 설정 확인
LanguageCode languageCode = memberRepository.findLanguageCodeByMemberId(
userDetails.getId());
return languageCode.getLocale();
}
} catch (Exception ex) {
LogUtils.writeErrorLog("resolveLocale", ex.getMessage());
}
// 모두 존재하지 않는 경우 영어로 설정
return LanguageCode.EN.getLocale();
}
@Override
public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
}
}
이렇게 간단하게 MessageSource와 Locale에 대해 살펴보았다.
이제 MessageSource를 사용하여 사용자 언어별 에러 코드를 반환해 보자.
2. 사용자 언어별 에러 코드 반환
우선 사용자별 언어 코드를 가지고 있어야 한다. 언어 코드는 아래와 같이 Enum으로 사용자 엔티티에 추가되도록 하였고, 회원 가입 시 언어 코드를 설정하도록 하였다.(회원 생성 로직은 생략한다.)
@Getter
@RequiredArgsConstructor
public enum LanguageCode {
EN("en-US", "영어", Locale.ENGLISH),
KO("ko-KR", "한국어", Locale.KOREAN),
JA("ja", "일본어", Locale.JAPANESE);
private final String code;
private final String description;
private final Locale locale;
}
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private LanguageCode languageCode;
public Member(String name, LanguageCode languageCode) {
this.name = name;
this.languageCode = languageCode;
}
}
사용할 예외 메시지는 아래와 같이 작성하였다.
error_ko.properties 내부
001_ACCESS_DENIED=접근 권한이 없습니다.
002_REQUEST_DATA_MISMATCH=유효한 타입이 아닙니다.
003_NOT_EXISTED_MEMBER=존재하지 않는 사용자입니다.
...
error_en.properties 내부
001_ACCESS_DENIED=You do not have access permission.
002_REQUEST_DATA_MISMATCH=Not a valid type.
003_NOT_EXISTED_MEMBER=This user does not exist.
...
error_ja.properties 내부
001_ACCESS_DENIED=アクセス権がありません。
002_REQUEST_DATA_MISMATCH=有効なタイプではありません。
003_NOT_EXISTED_MEMBER=存在しないユーザーです。
그리고 에러 코드는 Enum 타입으로 쉽게 관리할 수 있도록 작성하였다.
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ACCESS_DENIED("001_ACCESS_DENIED", "접근 권한이 없습니다."),
REQUEST_DATA_MISMATCH("002_REQUEST_DATA_MISMATCH", "유효한 타입이 아닙니다."),
NOT_EXISTED_MEMBER("003_NOT_EXISTED_MEMBER", "존재하지 않는 사용자입니다.");
private final String code;
private final String msg;
}
이제 예외 처리를 어떻게 하는지 확인해 보자.
우선 사용자의 languageCode를 가져와 LocaleContextHolder.setLocale()를 통해 등록해 준다.
private void setLanguage(){
LanguageCode languageCode = memberRepository.findLanguageCodeById(memberId); // 사용자의 언어를 가져온다.
Locale locale = languageCode.getLocale();
LocaleContextHolder.setLocale(locale); // locale을 설정한다.
}
그리고 실제 처리 메서드에서 locale를 가져와 MessageSouce를 사용하여 메시지를 반환한다.
public String process(){
Locale locale = LocaleContextHolder.getLocale();
// locale에 맞게 properties에 작성된 메시지를 반환한다.
return messageSource.getMessage(ErrorCode.ACCESS_DENIED.getCode(), null, locale);
}
실행된 결과는 아래와 같다.
3. 트러블 슈팅(LocaleContextHolder 사용 시 주의사항)
LocaleContextHolder를 사용할 때 주의해야 할 사항이 있다. LocaleContextHolder의 setLocale(), getLocale()는 동일한 스레드에서만 같은 인식을 한다는 점이다.
실제로 프로젝트를 진행 중 겪은 사항으로 @Async를 사용하는 특정 메서드를 구현한 적이 있다. 하지만 해당 어노테이션을 지정한 메서드에서 getLocale()를 호출하였지만 setLocale를 통해 지정했던 locale가 호출되지 않았다. 그 이유는 @Async를 사용한 메서드는 이전과 다른 스레드로 실행되었기 때문이다. 이는 메서드를 호출할 때, locale을 매개변수 값으로 건네주는 방법을 통해 해결할 수 있다.이처럼 @Async와 같이 다른 스레드로 실행될 때는 locale 사용에 주의해야 한다.
결론
이렇게 간단하게 메시지를 반환하도록 구현해 보았다. 최근 앱들은 대부분 다국어 버전으로 앱을 출시하는 경향이기에 어떻게 다국어 버전으로 설정을 하는지 알고 있는 것은 중요하다고 생각한다. 그렇기에 실제 프로젝트에서 사용했던 방식을 간단한 예시와 함께 다시 한번 공부하고 글을 작성해 보았다. 처음 접하는 사람들에게 많은 도움이 되었으면 좋겠다. 그리고 실제 비즈니스 로직을 작성할 때는 위의 에시보다 더 구체적으로 throw, exceptionHandler를 통해 해당 메시지를 호출하는 방법도 좋을 것 같다.
궁금한 점이 있거나 잘못된 내용이 있다면 댓글 달아주시면 감사하겠습니다!
참고
https://jjingho.tistory.com/13
https://terry9611.tistory.com/304
'Spring' 카테고리의 다른 글
Spring boot - thymeleaf를 통한 html 파일 다국어 이메일 전송 (0) | 2024.09.29 |
---|---|
Spring boot - @Async를 통한 메서드 비동기 실행 및 주의사항 (0) | 2024.08.29 |
OpenFeign을 통한 외부 API (With Spring Boot) (0) | 2024.07.13 |
파일 업로드, 다운로드, 이미지 미리보기 구현(Spring boot With React) (0) | 2024.05.30 |
Springboot 3.x.x 를 사용해보자 (0) | 2024.01.24 |