본문 바로가기
엔진 공부/UNITY

[Unity6] CSV 파일 불러오기 (Dictionary)

by OhEasy 2025. 3. 30.
728x90
SMALL

이전 글도 그렇고 현재 개인적인 프로젝트를 진행중이다.

 

[Unity][UI Toolkit] 커스텀 인스펙터 만들어봄

어쩌다보니 퇴사를 하고 Unity로 개발을 하고 있다.만들고 있는 것은 별 건 없고 어떤 게임의 템플릿과 같은 것을 만들려고 한다.비전공자에 개발을 배운 적이 없으니... ChatGPT를 열심히 두들기려

525easy.tistory.com

하고있는 프로젝트는 모든 데이터를 CSV로 관리하려고 하고있다. (이미지나 사운드 이런 것 말고)
Scriptable Object도 있지만 많은 양의 텍스트들이 들어가기도 하고 엑셀이 편해서 CSV로 선택했다.

물론 나중엔 에디터 상에서 Excel -> SO로 바꾸긴 할거다.

어쨌든 CSV를 쓰는데 있어서 아래 깃의 CSVReader를 사용하고 있다.

 

 

blog/csvreader at master · tikonen/blog

Blog snippets. Contribute to tikonen/blog development by creating an account on GitHub.

github.com

아래는 코드 전문이고 Gemini가 써준 주석이 포함되어 있다.

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;

public class CSVReader
{
	// CSV 파일에서 필드를 분리하는 정규 표현식입니다.
	// 쉼표를 기준으로 나누되, 따옴표로 묶인 쉼표는 무시합니다.
	static string SPLIT_RE = @",(?=(?:[^""]*""[^""]*"")*(?![^""]*""))";

	// CSV 파일에서 줄을 분리하는 정규 표현식입니다.
	// \r\n (Windows), \n\r (옛날 Mac), \n (Unix/Linux, 최신 Mac), \r (옛날 Mac) 등의 줄 바꿈 문자를 처리합니다.
	static string LINE_SPLIT_RE = @"\r\n|\n\r|\n|\r";

	// 필드 양 끝의 따옴표를 제거하는 데 사용될 문자 배열입니다.
	static char[] TRIM_CHARS = { '\"' };

	// CSV 파일을 읽고 파싱하여 List<Dictionary<string, object>> 형태로 반환하는 공용 정적 메서드입니다.
	// 매개변수 'file'은 Resources 폴더 내의 CSV 파일 이름을 나타냅니다.
	public static List<Dictionary<string, object>> Read(string file)
	{
		// 결과를 저장할 빈 리스트를 생성합니다. 각 Dictionary는 CSV 파일의 한 줄을 나타냅니다.
		var list = new List<Dictionary<string, object>>();

		// Resources.Load를 사용하여 지정된 이름의 TextAsset (CSV 파일)을 로드합니다.
		TextAsset data = Resources.Load (file) as TextAsset;

		// TextAsset의 텍스트 내용을 줄 단위로 분리하여 문자열 배열 'lines'에 저장합니다.
		var lines = Regex.Split (data.text, LINE_SPLIT_RE);

		// 만약 줄 수가 1개 이하이면 (헤더 라인만 있거나 파일이 비어있는 경우), 빈 리스트를 반환하고 종료합니다.
		if(lines.Length <= 1) return list;

		// 첫 번째 줄을 헤더로 간주하고, 필드 단위로 분리하여 문자열 배열 'header'에 저장합니다.
		var header = Regex.Split(lines[0], SPLIT_RE);

		// 두 번째 줄부터 마지막 줄까지 반복하며 각 데이터 줄을 처리합니다.
		for(var i=1; i < lines.Length; i++) {
			// 현재 데이터 줄을 필드 단위로 분리하여 문자열 배열 'values'에 저장합니다.
			var values = Regex.Split(lines[i], SPLIT_RE);

			// 만약 현재 줄이 비어있거나 첫 번째 필드가 비어있으면, 이 줄을 건너뛰고 다음 줄로 넘어갑니다.
			if(values.Length == 0 ||values[0] == "") continue;

			// 현재 데이터 줄의 필드명과 값을 저장할 새로운 Dictionary를 생성합니다.
			var entry = new Dictionary<string, object>();

			// 헤더 배열과 값 배열의 길이 중 더 작은 값까지 반복하며 각 필드를 처리합니다.
			for(var j=0; j < header.Length && j < values.Length; j++ ) {
				// 현재 필드의 값을 가져옵니다.
				string value = values[j];

				// 필드 값의 양 끝에 있는 따옴표를 제거하고, 백슬래시를 제거합니다.
				value = value.TrimStart(TRIM_CHARS).TrimEnd(TRIM_CHARS).Replace("\\", "");

				// 최종적으로 Dictionary에 저장할 값을 담을 변수를 초기화합니다. 기본적으로는 문자열 값입니다.
				object finalvalue = value;

				// 정수형으로 파싱을 시도할 변수를 선언합니다.
				int n;
				// 부동 소수점형으로 파싱을 시도할 변수를 선언합니다.
				float f;

				// 현재 필드 값을 정수형으로 파싱 시도합니다. 성공하면 finalvalue를 정수 값으로 설정합니다.
				if(int.TryParse(value, out n)) {
					finalvalue = n;
				}
				// 정수형 파싱에 실패하면, 부동 소수점형으로 파싱 시도합니다. 성공하면 finalvalue를 부동 소수점 값으로 설정합니다.
				else if (float.TryParse(value, out f)) {
					finalvalue = f;
				}

				// 현재 필드의 헤더를 키로 하고, 파싱된 최종 값을 값으로 하여 Dictionary에 저장합니다.
				entry[header[j]] = finalvalue;
			}
			// 처리된 한 줄의 데이터를 담고 있는 Dictionary를 결과 리스트에 추가합니다.
			list.Add (entry);
		}
		// 모든 데이터 줄을 처리한 후, 완성된 리스트를 반환합니다.
		return list;
	}
}

어찌되었던 반환 타입이 List<Dictionary<string, object>> 이다.
그러다보니 엑셀에 열심히 ID의 대역폭을 정하던 룰을 정하던 뭐건 반환된 내용은 순번만 있을 뿐이다.
아래 표를 예시로 들어본다.

여기는 CSVRead 반환 여기는 엑셀
- ID AtkPower Hp
0 101 100 50
1 201 200 20
... ... ... ...

열심히 Id에 101, 201, 301을 순서로 넣어도 반환되는 것이 List<dictionary<string, object>>기 때문에 0부터 시작한다.
그래서 직접 부여한 ID대로 동작 시키기 위해서는 Id가 Id로 작동할 수 있게 처리하는 과정이 필요했다.
미리해 놓지 않는다면 해당 데이터를 참조하기 위해 매번 반복문으로 일치하는 Id를 찾는 불필요한 과정도 생긴다.


우선 첫번째로는 저 CSVReader를 수정하지는 않고 
매개변수를 TextAsset으로 바로 때려 넣을 수 있는 오버로딩 함수를 추가한다.
아래 코드는 gemini가 써줬다. 그냥 기존 CSVReader 의 Read 함수 아래에 붙여넣는다.

// TextAsset을 직접 매개변수로 받아 CSV 데이터를 읽고 파싱하는 공용 정적 메서드입니다.
	// 매개변수 'data'는 읽을 CSV 데이터를 담고 있는 TextAsset 객체입니다.
	public static List<Dictionary<string, object>> Read(TextAsset data)
	{
		// 결과를 저장할 빈 리스트를 생성합니다. 각 Dictionary는 CSV 파일의 한 줄을 나타냅니다.
		var list = new List<Dictionary<string, object>>();

		// TextAsset이 null인 경우, 빈 리스트를 반환하고 종료합니다.
		if (data == null)
		{
			Debug.LogError("TextAsset is null. Cannot read CSV data.");
			return list;
		}

		// TextAsset의 텍스트 내용을 줄 단위로 분리하여 문자열 배열 'lines'에 저장합니다.
		var lines = Regex.Split (data.text, LINE_SPLIT_RE);

		// 만약 줄 수가 1개 이하이면 (헤더 라인만 있거나 파일이 비어있는 경우), 빈 리스트를 반환하고 종료합니다.
		if(lines.Length <= 1) return list;

		// 첫 번째 줄을 헤더로 간주하고, 필드 단위로 분리하여 문자열 배열 'header'에 저장합니다.
		var header = Regex.Split(lines[0], SPLIT_RE);

		// 두 번째 줄부터 마지막 줄까지 반복하며 각 데이터 줄을 처리합니다.
		for(var i=1; i < lines.Length; i++) {
			// 현재 데이터 줄을 필드 단위로 분리하여 문자열 배열 'values'에 저장합니다.
			var values = Regex.Split(lines[i], SPLIT_RE);

			// 만약 현재 줄이 비어있거나 첫 번째 필드가 비어있으면, 이 줄을 건너뛰고 다음 줄로 넘어갑니다.
			if(values.Length == 0 ||values[0] == "") continue;

			// 현재 데이터 줄의 필드명과 값을 저장할 새로운 Dictionary를 생성합니다.
			var entry = new Dictionary<string, object>();

			// 헤더 배열과 값 배열의 길이 중 더 작은 값까지 반복하며 각 필드를 처리합니다.
			for(var j=0; j < header.Length && j < values.Length; j++ ) {
				// 현재 필드의 값을 가져옵니다.
				string value = values[j];

				// 필드 값의 양 끝에 있는 따옴표를 제거하고, 백슬래시를 제거합니다.
				value = value.TrimStart(TRIM_CHARS).TrimEnd(TRIM_CHARS).Replace("\\", "");

				// 최종적으로 Dictionary에 저장할 값을 담을 변수를 초기화합니다. 기본적으로는 문자열 값입니다.
				object finalvalue = value;

				// 정수형으로 파싱을 시도할 변수를 선언합니다.
				int n;
				// 부동 소수점형으로 파싱을 시도할 변수를 선언합니다.
				float f;

				// 현재 필드 값을 정수형으로 파싱 시도합니다. 성공하면 finalvalue를 정수 값으로 설정합니다.
				if(int.TryParse(value, out n)) {
					finalvalue = n;
				}
				// 정수형 파싱에 실패하면, 부동 소수점형으로 파싱 시도합니다. 성공하면 finalvalue를 부동 소수점 값으로 설정합니다.
				else if (float.TryParse(value, out f)) {
					finalvalue = f;
				}

				// 현재 필드의 헤더를 키로 하고, 파싱된 최종 값을 값으로 하여 Dictionary에 저장합니다.
				entry[header[j]] = finalvalue;
			}
			// 처리된 한 줄의 데이터를 담고 있는 Dictionary를 결과 리스트에 추가합니다.
			list.Add (entry);
		}
		// 모든 데이터 줄을 처리한 후, 완성된 리스트를 반환합니다.
		return list;
	}

TextAsset으로 바로 때려 넣는 이유는 Resources.Load 때문이다.
해당 함수를 이용하기 위해서는 Assets 폴더 하위에 Resources라는 폴더가 존재해야 하는데,
Resources 폴더의 경우 실제 게임에 사용되지 않더라도 하위의 모든 에셋들을 빌드에 포함 시킨다.
이건 사실 완전 소규모고 쓸데없는거 다 지웠다면 그닥 큰 문제는 아닐 수 있다.
그래도 다음 이유 때문에 나는 저렇게 쓰고 있긴 하다.

1. 실제 데이터를 읽는 클래스에 public으로 TextAsset을 집어넣어준다면 좀 더 명확히 볼 수 있기 때문.
2. Resources 폴더의 하위에 여러 폴더들이 생기고 그 폴더 안에 있다면 원하는 파일을 불러올 수 없다.
그래서 경로를 다 짜줘야 한다. 근데 만약 위치가 바뀐다면?


이 이후에는 실제로 CSVData를 읽는 친구(들)을 만들어준다.
아래는 Gemini가 만들어준 코드다. 나는 저렇게 안한다.

using UnityEngine;
using System.Collections.Generic;

// MonsterData 클래스 정의
public class MonsterData
{
    public string id;
    public float atkPower;
    public int hp;

    public MonsterData(string id, float atkPower, int hp)
    {
        this.id = id;
        this.atkPower = atkPower;
        this.hp = hp;
    }
}

public class Sample : MonoBehaviour
{
    public TextAsset csvData;
    public Dictionary<string, MonsterData> testDataDictionary = new Dictionary<string, MonsterData>();

    void Start()
    {
        if (csvData != null)
        {
            List<Dictionary<string, object>> csvResult = CSVReader.Read(csvData);

            foreach (var row in csvResult)
            {
                if (row.ContainsKey("id") && row.ContainsKey("atkPower") && row.ContainsKey("hp"))
                {
                    string id = row["id"].ToString();
                    float atkPower = float.Parse(row["atkPower"].ToString());
                    int hp = int.Parse(row["hp"].ToString());

                    // MonsterData 객체를 생성하여 Dictionary에 추가
                    testDataDictionary.Add(id, new MonsterData(id, atkPower, hp));
                }
                else
                {
                    Debug.LogWarning("CSV 데이터의 컬럼명이 예상과 다릅니다. id, atkPower, hp 컬럼을 확인해주세요.");
                }
            }

            // testDataDictionary에 저장된 내용 확인 (선택 사항)
            foreach (var pair in testDataDictionary)
            {
                Debug.Log($"ID: {pair.Key}, AtkPower: {pair.Value.atkPower}, HP: {pair.Value.hp}");
            }
        }
        else
        {
            Debug.LogError("csvData가 할당되지 않았습니다. Inspector 창에서 TextAsset을 할당해주세요.");
        }
    }
}

Dictionary에 Value를 List나 배열로 넣으려고 개인적으로는 foreach 대신 for를 사용하는 편이다.

 

Dictionary<TKey,TValue>.ContainsKey(TKey) 메서드 (System.Collections.Generic)

Dictionary<TKey,TValue>에 지정한 키가 포함되어 있는지 여부를 확인합니다.

learn.microsoft.com

 

뭐 컬럼에 순번을 지정할 수 있는 컬럼을 만들기 귀찮아서 그런 것도 있다. 아래도 Gemini가 써준거다.

using UnityEngine;
using System.Collections.Generic;

public class MonsterData
{
    public string id;
    public float atkPower;
    public int hp;

    public MonsterData(string id, float atkPower, int hp)
    {
        this.id = id;
        this.atkPower = atkPower;
        this.hp = hp;
    }
}

public class Sample : MonoBehaviour
{
    public TextAsset csvData;
    public Dictionary<string, List<MonsterData>> testDataDictionary_02 = new Dictionary<string, List<MonsterData>>();

    void Start()
    {
        if (csvData != null)
        {
            List<Dictionary<string, object>> csvResult = CSVReader.Read(csvData);

            for (int i = 0; i < csvResult.Count; i++)
            {
                var row = csvResult[i];

                if (row.ContainsKey("id") && row.ContainsKey("atkPower") && row.ContainsKey("hp"))
                {
                    string id = row["id"].ToString();
                    float atkPower = float.Parse(row["atkPower"].ToString());
                    int hp = int.Parse(row["hp"].ToString());

                    // testDataDictionary_02에 데이터 추가
                    if (!testDataDictionary_02.ContainsKey(id))
                    {
                        testDataDictionary_02[id] = new List<MonsterData>();
                    }
                    testDataDictionary_02[id].Add(new MonsterData(id, atkPower, hp));
                }
                else
                {
                    Debug.LogWarning($"CSV 데이터의 {i + 1}번째 행의 컬럼명이 예상과 다릅니다. id, atkPower, hp 컬럼을 확인해주세요.");
                }
            }

            // testDataDictionary_02 내용 확인 (선택 사항)
            Debug.Log("<color=cyan>testDataDictionary_02 내용:</color>");
            foreach (var pair in testDataDictionary_02)
            {
                Debug.Log($"Monster ID: {pair.Key}");
                foreach (var monsterData in pair.Value)
                {
                    Debug.Log($"  ID: {monsterData.id}, AtkPower: {monsterData.atkPower}, HP: {monsterData.hp}");
                }
            }
        }
        else
        {
            Debug.LogError("csvData가 할당되지 않았습니다. Inspector 창에서 TextAsset을 할당해주세요.");
        }
    }
}

위와 같이 Dictionary로 변환해 둔다면 Id가 Id가 아닌 경우를 방지할 수 있다.

728x90
LIST

'엔진 공부 > UNITY' 카테고리의 다른 글

[Unity][UI Toolkit] 커스텀 인스펙터 만들어봄  (0) 2025.03.17

댓글