공용 스크립트 제작기🎈 - 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() 프로세스:
- Assembly에서 ICSVData 구현 타입 검색
- 모든 스키마 로드 (_Schema.csv)
- 순환 참조 검사
- 모든 데이터 로드 (Addressables)
- 참조 해결 (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에서 타입을 찾을 때 사용
🔄 데이터 흐름
개발 단계 (에디터)
- ItemData_Schema.csv 작성
- ItemData.csv 작성
- Unity에서 Tools > CSV > Generate Scripts 실행 ↓
- [CSVCircularReferenceChecker] 순환 참조 검사
- ItemData.cs 자동 생성 및 Addressables에 자동 등록
런타임 단계
- [게임 시작] CSVManager.Initialize() 호출 ↓
- [CSVManager] ICSVData 타입 검색
- [CSVSchemaParser] 모든 스키마 로드
- [CSVParser] Addressables로 CSV 파일 비동기 로드
- [CSVParser] 리플렉션으로 객체 생성 및 값 할당
- [CSVReferenceResolver] 테이블 간 참조 해결 ↓
- [완료] 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; // 순환 없음 }
결론
신경써서 만들어봤는데 잘 굴러갈지 의문이다. 하면서 더 개선해나가지 않을까 싶다.