본문 바로가기

진리는어디에

[C++/C#] CSV 파일 읽기

요즘 집에서 혼자 게임을 만들어 보려고 이것 저것 만지작 거리는 중이다. 게임의 메타데이터(아이템 정보, 몬스터 스탯, 퀘스트 같은 것)들을 엑셀로 관리하고 결과물을 csv 파일로 저장해서 사용할 예정이었다. 하지만... 언제나 그랬듯이 잘 되지 않는다.

요약

거두 절미하고 요약 부터 간다. 인터넷에서 검색 되는 대부분의 CSV 파일 읽기 프로그래밍 예제들은 콤마 대한 이스케이프 처리를 누락하고 있어 CSV 본문에 콤마를 사용 할 수가 없거나, 사용한다면 컬럼 개수가 늘어나 버리는 오류가 있다.이번 포스트에서는 엑셀에서 작성한 UTF8 인코딩 된 csv파일을 읽는 프로그램을 만들 때 흔히 놓치기 쉬운 부분을 살펴 보도록 한다.

  • 엑셀에서 UTF8 csv 파일을 저장하면 UTF8-BOM(파일 앞에 3바이트가 추가 됨) 형태로 저장된다.
  • 단순히 콤마(,)만으로 컬럼을 구분하게 되면 콤마를 포함한 텍스트를 사용하지 못한다.

UTF-8은 많이 들어 본것 같은데 BOM은 뭔가? Comma Seperated Value의 약자인 csv에서 콤마로 구분하지 못한다면 어떻게 구분하라는 이야기인가? 무슨 이게 뭐야라는 생각이 들더라도 3초만 참고 계속 읽어 보도록하자. 

이 포스트에서는 위 문제들이 무엇인지, 그리고 해결하는 위해서는 어떻게 해야하는지에 대한 방법을 이야기 하고 있다. 그런데 나는 그런거 궁금하지 않고 제대로 동작하는 샘플 코드만 필요하다면 [여기]로 가면 된다.

Example.csv

설명을 하기 전에 아래와 같은 데이터를 가진 csv 파일이 있다고 가정하자. 주의 깊게 봐야 하는 부분은 Mail_Message 컬럼의 내용들이다. 첫번 째는 '한글'로 작성 되어 있고, 두번째는 값에 콤마(,)가 포함되어 있다, 세번째는 따옴표와 콤마, 한글, 특수문자가 결합된 처리하기 곤란한 문자열 종합세트다.

Index Mail_Message  Mail_Expire_Day   Reward_Index Reward_Count
1 한글 이름 여덟 글자 14 100000 10
2 Comma,Seperated,Value 7 100001 30
3 "Event","한글","123","!@#" 365 100002 5

처음엔..

처음엔 csv파일을 파싱하는 프로그램은 간단히 아래 C++ 코드 처럼, 파일을 읽어 각 라인별로 쪼개고 다시 콤마(,)로 각 컬럼들을 구분하면 될것이라 간단히 생각했다.

std::ifstream file(filePath);
if(true == file.fail())
{
    // 파일 읽기가 실패하면 블라블라...
}

std::string cell;
std::string	line;

while(std::getline(file, line))
{
    std::stringstream lineStream(line);
    while (std::getline(lineStream, cell, ','))
    {
        // 파일 읽는 코드...
    }
}

하지만..세상 일이 그렇게 호락호락하지 않다.

문제 1. UTF-8 BOM 문제

파일을 읽었는데 맨앞의 몇 글자가 계속 깨지는 현상이 발생했다. 예를 들어 위의 csv 같은 경우 첫 라인을 읽었을 때 "Index,Mail_Message,Mail_Expire_Day,Item_Index,Item_Count" 와 같이 나와야 하지만 맨 앞의 텍스트가 깨져 아래와 같이 깨진 텍스트를 읽어 들인다(한글이 한글자도 포함 되지 않았는데도 텍스트가 깨지다니??).

"뷁쀆??ndex,Mail_Message..."

이유는 마이크로소프트 엑셀은 기본적으로 csv를 utf8로 export 할 때 utf8-bom(Byte Order Mark) 형태로 export 한다.
사실 utf8의 경우에는 바이트 오더 마크가 필요 없지만 MS 제품에서 생성하는 ut8문서에는 자동으로 삽입된다.
그래서 엑셀을 이용해 생성 된 csv파일은 처음 3byte가 0xEF, 0xBB, 0xBF 인 경우 bom을 나타내는 마크라고 생각하고 무시해주는 처리가 필요하다.

문제 2. 콤마와 따옴표 문제

위의 csv 데이터를 텍스트 편집기에서 열어 보면 아래와 같다. 뭔가 우리가 작성한 csv파일 보다 글자가 많다.

Index,Mail_Message,Mail_Expire_Day,Item_Index,Item_Count 
1,한글이름 여덟글자,14,100001,10 
2,"Comma,Seperated,Value",14,100002,10000 
3,"""Event"",""한글"",""123"",""!@#""",365,100003,10

아무생각 없이 단순히 콤마만을 컬럼을 나누는 기준이라고 생각하고 쪼개게 된다면, 2번째 데이터는 컬럼이 7개로 늘어나고 3번째 데이터는 8개로 늘어난다. 구분 문자를 본문에 사용하게 되면 컬럼 개수가 제멋대로 늘어나 버리는 문제가 발하기 때문에, 엑셀은 csv를 만들 때 아래와 같은 규칙으로 구분자(콤마)에 대한 이스케이프 처리를 한다.

  • 콤마가 들어간 Comma,Seperated,Value 텍스트는 앞뒤로 따옴표(")로 둘러싸이고, 이것이 콤마로 구분되는 개별 컬럼이 아닌 하나의 컬럼임을 나타내준다.
Comma,Sperated,Value -> "Comma,Seperated,Value"
  • 따옴표와 콤마가 함께 들어간 "Event","한글","123","!@#" 텍스트는 앞뒤로 따옴표로 둘러싸 콤마가 있음에도 하나의 문자열임을 나타냄과 동시에 원래의 따옴표 앞에 똑같은 따옴표 하나를 이스케이프 문자로 추가해 텍스트를 끝내는 따옴표가 아님을 나타낸다.
"Event","한글","123","!@#" -> """Event"",""한글"",""123"",""!@#"""

위 규칙을 처리하여 따옴표가 열리고 닫히기 전에 나오는 콤마는 일반 텍스트로 취급하고, 이스케이프 문자로써 추가된 따옴표를 삭제 처리 할수 있도록 아래와 같이 코드를 변경해보았다.

for(size_t i = pos; i<str.size(); i++)
{
    switch(str[i])
    {
        case '"' :
            ++quotes;
            break;
        case ',' :
        case '\n' :
            if(0 == quotes || ('"' == prev && 0 == quotes % 2))
            {
                if(2 <= quotes) // 앞뒤 따옴표 제거
                {
                    cell = cell.substr(1);
                    cell = cell.substr(0, cell.size() - 1);
                }
                if(2 < quotes) // 텍스트 내 이스케이프 처리된 따옴표 제거
                {
                    std::string::size_type findPos = 0;
                    std::string::size_type offset = 0;
                    const std::string pattern("\"\"");
                    const std::string replace("\"");
                    while((findPos = cell.find(pattern, offset)) != std::string::npos)
                    {
                        cell.replace(cell.begin() + findPos, cell.begin() + findPos + pattern.size(), "\"");
                        offset = findPos + replace.size();
                    }
                }
				
                // 여기까지 오면 컬럼 하나 읽기 완료
                row.push_back(cell);
                cell = "";
                prev = 0;
                quotes = 0;
                if ('\n' == str[i])
                {
                    // 여기까지 오면 한줄 읽기 완료
                    rows.push_back(row);
                    row.clear();
                }
                continue;
            }
            break;
        default :
            break;
        }
        cell += prev = str[i];
    }
}

마치며

인터넷에서 쉽게 검색 되는 대부분 CSV 파싱 코드는 구분 문자 콤마에 대한 이스케이프 처리를 하지 않아 본문에서 콤마를 사용 할 수 없거나, 사용한다면 컬럼 개수가 늘어나버리는 잠재적 버그를 가지고 있다. 이번 포스트에서는 간단하게 UTF-8 BOM과 구분자 콤마에 대한 이스케이프 처리를 하는 방법에 대해 알아 보았다.

혹시나 CSV를 사용 할 일이 있다면 어지간하면 엑셀을 통해서 작성하도록 하자.

부록 1. 전체 코드

C++

  • CSVReader.h
#ifndef _CSV_READER_H_
#define _CSV_READER_H_

#include <map>
#include <vector>
#include <string>

class CSVReader
{
public:
    class iterator
    {
    public:
        iterator(std::vector<std::vector<std::string>>::const_iterator itr);

        const std::vector<std::string>& operator * () const;
        iterator& operator ++ ();
        iterator& operator ++ (int);
        iterator* operator -> ();
        bool operator != (const iterator& itr) const;
        bool operator == (const iterator& itr) const;

        const std::string& GetValue(int index);
    private:
		std::vector<std::vector<std::string>>::const_iterator row_iterator;
	};

	typedef iterator Row;
public:
	bool ReadFile(const std::string& filePath);
	bool ReadStream(const std::string& stream);
		
	size_t GetRowCount() const;
	const std::vector<std::string>& GetRow(size_t rowIndex) const;
	const std::string& GetCell(int rowIndex, int columnIndex) const;
	const std::vector<std::string>& operator[](size_t rowIndex) const;

	iterator begin() const;
	iterator end() const;
private:
	std::vector<std::vector<std::string>> rows;
};

#endif
  • CSVReader.cpp
#include "CSVReader.h"
#include <fstream>
#include <streambuf>
#include <algorithm>
#include <sstream>

CSVReader::iterator::iterator(std::vector<std::vector<std::string>>::const_iterator itr)
    : row_iterator(itr)
{
}

const std::string& CSVReader::iterator::GetValue(int index)
{
    const std::vector<std::string>& row = (*row_iterator);
    if (0 > index || row.size() <= index)
    {
        throw std::out_of_range("invalid csv column index:" + std::to_string(index));
    }
    return row[index];
}

const std::vector<std::string>& CSVReader::iterator::operator * () const
{
    return *row_iterator;
}

CSVReader::iterator& CSVReader::iterator::operator ++ ()
{
    row_iterator++;
    return *this;
}

CSVReader::iterator& CSVReader::iterator::operator ++ (int)
{
    row_iterator++;
    return *this;
}

CSVReader::iterator* CSVReader::iterator::operator -> ()
{
    return this;
}

bool CSVReader::iterator::operator != (const CSVReader::iterator& itr) const
{
    if (row_iterator != itr.row_iterator)
    {
        return true;
    }
    return false;
}

bool CSVReader::iterator::operator == (const CSVReader::iterator& itr) const
{
    if (row_iterator == itr.row_iterator)
    {
        return true;
    }
    return false;
}

bool CSVReader::ReadFile(const std::string& filePath)
{
    std::ifstream file(filePath);
    if (true == file.fail())
    {
        throw std::ifstream::failure("fail to open file(path:" + filePath + ")");
    }

    std::string stream((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
    return ReadStream(stream);
}

bool CSVReader::ReadStream(const std::string& str)
{
    std::stringstream stream(str);
    std::string line;
    std::string cell;

    char utf8bom[3] = {};
    std::streamsize size = stream.readsome(utf8bom, 3);
    if(3 == size)
    {
        if ((char)0xEF == utf8bom[0] && (char)0xBB == utf8bom[1] && (char)0xBF == utf8bom[2])
        {
            stream.seekg(3);
        }
        else
        {
            stream.seekg(0);
        }
    }
    
    // read data
    {
        std::vector<std::string> row;
        std::streampos pos = stream.tellg();
        int quotes = 0;
        char prev = 0;

        std::string cell;
        for (size_t i = pos; i < str.size(); i++)
        {
            switch (str[i])
            {
            case '"':
                ++quotes;
                break;
            case ',':
            case '\n':
                if (0 == quotes || ('"' == prev && 0 == quotes % 2))
                {
                    if (2 <= quotes)
                    {
                        cell = cell.substr(1);
                        cell = cell.substr(0, cell.size() - 1);
                    }
                    if (2 < quotes)
                    {
                        std::string::size_type findPos = 0;
                        std::string::size_type offset = 0;
                        const std::string pattern("\"\"");
                        const std::string replace("\"");
                        while ((findPos = cell.find(pattern, offset)) != std::string::npos)
                        {
                            cell.replace(cell.begin() + findPos, cell.begin() + findPos + pattern.size(), "\"");
                            offset = findPos + replace.size();
                        }
                    }
                    row.push_back(cell);
                    cell = "";
                    prev = 0;
                    quotes = 0;
                    if ('\n' == str[i])
                    {
                        rows.push_back(row);
                        row.clear();
                    }
                    continue;
                }
                break;
            default:
                break;
            }
            cell += prev = str[i];
        }
    }
    
    return true;
}

size_t CSVReader::GetRowCount() const
{
    return rows.size();
}

CSVReader::iterator CSVReader::begin() const
{
    return iterator(rows.begin());
}

CSVReader::iterator CSVReader::end() const
{
    return iterator(rows.end());
}

const std::vector<std::string>& CSVReader::GetRow(size_t rowIndex) const
{
    if (0 > rowIndex || rows.size() <= rowIndex)
    {
        throw std::out_of_range("out of range row num:" + std::to_string(rowIndex));
    }
    return rows[rowIndex];
}

const std::vector<std::string>& CSVReader::operator [] (size_t rowIndex) const
{
    return GetRow(rowIndex);
}

const std::string& CSVReader::GetCell(int rowIndex, int columnIndex) const
{
    const std::vector<std::string>& row = GetRow(rowIndex);

    if (0 > columnIndex || row.size() <= columnIndex)
    {
        throw std::out_of_range("invalid csv column index:" + std::to_string(columnIndex));
    }
    return row[columnIndex];
}
  • Program.cpp
#include <iostream>
#include "CSVReader.h"
#ifdef _WIN32
#include <Windows.h>
#endif

int main()
{
#ifdef _WIN32
    SetConsoleOutputCP(CP_UTF8);
#endif
    /* Example.csv
        Index, Message, ExpireDay, RewardIndex, RewardCount
        1, contents, 14, 100000, 10
        2, "Comma,Seperated,Text", 7, 100001, 30
        3, """Event"",""contents"",""123"",""!@#""    ", 365, 100002, 5
    */

    CSVReader reader;
    reader.ReadFile("Example.csv");

    for(int i = 0; i<reader.GetRowCount(); i++)
    {
        auto& row = reader.GetRow(i);
        for(int j=0; j<row.size(); j++)
        {
            std::cout << row[j] << " ";
        }
        std::cout << std::endl;
    }

    for (auto& rows : reader)
    {
        for (auto& row : rows)
        {
            std::cout << row << " ";
        }
        std::cout << std::endl;
    }

    std::cout << reader.GetCell(1, 1) << std::endl;
}

C#

주의 : 링크된 C# 코드는 첫번째 행은 컬럼 이름, 두번째 행은 컬럼 타입, 세번째 행 부터 데이터를 담고 있는 구조 입니다. 포스트에 예제로 사용된 포멧을 그대로 사용한다면 데이터 첫번째 줄을 무시하게 됩니다. 혹시 코드를 사용하시게 된다면 이점 유의하여 각자의 포멧에 맞도록 수정하여 사용할 것을 권해드립니다.

부록 2. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!