공용 스크립트 제작기🎈 - CSV 파서 편

공용 스크립트 제작기🎈 - CSV 파서 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.

파서라고 하면 여러가지가 있는데… 그 중 가장 보편적인 CSV 파서를 만들어보려 한다. 내가 원하는건 ‘스키마 기반 자동 코드 생성과 테이블 간 참조 해결을 지원하는 데이터 관리 시스템’.

즉, 데이터가 존재하면 그 데이터의 형식을 설명하는 스키마가 존재하고, 스키마를 읽어서 자동으로 코드를 생성하여 참조까지 읽어낼 수 있는 CSV 파서를 말한다.

💡예시

예를들어, 게임에 아이템 데이터가 필요하다고 해보자. 그럴 때 이렇게 먼저 CSV를 만든다.

 Assets/Data/CSV/
  ├── ItemData_Schema.csv      ← 스키마 정의
  ├── ItemData.csv                 ← 실제 데이터

이 때, 아이템 데이터의 스키마는 이러한 구조를 가지고 있다.

ColumnName,Type,Description,Reference

ID,int,아이템 고유 ID,
Name,string,아이템 이름,
CategoryID,int,카테고리 ID,CategoryData.ID
Price,int,가격,
IsStackable,bool,중첩 가능 여부,
MaxStack,int?,최대 중첩 수

이 스키마를 보고 Unity에서 메뉴의 버튼을 누르면 CodeGenerator가 동작한다. 그럼 다음과 같은 코드가 자동으로 생성된다.

  // Auto-Generated from ItemData_Schema.csv
  // 수정하지 마세요!

  using System;
  using System.Collections.Generic;
  using UnityEngine;

  [Serializable]
  [CSVTable("ItemData")]
  public class ItemData : ICSVData
  {
      /// <summary>
      /// 아이템 고유 ID
      /// </summary>
      public int ID;

      /// <summary>
      /// 아이템 이름
      /// </summary>
      public string Name;

      /// <summary>
      /// 카테고리 ID
      /// </summary>
      [CSVReference("CategoryData", "ID")]  // 참조 정보
      public CategoryData Category;         // 참조 객체 (자동 생성!)

      public int CategoryID;                // ID 필드 (원본)

      /// <summary>
      /// 가격
      /// </summary>
      public int Price;

      /// <summary>
      /// 중첩 가능 여부
      /// </summary>
      public bool IsStackable;

      /// <summary>
      /// 최대 중첩 수
      /// </summary>
      public int? MaxStack;                 // ← Nullable 타입!
  }

이후 자동으로 csv는 다음의 과정을 거친다

  • 순환 참조 검사
  • Addressables에 등록 이를 런타임에서 사용하려면 다음과 같이 사용한다
    List<ItemData> allItems = CSVManager.Instance.GetTable<ItemData>();
    

다음으로는 구현은 어떻게 했는지 상세하게 설명해 보겠다.

📊 클래스 다이어그램

  ICSVData (인터페이스)
      ↑ implements
  ItemData, CategoryData, ... (생성된 클래스들)
      ↓ uses
  CSVManager (싱글톤)
      ├─> CSVSchemaParser
      ├─> CSVParser
      │      └─> CSVComplexTypeParser
      ├─> CSVCircularReferenceChecker
      └─> CSVReferenceResolver

  CSVCodeGenerator (에디터 전용)
      ├─> CSVSchema / CSVSchemaColumn
      └─> Addressables 설정

복잡해 보이지만 하나씩 뜯어보면 이해가 갈 것이다.


📁 시스템 아키텍처

핵심 컴포넌트

CSVManager (싱글톤 매니저)
  • 역할: 전체 CSV 데이터의 생명주기 관리
  • 주요 기능:
    • 모든 CSV 테이블의 로딩 및 초기화
    • 테이블 간 참조 자동 해결
    • 순환 참조 검사
    • 로드된 데이터에 대한 접근 인터페이스 제공
  • Initialize() 프로세스:
    1. Assembly에서 ICSVData 구현 타입 검색
    2. 모든 스키마 로드 (_Schema.csv)
    3. 순환 참조 검사
    4. 모든 데이터 로드 (Addressables)
    5. 참조 해결 (ID → 객체 연결)
CSVParser (파싱 엔진)
  • 역할: CSV 파일을 C# 객체로 변환
  • 주요 기능:
    • Addressables 기반 비동기 로딩
    • 리플렉션 캐싱으로 성능 최적화
    • 다양한 타입 지원 (기본 타입, Nullable, 복합 타입)
    • CSV Injection 방어
    • 두 가지 파싱 모드 지원
      • Lenient 모드: 변환 실패 시 기본값 사용
      • Strict 모드: 변환 실패 시 행 전체 스킵
CSVCodeGenerator (에디터 전용)
  • 역할: 스키마 파일로부터 C# 클래스 자동 생성
  • 주요 기능:
    • Dirty Check (변경된 스키마만 재생성)
    • Addressables 자동 등록
    • 고아 파일 검사 및 삭제
    • 참조 필드 자동 생성 ItemData_Schema.csv ↓ (Generate) ItemData.cs (ICSVData 구현) ↓ (Auto Register) Addressables “CSV Data” Group

참조 시스템

CSVReferenceResolver
  • 역할: 테이블 간 참조를 ID로부터 실제 객체로 연결
  • 동작 방식: 스키마를 보고 → 코드를 생성하고 → 런타임에 자동 연결합니다.
CSVCircularReferenceChecker
  • 역할: 테이블 간 순환 참조 탐지
  • 알고리즘: DFS (Depth-First Search)
  • 동작 시점: Initialize() 초기화 단계
  • 순환 참조 예시 (에러): A → B → C → A

타입 시스템

CSVComplexTypeParser
  • 역할: 복합 타입(배열, 리스트, 딕셔너리, 커스텀 클래스) 파싱
  • 지원 타입:
    • 커스텀 타입: JSON 형식 → “{"x":10,"y":20}”
    • 배열: “1;2;3”
    • 딕셔너리: “hp:100;mp:50”
    • 리스트: “1;2;3”
CSVSchema / CSVSchemaColumn
  • 역할: 스키마 정보 모델링
  • 구조:
    • ColumnName: “CategoryID”
    • Type: “int”
    • Description: “카테고리 ID”
    • Reference: “CategoryData.ID” // 선택적

속성 시스템

CSVTableAttribute

[CSVTable(“ItemData”)] // 테이블명 지정 public class ItemData : ICSVData { } CSVReferenceAttribute

[CSVReference(“CategoryData”, “ID”)] // 참조 정보 public CategoryData Category;

ICSVData (인터페이스)
  • 모든 CSV 데이터 클래스가 구현하는 마커 인터페이스
  • CSVManager가 Assembly에서 타입을 찾을 때 사용

🔄 데이터 흐름

개발 단계 (에디터)
  1. ItemData_Schema.csv 작성
  2. ItemData.csv 작성
  3. Unity에서 Tools > CSV > Generate Scripts 실행 ↓
  4. [CSVCircularReferenceChecker] 순환 참조 검사
  5. ItemData.cs 자동 생성 및 Addressables에 자동 등록
런타임 단계
  1. [게임 시작] CSVManager.Initialize() 호출 ↓
  2. [CSVManager] ICSVData 타입 검색
  3. [CSVSchemaParser] 모든 스키마 로드
  4. [CSVParser] Addressables로 CSV 파일 비동기 로드
  5. [CSVParser] 리플렉션으로 객체 생성 및 값 할당
  6. [CSVReferenceResolver] 테이블 간 참조 해결 ↓
  7. [완료] CSVManager.GetTable<>() 사용 가능

🎯 신경 쓴 부분

리플렉션 캐싱
  • 딱 한 번 GetField()를 호출하고, ColumnMapper를 미리 만들어 리플렉션 결과를 재사용 함
    private class ColumnMapper
    {
        public string HeaderName;        // CSV 헤더명 (예: "Name")
        public FieldInfo Field;          // 리플렉션 결과 캐시
        public PropertyInfo Property;    // 프로퍼티 캐시
        public Type TargetType;          // 타입 정보 캐시
        public bool IsNullable;          // Nullable 여부 캐시
        public Type UnderlyingType;      // 기본 타입 캐시
    }
    
List 생성 시 최적화
  • 생성 시 예상 크기를 지정하여 Capacity를 최적화
     int estimatedRows = lines.Length - 1;  // 정확한 행 수 계산
     List<T> result = new List<T>(estimatedRows);  // 초기 용량 설정
    
    Dirty Check 사용
  • 변경되지 않은 스키마는 솎아내고 변경된 스키마만 재생성
  • 별도로, 삭제된 CSV에 대응하는 .cs 파일도 자동 검사하여 삭제하겠냐는 알림 띄움
    private static bool IsDirty(string schemaPath, string csFilePath)
    {
        // 1. C# 파일이 없으면 무조건 생성 필요
        if (!File.Exists(csFilePath))
            return true;
    
        // 2. 파일의 마지막 수정 시간 가져오기
        System.DateTime schemaTime = File.GetLastWriteTime(schemaPath);
        System.DateTime csTime = File.GetLastWriteTime(csFilePath);
    
        // 3. 스키마가 더 최신이면 재생성 필요
        return schemaTime > csTime;
    }
    
순환 참조 검사
  • 시트가 다른 시트를 참조할 때 순환참조하지 못하도록 경고를 띄워줌
  • DFS로 순환 참조 검가
     public static bool HasCircularReference(graph, out List<string> cycle)
    {
        var visited = new HashSet<string>();        // 방문한 노드
        var recursionStack = new HashSet<string>(); // 현재 경로상의 노드
        var path = new List<string>();              // 현재 경로
    
        // 모든 노드에서 DFS 시작
        foreach (var node in graph.Keys)
        {
            if (!visited.Contains(node))
            {
                if (DFS(node, graph, visited, recursionStack, path, out cycle))
                {
                    return true;  // 순환 발견!
                }
            }
        }
    
        cycle = null;
        return false;  // 순환 없음
    }
    

결론

신경써서 만들어봤는데 잘 굴러갈지 의문이다. 하면서 더 개선해나가지 않을까 싶다.


© 2022. All rights reserved.