배경
소상공인 예약을 위한 앱을 만들고 있는 중에, 번역 문제에 부딪혔다. 될 수 있으면 페이지 번역 기능을 활용해 프론트엔드에서 처리하려 했으나, 5개국 언어를 완벽하게 번역하는 데는 여러 어려움이 있었다. 제공하는 제품의 이름을 페이지 번역기로 돌렸을 때, 제품이 무엇인지 알아보기 힘들 정도로 번역이 잘 되지 않았으며, 추가적으로 매장 이름은 번역하지 않는 것이 좋겠다는 요청도 받았다. 이에 따라 메뉴, 메뉴 설명, 직원 정보 등을 모두 수동으로 처리해 5개국 언어를 모두 백엔드에서 관리할 수 있도록 요청받았다.
결국 동적 데이터는 백엔드에서 번역 관리를 해야만 했다. 다행히 번역해야 할 필드가 10개 미만이어서 시간 투자는 많지 않았지만, 번역을 위한 테이블 구조 결정과 언어 감지, 필요한 데이터를 찾아 수정하는 과정은 매우 번거로웠다. 이 글에서는 번역 작업을 위한 의사 결정 과정과 동적 데이터의 번역 처리 과정을 Spring Boot 코드 예제로 설명하려고 한다.
데이터 베이스 설계 의사결정 과정
다국어 처리를 위한 데이터 베이스를 설계의 선택지는 3가지가 있었다.
1. 언어별로 필드를 추가하는 방식:
@Entity
public class Product {
@Id
private Long id;
private String name_en;
private String name_kr;
private String name_de;
}
- 장점:
- 데이터베이스 구조가 단순하며, 각 언어별로 필드를 명확히 구분할 수 있다.
- 데이터의 일관성을 유지하기 쉬우며, 특정 언어의 데이터만 필요할 경우 간단히 조회가 가능하다.
- 단점:
- 새로운 언어가 추가될 때마다 각 테이블에 새로운 필드를 추가해야 하므로, 데이터베이스 구조 변경이 필요하다.
- 모든 언어에 대해 동일한 데이터를 저장해야 하므로, 사용되지 않는 언어의 데이터로 인한 저장 공간 낭비가 발생한다.
2. 각 언어별로 Locale
, field1
, field2
, Table_fk
(외래 키)를 가지는 테이블을 생성하는 방식:
@Entity
public class Product {
@Id
private Long id;
}
@Entity
public class Product_en {
@Id
private Long id;
private String name;
@ManyToOne
private Product product;
}
- 장점:
- 각 언어별 데이터를 명확하게 구분할 수 있다.
- 새로운 언어를 추가할 때, 기존 데이터 구조에 큰 영향을 끼치지 않고 확장이 가능하다.
- 단점:
- 번역 언어의 추가 또는 필드의 추가에 따라 데이터베이스의 테이블 수가 많아질 수 있어 관리가 복잡할 수 있다.
3. Key-Value 테이블 방식:
@Entity
public class Translation {
private String key;
private String locale;
private String value;
}
- 장점:
- 언어 추가가 유연하다.
- 중복된 데이터 구조를 피할 수 있다.
- 단점:
- 데이터 조회 시 조인이 필요할 수 있어 복잡성이 증가한다.
- 데이터 관리에 주의가 필요하다.
하나의 테이블로 관리하여 가장 유연하고 확장성이 높으며, 중복을 최소화 할 수 있는 Key-Value 테이블 방식을 채택했다.
Spring Boot JPA의 Key-Value 테이블 설계
Key-Value 테이블 방식은 MySQL을 사용하는 현재 프로젝트에서 적합한지 고민이 많았다. 일반적인 관계형 데이터베이스 모델과는 다른 접근 방식이기 때문이었다. 번역 데이터를 사용하는 테이블에서 CRUD를 한 번 더 실행해야했기도 했고, 특히나 Mapstruct를 사용하여 Entity-dto 매핑시, FK가 걸려있지 않아서 번역 데이터와 관련된 Mapper는 모두 수정해야했다.
테이블 구조
1.Language
Enum
@Getter
public enum Language {
EN(1, Locale.ENGLISH),
VI(2, new Locale("vi")),
KR(3, Locale.KOREAN),
CN(4, Locale.CHINESE),
JP(5, Locale.JAPANESE);
private final Integer code;
private final Locale locale;
Language(Integer code, Locale locale) {
this.code = code;
this.locale = locale;
}
public static final Locale[] fallbackLocales = {
Locale.ENGLISH,
new Locale("vi"),
Locale.CHINESE,
Locale.KOREAN,
Locale.JAPANESE
};
public static Language ofCode(Integer code) {
return Arrays.stream(Language.values())
.filter(language -> language.getCode().equals(code))
.findAny()
.orElseThrow(() -> new ApiException(ExceptionEnum.NOT_FOUND));
}
public static Language ofLocale() {
String languageStr = LocaleContextHolder.getLocale().getLanguage();
Locale locale = new Locale(languageStr);
return Arrays.stream(Language.values())
.filter(language -> language.getLocale().equals(locale))
.findAny()
.orElseThrow(() -> new ApiException(ExceptionEnum.NOT_FOUND));
}
public static Language ofLocale(Locale locale) {
return Arrays.stream(Language.values())
.filter(language -> language.getLocale().equals(locale))
.findAny()
.orElseThrow(() -> new ApiException(ExceptionEnum.NOT_FOUND));
}
}
Language
는 언어 코드와Locale
을 매핑하며 각 언어별로 고유한 코드와Locale
객체가 할당된다.- 현재 개발 중인 시스템에서는 특정 언어의 번역 데이터가 누락되었을 경우, 미리 정의된 우선순위에 따라 대체 언어로 된 데이터를 반환하는 기능을 구현하고 있어 이를 위해
fallbackLocales
라는 배열을 사용하여, 만약 요청받은 언어로 된 데이터가 존재하지 않을 시 사용될 대체 언어 목록을 지정했다. - 다국어 처리의 유연성을 위해
ofCode
,ofLocale
메소드를 통해 코드 또는Locale
에 해당하는 언어 열거형 인스턴스를 검색한다.
2. TranslationId
Composite key Class
@Embeddable
@Getter
@Setter
@NoArgsConstructor
public class TranslationId implements Serializable {
private String key;
@Convert(converter = LanguageConverter.class)
@Column(name = "e_language", nullable = false)
@Comment("언어")
private Language language;
public TranslationId(String key, Language language) {
this.key = key;
this.language = language;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TranslationId that = (TranslationId) o;
return Objects.equals(key, that.key) && language == that.language;
}
@Override
public int hashCode() {
return Objects.hash(key, language);
}
}
TranslationId
는Serializable
인터페이스를 구현하여 JPA의 복합 키 요구사항을 충족시킨다.key
필드와Language
열거형을 사용하여 각 번역의 고유성을 보장한다.equals
와hashCode
메소드를 사용하여 객체의 동등성 비교를 정의한다.
3. Translation
Entity
@Entity
@Setter
@Getter
@NoArgsConstructor
@Table(name = "translation", indexes = {
@Index(name = "idx_translation_key", columnList = "key")})
public class Translation {
@EmbeddedId
private TranslationId translationId;
@Lob
@Column(nullable = false)
@Comment("번역 값")
private String value;
public Language getLanguage() {
return translationId.getLanguage();
}
public String getKey() {
return translationId.getKey();
}
}
@EmbeddedId
어노테이션을 사용하여TranslationId
복합키를 내장한다. 이는 번역 테이블의 각 레코드가 고유한key
와Language
조합을 가짐을 의미한다.getLanguage
와getKey
메소드를 통해 복합 키의 각 컴포넌트에 쉽게 접근하게 한다.TranslationId
내의key
필드에 인덱싱을 추가하여 조회 성능을 개선한다.
복합키의 사용과 관리
- 복합키 복합키는 데이터의 고유성을 보장하는 강력한 방법이지만, 그 사용과 관리에 있어 몇 가지 고려해야 할 점들이 존재했다.
- 복잡한 키 관리: 복합 키의 사용은 단일 키에 비해 관리의 복잡성을 증가시킨다. 각 키의 구성 요소가 명확하게 정의되어야 하며, 이에 대한 충분한 이해가 필요하다.조회 성능의 고려: 특히, 문자열 기반의 복합 키를 사용할 경우, 조회 성능에 영향을 미칠 수 있기때문에
Key
필드에 인덱싱을 적용하여, 조회 성능을 개선하기로 했다.
- 인덱스 관리 비용: 인덱스의 추가는 저장 공간이 더 필요하며, 데이터가 변경될 때마다 이를 업데이트해야 하는 비용이 발생한다. 그러나 정보의 조회가 삽입이나 수정보다 빈번하게 일어나므로, 이러한 비용을 감수하기로 했다.
- 조회 성능 개선:
Key
필드에 인덱스를 적용함으로써, 데이터베이스의 조회 성능이 크게 향상될 것으로 기대했다. 이는 사용자 경험을 개선하고, 전반적인 반응 속도를 높이는 데 도움이 될 것이라 생각했다.
- 복잡한 키 관리: 복합 키의 사용은 단일 키에 비해 관리의 복잡성을 증가시킨다. 각 키의 구성 요소가 명확하게 정의되어야 하며, 이에 대한 충분한 이해가 필요하다.조회 성능의 고려: 특히, 문자열 기반의 복합 키를 사용할 경우, 조회 성능에 영향을 미칠 수 있기때문에
번역 데이터 처리를 위한 Spring Boot 필터 설정
사용자의 언어 환경을 자동으로 감지하고 적절한 언어로 서비스를 제공하는 것은 매우 중요하다. 사용자의 언어를 감지하는 방법은 다양하지만, 특히 HTTP 요청의 ‘Accept-Language’ 헤더를 활용하는 방식이 널리 채택되고 있다. 이외에도 사용자의 언어 설정을 감지하기 위해 쿠키를 사용하거나, 사용자의 IP 주소나 브라우저 설정을 기반으로 언어를 추정할 수도 있지만, ‘Accept-Language’ 헤더를 활용하는 방법이 구현이 가장 간단하고 대중적이어서 채택했다.
Spring Boot는 이러한 다국어 지원을 용이하게 하기 위해 LocaleResolver
인터페이스를 제공하는데, 이 인스턴스를 사용하면 각각의 HTTP 요청에 대한 Locale을 쉽게 결정하고 관리할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(Locale.ENGLISH); // 기본 Locale 설정
return localeResolver;
}
}
이 코드는 LocaleResolver
빈을 정의하고, AcceptHeaderLocaleResolver
를 사용하여 사용자의 언어 환경을 처리한다. AcceptHeaderLocaleResolver
는 HTTP 요청 헤더의 Accept-Language
값을 사용하여 사용자의 언어 설정을 결정한다. 만약 Accept-Language
헤더가 없는 경우, localeResolver.setDefaultLocale(Locale.ENGLISH)
에 의해 기본적으로 영어 언어 설정이 적용되도록 적용했다.
다국어 지원을 위한 테이블 관리
@Entity
@Setter
@Getter
@NoArgsConstructor
@Table(name = "store__store")
public class Store extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
@Comment("그룹 PK")
private Long id;
...
/*
@Column(name = "desciption", nullable = false, length = 45)
@Comment("그룹 이름")
private String descripton;
*/
...
public String getDescriptionColumnKey() {
return "store_description_" + this.id;
}
public TranslationId getDescriptionColumn() {
return new TranslationId(getDescriptionColumnKey(), Language.ofLocale());
}
}
getDescriptionColumn()
메소드에서는 현재 Locale 정보를 바탕으로 TranslationId
객체를 반환하여, 언어에 맞는 번역 값을 실시간으로 조회할 수 있도록 했다.
위 코드에서 주석 처리된 부분은 이전의 정적인 필드명을 대체하기 위한 준비 과정으로, 번역 데이터 연동 작업이 완료된 후에 최종적으로 필드를 제거하는 것이 좋다. 이러한 접근 방식은 개발 과정 중에 발생할 수 있는 빌드 에러를 피하기 위함이며, 전체적인 작업 흐름을 원활하게 유지하는 데 도움을 준다. 필드 제거 시 발견되지 않았던 문제점들을 컴파일 에러를 통해 쉽게 파악할 수 있기 때문에, 번역 작업의 완성도를 높이는 데 기여했다.
비록 이 프로젝트에서는 테스트 코드를 작성하지 않았지만, 테스트 코드의 존재했다면, 변경 사항들이 시스템에 미치는 영향을 검증하는 데 큰 역할을 할 것이다.
Service/Repository/Mapper 단에서의 적용
// Service 레이어...
@Transactional(readOnly = true)
public StoreDetailDto getStoreDetail(String storeLink) {
Store store = storeRepositoryFacade.findStoreByLink(storeLink);
String description = translationRepositoryFacade.findStoreTranslation(store);
StoreDetailDto storeDetailDto = storeMapper.toDetailDto(store, description);
..
return storeDetailDto;
}
public class TranslationRepositoryFacade {
...
public String findStoreTranslation(Store store) {
String key = store.getDescriptionColumnKey();
List<Translation> translations = translationRepository.findByKey(key);
return TranslationResolver.getTranslationWithFallback(store.getDescriptionColumn(), translations);
}
...
}
TranslationResolver 구현
public record TranslationResolver(TranslationId translationId, List<Translation> translations) {
public String getTranslationWithFallback() {
Optional<Translation> translationOptional = translations.stream()
.filter(translation -> translation.getTranslationId().equals(this.translationId))
.findAny();
if(translationOptional.isEmpty()) {
for (Locale locale : Language.fallbackLocales) {
TranslationId fallbackId = generateTranslationId(locale);
translationOptional = translations.stream()
.filter(translation -> translation.getTranslationId().equals(fallbackId))
.findAny();
if (translationOptional.isPresent()) {
break;
}
}
}
return translationOptional.map(Translation::getValue).orElse(null);
}
public TranslationId generateTranslationId(Locale locale) {
return new TranslationId(this.translationId.getKey(), Language.ofLocale(locale));
}
public static String getTranslationWithFallback(TranslationId translationId, List<Translation> translations) {
TranslationResolver translationResolver = new TranslationResolver(translationId, translations);
return translationResolver.getTranslationWithFallback();
}
public String getTranslation() {
Optional<Translation> translationOptional = translations.stream()
.filter(translation -> translation.getTranslationId().equals(this.translationId))
.findAny();
return translationOptional.map(Translation::getValue).orElse(null);
}
public static String getTranslation(TranslationId translationId, List<Translation> translations) {
TranslationResolver translationResolver = new TranslationResolver(translationId, translations);
return translationResolver.getTranslation();
}
}
- 사용자 요청에 따라
TranslationResolver
객체가 생성된다. getTranslationWithFallback()
메소드가 호출되어 스트림을 통해 해당 번역 ID를 찾는다.- 찾고자 하는 번역이 리스트에 없을 경우,
fallbackLocales
배열을 순회하면서 대체 번역을 찾는다. - 대체 번역도 없는 경우,
orElse(null)
을 통해null
을 반환한다.
정리
다국어 지원 기능의 구현은 복잡한 과정을 필요로 하는 작업이었다. 첫째, 데이터베이스 설계 방식을 선택해야 했다. 이때 세 가지 방식을 고려했으며, 각각의 장단점을 비교 분석한 결과, 가장 유연하고 확장성이 높으며 중복을 최소화할 수 있는 Key-Value 테이블 방식을 채택했다.
둘째, 복합키의 사용과 관리에 대한 문제를 해결해야 했다. 복합키는 데이터의 고유성을 보장하는 강력한 방법이지만, 그 사용과 관리는 단일 키에 비해 복잡성을 증가시킨다. 이를 해결하기 위해, 복합 키의 각 구성 요소가 명확하게 정의되어야 하며, 이에 대한 충분한 이해가 필요하다는 점을 인식했다.
셋째, 사용자의 언어 환경을 자동으로 감지하고 적절한 언어로 서비스를 제공해야 했다. 이를 위해 Spring Boot의 LocaleResolver 인터페이스를 사용하였고, 이를 통해 각각의 HTTP 요청에 대한 Locale을 쉽게 결정하고 관리할 수 있었다.
마지막으로, 사용자의 언어 환경에 맞게 적절한 번역을 제공하기 위한 로직을 구현하였다. 이를 위해 TranslationResolver를 구현하여 사용자의 언어 환경을 실시간으로 감지하고, 적절한 번역을 제공할 수 있게 하였다.
이렇게 복잡한 과정을 거쳤지만, 이를 통해 시스템은 사용자의 언어 환경에 맞게 동적으로 콘텐츠를 제공할 수 있다는 큰 장점을 얻었다. 이 과정을 통해 다국어 처리 기능을 가진 소프트웨어를 구현하는데 필요한 핵심 원칙과 접근 방식을 배울 수 있었다.
글 잘보았습니다.
저도 동일하게 적용해보았는데요,
LocaleContextHolder.getLocale() 과 LocaleContextHolder.getLocale().getLanguage() 을 통해 얻어온 Locale, String 값이 null이 아닌 empty 인 경우가 발생하는데, 혹시 유사한 증상은 없으셨나요?
세팅한 default locale을 찾지 못하는 것 같기도 하고..
답변 늦어져서 죄송합니다!
가장 의심되는 건 Accept-Language 헤더에 빈 값이 들어간 것이 아닐까 싶네요. Accept-Language 헤더 자체가 설정되지 않았을 때 Default 값으로 설정되게 했지만, 빈 값에 대한 요청을 처리한 코드는 아니여서요.
디버깅해서 원인을 찾았었습니다 ㅎㅎ
LocaleResolver bean 구성 시 default locale뿐만 아니라 support locales도 지정해줘야 하더라구요
관련해서 제 블로그에 글 남겨두었으니 나중에 한번 참고해보셔용
https://siahn95.tistory.com/182