📕학습 개요

오늘은 유니티 게임 데이터 저장/로드 시스템을 구현하며 마주친 클래스 상속 관계(다형성)의 직렬화 문제를 깊이 있게 학습했다. 두 가지 해결 방안의 장단점을 비교하며 어떤 방식이 더 유지보수하기 좋은 구조인지 명확하게 이해할 수 있었다.


📖학습 내용


1. 유니티 생명주기와 초기화 순서의 이해

  • 문제점: MonoBehaviour가 아닌 일반 C# 클래스의 생성자에서 Application.persistentDataPath 같은 유니티 API를 호출하면 UnityException이 발생했다.
  • 원인: C# 클래스의 생성자는 유니티 엔진이 완전히 준비되기 전에 호출된다. 따라서 이 시점에는 파일 경로 같은 플랫폼 종속적인 정보에 접근할 수 없다.
  • 핵심 원칙: 유니티 API 호출은 반드시 엔진이 준비된 후인 Awake()Start() 메서드 내에서 이루어져야 한다.
  • 설계 패턴: 일반 C# 클래스(SaveLoadManager)가 유니티 API에 직접 의존하는 대신, MonoBehaviour를 상속하는 상위 관리자(Managers)가 Awake()에서 경로를 생성하여, SaveLoadManager의 생성자에 매개변수로 전달(의존성 주입)하는 방식으로 문제를 해결했다. 이로써 클래스 간의 의존성이 분리되고 코드가 더 명확해졌다.

2. 데이터 직렬화 기법과 선택

  • JsonUtility의 한계: 유니티 내장 JsonUtility는 빠르지만 Dictionary 타입을 직접 직렬화하지 못하는 명백한 한계가 있다.
  • Newtonsoft.Json의 활용: 외부 패키지인 Newtonsoft.Json은 딕셔너리를 포함한 거의 모든 C# 객체를 손쉽게 직렬화할 수 있어 매우 강력하다. Package Manager를 통해 쉽게 설치할 수 있다.
  • BigInteger 저장: long 타입을 초과하는 매우 큰 숫자를 다루는 BigInteger는 JSON의 숫자 타입 한계를 넘을 수 있다. 데이터 손실을 방지하기 위해 ToString()으로 변환하여 문자열(string)로 저장하고, 불러올 때 BigInteger.Parse()로 복원하는 것이 가장 안전하다. [JsonIgnore]와 대리 속성(Surrogate Property)을 사용하면 이 과정을 깔끔하게 자동화할 수 있다.
[JsonIgnore] public BigInteger Gold { get; private set;}
// 안전하게 저장하기 위해 문자열로 변환해서 저장
public string Gold_str
{
    get{return Gold.ToString();}
    set
    {
        if (!string.IsNullOrEmpty(value))
        {
            Gold = BigInteger.Parse(value);
        }
        else
        {
            Gold = BigInteger.Zero;
        }
    }
}

3. 다형성(Polymorphism) 데이터 처리

  • 문제점: Dictionary<int, ItemState>처럼 부모 클래스 타입으로 저장된 데이터를 불러올 때, Newtonsoft.Json은 각 항목이 어떤 자식 클래스(GearState, SkillState 등)인지 알지 못해 모두 부모 클래스로 생성했다. 이로 인해 is GearState 같은 타입 캐스팅이 실패했다.
  • 해결 방안 1: TypeNameHandling 설정 (추천)
    • JsonSerializerSettings에서 TypeNameHandling = TypeNameHandling.Objects 옵션을 설정했다.
    • 이 옵션은 직렬화 시 JSON에 $type이라는 메타데이터를 추가하여 각 객체의 정확한 타입을 기록한다.
    • 역직렬화 시 이 $type 정보를 읽어 정확한 자식 클래스로 객체를 생성해주므로, 타입 캐스팅 문제가 완벽히 해결된다.
    • 장점: 확장성이 매우 뛰어나다. 나중에 새로운 아이템 타입이 추가돼도 저장/로드 관련 코드를 전혀 수정할 필요가 없다.
  • 해결 방안 2: 타입별 컨테이너 분리
    • Dictionary<int, GearState>, Dictionary<int, SkillState>처럼 각 타입별로 별개의 딕셔너리에 나눠서 저장하는 방식.
    • 단점: 새로운 타입이 추가될 때마다 SaveData 클래스, 저장 로직, 로드 로직 등 여러 곳을 수정해야 해서 유지보수가 어렵다.

4. 안정적인 데이터 로딩 로직 설계

  • “게임을 불러오면 인벤토리가 비는” 버그를 통해 체계적인 디버깅의 중요성을 배웠다.
  • 데이터 파이프라인 확인: 문제가 발생했을 때, 저장 → 파일 기록 → 파일 읽기 → 역직렬화 → 데이터 적용의 전 과정을 단계별로 의심하고 확인해야 한다.
  • 디버깅 체크리스트:
    1. 저장 파일 원본 확인: Application.persistentDataPath 경로의 .json 파일에 데이터가 제대로 기록되었는지 직접 열어서 확인한다.
    2. 역직렬화 결과 확인: 데이터를 불러온 직후, SaveData 객체 내의 컬렉션(itemStates 등)에 데이터가 들어있는지 Debug.Log로 개수를 찍어본다.
    3. 초기화 순서 확인: InventoryManager.Init()ItemDatabase의 초기화보다 먼저 실행되는 등의 순서 문제를 확인한다. Script Execution Order 설정이 필요할 수 있다.
    4. 데이터 재연결(Re-linking) 로직 확인: 불러온 상태 데이터(ItemState)에 원본 데이터(ItemData)를 다시 연결해주는 로직이 누락되지 않았는지 확인한다. (주석 처리되어 있던 부분)
    5. 방어 코드: as 연산자 사용 후에는 항상 null 체크를 하여 예기치 않은 NullReferenceException을 방지한다.

댓글남기기