중첩 JSON을 데이터 손실 없이 CSV로 평탄화하기
JSON을 CSV로 바꾸는 도구는 문서화되지 않은 결정을 열 개 넘게 여러분 대신 내립니다. 그 결정들을 짚어 봅니다.
이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.
이걸 아무 변환기에나 한번 넣어 보세요.
[
{ "id": 1, "name": "Ada", "tags": ["admin"] },
{ "id": 2, "name": "Grace", "tags": ["admin", "ops"], "team": { "name": "core" } }
]
많은 도구가 세 개의 열, id, name, tags를 돌려줍니다. team 객체는 사라졌습니다. 잘린 것도, 경고가 붙은 것도 아니고 그냥 없습니다. 변환기가 첫 객체의 키를 읽고 그것을 스키마로 취급했기 때문입니다.
CSV는 직사각형입니다. 열 집합이 고정이고 한 칸에 스칼라 하나가 들어갑니다. JSON은 트리이고, 키는 선택적이며, 깊이는 임의이고, 배열은 어디에나 올 수 있습니다. 둘 사이에 올바른 대응은 존재하지 않고 방침의 집합만 있을 뿐이며, 다루기 쉬워 보이는 변환기란 그 방침을 아무 말 없이 대신 골라 준 도구를 말합니다.
열 결정: 첫 행이 아니라 합집합
열을 정하는 방법은 두 가지입니다. 모든 행을 훑어 잎 경로의 합집합을 모으거나, 객체 하나를 읽고 그 키를 취하거나.
두 번째는 성능 최적화가 아니라 그럴싸한 변명이 붙은 데이터 손실입니다. Papa Parse는 명시적인 columns 옵션을 넘기지 않는 한 첫 객체의 키에서 필드를 가져옵니다.
Papa.unparse([{ a: 1 }, { a: 2, b: 3 }]);
// "a\r\n1\r\n2" b 열은 애초에 존재한 적이 없다
Papa.unparse(rows, { columns: ['a', 'b'] });
// 합집합은 직접 넘긴다
pandas의 json_normalize는 합집합을 취하고, 사람들이 이걸 찾는 이유 중 하나가 그것입니다. 대가는 희소한 문서가 넓고 대부분 비어 있는 표가 된다는 점인데, 그것이 희소한 문서의 정직한 표현입니다. 열을 줄이고 싶다면 의도적으로 버리세요.
스트리밍이면 이게 정말로 어려워집니다. NDJSON에서는 마지막 줄까지 가야 열 집합이 확정되므로, 파일을 버퍼링하거나 두 번 훑거나 해야 합니다.
중첩 객체와, 반드시 노출해야 하는 구분자
중첩 객체는 점으로 이어진 경로로 평탄화되므로 {"team": {"name": "core"}}는 team.name이 됩니다. pandas는 기본으로 .을 쓰고 바꿀 수도 있게 해 줍니다.
pd.json_normalize({"user": {"name": {"first": "Ada"}}})
# 열: user.name.first
pd.json_normalize(data, sep="__")
# 열: user__name__first
구분자는 설정 가능해야 합니다. 점은 JSON 키에 들어갈 수 있는 합법적인 문자이기 때문입니다. 다음 두 문서는 같은 열로 평탄화됩니다.
{ "a": { "b": 1 } }
{ "a.b": 1 }
한번 충돌하면 되돌아오는 길은 추측이 되고, 한쪽이 다른 쪽을 조용히 덮어쓰도록 놔두는 변환기는 복원하면 형태가 달라지는 파일을 만든 셈입니다. 키에 없는 구분자를 고르거나, 키 안에 나타나는 자리에서 이스케이프하세요. 키에 점이 절대 안 나온다고 가정하지 마세요. 이벤트나 애널리틱스 페이로드에서는 수시로 나옵니다.
배열: 네 가지 방침과 하나의 합리적인 기본값
변환기들이 가장 크게 갈리는 지점입니다.
| 방침 | tags: ["admin","ops"]의 출력 |
대가 |
|---|---|---|
| 색인 열 | tags.0 = admin, tags.1 = ops |
열 개수를 파일에서 가장 긴 배열이 정한다. 태그가 400개인 행 하나가 모든 행을 400열로 만든다 |
| 한 칸에 이어 붙이기 | tags = admin,ops |
값에 이음 문자가 들어가는 순간 깨지고, []와 [""]가 똑같이 보인다 |
| 한 칸에 JSON | tags = ["admin","ops"] |
보기 나쁘고 따옴표를 정확히 써야 하지만, 왕복이 정확히 성립한다 |
| 행으로 펼치기 | 두 행이 되고 나머지 필드가 반복된다 | 행 수가 레코드 수와 어긋나므로 다른 열에 대한 집계가 이중으로 잡힌다 |
색인 열은 원소 수가 작고 고정일 때 괜찮습니다. 위경도 쌍, RGB 세 값 같은 경우죠. 상한이 없는 것에서는 열 개수를 전형적인 행이 아니라 최악의 행이 결정하게 됩니다.
이어 붙이기는 가장 흔한 기본값이자 가장 나쁜 선택으로, 세 방향으로 동시에 정보를 잃습니다. 구분 문자가 데이터에 나타날 수 있고, 빈 배열과 빈 문자열 하나짜리 배열이 하나로 뭉개지며, 중첩 객체는 어차피 문자열이 되어 버립니다.
펼치지 않을 배열에는 한 칸에 JSON이 올바른 기본값입니다. 정확히 되돌릴 수 있는 유일한 방침이기 때문입니다. 칸은 RFC 4180에 따라 따옴표로 감싸이고 내부 따옴표는 두 번 씁니다. 그 열이 JSON을 담고 있다는 걸 아는 읽는 쪽은 곧바로 다시 파싱합니다. 엑셀에서는 더 못생겼고, 그리고 정확합니다.
펼치기가 맞는 경우는 배열 자체가 주제일 때입니다. 주문의 품목 줄, 세션 안의 이벤트 같은 것이죠. record_path가 하는 일이 그것입니다.
import pandas as pd
data = [
{"id": 1, "name": "Ada", "orders": [{"sku": "A1", "qty": 2}]},
{"id": 2, "name": "Grace", "orders": [{"sku": "B7", "qty": 1},
{"sku": "C3", "qty": 5}]},
]
pd.json_normalize(data, record_path="orders", meta=["id", "name"])
# sku qty id name
# 0 A1 2 1 Ada
# 1 B7 1 2 Grace
# 2 C3 5 2 Grace
record_path는 행으로 바꿀 배열을, meta는 각 행에 복사할 부모 필드를 지정합니다. orders 배열이 빈 레코드에 무슨 일이 일어나는지 보세요. 행이 하나도 생기지 않고 통째로 사라집니다. 그리고 한 번에 배열 하나만 다룰 수 있습니다. 형제 배열 둘은 곱집합이 필요해지기 때문이니, 배열마다 한 번씩 돌리고 id로 결합하세요.
이질적인 배열
객체마다 키가 다른 배열은 한 층 아래에서 벌어지는 같은 합집합 문제입니다. 색인 열에서는 [{"a":1},{"b":2}]가 items.0.a와 items.1.b가 되어, 결코 둘 다 채워지지 않는 두 열이 생기고, 열 집합이 원소의 위치에 의존하게 됩니다. 펼치기에서는 a와 b 열을 가진 두 행이 되는데, 위치가 정체성의 일부가 아니게 되므로 이쪽이 낫습니다. 스칼라와 객체가 섞인 배열은 직사각형 형태 자체가 없습니다. 한 칸에 JSON으로 직렬화하세요.
CSV에 대응물이 없는 값
CSV의 타입은 하나, 텍스트뿐입니다. 나머지는 전부 관행입니다.
null 대 빈 문자열. JSON은 둘을 구별하고 CSV는 구별하지 않습니다. 대부분의 읽는 쪽에게 ,,와 ,"",는 같은 값이라 왕복에서 한쪽이 다른 쪽으로 접힙니다. 그게 중요하다면 \N(Postgres COPY의 관행) 같은 표지값을 쓰거나, null이 빈 문자열로 돌아온다는 사실을 받아들이고 그렇게 명시하세요.
불리언. 소문자 true와 false가 JSON의 표기이고 그대로 살아남습니다. 엑셀은 TRUE/FALSE로 표시하고 어떤 도구는 1/0을 내놓는데, 둘 다 되돌릴 때 명시적인 대응이 필요합니다.
숫자. 007을 담은 JSON 문자열을 엑셀은 7로 읽고, 1E5는 100000이 됩니다. CSV의 따옴표로는 막지 못합니다. 엑셀은 따옴표를 벗긴 뒤에 타입을 추측하니까요. 큰 정수는 경로 어딘가에서 float를 거치면 정밀도 경계에 부딪힙니다. 숫자는 원본 텍스트 그대로 내보내세요.
날짜. JSON에는 날짜 타입이 없고, ISO 8601이나 RFC 3339 문자열이 관행입니다. 엑셀은 2026-03-04 같은 날짜처럼 생긴 문자열을 날짜 값으로 바꾸고 그 컴퓨터의 로캘 형식으로 다시 표시합니다. 03/04/2026 같은 모호한 형식은 아예 다른 날로 돌아올 수 있으니, 스프레드시트를 중간 경유지로 삼지 마세요.
물어뜯는 CSV의 실무 규칙
RFC 4180은 짧고 따를 만합니다. 쉼표, 큰따옴표, 줄바꿈이 든 필드는 따옴표로 감싸야 하고, 따옴표로 감싼 필드 안의 리터럴 큰따옴표는 두 번 씁니다. 줄 끝은 CRLF입니다. 따옴표로 감싼 필드 안에 들어간 줄바꿈은 합법이지만 여전히 이를 틀리는 CSV 리더가 수두룩하니, 문자열에 줄바꿈이 들어간다면 소비하는 쪽부터 시험해 보세요.
구분자가 늘 쉼표인 것도 아닙니다. 소수점 구분자가 쉼표인 로캘의 엑셀은 세미콜론으로 구분된 파일을 기대합니다. 멀쩡한 CSV가 동료의 컴퓨터에서 한 열로 열리는 이유가 이것입니다. 구분자 설정을 제공하거나, 엑셀이 이해하는 sep=; 첫 줄을 붙여 배포하세요.
그다음은 바이트 순서 표식입니다. 엑셀이 UTF-8 CSV를 UTF-8로 읽는 것은 파일이 BOM으로 시작할 때뿐이고, 없으면 악센트 붙은 문자가 시스템 코드페이지로 해독되어 깨집니다. 그 3바이트는 다른 모든 도구에는 잡음이니, BOM은 토글로 만들고 엑셀 경로에서만 켜세요.
CSV 인젝션
칸의 첫 문자가 =, +, -, @ 중 하나면 엑셀, 구글 스프레드시트, LibreOffice는 그 칸을 수식으로 보고 열 때 평가합니다. OWASP는 이를 CSV 인젝션이라고 부릅니다. 어떤 지침은 탭과 캐리지 리턴도 방아쇠 목록에 넣습니다.
따옴표로는 빠져나갈 수 없습니다. RFC 4180의 따옴표는 수식이 평가되기 전에 읽는 쪽이 벗겨 내기 때문입니다. 따라서 JSON 안의 어떤 문자열이 사용자에게서 왔고 그것이 누군가 여는 CSV에 도달한다면, 신뢰된 맥락에서 실행되는 수식을 공격자에게 건넨 셈입니다. 수식은 원격 URL을 가져올 수 있고, 이는 옆 칸의 내용이 건물 밖으로 나갈 수 있다는 뜻입니다.
완화책은 칸을 쓸 때 앞 문자를 무력화하는 것입니다.
const RISKY = /^[=+\-@\t\r]/;
function safeCell(value) {
const s = String(value);
return RISKY.test(s) ? "'" + s : s;
}
작은따옴표는 엑셀이 내용을 텍스트로 다루게 만듭니다. 공짜는 아닙니다. 스프레드시트가 아닌 읽는 쪽에게는 이제 그것이 데이터의 일부이므로, 그 값들에 대해서는 왕복이 깨집니다. 앞에 붙이기는 사람이 스프레드시트로 여는 파일에는 맞고 기계가 되읽는 파일에는 틀립니다. 그러니 숨은 기본값이 아니라 내보내기마다 정하는 스위치여야 합니다.
반대 방향으로 돌아가기
CSV에서 JSON으로 갈 때는 큰 함정이 하나 있습니다. 타입 추론입니다. 파일 안의 모든 값이 텍스트이므로 변환기는 어느 것이 숫자인지 추측하고, 예측 가능한 방식으로 실패합니다. 007은 7이, 1E5는 100000이, 1.0은 1이 됩니다. 우편번호, 부품번호, 전화번호, 버전 문자열이 모두 같은 규칙에 죽습니다. 안전한 기본값은 모든 값을 문자열로 내보내고, 아는 것만 호출한 쪽이 변환하게 하는 것입니다. 추론은 파일 전체에 적용되는 휴리스틱이 아니라 열 단위 선택 사항이어야 합니다. CSV → JSON 도구가 그 스위치를 명시해 둔 이유가 바로 이것입니다.
밝혀 둘 만한 기본 방침
| 결정 | 기본값 | 이유 |
|---|---|---|
| 열 | 모든 잎 경로의 합집합, 정렬 | 첫 객체 표본 추출은 경고 없이 필드를 떨어뜨린다 |
| 중첩 객체 | 점으로 이은 경로, 구분자는 설정 가능 | 키가 구분자를 합법적으로 담을 수 있다 |
| 배열 | 한 칸에 JSON | 되돌릴 수 있는 유일한 방침. 배열이 곧 레코드면 펼친다 |
| 빈 컨테이너 | []와 {} 그대로 |
null과도, 빈 문자열과도 구별된다 |
| null | 빈 칸, 다만 명시 | 구별이 무게를 지탱하는 곳에서는 표지값을 쓴다 |
| 숫자 | 원본 텍스트 그대로 | 나가는 길에 결코 float를 거치지 않는다 |
| 줄 끝 | CRLF | RFC 4180이 그렇고, LF가 CRLF보다 더 많은 리더를 깨뜨린다 |
| BOM | 꺼짐, 엑셀용 토글 제공 | 파이프라인에는 맞고 엑셀에는 틀리니 사용자가 고르게 한다 |
| 수식 문자 | 엑셀 경로에서만 앞에 붙임 | 앞에 붙이면 데이터가 바뀌므로 조용히 일어나서는 안 된다 |
이것만이 방어 가능한 답은 아닙니다. 요점은, 어떤 변환기든 알려 주든 말든 아홉 가지 결정을 전부 내린다는 것, 그리고 눈으로 훑기엔 너무 큰 페이로드를 맡길 수 있는 건 알려 주는 쪽뿐이라는 것입니다. 평탄화 도구는 확정하기 전에 경로 집합을 보여 주고, 표 보기는 곧 받게 될 직사각형을 보여 줍니다.