CSV를 활용한 데이터 저장 및 불러오기

Tom·2024년 9월 11일
0
post-custom-banner

데이터를 저장하는데에 "반드시 이것만 써야해!"라는 규격은 없다. 프로젝트나 사용하는 외부 솔루션에서 지원하는 형식에 알맞게 데이터를 파싱해서 사용하면 된다. 데이터 저장에 사용되는 여러가지 방식이 있는데, 내가 써본 건 CSV랑 JSON밖에 없긴함.

CSV

CSV라고 하면 뭔가 거창한 뜻이 있을 것 같은데, 그냥 Comma Separated Values다. "콤마로 나눠진 값"이라는 뜻. 이름만큼 형식도 간단하다

nameageOccupation
John Doe30Engineer
ane Smith25Data Analyst
Bob Johnson45Manager

자주 볼 수 있는 스프레드시트에 담긴 데이터다. 첫 행에는 각 열의 데이터가 의미하는 데이터의 이름이 들어가 있고 아래의 행에는 데이터가 들어가 있다. 이를 CSV형식으로 변환하면 아래와 같다.

Name,Age,Occupation
John Doe,30,Software Engineer
Jane Smith,25,Data Analyst
Bob Johnson,45,Manager

엥 이거 완전 표만 없지 스프레드시트랑 똑같잖아? 맞다 사실 CSV는 사람이 읽기도 굉장히 읽기도 편함. CSV라고 해서 꼭 ,로만 값을 나눠야 하는 것은 아니다. 데이터에 잦은 콤마가 등장한다면 등장 빈도가 낮은 다른 문자를 구분자로 사용해도 됨.

Name~Age~Occupation
John Doe~30~Software Engineer
Jane Smith~25~Data Analyst
Bob Johnson~45~Manager

그래서 내가 ~SV를 만들었다. ~로 나눠진 값. 근데 아무래도 콤마가 훨씬 읽기 편한 것 같으니 그냥 콤마로 쓰도록 하자.

CSV의 파싱

근데 이런식으로 데이터가 저장되어 있으면 이제 읽어와서 알맞은 형식으로 파싱해줘야하는데, 그 과정은 어떻게 해야할까? 그냥 있는거 다 불러와서 , 단위로 나눠서 각 데이터 필드에 넣어주면 됨. 일단 위 형식에 맞는 구조체를 만들어줬다.

using System;

[Serializable]
public struct User
{
	public string name;
    public int age;
    public string occupation;
    
    // 생성자로 만들어질 때 담을래요
    public User(string name, int age, string occupation)
    {
        Name = name;
        Age = age;
        Occupation = occupation;
    }
}

그 다음엔 각 멤버에 맞게 데이터를 할당해주는 클래스를 작성해줬다.

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

public class CSVReaderTest : Singleton<CSVReaderTest>
{
    public List<User> Read(string fileName)
    {
        List<User> users = new List<User>();

        TextAsset csv = Resources.Load<TextAsset>(fileName); // 폴더 내 파일 찾기
        string[] lines = csv.text.Split(new char[] { '\n' }); // 한 줄 단위로 나누기

        for (int i = 1; i < lines.Length; i++)
        {
            string[] values = lines[i].Split(',');
            if (values.Length == 3)
            {
                // CSV 파일의 데이터를 구조체에 저장
                string name = values[0];
                int age = int.Parse(values[1]);
                string occupation = values[2];

                // Person 구조체 인스턴스 생성 후 리스트에 추가
                User newUser = new User(name, age, occupation);
                users.Add(newUser);
            }
        }

        return users;
    }
}

진짜로 그냥 User 구조체만 반환 가능한 Read() 메서드를 만들어봤다. 리소스 폴더에 csv파일을 넣어놓고 읽어와서 로그를 찍어보자.

using UnityEngine;

public class Test : MonoBehaviour
{
    public List<User> users;
    private void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))
        {
            users = CSVReaderTest.Instance.Read("UserData");
        }

        if(Input.GetKeyDown(KeyCode.Return))
        {
            LogDatas();
        }
    }

    public void LogDatas()
    {
        foreach (var user in users)
        {
            Debug.Log(user.name);
            Debug.Log(user.age);
            Debug.Log(user.occupation);
        }
    }
}

테스트를 위해서 간단하게 로그만 찍어주는 코드를 작성했음.

구조체에 알맞은 형태로 잘 들어가는걸 확인했다. 근데 혹시 있는 데이터를 저장해야 할 일이 생길지도 모르겠네? 그럼 원래 구조체를 CSV 형식으로 변환도 해줘야겠다.

    public void Save(List<User> saveUserDatas, string filePath)
    {
        // CSV 헤더
        string csv = "name,age,occupation\n";

        // User 데이터를 CSV 형식으로 변환
        foreach (User user in saveUserDatas)
        {
            csv += $"{user.name},{user.age},{user.occupation}\n";
        }

        // 파일 경로가 존재하지 않으면 생성
        File.WriteAllText(filePath, csv);
        Debug.Log("Data saved to " + filePath);
    }

원하는 경로에 저장이 되잖아? 근데 사실 코드를 이런식으로 쓰면 User데이터를 제외한 다른 데이터에 대해 대응이 불가능하다는 단점이 있다. 이건 User 형식의 구조체만 처리가 가능하니까. 근데 CSV 읽고 쓰는 형식은 이미 인터넷이나 채찌피티가 정확하게 알고있으니 물어봐서 쓰도록 하자.

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

public class CSVHelper<T> where T : class, new()
{
    private static readonly char Separator = ',';
    private static readonly char Quote = '"';

    public static List<T> Read(string fileName)
    {
        List<T> list = new List<T>();

        TextAsset csv = Resources.Load<TextAsset>(fileName); // Resources 폴더에서 파일 읽기
        if (csv == null)
        {
            Debug.LogError("File not found: " + fileName);
            return list;
        }

        string[] lines = csv.text.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
        if (lines.Length == 0)
        {
            Debug.LogWarning("CSV file is empty.");
            return list;
        }

        // CSV 헤더를 읽어 필드 이름을 추출
        string[] headers = lines[0].Split(Separator);
        
        for (int i = 1; i < lines.Length; i++)
        {
            string[] values = lines[i].Split(Separator);
            if (values.Length != headers.Length) continue;

            T obj = new T();
            for (int j = 0; j < headers.Length; j++)
            {
                var property = typeof(T).GetProperty(headers[j]);
                if (property != null)
                {
                    object value = Convert.ChangeType(values[j].Trim(Quote), property.PropertyType);
                    property.SetValue(obj, value);
                }
            }
            list.Add(obj);
        }
        return list;
    }

    public static void Save(string filePath, List<T> list)
    {
        if (list.Count == 0)
        {
            Debug.LogWarning("No data to save.");
            return;
        }

        var type = typeof(T);
        var properties = type.GetProperties();

        // CSV 헤더 생성
        string csv = string.Join(Separator.ToString(), Array.ConvertAll(properties, p => p.Name)) + "\n";

        // 데이터 추가
        foreach (var obj in list)
        {
            string line = string.Join(Separator.ToString(), Array.ConvertAll(properties, p => Quote + p.GetValue(obj)?.ToString() + Quote));
            csv += line + "\n";
        }

        // 파일로 저장
        File.WriteAllText(filePath, csv);
        Debug.Log("Data saved to " + filePath);
    }
}

이러면 다양한 형식도 대응 가능. Regex을 사용해 정규 표현식을 통해 처리할 수도 있다. 그건 아직 잘 모르니까 나중에 해야지.

profile
여기 글은 보통 틀린 경우가 많음
post-custom-banner

0개의 댓글