본문 바로가기
Spring boot

i18N 다국어 API 개발

by Subi 2023. 8. 14.

Internationalization의 축약형

국제화, Internationalization은 알파벳이 20개인데, 가장 마지막 글자인 n 사이에 알파벳이 18개가 있다고 하여 i18n 이라 한다.

Front End 에서 주는 JsonData 의 값은

{
  "content": [
    {
      "Key": "common.button.confirm",
      "ko-KR": "확인",
      "en-US": "Confirm"
    },
    {
      "Key": "common.button.delete",
      "ko-KR": "삭제",
      "en-US": "Delete"
    },
    {
      "Key": "common.button.register",
      "ko-KR": "등록",
      "en-US": "Register"
    },
    {
      "Key": "common.button.list",
      "ko-KR": "목록",
      "en-US": "List"
    }
   ]
}

형태로 전달을 해준다고 하였다.

여기서 가장 중요했던 점은, 언어에 대한 번역에 유연해야 한다는 것 이였다.

그래서 DB 설계를 할 때 그 부분을 중점으로 개발을 하였다.

 

데이터에 대한 컬럼은

  • language_code
  • tanslation_key
  • tanslation_value

이 세가지 컬럼이다.

  1. language_code 는 언어에 대한 key 값이 들어가게 된다.
  2. ex) ko-KR, en-US
  3. tanslation_key는 전달 받은 Request의 Key 값이 들어가게 된다.
  4. ex) common.button.list , common.button.register
  5. translation_value는 전달 받은 Request의 언어에 맞는 Value 값이 들어간다.
  6. ex) 목록, List, 등록, Register

이러한 저장에 대한 특이성이 있어 Request 와 Response 에서는 Json Data 에 값은 형태를 유지하고 저장에 대한 값을 위한 캐스팅이 필요했다.

업로드

public ResponseResult<Long> createTranslations(TranslationsUploadRequest translationsUploadRequest) {
        deleteTranslations();
        JsonNode content = translationsUploadRequest.getContent();
        ObjectMapper mapper = new ObjectMapper();
        Long saveCount = 0L;
        try {
            List<Map<String, String>> list = mapper.readValue(content.traverse(), new TypeReference<List<Map<String, String>>>() {
            });
            for (Map<String, String> map : list) {
                Set<String> keys = map.keySet();
                String keyValue = map.get("Key");
                for (String key : keys) {
                    if (!"Key".equals(key)) {
                        Translations translations = Translations.builder()
                                .languageCode(key)
                                .translationKey(keyValue)
                                .translationValue(map.get(key))
                                .build();
                        Translations save = translationsRepository.save(translations);
                        if (save != null) {
                            saveCount++;
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return ResponseResult.response(saveCount, ResponseStatus.CREATE);
    }
  • 들어오는 Data 는 JsonNode 의 자료형으로 받게 된다.
  • 기존의 형태를 가져와 List<Map<String, String>> 의 형태로 캐스팅을 진행하게 된다.
  • 그리고 가져온 리스트에 Map 을 반복문으로 추출하게 된다.
  • Map의 추출에서 Key 값을 추출하여 Key 값 중 고정으로 들어가는 Key의 값을 기준으로 잡고 Builder를 진행하게 된다.
  • Key 값은 translationKey 의 값으로 저장하게 된다.
  • 그리고 Map 의 Key 값이 Key 가 아닐 시의 값으로 languageCode 와 translationValue
    • 이렇게 하면 하나의 Key 값은 LoagugeCode의 수 만큼 저장되게 되어 원하는 Translations 객체가 만들어 지게 된다.
  • Request JSON Data
{
  "content": [
    {
      "Key": "common.button.confirm",
      "ko-KR": "확인",
      "en-US": "Confirm"
    },
    {
      "Key": "common.button.delete",
      "ko-KR": "삭제",
      "en-US": "Delete"
    }
   ]
}
  • 결과물

다운로드

@Cacheable
    public ResponseResult<ArrayNode> downloadTranslations(){

        List<Translations> allTranslations = translationsRepository.findAll();

        Map<String, List<Translations>> groupedTranslations = allTranslations.stream()
                .collect(Collectors.groupingBy(Translations::getTranslationKey));

				ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
        ArrayNode arrayNode = objectMapper.createArrayNode();

        groupedTranslations.forEach((key, translationsList) -> {
            Map<String, String> translationData = new HashMap<>();
            translationData.put("Key", key);

            translationsList.forEach(translation ->
                    translationData.put(translation.getLanguageCode(), translation.getTranslationValue())
            );

            arrayNode.add(objectMapper.valueToTree(translationData));
        });

        return ResponseResult.response(arrayNode,ResponseStatus.SUCCESS);
    }
  • 다운로드는 모든 데이터를 다 출력해야 되기 때문에 DataJPA 를 이용하여 findAll()을 이용하여 모든 데이터를 가져온다.
  • 그 다음 stream.collect를 이용해 데이터를 Map으로 캐스팅 해준다.
    • 캐스팅을 진행 해줄 때 translationKey를 이용해 grouping 을 같이 진행 시켜 준다.
  • 그 후 Map 형태의 데이터를 key를 기준으로 forEach를 이용해 반복문을 진행 시켜 ArrayNode 형태로 캐스팅을 진행 시켜 준다.
  • 그리고 데이터의 key,value 형식으로 넣어주는데 key 값에는 languageCode, value 값에는 translationsValue 값이 들어가게 된다.

최종적으로

{
  "content": [
    {
      "Key": "common.button.confirm",
      "ko-KR": "확인",
      "en-US": "Confirm"
    },
    {
      "Key": "common.button.delete",
      "ko-KR": "삭제",
      "en-US": "Delete"
    }
   ]
}

이 형태로 데이터가 출력이 된다.

목록

@Cacheable
public ResponseResult<Page<JsonNode>> listTranslations(TranslationsSearchCondition translationsSearchCondition, Pageable pageable) {

    int pageSize = (int) (translationsRepository.groupByLanguageCode(translationsSearchCondition) * pageable.getPageSize());
    Map<String, List<Translations>> groupedTranslations = translationsRepository.translationsGroup(translationsSearchCondition, PageRequest.of(offSet(pageable.getOffset()), pageSize));
    if (groupedTranslations == null) {
        return ResponseResult.response(null, ResponseStatus.NO_CONTENTS);
    }

   	ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
    ArrayNode arrayNode = objectMapper.createArrayNode();

    groupedTranslations.forEach((key, translationsList) -> {
        Map<String, String> translationData = new HashMap<>();
        translationData.put("Key", key);

        translationsList.forEach(translation ->
                translationData.put(translation.getLanguageCode(), translation.getTranslationValue())
        );

        arrayNode.add(objectMapper.valueToTree(translationData));
    });

    List<JsonNode> listFromNode = new ArrayList<>();
    arrayNode.forEach(listFromNode::add);

    Long totalCount = translationsRepository.translationsCount(translationsSearchCondition);
    LongSupplier longSupplier = () -> totalCount;
    Page<JsonNode> page = PageableExecutionUtils.getPage(listFromNode, pageable, longSupplier);
    return ResponseResult.responseTranslations(page, totalCount, ResponseStatus.SUCCESS);
}
  • 목록의 가장 큰 이슈는 페이징 과 검색 이였다.
  • Page 에 부분은 기존의 하나의 Json Data 가 DB에 저장 될때는 Language의 수 만큼 저장이 되기 때문에 기존 입력한 페이징에 대한 정보를 다시 불러오고, Language Code로 그룹화하여 그 수를 가져온 쿼리로 곱하여 페이징을 진행하게 된다.
  • 그렇게 한 다음 최종 데이터는 다시 Json Data 와 같은 형태로 나가기 때문에 Page에 요청한 페이징 데이터가 출력된다.
    • ex) offset : 1 /page : 10 → offset:1 / page : 10 * (group Language 수 = 2) = 20
  • 검색 조건은 2개가 존재 했다.
    • key 값으로 조회
    • value 값으로 조회
  • 하지만 여기서 문제점은 value 값으로 조회를 했을 때, 한글명에 대한 값을 조회해도 이거에 매칭되는 영문의 값도 같이 가져와야 되는것이였다.
  • 그러기 때문에 검색해도 한가지 로직이 필요했다.
    • 먼저 value 값으로 검색을 하여 key 값을 가져온다.
    • 가져온 Key 값으로 다시 매칭되는 list 를 가져온다.
private BooleanExpression translationValueContains(String translationValue) {
    return hasText(translationValue) ? translations.translationKey.in(getTranslationValue(translationValue)) : null;
}

private BooleanExpression translationValueContainsGetKey(String translationValue) {
    return hasText(translationValue) ? translations.translationValue.contains(translationValue) : null;
}

private List<String> getTranslationValue(String translationValue) {
    return queryFactory
            .select(translations.translationKey)
            .from(translations)
            .where(
                    translationValueContainsGetKey(translationValue)
            ).fetch();
}
  • 이렇게 Data를 불러 오게 된다.
  • 그 후 Map 형태의 데이터를 key를 기준으로 forEach를 이용해 반복문을 진행 시켜 ArrayNode 형태로 캐스팅을 진행 시켜 준다.
  • 그리고 데이터의 key,value 형식으로 넣어주는데 key 값에는 languageCode, value 값에는 translationsValue 값이 들어가게 된다.

'Spring boot' 카테고리의 다른 글

Error Response 작업  (0) 2023.09.05
Request Test  (0) 2023.08.01
EnumVaild Anotation  (0) 2023.08.01
ResponseEntity Code Convention  (0) 2023.07.26
ResponseResult 개발 진행  (0) 2023.07.26

댓글