유니티는 공식적으로 Localization을 지원하긴 하나
실력을 늘리고 싶어 직접 만들어 출시 준비중인 게임에 적용해보았습니다.

1. 언어 별 폰트 구하기
무료 폰트로 범용성, 가독성이 좋은 Nato Sans CJK를 추천하지만,
게임 특성상 둥글둥글한 폰트가 필요하여 언어별 다른 무료 폰트 찾음
TMPro를 쓸 것이기에 받은 폰트로 폰트 에셋 생성

중국어 폰트를 생성할 때는 해당 옵션을 사용하였고, 생성한 폰트 에셋에는 Atlas Population Mode = Dynamic 설정
그 중에 중국어 폰트가 너무 얇게 나오고 폰트의 Weight을 조절하여 폰트 파일로 재추출하는데는 유로 프로그램이 필요했기에 가난한 개발자 팀으로써는 부담이 되어 다른 방법을 찾음
아래 코드들에서 중국어 폰트일 때 TMP_Text의 Outline 두께 및 색상을 조절하여 해당 글씨체처럼 보이게끔 조치
해당 포스팅으로 공부하는 분은 아래 코드에서 Outline 관련은 제외하고 보길 권장

2. LocalizationManager
게임 중 모든 텍스트 관련해서 접근이 가능하게 싱글톤
현지화 관련된 핵심 기능들이 담겨 있는데,
(1) 초기 언어 설정: 처음 실행 시 스팀, 시스템 os 언어 설정을 읽어와 해당하는 언어를 기본으로 사용하게끔
(2) 언어 설정값 반영: 설정에서 언어 선택 시 해당 메서드 호출. 설정값 기억, 언어에 맞는 폰트 설정, 등록된 언어 변경 이벤트들을 실행하여 언어 설정값에 맞게 표시 문자열 변경
(3) 번역 문자열 반환 : 현재 언어 옵션의 key에 해당하는 문자 값을 반환 >> 런타임 도중 텍스트를 변경해야 할 경우 사용
스크립트로 문자열을 직접 반환받아 제어할 경우, 아래와 같이 enum 항목 명칭을 key로 사용
string weaponName = LocalizationManager.instance.GetText(Weapon_Keys.Weapon_Shooter_Name.ToString());
(4) 폰트 설정 : 매개변수로 받는 TMP_Text 타입의 폰트를 언어에 맞게 교체 및 필요한 옵션 설정
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using TMPro;
// 스팀에서 플레이 시 스팀 언어 설정대로 기본 언어를 설정하기 위해 추가
#if USE_STEAM
using Steamworks;
#endif
// 스팀에서 어떤 언어인지 반환하는 문자열을 언어로 설정
// !주의! : LocalizedValue 클래스와 언어 인덱스 순서를 동일하게 하기
enum LanguageCode
{
koreana, // 한국어
english, // 영어
schinese, // 중국어(간체)
tchinese, // 중국어(번체)
japanese, // 일어
russian, // 러시아어
german, // 독일어
french, // 프랑스어
italian, // 이탈리아어
spanish, // 스페인어
polish, // 폴란드어
portuguese, // 포르투갈어(유럽) >> 브라질어와 따로 번역하는 게 안전
unknown
}
/* !!! 주의 !!!
/////////////////////////////////////////////////////////////////
1. 컨텐츠 추가 시,
.json 파일에 key, value 값을 추가하면서
enum lacalizeKeys에 해당 key 값을 포함해둬야 함
2. Assets.StreamingAssets 폴더
유니티에서 특별한 기능을 맡는 폴더(Assets.Resources와 유사)
명칭 변경/폴더 이동 시 이하 코드가 제대로 동작하지 않음
필요하다면 Assets.StreamingAssets 폴더 내부에 하위 폴더를 만들어서 세분화하고,
LoadLanguage()의 string path 참조 경로를 알맞게 바꿔주는 것을 추천
//////////////////////////////////////////////////////////////////
*/
// 현지화 데잍터의 딕셔너리(키, 값 페어)
[System.Serializable]
public class LocalizationData
{
public string key;
public LocalizedValue value;
}
// 현지화 데이터들의 배열(모음)
[System.Serializable]
public class LocalizationFile
{
public LocalizationData[] items;
}
// 언어 별 value 나누는 키워드
[System.Serializable]
public class LocalizedValue
{
public string ko;
public string en;
public string zh_CN;
public string zh_TW;
public string jp;
}
// 인스펙터 정리 + 작업 효율을 위한 커스텀 에디터를 쓰기 위해 사용
// 텍스트
[System.Serializable]
public class LocalizeKeySelector
{
public LocalizeCategory category;
public string key; // 실제 선택된 키 이름 (문자열로 저장)
}
// 드롭다운
[System.Serializable]
public class LocalizeDropDownKeysSelector
{
public LocalizeKeySelector[] categories; // 실제 선택된 키 이름 (문자열로 저장)
}
public class LocalizationManager : MonoBehaviour
{
#region 싱글톤
// 게임 컨텐츠들 어디서나 언어에 따른 텍스트들에 접근하기 용이하게 싱글톤
public static LocalizationManager instance { get; private set; }
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
InitLanguage();
LoadLanguage();
}
else
Destroy(gameObject);
}
#endregion
#region 현지화
Dictionary<string, LocalizedValue> localizedText;
// 각 인덱스가 어느 언어에 해당하는지는 GetText() 내 switch 구문 참고
public int languageIndex { get; private set; }
readonly string failText = "Translation Failed";
readonly string fileName = "localization.json";
readonly string languageSetting = "language";
// 한국어, 영어 폰트 : Paperlogy-9Black SDF
public TMP_FontAsset font_KE;
// 중국어 간체 폰트
public TMP_FontAsset font_zh_CN;
// 중국어 번체 폰트
public TMP_FontAsset font_zh_TW;
public void SetLanguage(int index)
{
languageIndex = index;
// 설정한 언어 옵션으로 다음에 실행되게끔 저장
PlayerPrefs.SetInt(languageSetting, languageIndex);
// 씬 시작 때 한번만 폰트를 변화하기에 옵션을 바꿀 때
// 해당 씬 내 모든 텍스트의 폰트 일괄 변경 기능 추가
TextMeshProUGUI[] tmp_texts = FindObjectsOfType<TextMeshProUGUI>(true);
for (int i = 0; i < tmp_texts.Length; i++)
{
GetFont(tmp_texts[i]);
}
// 언어 변경 이벤트 호출
OnLanguageChanged?.Invoke();
}
// 언어 변경 시 이벤트
public static event System.Action OnLanguageChanged;
// 언어 기본 설정
void InitLanguage()
{
// 1, PlayerPrefs에 저장된 언어(기존 선택/플레이했던 언어)
if(PlayerPrefs.HasKey(languageSetting))
{
languageIndex = PlayerPrefs.GetInt(languageSetting);
return;
}
// 아래는 처음 실행할 때만 실행
#if USE_STEAM
// 2. 스팀에서 설정한 언어(게임을 해당 언어로 플레이하고 싶은 유저이기에 스팀 언어를 따라가는 게 맞음)
// 스팀 초기화가 되었는지 체크 >> 순서에 문제가 있다면, PlayerSettings에서 SteamManager.Initialized 이후에 해당 스크립트가 실행되게끔 순서 지정
if (SteamManager.Initialized)
{
// 스팀 클라이언트에서 사용자가 설정한 언어 읽어오기
string steamLanguage = SteamApps.GetCurrentGameLanguage();
switch(steamLanguage)
{
// 번역한 언어들을 쓰는지 체크
case SteamLanguage.koreana.ToString():
languageIndex = (int)LanguageCode.koreana;
break;
case SteamLanguage.english.ToString():
languageIndex = (int)LanguageCode.english;
break;
case SteamLanguage.schinese.ToString():
languageIndex = (int)LanguageCode.schinese;
break;
case SteamLanguage.tchinese.ToString():
languageIndex = (int)LanguageCode.tchinese;
break;
case SteamLanguage.japanease.ToString():
languageIndex = (int)LanguageCode.japanese;
break;
// 어떠한 언어도 매칭되지 않으면 공통어로 많이 쓰는 영어로 설정
default:
languageIndex = (int)LanguageCode.english;
break;
}
PlayerPrefs.SetInt(languageSetting, languageIndex);
return;
}
#endif
// 3. 시스템 OS에서 설정된 언어(스토브, 스팀 초기화가 안되었을 때)
SystemLanguage systemLanguage = Application.systemLanguage;
switch (systemLanguage)
{
case SystemLanguage.Korean:
languageIndex = (int)LanguageCode.koreana;
break;
case SystemLanguage.English:
languageIndex = (int)LanguageCode.english;
break;
case SystemLanguage.ChineseSimplified:
languageIndex = (int)LanguageCode.schinese;
break;
case SystemLanguage.ChineseTraditional:
languageIndex = (int)LanguageCode.tchinese;
break;
case SystemLanguage.Japanese:
languageIndex = (int)LanguageCode.japanese;
break;
// 어떠한 언어도 매칭되지 않으면 공통어로 많이 쓰는 영어로 설정
default:
languageIndex = (int)LanguageCode.english;
break;
}
PlayerPrefs.SetInt(languageSetting, languageIndex);
}
// 설정에 맞는 언어 파일을 읽어들여 localizedText 딕셔너리에 정리
// >> 언제든 효율적으로 찾을 수 있도록
public void LoadLanguage()
{
string path = Path.Combine(Application.streamingAssetsPath, fileName);
string json = File.ReadAllText(path);
LocalizationFile file = JsonUtility.FromJson<LocalizationFile>(json);
localizedText = new Dictionary<string, LocalizedValue>();
foreach (var item in file.items)
{
localizedText[item.key] = item.value;
}
}
// 해당 언어 설정으로 번역된 문자열 값을 가져오되 실패 시 실패했다고 텍스트 알림 >> 추후 번역 실패가 뜬다면 제보받은 부분에 쉽게 접근할 수 있음
public string GetText(string key)
{
if(localizedText.TryGetValue(key, out var value))
{
return languageIndex switch
{
0 => value.ko,
1 => value.en,
2 => value.zh_CN,
3 => value.zh_TW,
4 => value.jp,
_ => failText
};
}
return failText;
}
// 현재 언어에 맞는 폰트 반환
public void GetFont(TMP_Text uiText)
{
TMP_FontAsset fontNext = null;
switch (languageIndex)
{
case (int)LanguageCode.koreana:
case (int)LanguageCode.english:
case (int)LanguageCode.japanese:
fontNext = font_KE;
break;
case (int)LanguageCode.schinese:
// 글자 아웃라인 색을 변경하기 위해 머티리얼 복제 후 색상 변경
Material newMat_sc = new Material(uiText.fontSharedMaterial);
uiText.fontMaterial = newMat_sc;
uiText.fontMaterial.SetColor(ShaderUtilities.ID_OutlineColor, uiText.color);
fontNext = font_zh_CN;
break;
case (int)LanguageCode.tchinese:
// 글자 아웃라인 색을 변경하기 위해 머티리얼 복제 후 색상 변경
Material newMat_tc = new Material(uiText.fontSharedMaterial);
uiText.fontMaterial = newMat_tc;
uiText.fontMaterial.SetColor(ShaderUtilities.ID_OutlineColor, uiText.color);
fontNext = font_zh_TW;
break;
default:
fontNext = font_KE;
break;
}
uiText.font = fontNext;
// 폰트 변경 후 메시 업데이트로 다시 로딩
uiText.ForceMeshUpdate();
}
#endregion
}
3. Localization.json + 번역

해당 json 파일은 Assets\StreamingAssets 경로에 위치
key 하나에 언어 별 대응이 가능하여 새로운 항목/언어에 대해 추가/관리가 편하기에 해당 형태로 작성
AI 번역 교차 검증, 지인 검수를 통해 번역
AI의 번역 정확도가 매우 높아 검수를 통해 오역이 검출되지는 않음
4. LocalizationKeys

카테고리, 화면 내 텍스트를 각각을 지칭하는 key로 enum을 나누어 분류
LocalizationManager의 텍스트 반환 메서드(GetText)에서 어떤 문자열을 반환할지 지시
5. LocalizeKeySelectorDrawer
커스텀 에디터로 첫 드롭다운 선택 시 다음 드롭다운의 항목 리스트가 바뀌게끔 구조 생성
LocalizationKeys의 LocalizeCategory의 큰 범주를 고르면, 그 아래 세부 항목이 출현하게끔 하여 인스펙터 할당 작업 효율 상승
다음에 더 복잡한 게임에 현지화 작업을 할 일이 있다면 카테고리를 더 세분화할 예정
// 드롭다운에서 enum을 교체하여 로컬라이징 항목을 세분화(작업 효율성 증대)를 위한 커스텀 에디터
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System;
[CustomPropertyDrawer(typeof(LocalizeKeySelector))]
public class LocalizeKeySelectorDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
var categoryProp = property.FindPropertyRelative("category");
var keyProp = property.FindPropertyRelative("key");
EditorGUI.BeginProperty(position, label, property);
// 카테고리 선택
Rect categoryRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
EditorGUI.PropertyField(categoryRect, categoryProp);
// 카테고리에 따라 enum 타입 결정
Type enumType = null;
switch ((LocalizeCategory)categoryProp.enumValueIndex)
{
case LocalizeCategory.Title: enumType = typeof(Title_Keys); break;
case LocalizeCategory.Lobby: enumType = typeof(Lobby_Keys); break;
case LocalizeCategory.Setting: enumType = typeof(Setting_Keys); break;
case LocalizeCategory.WaitingRoom: enumType = typeof(WaitingRoom_Keys); break;
case LocalizeCategory.Tutorial: enumType = typeof(Tutorial_Keys); break;
case LocalizeCategory.Game: enumType = typeof(InGame_Keys); break;
case LocalizeCategory.Map: enumType = typeof(MapName_Keys); break;
case LocalizeCategory.Weapon: enumType = typeof(Weapon_Keys); break;
case LocalizeCategory.Item: enumType = typeof(Item_Keys); break;
case LocalizeCategory.Paintables: enumType = typeof(Paintable_Keys); break;
case LocalizeCategory.Customize: enumType = typeof(Customize_keys); break;
// 큰 카테고리 enum 추가 시 enum LocalizeCategory에 항목을 추가한 뒤, 여기에 추가
}
// enumType에 따라 그에 해당하는 enum 항목들을 드롭다운으로 생성
if (enumType != null)
{
Array values = Enum.GetValues(enumType);
string[] names = Enum.GetNames(enumType);
int index = Array.IndexOf(names, keyProp.stringValue);
if (index < 0) index = 0;
Rect keyRect = new Rect(position.x, position.y + EditorGUIUtility.singleLineHeight + 2, position.width, EditorGUIUtility.singleLineHeight);
int newIndex = EditorGUI.Popup(keyRect, "Key", index, names);
keyProp.stringValue = names[newIndex];
}
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight * 2 + 4;
}
}
#endif
6. LocalizedText

런타임 중 변하지 않는 텍스트를 언어 옵션에 맞게 바꿔주는 스크립트
작업 팁: 씬 하이어라키에서 t:tmp_text 를 검색. 이 중 고정 텍스트에만 해당 스크립트 부착 및 드롭다운 설정
아래 코드에서 텍스트 머티리얼의 셰이더를 변경하는 부분들이 있는데, 이는 게임 중 오버레이가 필요한 텍스트들이 있었기에 조치한 내용이므로 공부용으로 보는 분은 해당 내용 제외할 것
using TMPro;
using UnityEngine;
[RequireComponent(typeof(TextMeshProUGUI))]
public class LocalizedText : MonoBehaviour
{
// JSON에서 사용할 키 >> 인스펙터에서 드롭다운 할당 필요
[SerializeField] LocalizeKeySelector localizeKeySelector;
private TextMeshProUGUI uiText;
Material newMat;
Shader tmpShader;
readonly string overlayShaderPath = "TextMeshPro/Distance Field Overlay",
nonOverlayShaderPath = "TextMeshPro/Distance Field";
private void Awake()
{
uiText = GetComponent<TextMeshProUGUI>();
// 머티리얼을 공유본에서 복사
newMat = new Material(uiText.fontSharedMaterial);
// 구조물 이름 표시는 Distance Field Overlay 셰이더가 필요
// 다만, 이전에 작업하다 실수로 모든 텍스트 셰이더를 덮어 씌웠기에 원복용으로 else 구문 투입
string tmpShaderPath = null;
if (localizeKeySelector.category == LocalizeCategory.Paintables)
{
tmpShaderPath = overlayShaderPath;
}
else
{
tmpShaderPath = nonOverlayShaderPath;
}
tmpShader = Shader.Find(tmpShaderPath);
if (tmpShader != null)
{
newMat.shader = tmpShader;
}
else
{
Debug.LogWarning(tmpShaderPath + " 셰이더를 찾을 수 없습니다.");
}
uiText.fontMaterial = newMat;
}
// 언어 변경 시 자동으로 해당 설정으로 텍스트가 바뀌게끔 이벤트 등록/해제
private void OnEnable()
{
LocalizationManager.OnLanguageChanged += UpdateText;
// 활성화 때 언어 설정에 따라 출력 정보 변경
UpdateText();
}
private void OnDisable()
{
LocalizationManager.OnLanguageChanged -= UpdateText;
}
// 언어 설정에 따른 텍스트 표시 변경
public void UpdateText()
{
if (LocalizationManager.instance != null && !string.IsNullOrEmpty(localizeKeySelector.key.ToString()))
{
// 언어에 따라 대응하는 폰트 변경
LocalizationManager.instance.GetFont(uiText);
// 폰트 변경 후
// 머티리얼을 공유본에서 복사
newMat = new Material(uiText.fontSharedMaterial);
// 머티리얼의 셰이더를 맞는 것으로 변경
newMat.shader = tmpShader;
uiText.fontMaterial = newMat;
// 중국어일 경우, 머티리얼 복사본(독립됨)의 아웃라인 색상을 폰트 색상과 같게 변경
if (LocalizationManager.instance.languageIndex == (int)LanguageCode.schinese || LocalizationManager.instance.languageIndex == (int)LanguageCode.tchinese)
{
uiText.fontMaterial.SetColor(ShaderUtilities.ID_OutlineColor, uiText.color);
}
// 텍스트 변경
uiText.text = LocalizationManager.instance.GetText(localizeKeySelector.key.ToString());
// 텍스트 다시 랜더링
uiText.ForceMeshUpdate();
}
}
}
7. LocalizedDropDown
드롭다운 각 항목에 번역이 필요한 경우 사용


using TMPro;
using UnityEngine;
// 드롭다운 번역 사용법
// 드롭다운 아이템들을 각 언어에 맞춰 번역만 할 때는 해당 스크립트를 드롭다운이 있는 오브젝트에 부착
// 인스펙터에서 어떤 내용을 표시할지 할당해주기
// 기존에 없던 내용 추가 시, LocalizationKey.cs, Localization.json 에 기입
// 아래 경우 해당 스크립트를 상속하여 기능을 추가하면 확장 가능
// 1. 초기 value 선택이 필요 >> SetEnableItem()을 오버라이드하여 초기 선택과 관련한 내용 작성
// 2. 드롭다운 선택으로 value가 바뀌었을 때 이벤트 필요 >> OnDropdownValueChanged()을 오버라이드하여 base.OnDropdownValueChanged();을 포함.
[RequireComponent(typeof(TMP_Dropdown))]
public class LocalizedDropDown : MonoBehaviour
{
// JSON에서 사용할 키들 >> 인스펙터에서 드롭다운 할당 필요
[SerializeField] protected LocalizeDropDownKeysSelector dropDownSelector;
protected TMP_Dropdown dropDown;
private void Awake()
{
// 드롭다운 값이 변경될 때마다 실제 리스트 항목의 폰트를 맞춰주게끔 이벤트 등록
if (TryGetComponent(out dropDown))
dropDown.onValueChanged.AddListener(OnDropdownValueChanged);
}
// 언어 변경 시 자동으로 해당 설정으로 텍스트가 바뀌게끔 이벤트 등록/해제
private void OnEnable()
{
LocalizationManager.OnLanguageChanged += UpdateDropdown;
// 활성화 때 언어 설정에 따라 출력 정보 변경
UpdateDropdown();
SetEnableItem();
}
private void OnDisable()
{
LocalizationManager.OnLanguageChanged -= UpdateDropdown;
}
private void OnDestroy()
{
dropDown.onValueChanged.RemoveListener(OnDropdownValueChanged);
}
// 언어 설정에 따른 텍스트 표시 변경
protected virtual void UpdateDropdown()
{
if (LocalizationManager.instance == null)
return;
// 언어에 따라 대응하는 폰트 변경
LocalizationManager.instance.GetFont(dropDown.itemText);
// 기존 드롭다운 옵션 클리어
dropDown.options.Clear();
// 언어에 해당하는 텍스트로 항목들을 변경
for (int i = 0; i < dropDownSelector.categories.Length; i++)
{
string keyTmp = dropDownSelector.categories[i].key.ToString();
if (string.IsNullOrEmpty(keyTmp))
continue;
string localizedItem = LocalizationManager.instance.GetText(keyTmp);
dropDown.options.Add(new TMP_Dropdown.OptionData(localizedItem));
}
// 바꾼 내용대로 드롭다운 리로드
dropDown.RefreshShownValue();
}
// 각 드롭다운 별 값이 바뀌었을 때 수행할 내용.
// 기본형은 공통으로 들어갈 내용만 담겨 있음
protected virtual void OnDropdownValueChanged(int index)
{
// 드롭다운 열림 시점에 리스트 항목들의 폰트를 강제 적용
var texts = dropDown.GetComponentsInChildren<TextMeshProUGUI>(true);
foreach (var t in texts)
{
t.font = dropDown.itemText.font;
}
}
// 각 드롭다운 별 초기 항목 선택 관련 추상 메서드
protected virtual void SetEnableItem(){}
}
위의 코드를 상속한 드롭다운 항목 선택 시 / 초기 옵션 세팅 예시
public class DropDown_LocalizationSetting : LocalizedDropDown
{
protected override void OnDropdownValueChanged(int index)
{
base.OnDropdownValueChanged(index);
// 선택한 항목을 LocalizationManager에 반영
LocalizationManager.instance.SetLanguage(index);
}
protected override void SetEnableItem()
{
// 언어 옵션에 따라 초기 항목 선택 변경
int languageIndex = LocalizationManager.instance.languageIndex;
if(dropDown != null)
{
if(languageIndex <dropDown.options.Count)
dropDown.value = LocalizationManager.instance.languageIndex;
}
}
}
8. 주의사항 및 세부작업
다른 언어로 바꿨을 때 같은 뜻이라도 문장 길이가 달라짐
이에 따라 텍스트가 UI를 삐져나옴
특히, 영어가 같은 뜻이라도 길어지는 경우가 많았음
(1) 텍스트 박스 크기 조정 = 텍스트를 표시할 영역에 맞춤



(2) Auto Size + 옵션 Min/Max 조절을 통해 주변 UI와 글자 크기를 맞추어 이질감을 최소화

'기타' 카테고리의 다른 글
| 시프트+딜리트로 실수로 파일을 삭제했을 때 무료 복구 방법 (7) | 2025.03.29 |
|---|---|
| 티스토리 다크 모드 무료 스킨 추천 및 적용 (0) | 2025.03.02 |
| C/C++ 비전공자의 눈으로 보는 포인터 (Pointer) (2) | 2025.03.02 |
| C) 하노이의 탑 실행창에서 게임으로 즐기기 (exe, 소스 코드 첨부) (0) | 2025.03.01 |
| gif 배너 만들 때 사용한 툴 정리 (0) | 2024.03.14 |