Android에서 사용하는 다국어 개선하기

안녕하세요, SmartEditor Android 파트 배명환입니다.

최근 저희 팀에서는 앱에서 사용하는 불필요한 다국어를 정리하고 자동화하는 개선 작업을 진행했습니다. 기존 1200개가 넘는 다국어 리소스 중 불필요한 다국어, 중복된 다국어 등을 선별해 그중 200개를 넘게 정리하였으며, 현재는 자동화를 통해 매일 Repository로 최신 업데이트가 되고 있습니다. 지금부터 다국어 리소스 개선 과정을 공유하고자 합니다.

들어가기 전에

SmartEditor 팀에서는 다국어를 쉽게 관리하기 위해 Lokalise를 이용하고 있습니다.

Lokalise 소개

Lokalise에서 제공하는 대표적인 기능입니다.

  • 다양한 포맷의 파일 불러오기/내보내기(xml/.strings/plist/json/xlsx/csv)
  • 키값 정렬 및 태그 검색 기능
  • 스크린샷 업로드 및 특정 부분 Key referencing 기능
  • Project 스냅샷으로 백업 기능
  • Rest API 제공(CI에 연동 가능)
  • Multiplatform SDK

SmartEditor Android, iOS 파트에서는 Lokalise를 사용해 문구를 함께 관리하며, 각각의 플랫폼에 맞게 문구를 다운로드하여 사용하고 있습니다. Lokalise에 대한 자세한 설명은 Lokalise Documentation에서 확인해 주세요.

다국어 리소스 관리의 문제점

1. 프로젝트 내 다국어 관리

  • 기존에는 각 모듈마다 strings.xml이 존재했으며, 모듈 안에서 필요한 strings.xml의 문구만 쓰는 게 아니라 dependency를 가진 다른 모듈의 문구를 혼용했습니다.
  • SmartEditor 문구의 키 네이밍 컨벤션으로 <module>_<where>_<description>를 사용했습니다. 만약 새로 추가되는 문구가 여러 모듈에서 사용한다면 어떤 module prefix를, 어떤 module에 추가할지 혼동이 생기게 됩니다.

또한 더 큰 문제는 다국어 문구를 추가하는 과정에 있었습니다.

2. 다국어 문구 추가 과정

기존에는 아래와 같이 신규 문구를 추가하고 있었습니다.

  1. 설계 분들께 받은 한국어 문구를 번역 요청합니다.
  2. 번역이 완료된 다국어를 Lokalise에 추가합니다.
  3. 개발자가 직접 프로젝트 내 strings.xml 파일에 수동으로 문구를 추가합니다.

개발자가 수동으로 추가하면서 여러 휴먼 에러가 생기게 됩니다.

  1. 같은 key 값의 문구가 Lokalise와 strings.xml이 서로 달랐습니다.
  2. strings.xml에 있지만 Lokalise에는 없는 문구가 있었습니다.
  3. 반대로 Lokalise에 있지만 strings.xml에 없는 문구가 있었습니다.
  4. 개발 과정에서 추가한 임시 리소스를 문구가 번역된 이후에도 계속 사용했습니다.
  5. strings.xml에 영어, 일본어와 같은 다국어를 추가하지 않은 문구가 있었습니다.
  6. Lokalise에 Android에서 사용할 수 없는 placeholder(예: %@)로 등록된 문구가 있었습니다.

즉, 다국어를 쉽게 관리하기 위해서는 프로젝트 내 strings.xml과 Lokalise의 문구가 서로 다른 부분을 먼저 해결해야 했습니다.

다국어 문제 해결 과정

💡SmartEditor Android는 SDK 형태로 네이버의 서비스(네이버 블로그, 카페, 지식iN 등)에 적용되기 때문에 각 문구마다 서비스 사용 여부를 함께 검토했습니다.

따라서 작업 과정의 효율성과 실수 방지를 위해 개발한 스크립트 위주로 소개합니다.

1. 기준 정립

앞으로 다국어를 관리할 두 가지 기준을 정립했습니다.

  1. 각각의 모듈에 있는 strings.xml을 공통 유틸의 목적으로 사용하는 모듈 하나로 통합합니다.
  2. 공통 모듈 내 두 가지 파일을 두어 Lokalise에 존재하는 문구, 존재하지 않는 문구로 나눴습니다.
    • strings.xml : Lokalise에 등록된 문구
    • strings_local.xml : Lokalise에 등록되지 않은 문구, 임시 또는 개발 편의상 사용하는 문구

2. 정립한 기준에 따라 문구 이동

정립한 기준에 따라 모든 문구를 공통 모듈 내 strings.xml, strings_local.xml로 이동했습니다.

이 작업은 실수를 방지하기 위해 개발한 Python 스크립트로 진행했으며, 그중 Android 다국어 XML 파일을 수정하는 스크립트입니다.

from xml.etree import ElementTree

class StringRes:
    def __init__(self, path):
        self.path = path
        self.res = self._get_res()
        self.root = self.res.getroot()

    def _get_res(self):
        return ElementTree.parse(self.path)

    def parse(self):
        return {child.attrib["name"]: child.text for child in self.root.iter("string")}

    def remove(self, remove_keys):
        for key in remove_keys:
            element = self._find_element(key)
            if element is not None:
                self.root.remove(element)

    def replace(self, replace_dict):
        for key, value in replace_dict.items():
            element = self._find_element(key)
            if element is not None:
                element.text = replace_dict[key]

    def insert(self, insert_dict):
        for key, value in insert_dict.items():
            if self._find_element(key) is None:
                element = ElementTree.Element("string")
                element.set("name", key)
                element.text = value
                self.root.append(element)

    def _find_element(self, key):
        key_path = './/string[@name="%s"]' % key
        return self.root.find(key_path)

    def save(self, path=None):
        if path is None:
            path = self.path
        self._indent()
        self.res.write(path, encoding="UTF-8", xml_declaration=True)

    def _indent(self):
        if not len(self.root):
            return
        level = 0
        space = "\t"
        indentations = ["\n" + level * space]

        def _indent_children(element, level):
            child_level = level + 1
            try:
                child_indentation = indentations[child_level]
            except IndexError:
                child_indentation = indentations[level] + space
                indentations.append(child_indentation)

            if not element.text or not element.text.strip():
                element.text = child_indentation

            for child in element:
                if len(child):
                    _indent_children(child, child_level)
                if not child.tail or not child.tail.strip():
                    child.tail = child_indentation

            if not child.tail.strip():
                child.tail = indentations[level]

        _indent_children(self.root, 0)

    def log(self):
        ElementTree.dump(self.root)

Python의 ElementTree Library를 이용해 다국어 XML 파일을 파싱 및 수정하는 StringRes 클래스를 만들었습니다.

  • insert(), replace(), remove() : 문구를 추가, 대체, 제거할 수 있습니다. 파라미터로는 Dictionary, Set 타입 (key : 다국어 Key 값, value : 실제 다국어 문구)을 받아 여러 문구를 한 번에 처리합니다.
  • save() : 수정한 다국어 리소스를 실제 파일로 저장합니다.

3. strings.xml, strings_local.xml 정리

이 과정은 가장 오랜 시간이 소요되었는데, 최종적으로 아래와 같이 정리했습니다.

  1. strings.xml : Lokalise에 이미 존재하는 key이므로 Lokalise와 sync를 맞춥니다. 해당 파일에 존재했던 문구의 특징입니다.
    • 같은 key이지만 Lokalise와 내용이 다른 문구
    • Android에서 사용할 수 없는 placeholder(예: %@)를 사용하는 문구
  2. strings_local.xml : Lokalise에 없는 문구이므로 기존에 약 120개이었던 것을 최종적으로 2개만 남겨두고 모두 정리했습니다. 해당 파일에 존재했던 문구의 특징도 분류하면 아래와 같습니다.
    • 특정 서비스 리소스
    • 개발용 임시 리소스
    • 동일한 내용의 번역된 문구가 Lokalise에 있는 리소스
    • 서비스의 구현이 필요한 리소스
    • 스펙이 사라져 불필요한 리소스
    • Lokalise에 등록이 필요한 리소스

이러한 각각의 특징을 파악하고, 정리하는 일련의 과정은 아래와 같이 모두 동일합니다.

  1. SmartEditor 내, 서비스에서 SmartEditor Android SDK의 문구 사용 여부 판단
  2. 사용하고 있는 문구, 사용하지 않는 문구를 각각의 종류별로 분류
  3. 각 종류마다 strings.xml, strings_local.xml에 정리 작업 진행

여기서 1번과 3번 과정은 스크립트화할 수 있습니다.

문구별 사용 여부 및 사용성 판단

첫 번째 사용 여부를 판단하는 과정입니다. AndroidStudio에서는 RefactorRemove Unused Resources 기능은 부정확한 경우가 많아 적절하지 않으므로 스크립트를 만들었습니다.

import os
import mmap
from glob import iglob

def search_modules(modules):
    for module in modules:
        find_in_kotlin(module)
        find_in_java(module)
        find_in_resource(module)

def find_in_kotlin(module):
    path = get_root_path() + module + _SRC_MAIN_PATH + "java/**/*.kt"
    for file in iglob(path, recursive=True):
        find_usage_in_file(file)

def find_in_java(module):
    path = get_root_path() + module + _SRC_MAIN_PATH + "java/**/*.java"
    for file in iglob(path, recursive=True):
        find_usage_in_file(file)

def find_in_resource(module):
    path = get_root_path() + module + _SRC_MAIN_PATH + "res*/**/*.xml"
    for file in iglob(path, recursive=True):
        if not os.path.basename(file).startswith("string"):
            find_usage_in_file(file)

def find_usage_in_file(file_path):
    global _all_keys
    with open(file_path, 'rb', 0) as f:
        s = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        used_keys = {key for key in _all_keys if s.find(bytes(key, 'utf-8')) != -1}
        print("Usage in " + file_path)
        print(used_keys)

여기서는 크게 두 개의 Python Library를 사용합니다.

  • glob Library : 모든 파일(kotlin, java, xml)을 재귀적으로 탐색하는 데 사용합니다. 예시로 Android의 모든 kotlin 파일은 모두 ../src/main/java/ 경로에 있으므로 "src/main/java/**/*.kt"로 모든 .kt 확장자를 가진 파일을 찾습니다.
  • mmap Library : 파일 내에서 문구를 사용하고 있는지 판단하는 데 사용합니다. 각 문구마다 mmap의 find() 함수를 반복해 사용 여부를 확인하게 됩니다.

이 스크립트는 모든 파일 내에서 모든 문구를 사용하는지 확인하기 때문에 성능적인 단점이 있지만, 성능보다 편의를 위한 목적이 더 크므로 감안했습니다.

strings.xml, strings_local.xml 정리

strings.xml, strings_local.xml에 정리하는 단계입니다. 아래 스크립트를 통해 strings.xml, strings_local.xml을 직접 수정하지 않고 Lokalise만 수정하면 자동으로 수정되도록 했습니다.

def compare_dict(lokalise_dict, strings_dict):
    lokalise_keys = set(lokalise_dict.keys())
    strings_keys = set(strings_dict.keys())

    already_in_strings = strings_keys.intersection(lokalise_keys)
    modified = {key: lokalise_dict[key] for key in already_in_strings if strings_dict[key] != lokalise_dict[key]}
    add_to_strings = {key: lokalise_dict[key]
                      for key in lokalise_keys.difference(already_in_strings).intersection(_strings_local_keys)}
    remove_from_strings = {key: strings_dict[key] for key in strings_keys.difference(already_in_strings)}
    return modified, add_to_strings, remove_from_strings

compare_dict는 스크립트의 핵심이 되는 함수로 Lokalise 와 strings.xml의 문구를 파라미터로 받아 각각의 문구를 비교합니다.

  1. Lokalise에 존재, strings.xml에 미 존재 (add_to_strings) : strings.xml에 추가, strings_local.xml에는 제거합니다.
  2. Lokalise, strings.xml에 모두 존재 (modified) : Lokalise, strings.xml 중 같은 key 값이지만 문구가 다른 것을 Lokalise 와 동일하게 수정합니다.
  3. Lokalise에 미 존재, strings.xml에 존재 (remove_from_strings) : strings.xml에 있는 문구 중 Lokalise에 없다면 strings.xml에서 제거, strings_local.xml에 추가합니다.

이렇게 비교한 3개의 결과는 아래 함수에서 처리합니다.

def sync_with_lokalise():
    def sync_values_with_lokalise(values):
        lokalise_res = StringRes(LOKALISE_ROOT_PATH + values + STRINGS_XML)
        strings_res = StringRes(STRING_ROOT_PATH + values + STRINGS_XML)
        strings_local_res = StringRes(STRING_ROOT_PATH + values + STRINGS_LOCAL_XML)
        modified, add_to_strings, remove_from_strings = compare_dict(lokalise_res.parse(), strings_res.parse())

        strings_res.remove(remove_from_strings)
        strings_res.insert(add_to_strings)
        strings_res.replace(modified)
        strings_res.save()

        strings_local_res.insert(remove_from_strings)
        strings_local_res.remove(add_to_strings)
        if values != VALUES_EN:
            strings_local_res.remove(check_lokalise_untranslated(strings_local_res.parse()))
        strings_local_res.save()

    for values in ALL_VALUES:
        sync_values_with_lokalise(values)

strings.xml, strings_local.xmlStringRes에서 만든 insert(), remove(), replace() 함수로 수정 사항을 반영해 최종적으로 저장하게 됩니다.

4. 중복 리소스 정리

Lokalise에 있는 문구 중 키값은 달라도 번역된 한, 영, 일 문구가 모두 같은 문구를 파악하는 스크립트 중 일부입니다.

import collections
from pprint import pprint

def find_duplicate():
    all_duplicate = set()
    for value in ALL_VALUES:
        duplicate = find_duplicate_in_value(value)
        if all_duplicate:
            all_duplicate = all_duplicate.intersection(duplicate)
        else:
            all_duplicate = all_duplicate.union(duplicate)
    pprint(all_duplicate)

def find_duplicate_in_value(value):
    all_dicts = get_all_strings(value)
    value_to_key = collections.defaultdict(list)
    for key, value in all_dicts.items():
        value_to_key[value].append(key)

    return {frozenset(value) for key, value in value_to_key.items() if len(value) > 1}
  • find_duplicate_in_value() : 특정 언어에서 같은 문구가 2개 이상 중복된 key 값을 찾습니다. 중복을 판단하기 위해 Dictionary(key : 문구, value : 해당 문구를 가진 키 list)를 만들어 value 가 2개 이상인 key만 찾습니다.
  • find_duplicate() : 한, 영, 일 모든 언어에서 동일하게 중복된 문구를 가진 key만 걸러냅니다.

이 스크립트를 통해 약 50개의 중복 리소스를 찾았으며, 그중 11개를 정리했습니다.

5. 미사용 리소스 정리

마지막으로 Lokalise에 있는 문구 중 더 이상 사용하지 않아 불필요한 리소스를 제거하는 단계입니다. 위에서 만든 문구를 사용하는지 판단하는 스크립트에서 일부만 수정했습니다.

...

def find_unused_in_file(file_path):
    global _all_keys, _all_used_keys
    with open(file_path, 'rb', 0) as f:
        s = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        used_keys = {key for key in _all_keys if s.find(bytes(key, 'utf-8')) != -1}
        _all_used_keys = _all_used_keys.union(used_keys)

...
unused_keys = _all_keys - _all_used_keys
print("\\n".join(unused_keys))

이렇게 찾은 모든 미사용 리소스는 아래와 같은 스펙 확인 절차를 거쳐 걸러냈습니다.

  1. SmartEditor 미사용 문구 확인
  2. 모든 서비스에서 사용하는 SmartEditor의 문구 확인
  3. SmartEditor 스펙상 제공되어야 하는 문구 확인
unused_android 태그로 분류

최종적으로 걸러진 약 100개 리소스는 프로젝트 내에서는 제거하더라도 Lokalise에는 제거는 하지 않고, 백업을 위해 Lokalise의 태그 기능을 이용해 unused_android 태그로 따로 분리해놨습니다.

자동화를 통한 다국어 처리 개선

위 문제점에서 보았듯이 가장 큰 레거시가 생기는 원인이었던 개발자가 직접 프로젝트 내 strings.xml에 추가하는 과정을, 자동화를 통해 개선할 수 있습니다.

Lokalise에서 지원하는 자동화

먼저 Lokalise에서 지원하는 자동화를 소개합니다.

이 외에도 Lokalise에서는 다양한 API를 지원하고 있습니다.

최종적으로 Lokalise File Download REST API를 사용하며, 데일리로 실행할 수 있도록 아래와 같은 Jenkins pipeline 을 만들었습니다.

pipeline {
    agent any

    environment {
        BRANCH = ''
        DOWNLOAD_LOKALISE = ''
    }

    stages {
        stage('Pull Latest') {
            steps {
                script {
                    BRANCH = withoutOrigin(env.GIT_BRANCH)
                }
                withEnv(["""BRANCH=$BRANCH"""]) {
                    sh 'python3.7 tools/checkout_branch.py'
                }
                sh "git pull origin $BRANCH"
            }
        }

        stage('Download Lokalise') {
            steps {
                sh 'python -m pip install requests --user'
                script {
                    DOWNLOAD_LOKALISE = """${sh(script: 'python tools/string/download_lokalise.py', returnStatus: true)}"""
                }
            }
        }

        stage('Sync with Strings Local') {
            when {
                expression { DOWNLOAD_LOKALISE == '0' }
            }
            steps {
                sh 'python tools/string/sync_strings_local.py'
            }
        }

        stage('Check and Commit'){
            when {
                expression { DOWNLOAD_LOKALISE == '0' }
            }
            steps {
                withEnv(["""BRANCH=$BRANCH"""]) {
                    script {
                        def result = sh(script: 'python3.7 tools/string/check_and_commit.py', returnStdout: true).split('\\n').last()
                    }
                }
            }
        }
    }
}

def withoutOrigin(branch) {
    return branch.startsWith('origin/') ? branch - 'origin/' : branch
}

1. checkout & pull latest

Jenkins pipeline은 파라미터로 받은 branch로 빌드 되어 실행됩니다. 따라서 git checkout, git pull 을 하지 않아도 된다고 생각할 수 있지만, 이 단계를 진행하지 않으면 git push를 할 때 아래와 같은 에러가 발생합니다.

따라서 git checkout, git pull을 하여 GitHub origin과 Jenkins workspace 간의 sync를 맞춰야 합니다.

2. Lokalise에서 strings.xml 다운로드

Lokalise File Download REST API를 이용해 strings.xml을 다운로드하는 download_lokalise.py 스크립트를 실행합니다

def get_lokalise_download_url():
    url = _LOKALISE_FILES_DOWNLOAD_URL % _LOKALISE_PROJECT_ID
    params = {
        "filter_langs": ["en", "ja", "ko"],
        "include_tags": ["common", "core", "gallery", "voiceover_android"],
        "exclude_tags": ["unused_android"],
        "format": "xml",
        "original_filenames": False,
        "all_platforms": False,
        "indentation": "tab",
        "add_newline_eof": True,
        "export_sort": "first_added",
        "export_empty_as": "skip",  # 해당 언어 번역이 없을 경우 skip
        "bundle_description": "android_sdk",
        "bundle_structure": "values-%LANG_ISO%/strings.%FORMAT%"
    }
    headers = {
        "Accept": "application/json",
        "Content-Type": "application/json",
        "X-Api-Token": _LOKALISE_API_TOKEN
    }
    json_response = json.loads(requests.post(url, json=params, headers=headers).text)
    if 'bundle_url' in json_response:
        return json_response['bundle_url']
    else:
        return None

Lokalise File Download REST API에 전달하는 params에 "exclude_tags": ["unused_android"]를 두어 미사용 리소스는 제외하고 다운로드합니다.

3. strings.xml 과 strings_local.xml 동기화

strings_local.xml에 있는 개발용 임시 문구가 번역 완료되어 Lokalise에 추가되었다면 더 이상 strings_local.xml에는 필요 없으므로 지워야 합니다. 아래 스크립트를 통해 strings.xmlstrings_local.xml을 동기화해 필요 없는 문구를 제거합니다.

def sync_strings_with_local():
    def sync_values_strings_with_local(values):
        strings_res = StringRes(get_jenkins_string_root_path() + values + STRINGS_XML)
        strings_local_res = StringRes(get_jenkins_string_root_path() + values + STRINGS_LOCAL_XML)
        remove_from_local = compare_strings_with_local(strings_res.parse(), strings_local_res.parse())

        strings_local_res.remove(remove_from_local)
        strings_local_res.save()

    for values in ALL_VALUES:
        sync_values_strings_with_local(values)

def compare_strings_with_local(strings_dict, strings_local_dict):
    strings_keys = set(strings_dict.keys())
    strings_local_keys = set(strings_local_dict.keys())

    remove_from_local = strings_keys.intersection(strings_local_keys)
    remove_from_local.update(_en_strings_keys.intersection(strings_local_keys))
    return remove_from_local

4. 변경사항 commit & push

최종적으로 변경된 게 있다면 수정사항을 Github Repository에 업데이트합니다.

import subprocess as cmd
import os

def check_changed():
    cmd.run("git add .", check=True, shell=True)
    proc = cmd.run("git diff --quiet HEAD", capture_output=True, shell=True)
    return bool(proc.returncode)

if __name__ == '__main__':
    branch = os.environ["BRANCH"]
    if check_changed():
        cmd.run("git commit -m 'updated strings.'", check=True, shell=True)
        cmd.run("git push origin %s" % branch, check=True, shell=True)

git diff 을 하기 이전에 git add .를 하고 있습니다. Jenkins의 경우 우리가 평소에 사용하는 로컬 환경과 달리 새로운 파일 (strings.xml) 이 추가될 경우 git에 untracked 상태이기 때문에 반드시 해줘야 합니다.


이렇게 만들어진 Jenkins pipeline은 기본적으로 매일 스케줄링 되어 자동 실행되며, 필요시 직접 원하는 브랜치에서 실행할 수 있습니다. 업데이트 시 Github에 반영된 것을 확인할 수 있으며, 업데이트 여부를 slack으로 알려주고 있습니다.

느낀 점

다국어 정리 및 개선 과정은 처음 예상보다 많은 시간이 들었습니다. 이 포스팅에서는 자세히 다루지 않았지만 서비스에서의 사용 여부나 기본 스펙 여부, 동시에 iOS는 어떻게 노출하고 있는지 등 체크해야 할 게 많았기 때문입니다. 또한 Python, Jenkins 모두 제게는 처음이라 약 2주 동안 공부하여 스크립트를 개발했습니다.
그럼에도 결과적으로 다국어를 경량화, 자동화 외에도 그동안 레거시로 남아있는 코드를 없애고, 리소스 dependency를 개선, 다시 한번 스펙을 정리하는 등 여러 부가적인 효과들이 있었습니다. 더욱이 곧 입사 1년 차가 되어가는 제게는 프로젝트를 이해하고, 새로운 영역을 공부할 수 있었던 좋은 경험이었습니다.

만약 본인의 팀에 이런 레거시가 없다면 다행이지만 그게 아니라면 한 번쯤 시도해보시는걸 추천합니다.
저희도 이게 시작일 것입니다. 모든 다국어 레거시를 없애는 그날까지~

배명환 | SmartEditor
배명환 | SmartEditor

Android Developer / Coffee, Trip, Driving