[js] CSV 사용하기

현재 데이터 전달 형식의 대세는 누가 뭐래도 json입니다. jsonxml에 비해 훨씬 가볍고 자바스크립트 입장에선 오브젝트로 환원되기 때문에 편리합니다. 하지만 CSV.. 아직 죽지 않았습니다. CSV 형식의 의의와 자바스크립트에서의 사용법을 살펴보겠습니다.

CSV란?

위키(http://en.wikipedia.org/wiki/Comma-separated_values)에 자세한 설명이 있습니다. 하지만 영어이기도 하고 귀찮으니 간단히 CSV 로 표현한 데이터를 보죠.

hika, 10, 50
nick, 20, 30
sam, 60, 70

보시면 아시겠지만 컴마와 개행 문자를 통해 간단히 2차원 배열을 표현하는 것이 바로 CSV입니다. 이 형식의 최대 장점은 일단 데이터 형식 중엔 가장 가볍다는 것입니다. 데이터 외에는 최소한의 구분자만 사용하기 때문에 대용량 데이터 전송에 매우 유리합니다. CSV형식은 사실 레코드 구분자와 필드 구분자만 정해주면 성립하기 때문에 굳이 개행문자와 컴마일 필요는 없습니다. 예를 들어 아래와 같은 형식도 성립합니다.

hika 10 50 | nick 20 30 | sam 60 70

(하지만 CSV의 C가 Comma이니 만큼 컴마가 아니라면 CSV라고 부르기가..^^)
실무적으로 보면 상황에 맞춰 필드 구분자와 레코드 구분자를 정해주는 편이 합리적이겠죠.

 
 
 

CSV형식의 장단점

반대로 xml이나 json에서는 간단하다는 점을 csv의 단점으로 꼽고 있죠. 주로 아래와 같은 이유가 단점으로 꼽힙니다.

  1. 각 필드의 의미를 기술할 수 없다.
  2. 복잡한 데이터의 다중 구조를 기술할 수 없다.
  3. 데이터 형식에 대한 정보를 기술할 수 없다.

이건 맞기도 하고 틀리기도 한데, 하나하나 짚어보죠.
각 필드의 의미를 기술할 수 없다는 건 사실 첫 번째 레코드를 필드설명으로 할당하면 됩니다.

id, level, stage
hika, 10, 50
nick, 20, 30
sam, 60, 70

엑셀의 유명한 첫 줄을 머리글로 쓰는 기능도 동일한 원리입니다.

단 이렇게 첫 줄을 필드를 기술하는데 쓸지 말지 구분해야겠죠. 그거야 개발자가 미리 정해두면 그만 입니다. 예를 들어 첫 줄이 @@로 시작하면 필드에 대한 설명이다 라고 정하면 다음과 같겠죠.

@@id, level, stage
hika, 10, 50
nick, 20, 30
sam, 60, 70

반대로 json 형식에서 필드에 대한 정보를 제공하는 부분을 볼까요?

[
	{ id:hika, level:10, stage:50 },
	{ id:nick, level:20, stage:30 },
	{ id:sam, level:60, stage:70 }

]

새삼 CSV입장에서 보면 저게 왠 삽질인가 싶습니다. 바라보기 나름인 거죠.

복잡한 데이터의 다중 구조에 대해서는 먼저 예제를 통해 어떤 경우인지 이해해야 합니다.
json으로 보면 아래와 같습니다.

{
	object:{id:a, age:3},
	array:[0, 10, 20, 30, 40],
	simple:3
}

json 같은 경우 내부 키에 다시 배열이나 오브젝트를 할당할 수 있어 보다 복잡한 구조를 기술할 수 있습니다. 그에 비해 CSV는 2차원 배열을 기본으로 하고 있으니 분명 일리 있는 지적이긴 합니다(하지만 크록포드 아저씨가 공식으로 배포하고 있는 json파서를 보면 str함수가 재귀 호출하여 다중 데이터 형식을 처리하기 때문에 정말 복잡하게 만들면 스택오버플로가 됩니다 ^^)

근데 여기서 중요한 점은 사실 CSVjson의 적이 아니라는 점입니다. 개발자가 어떻게 파서를 짜느냐에 달렸지만 CSV안에 json을 넣어도 되고 반대로 json안에 CSV를 넣어도 됩니다.

CSVjson을 넣은 예를 먼저 보여드리면 아래와 같습니다.

hika,{level:10, data:[10,20,30,40]}
nick,{level:20, data:[1,2,3,4]}

반대로 jsonCSV를 넣으면 아래와 같겠죠.

{data:'hika,10,30|nick,20,50|sam,50,60'}

CSVjson을 넣은 경우는 필드의 값이 {}로 쌓여있는지 확인하면 되는 수준이기 때문에 별도의 구분자가 없어도 파싱하는데 어려움이 없습니다만 jsonCSV를 넣으면 그게 CSV라는 신호가 필요할 겁니다. 또한 특성 상  필드 구분자와 레코드 구분자도 알려줄 필요가 있습니다. 따라서 좀 개량하자면 다음과 같은 형태가 되겠죠.

{data:'@@CSV,|@@hika,10,30|nick,20,50|sam,50,60'}

따라서 개발자가 어떤 파싱 전략을 갖고 있냐의 문제지 CSV가 복잡한 데이터를 기술할 수 있냐 없냐의 문제는 아닙니다.

마지막으로 데이터의 형식을 기술할 수 없다고 하는데 그건 json도 마찬가지이고 심지어 xml의 경우도 복잡한 xsl를 기술하지 않는 이상은 데이터 형을 기술할 수 없습니다. 또한 표준 xml파서는 대부분 값을 전부 문자열로 바꿔버립니다.

반대로 json진영은 값을 기술한 형태를 정규식으로 판단하여 데이터 형을 결정하고 있습니다. 그렇게 따지면 CSV도 완전히 동일한 파싱 전략을 가져갈 수 있습니다.

'hika', 30, 50.5
'nick', 20, 2012-01-01

파서가 개별 필드에 대해서 정규식을 통해 직접 형 변환을 실시하면 될 일입니다.

 
 
 

CSV파서

이제 슬슬 근질거릴 때가 되었으니 파서를 짜보죠. 우선은 넘겨받은 텍스트 데이터를 레코드 구분자와 필드 구분자로 나눠 2차원 배열을 만드는 가장 기본적인 파서를 작성해보죠.

function CSV( $data ){
	var i, result;
	result = $data.split( 'n' );
	i = result.length;
	while( i-- ) result[i] = result[i].split( ',' );
	return result;
}

CSV파싱의 놀라운 점은 정말 간단하다는 점입니다. 아무 기능도 없는 CSV는 고작 저 정도로 파싱이 가능하죠. 하지만 이렇게 짜면 trim조차 안되어있으므로 호스트코드 측에 해야 할 일이 많아집니다. 따라서 trim 정도를 해주도록 합시다.

var trim = /^[sxA0]+|[sxA0]+$/;
function CSV( $data ){
	var i, j, result;
	result = $data.split( 'n' );
	i = result.length;
	while( i-- ){
		result[i] = result[i].split( ',' );
		j = result[i].length;

		// 각 필드를 trim 해준다.
		while( j-- ) result[i][j] = result[i][j].replace( trim, '' );
	}
	return result;
}

앞에 코드와 달라진 점은 레코드 별로 각 필드에 trim을 해주는 것 뿐입니다. 기본은 되었으니 조금 더 확장해 보죠.

우선 첫 번째 줄을 필드의 정보로 사용할지 판단하는 부분이 있었습니다. 이걸 구현하려면 약간 까다로운 점이 있습니다. 첫 줄이 필드 정보용인지 판단하는 건 간단하지만 그 정보를 각 레코드에 적용해야 하는 부분이 까다롭죠. 이를 위해서는 선행해서 이해해야 하는 개념이 있습니다. 배열도 오브젝트이므로 자유롭게 키를 설정할 수 있다는 점입니다. 즉 아래와 같습니다.

var array = ['hika', 10, 20];

// 배열도 오브젝트이므로 키에 값을 할당할 수 있다.
array.id = array[0];
array.level = array[1];
array.stage = array[2];

var array = ['hika', 10, 20];
var field = ['id', 'level', 'stage'];

var i = field.length;
while( i-- ) array[field[i]] = array[i];

//필드에 맞춰 검색할 수 있다!
array.id == 'hika'
array.level == 10
array.stage == 20

배열의 인덱스에 해당되는 키를 정해주면 손쉽게 키워드로 인덱스 검색을 대체할 수 있게 됩니다. 이제 부속이 다 갖춰졌으므로 첫 번째 줄에 @@가 있는 경우 필드정보 행으로 처리하는 기능을 추가해보죠.

function CSV( $data ){
	var i, j, result, field;
	result = $data.split( 'n' );

	//0번 레코드를 확인하여 field배열을 생성한다.
	if( result[0].substr( 0, 2 ) == '@@' ){
		field = result[0].substr(2).split( ',' );
		i = field.length;
		while( i-- ) field[i] = field[i].replace( trim, '' );

		//0번 레코드는 데이터가 아니므로 날려버리자!
		result.shift();
	}

	i = result.length;
	while( i-- ){
		result[i] = result[i].split( ',' );
		j = result[i].length;
		while( j-- ){
			result[i][j] = result[i][j].replace( trim, '' );

			//field가 있으면 반영하자
			if( field ) result[i][field[j]] = result[i][j];
		}
	}
	return result;
}

최초 1번에서 필드 정보를 셋팅하고 이후 파싱 루프에서 필드 정보가 있는 경우 각 레코드 별 필드이름으로도 값을 넣어주고 있습니다.

 
 
 

CSV파서 확장

대략 기본은 구현했으니 이 후에 남은 과제를 정리해보면 아래와 같습니다.

  • 주석을 처리할 수 있어야 한다.
  • 데이터 형을 처리할 수 있어야 한다.

일단 주석처리를 하려면 주석이 들어간 CSV가 어떻게 생겼는지 알아야 합니다. INI형식이나 CSV에서 주로 쓰이는 주석은 줄 주석으로 보통 젤 앞에 #이 붙어있는 형식입니다.

# 이게 나
hika, 20, 30
# 이건 누구?
nick, 40, 50

위의 데이터에서 실제 해석되어야 하는 부분은 #으로 시작하는 주석을 제외한 줄입니다. 이를 구현하려면 앞의 파서처럼 split의 결과를 직접 반환하면 안됩니다. 또한 제가 좋아하는 역순 루프도 사용하면 안됩니다. 별도의 반환용 배열을 만들고 순방향 루프로 고쳐보죠.

function CSV( $data ){
	var i, j, k, len, temp, record, result, field;

	// 1. temp로 격하되었다!
	temp = $data.split( 'n' );

	if( temp[0].substr( 0, 2 ) == '@@' ){
		field = temp[0].substr(2).split( ',' );
		i = field.length;
		while( i-- ) field[i] = field[i].replace( trim, '' );
		temp.shift();
	}

	// 2. 반환용으로 새 배열 생성
	result = [];

	i = 0, j = temp.length;
	while( i < j ){
		record = temp[i++];

		// 3. 주석이면 넘어가자!
		if( record.charAt(0) == '#' ) continue;

		len = result.length;
		result[len] = record.split( ',' );
		k = result[len].length;
		while( k-- ){
			result[len][k] = result[len][k].replace( trimL, '' ).replace( trimR, '' );
			if( field ) result[len][field[k]] = result[len][k];
		}
	}
	return result;
}

주석은 이제 처리되었고 데이터 형의 처리만 남았습니다. 데이터 형의 판단 및 처리는 결국 필드값을 보고 판단해야 할 일입니다. 현재 각 필드의 값에 대해 가공하는 작업은 오직 trim 뿐입니다만, 포괄적으로 trim 뿐만 아니라 데이터 형 변환까지 전부 처리하는 함수를 생각해볼 수 있습니다. 그럼 위의 소스에서 trim을 시키는 부분만 value라는 함수로 바꾸면 그만입니다.

function value( $data ){
	// trim 및 여러 가지 형 변환
}

function CSV( $data ){
	//...
	while( i < j ){
		//...
		while( k-- ){

			// 기본 trim처리 대신 value함수에 위임하자
			result[len][k] = value( result[len][k] );

			//...
		}
	}
	return result;
}

value함수의 작성은 이 포스트에서 다루지 않습니다(문자열로부터 데이터 형을 판단하는 다양한 정규식 및 처리 예제는 검색하면 수두룩하게 나올 뿐더러 크록포드 아저씨의 json파서 안에도 상세히 있습니다)

 
 
 

결론

  • CSV는 매우 가벼운 데이터 형식이다.
  • CSVjson과 배치되는 개념이 아니라 상호 운영 가능하다.
  • CSV파서는 간단히 작성할 수 있다.