JSON은 트리이고, CSV는 직사각형 표입니다. 하나를 다른 하나로 바꾸려면 트리를 어떻게 평평하게 펼칠지 정해야 하고, 모든 변환기는 그 결정을 대신 내립니다. 대개 무엇을 정했는지는 알려 주지 않습니다. 같은 파일을 온라인 변환기 세 곳에 넣으면 서로 다른 스프레드시트 세 개가 나오는 이유입니다.
세 가지 결정을 알면, 원하는 방식으로 결정해 주는 도구나 설정을 고를 수 있습니다.
빠른 답
- 객체로 이루어진 평평한 배열(
[{"id":1,"name":"Ada"}, …]): 어떤 변환기든 됩니다.jq, Python, DuckDB, Excel, 앱 모두 쓸 수 있습니다. - 중첩된 객체(
"address": {"city": "Leeds"}):address.city처럼 점으로 구분한 열로 펼칩니다. Python의json_normalize는 한 줄로 처리합니다. - 각 레코드 안의 배열(
"tags": ["a","b"]): 항목마다 열 하나, 항목마다 행 하나, 하나의 셀에 이어 붙이기 중에서 고릅니다. 정답은 없고, 결과를 어디에 쓸지에 맞는 방식이 답입니다. - 레코드가 최상위에 없을 때: 먼저 배열(보통
data,items,results)을 찾고, 그 배열을 변환합니다.
결정 1: 어떤 배열이 행이 되는가
CSV에는 비슷한 항목의 목록이 필요하고, 항목 하나가 행 하나입니다. 실제 JSON은 거의 그렇게 시작하지 않습니다. API 응답은 이렇게 생겼습니다.
{
"status": "ok",
"page": 1,
"data": {
"items": [
{ "id": 1, "name": "Ada", "address": { "city": "Leeds" }, "tags": ["admin"] },
{ "id": 2, "name": "Lin", "address": { "city": "Hue" }, "tags": [] }
]
}
}
행이 되는 것은 data.items입니다. 루트를 대상으로 한 변환기는 객체 하나만 보고, status, page와 이스케이프된 JSON으로 가득 찬 열이 있는 행 하나를 돌려줍니다. "변환기가 고장 났다"는 불만 중 가장 흔한 경우이고, 변환기가 아니라 경로의 문제입니다.
결정 2: 중첩된 객체는 어떻게 하는가
address는 각 레코드 안에 있는 객체입니다. 보통은, 그리고 대부분의 용도에서 맞는 방법은 점으로 구분한 열 이름으로 펼치는 것입니다.
| id | name | address.city |
|---|---|---|
| 1 | Ada | Leeds |
| 2 | Lin | Hue |
다른 방법은 객체 전체를 JSON 문자열로 하나의 셀에 넣는 것입니다. 모든 정보가 남지만, 정렬도 필터링도 할 수 없는 열이 됩니다.
결정 3: 레코드 안의 배열은 어떻게 하는가
tags는 목록이고, 목록은 셀 하나에 들어가지 않습니다. 선택지는 세 가지입니다.
| 방식 | 결과 | 적합한 용도 |
|---|---|---|
| 인덱스 열 | tags[0], tags[1], … | 최대 개수를 아는 짧은 목록 |
| 행으로 펼치기 | 태그마다 한 행, 나머지 열은 반복 | 태그별 집계나 필터링 |
| 한 셀에 이어 붙이기 | admin;editor | 읽기용, 분석에는 부적합 |
행으로 펼치면 행 수가 바뀝니다. 태그가 세 개인 사람은 이제 세 행입니다. "태그별로 몇 명인가"에는 맞지만 "전체 몇 명인가"에는 틀린 결과입니다. 고르기 전에 이 CSV로 어떤 질문에 답할지 먼저 정하세요.
그 밖에 잃는 것
완벽하게 펼쳐도 CSV가 표현하지 못하는 정보는 사라집니다.
- 형식.
"42"와42는 모두42가 됩니다.true는 텍스트true가 됩니다. - null과 빈 값.
null,"", 키가 없는 경우가 모두 빈 셀이 됩니다. - 듬성듬성한 레코드의 열. 10,000개 레코드 중 하나에만
coupon필드가 있다면, 첫 번째 레코드의 키로 헤더를 만드는 변환기는 그 열을 버립니다. 좋은 변환기는 모든 레코드 키의 합집합을 사용합니다.
jq로 하기
최상위가 평평한 배열일 때:
jq -r '(.[0] | keys_unsorted) as $k | $k, (.[] | [.[$k[]]]) | @csv' in.json > out.csv
이 명령은 첫 번째 레코드로 헤더를 만들기 때문에, 첫 레코드에 없는 키는 버려집니다. 위에서 말한 듬성듬성한 레코드 문제입니다. 배열이 더 아래에 있으면 처음의 .를 .data.items 같은 경로로 바꿉니다. 중첩된 데이터도 jq로 처리할 수 있지만 식을 읽기 어려워집니다. 그럴 때는 Python을 쓰세요.
Python으로 하기
pandas.json_normalize는 세 가지 결정을 한 번의 호출로 처리하고, 키의 합집합도 사용합니다.
import json, pandas as pd
data = json.load(open("in.json"))
df = pd.json_normalize(data["data"]["items"], sep=".")
df.to_csv("out.csv", index=False)
중첩된 객체는 address.city가 됩니다. 목록은 한 셀 안에 목록으로 남습니다. 행으로 펼치려면 저장하기 전에 df = df.explode("tags")를 추가하세요.
DuckDB로 하기
큰 파일이라면 DuckDB가 JSON을 바로 읽고, 전체를 메모리에 올리지 않고 CSV로 씁니다.
duckdb -c "COPY (SELECT * FROM read_json_auto('in.json')) TO 'out.csv'"
평평한 배열이라면 가장 빠른 방법입니다. 중첩된 객체는 DuckDB 고유의 struct 표기로 한 열에 출력됩니다. SELECT에서 펼치거나, 중첩된 데이터는 Python을 쓰세요.
Excel로 하기
데이터 → 데이터 가져오기 → 파일에서 → JSON에서를 선택하면 Power Query가 열립니다. 테이블로를 클릭한 뒤 열 머리글의 확장 아이콘(화살표 두 개)으로 레코드와 목록을 한 단계씩 펼칩니다. 클릭만으로 할 수 있고, 각 결정을 내리는 즉시 결과를 볼 수 있습니다. 그다음 CSV로 저장합니다. 저장할 때의 형식 변환에 주의하세요. 설정하지 않으면 Excel은 CSV 데이터를 바꿉니다.
온라인 변환기
잘 동작하고, 공개 데이터라면 문제없습니다. 주의할 점은 두 가지입니다. 대부분은 세 가지 결정을 숨기므로, 출력의 행 수와 열 수가 예상과 맞는지 확인하세요. 또 API가 돌려주는 JSON에는 토큰, 이메일, 고객 정보가 들어 있는 경우가 많으니, 업로드하지 않고 브라우저 안에서 동작하는 변환기를 쓰는 편이 좋습니다.
iPhone과 iPad에서
Smart Json은 레코드 모드라는 방식으로 JSON을 CSV로 변환합니다. 객체 배열이 행이 되고, 중첩된 값은 점으로 구분한 열(address.city, tags[0])로 펼쳐지며, 헤더는 모든 레코드 키의 합집합입니다. 첫 번째 객체 배열은 자동으로 찾아 주고, 다른 배열을 고르거나 $.data.items 같은 JSONPath를 직접 입력할 수도 있습니다. 구분자는 바꿀 수 있고, 객체 배열이 없는 문서는 쓸모없는 두 열짜리 결과를 내는 대신 그 사실을 알려 줍니다.

미리보기는 무료이므로 CSV가 어떤 모습이 될지 정확히 볼 수 있습니다. 결과를 복사하거나 저장하는 것은 Pro 기능입니다.
JSON을 CSV로 미리 보여 주고, 중첩된 값을 점으로 구분한 열로 펼칩니다. 레코드 배열을 JSONPath로 고를 수 있으며, CSV가 담을 수 없는 내용을 분명하게 알려 줍니다.
기능CSV가 되고 나면 휴대폰에서 여는 일이 또 하나의 작은 문제입니다. 그 가이드에서 다룹니다.
결과 확인하기
어떤 도구를 쓰든 확인할 것은 세 가지입니다.
- 행 수. 배열의 레코드 수와 일치합니까(목록을 일부러 펼쳤다면 그보다 많습니다)?
- 열 수. 일부 레코드에만 있는 필드가 헤더에 들어갔습니까?
- 중첩된 값 하나. 중첩된 객체가 있는 레코드를 골라, 그 값이 올바른 점 구분 열에 들어갔는지 확인합니다.