2025-06-30 TIL
📕학습 개요
오늘은 게임의 핵심 데이터 구조를 설계하고, 여러 시스템을 유기적으로 연동하며 발생했던 다양한 문제들을 해결하는 데 집중한 하루였다. 특히 데이터 주도 설계의 중요성과 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. 데이터 주도 설계: 동료와 투사체의 연결
- 문제/상황: ‘궁수’ 동료가 ‘화살’을 쏘도록 연결하는 방법을 고민했다. 컨트롤러 스크립트에서 타입을 체크하여 분기 처리하는 방식을 생각했다.
- 해결/학습: 로직에 의존하는 대신, 데이터가 행동을 결정하도록 설계했다.
PartyData에projectileType이라는 필드를 추가하여, “어떤 투사체를 쏠지”에 대한 정보를 데이터 레벨에서 정의했다. 컨트롤러는 이 데이터를 읽어 해당 타입의 투사체를 풀에서 요청할 뿐이다. - 핵심 개념: 로직(Code)과 데이터(Data)를 분리하면 유연성과 확장성이 극대화된다. “불화살을 쏘는 궁수”를 추가할 때, 코드 수정 없이 데이터 추가만으로 새로운 개체를 만들 수 있다.
2. 시스템 구현 및 리팩토링
2-1. 대규모 숫자 표현: double vs BigInteger
- 문제/상황: 방치형 게임 특성상 매우 큰 재화/데미지 수치를 UI에 축약(1K, 1M, 1A, 1B…)하여 표시해야 했다.
- 해결/학습: 처음에는
double과Math.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)에 대한 정확한 이해를 바탕으로 구현해야 버그를 막을 수 있다.
댓글남기기