📕학습 개요

오늘은 게임의 핵심 데이터 구조를 설계하고, 여러 시스템을 유기적으로 연동하며 발생했던 다양한 문제들을 해결하는 데 집중한 하루였다. 특히 데이터 주도 설계의 중요성과 C# 및 유니티 엔진의 깊이 있는 작동 원리에 대해 많이 배울 수 있었다.


📖학습 내용


1. 데이터 아키텍처 설계

1-1. 아이템 데이터의 분리: ItemData vs ItemState

  • 문제/상황: 아이템의 고유 정보(이름, 아이콘)와 플레이어의 성장 정보(레벨, 경험치)를 어떻게 관리할지 구조가 불명확했다.
  • 해결/학습: 두 데이터의 성격이 다르다는 점을 인지하고, 역할을 명확히 분리했다.
    • ItemData (설계도): ScriptableObject 또는 JSON으로 관리되는, 절대 변하지 않는 아이템의 원본 데이터.
    • ItemState (성장 앨범): 레벨, 경험치, 획득 여부 등 플레이어의 진행 상황에 따라 변하는 데이터. 이 부분이 실제 저장/로드의 대상이 된다.
  • 핵심 개념: 데이터의 ‘불변성(Immutability)’‘가변성(Mutability)’을 분리하는 것은 데이터 오염을 막고, 세이브/로드 시스템을 안정적으로 만드는 핵심적인 설계 원칙이다.

1-2. 공통 효과와 개별 효과의 분리

  • 문제/상황: 모든 ‘레어 등급 동료’가 동일한 ‘보유 효과’를 갖게 하는 규칙을 효율적으로 적용하고 싶었다.
  • 해결/학습: ‘보유 효과’ 데이터를 개별 ItemData에서 분리하여, (아이템 카테고리, 등급)을 키로 사용하는 별도의 데이터 테이블(possession_effects.json)로 만들었다. PlayerStat은 스탯 계산 시 이 공통 테이블을 참조한다. 반면, ‘장착 효과’는 아이템 고유의 특성이므로 ItemData에 그대로 유지했다.
  • 핵심 개념: 데이터의 중복을 제거하고, 밸런싱을 용이하게 하기 위해 규칙 기반 데이터(Rule-Based Data)를 별도의 테이블로 분리하는 것은 매우 효율적인 데이터 주도 설계 방식이다.

1-3. 데이터 주도 설계: 동료와 투사체의 연결

  • 문제/상황: ‘궁수’ 동료가 ‘화살’을 쏘도록 연결하는 방법을 고민했다. 컨트롤러 스크립트에서 타입을 체크하여 분기 처리하는 방식을 생각했다.
  • 해결/학습: 로직에 의존하는 대신, 데이터가 행동을 결정하도록 설계했다. PartyDataprojectileType이라는 필드를 추가하여, “어떤 투사체를 쏠지”에 대한 정보를 데이터 레벨에서 정의했다. 컨트롤러는 이 데이터를 읽어 해당 타입의 투사체를 풀에서 요청할 뿐이다.
  • 핵심 개념: 로직(Code)과 데이터(Data)를 분리하면 유연성과 확장성이 극대화된다. “불화살을 쏘는 궁수”를 추가할 때, 코드 수정 없이 데이터 추가만으로 새로운 개체를 만들 수 있다.

2. 시스템 구현 및 리팩토링

2-1. 대규모 숫자 표현: double vs BigInteger

  • 문제/상황: 방치형 게임 특성상 매우 큰 재화/데미지 수치를 UI에 축약(1K, 1M, 1A, 1B…)하여 표시해야 했다.
  • 해결/학습: 처음에는 doubleMath.Log10을 사용했으나, 정수 연산의 정확성을 보장하기 위해 System.Numerics.BigInteger를 도입했다. BigInteger는 로그 함수를 지원하지 않으므로, 1000으로 반복해서 나누는 루프를 통해 단위를 결정하고, 최종 표시를 위한 소수점 계산만 double로 처리하는 NumberFormatter를 구현했다.
  • 핵심 개념: 데이터의 성격에 맞는 타입을 선택하는 것이 중요하다. 근사치로 충분하면 double, 완벽한 정수 정확성이 필요하면 BigInteger를 사용한다.
public static class NumberFormatter
{
    // 사용할 숫자 단위 접미사 리스트
    private static readonly List<string> suffixes = new List<string>
    { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
        "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ"};


    //BigInteger 타입의 매우 큰 숫자를 A, B, C 단위의 축약된 문자열로 변환
    public static string FormatNumber(BigInteger number)
    {
        // 1000 미만은 그대로 표시
        if (number < 1000)
        {
            return number.ToString();
        }

        // 숫자를 1000으로 몇 번 나눴는지(단위 인덱스)를 계산할 변수
        int unitIndex = 0;
        // 계산을 위한 임시 변수
        BigInteger tempNumber = number;

        // 숫자가 1000 이상인 동안 계속 1000으로 나누며 단위를 올림
        while (tempNumber >= 1000)
        {
            tempNumber /= 1000;
            unitIndex++;
        }

        // 실제 사용할 접미사 인덱스 (A부터 시작하므로 -1)
        int suffixIndex = unitIndex - 1;

        // 만약 정의된 접미사 범위를 넘어서면 가장 큰 단위를 사용
        if (suffixIndex >= suffixes.Count)
        {
            Debug.LogWarning("숫자가 너무 커서 정의된 단위를 넘어섰습니다.");
            suffixIndex = suffixes.Count - 1;
        }

        // ★★★ 핵심: 최종 표시를 위해 BigInteger를 double로 변환하여 소수점 표현 ★★★
        // 예: 1,523,456 -> 1523.456 -> 1.523...
        double displayValue = (double)number / Math.Pow(1000, unitIndex);

        // 소수점 첫째 자리까지 표시하고, 단위 접미사를 붙임
        return displayValue.ToString("F1") + suffixes[suffixIndex];
    }
}

2-2. 재화 관리 시스템 설계

  • 문제/상황: GameManager에서 골드, 보석 같은 재화를 체계적으로 관리할 필요가 있었다.
  • 해결/학습: public int Gold { get; private set; } 와 같이 외부에서는 읽기만 가능하도록 프로퍼티를 설정하여 데이터 무결성을 지켰다. AddGold, UseGold 같은 명확한 함수를 통해 데이터 변경 경로를 단일화하고, EventBus를 사용해 재화의 변경을 UI 등 다른 시스템에 안전하게 통보(Notify)하는 구조를 구현했다.
  • 핵심 개념: 중앙 관리자는 데이터의 ‘단일 진실 공급원(Single Source of Truth)’이 되어야 하며, 외부 시스템과는 이벤트(Event)를 통해 소통하여 결합도를 낮추는 것이 좋은 설계다.

3. C# 및 유니티 엔진 심화

3-1. InvalidOperationException: 컬렉션 순회 중 수정

문제/상황: foreach로 몬스터 목록을 순회하며 풀에 반납(Remove) 시도 시 에러가 발생했다.

해결/학습: foreach 순회 중에는 해당 컬렉션을 수정할 수 없다는 C#의 규칙을 배웠다. 모든 아이템을 제거하는 경우, foreach로는 풀 반납만 처리하고 루프가 끝난 뒤 List.Clear()로 한번에 비우는 것이 더 효율적임을 알게 되었다.

핵심 개념: 순회 중 컬렉션 수정이 필요할 때는 역순 for문 (for (int i = list.Count - 1; i >= 0; i–))을 사용하는 것이 표준적인 해결책이다.

3-2. MissingReferenceException: 이벤트 리스너 구독 해제 실패

문제/상황: 씬 전환 시 OnDisable에서 이벤트를 구독 해제했음에도, 파괴된 오브젝트의 리스너가 호출되어 에러가 발생했다.

해결/학습: OnEnable과 OnDisable에서 () => MyFunction() 과 같은 람다 표현식을 각각 사용하면, 서로 다른 델리게이트 객체가 생성되어 구독 해제가 실패한다는 것을 배웠다. Awake에서 Action 타입의 멤버 변수에 델리게이트를 저장해두고, OnEnable/OnDisable에서 이 동일한 변수를 사용해야 정상적으로 구독/해제된다.

핵심 개념: 이벤트 구독과 해제는 반드시 ‘동일한 델리게이트 인스턴스’를 대상으로 해야 한다.

3-3. Awake() vs OnEnable() in Object Pooling

문제/상황: 풀링된 오브젝트가 SetActive(true) 될 때마다 Awake가 호출되는 것으로 오해했다.

해결/학습: Awake()는 Instantiate로 메모리에 처음 생성될 때 단 한 번 호출되며, OnEnable()은 SetActive(true)가 될 때마다 매번 호출된다는 유니티 생명주기를 명확히 구분했다. 풀링 오브젝트의 ‘최초 초기화’(GetComponent 등)는 Awake에, ‘재사용 시 초기화’(타이머 시작 등)는 OnEnable에 작성해야 한다.

핵심 개념: 오브젝트 풀링은 유니티의 생명주기(Lifecycle)에 대한 정확한 이해를 바탕으로 구현해야 버그를 막을 수 있다.

댓글남기기