같은 54만 행이 3.5MB도 되고 102.7MB도 됩니다: CSV·Excel·JSON·Parquet 고르는 법
같은 온라인몰 거래 54만 행을 저장했더니 Parquet은 3.5MB, CSV는 45.0MB, JSON Lines는 102.7MB였습니다. 다시 읽는 데는 Parquet 0.1초, CSV 0.24초, JSON 1.3초, 원본 Excel은 20초 안팎이 걸렸습니다. 더 중요한 차이는 타입입니다. CSV와 JSON은 다시 읽는 순간 날짜가 문자열로, 고객 번호 17850이 17850.0으로 바뀌었고, Parquet은 그대로였습니다. 반대로 151행짜리 작은 데이터에서는 CSV가 Parquet보다 작았습니다. 형식은 용량 하나로 고르는 게 아니라 누가 읽고, 얼마나 자주 읽고, 타입이 살아남아야 하는지로 고릅니다.
총매출 글, pandas 타입 글과 같은 UCI Online Retail II 데이터를 쓰고, JSON은 PokeAPI로 실습합니다.
네 형식의 성격
| 형식 | 모양 | 타입 정보 | 사람이 열어보기 | 주로 쓰는 곳 |
|---|---|---|---|---|
| CSV | 표, 행 단위 텍스트 | 없음 | 쉬움 | 사람과 공유, 어떤 도구로든 열려야 할 때 |
| Excel | 표 + 서식·수식 | 일부 | 가장 편함 | 사람이 직접 보고 고칠 때 |
| JSON | 중첩 가능한 텍스트 | 문자·숫자 정도 | 가능 | API 응답, 항목마다 딸린 개수가 다른 데이터 |
| Parquet | 표, 컬럼 단위 바이너리 | 보존 | 어려움 | 분석 중간 저장, 반복해서 읽는 데이터, 대용량 |
Parquet은 행이 아니라 컬럼 단위로 저장합니다. 같은 컬럼의 비슷한 값이 연달아 놓이니 압축이 잘 되고, 필요한 컬럼만 골라 읽을 수 있습니다. 그리고 각 컬럼의 타입을 파일 안에 함께 적어 둡니다. 이 세 가지가 아래 측정에서 그대로 드러납니다.
저장부터 막혔습니다: Parquet은 섞인 타입을 거부합니다
이전 글에서 본 대로 송장·상품·고객 번호의 타입을 지정해 읽은 뒤, 세 형식으로 저장했습니다. CSV와 JSON은 문제없이 저장됐는데 Parquet에서 에러가 났습니다.
Excel 읽기: 22.6초, 541,910행
Parquet 저장 실패: ArrowTypeError - ("Expected bytes, got a 'int' object", 'Conversion failed for column Description with type
Description 값의 파이썬 타입: {'str': 540455, 'float': 1454, 'int': 1} | 숫자인 예: [20713]
Description을 문자열로 지정한 뒤 저장 성공
상품 설명(Description) 컬럼에 문자열 540,455개, 빈 값(NaN, 파이썬에서는 실수형) 1,454개와 함께 숫자 20713이 딱 하나 들어 있었습니다. Excel에서 숫자로 읽힌 값입니다. CSV는 이런 혼합을 아무 말 없이 텍스트로 저장하고, Parquet은 “한 컬럼에는 한 타입”을 요구하기 때문에 저장을 거부합니다.
귀찮은 에러처럼 보이지만, 사실은 이전 글에서 본 ‘조용히 틀리는’ 문제를 저장 단계에서 잡아 준 것입니다. 설명 컬럼을 문자열로 지정하자 저장됐습니다.
용량과 읽기 시간
원본 xlsx(시트 2개): 43.5 MB
CSV 용량: 45.0 MB
JSON Lines 용량: 102.7 MB
Parquet 용량: 3.5 MB
CSV 읽기: 0.242초 (3회 중 최소)
JSON Lines 읽기: 1.295초 (3회 중 최소)
Parquet 읽기: 0.099초 (3회 중 최소)
Parquet(2개 컬럼만) 읽기: 0.003초 (3회 중 최소)
읽기 시간은 한 번 미리 읽은 뒤 세 번 재서 가장 빠른 값입니다. Excel은 이 스크립트에서 한 번만 쟀고, 위 실행에서는 22.6초, 같은 스크립트를 몇 차례 다시 돌렸을 때는 18~23초 사이였습니다.
| 형식 | 용량 | 읽기 시간 |
|---|---|---|
| Excel (원본, 시트 2개) | 43.5 MB | 약 18~23초 (한 시트) |
| CSV | 45.0 MB | 0.242초 |
| JSON Lines | 102.7 MB | 1.295초 |
| Parquet | 3.5 MB | 0.099초 |
| Parquet, 필요한 2개 컬럼만 | (같은 파일) | 0.003초 |
Parquet은 CSV의 13분의 1 크기입니다. 54만 행에 나라 이름 38개와 같은 상품 설명이 계속 반복되는 데이터라 컬럼 단위 압축이 특히 잘 됩니다. JSON Lines는 행마다 컬럼 이름을 다시 적기 때문에 CSV의 두 배가 넘습니다.
읽기 시간에서는 이 크기의 데이터라면 CSV도 충분히 빠르다는 점이 눈에 띕니다. Parquet의 진짜 강점은 마지막 줄에 있습니다. 수량과 단가 두 컬럼만 필요하면 그 컬럼만 디스크에서 꺼내니 0.003초면 끝납니다. 컬럼이 수백 개인 실무 테이블일수록 이 차이가 커집니다. 참고로 원본 xlsx는 이미 압축된 형식이라 CSV와 용량이 비슷하지만, 읽는 데는 CSV보다 70~90배 오래 걸렸습니다.
다시 읽었을 때 타입이 살아남는가
저장한 파일을 아무 옵션 없이 다시 읽고 타입을 비교했습니다.
[CSV] InvoiceDate=object, Customer ID=float64, Invoice=object, StockCode=object, Customer ID 예시=np.float64(17850.0), 값 동일(Customer ID)=False
[JSON Lines] InvoiceDate=object, Customer ID=float64, Invoice=object, StockCode=object, Customer ID 예시=np.float64(17850.0), 값 동일(Customer ID)=False
[Parquet] InvoiceDate=datetime64[ns], Customer ID=string, Invoice=object, StockCode=object, Customer ID 예시='17850', 값 동일(Customer ID)=True
| 컬럼 | 원래 타입 | CSV로 다시 읽으면 | JSON Lines로 다시 읽으면 | Parquet으로 다시 읽으면 |
|---|---|---|---|---|
| InvoiceDate | 날짜 | 문자열 | 문자열 | 날짜 |
| Customer ID | 문자열 '17850' | 실수 17850.0 | 실수 17850.0 | 문자열 '17850' |
CSV와 JSON은 “이 컬럼은 날짜”, “이 컬럼은 문자열 ID”라는 정보를 담지 못합니다. 다시 읽을 때 pandas가 값만 보고 추측하는데, 숫자처럼 생긴 고객 번호는 숫자로, 빈 값이 섞였으니 실수로 추측합니다. 읽을 때 겨우 고친 타입 문제가 저장 한 번에 되살아나는 셈입니다. 날짜는 문자열이 되어 월별 집계를 하려면 다시 변환해야 합니다.
CSV를 써야 한다면 읽을 때마다 dtype과 parse_dates를 명시해야 합니다. 분석 도중 중간 결과를 저장하는 용도라면 Parquet이 이 일을 대신해 줍니다.
매일 쌓이는 데이터는 쪼개서 저장합니다: 파티션
주문처럼 매일 쌓이는 데이터를 파일 하나에 몰아넣으면 “이번 달 치만” 보고 싶어도 전체를 읽어야 합니다. 그래서 날짜 같은 기준으로 폴더를 나눠 저장합니다. 이것을 파티션이라고 합니다. 폴더 이름을 컬럼=값 모양으로 지으면 대부분의 도구가 이 이름을 읽어 컬럼으로 되살려 줍니다.
df["ym"] = df["InvoiceDate"].dt.strftime("%Y-%m")
df.to_parquet("by_month", partition_cols=["ym"], index=False) # 월마다 폴더 하나
nov = pd.read_parquet("by_month", filters=[("ym", "==", "2011-11")]) # 11월 폴더만 읽기
파티션 폴더: 13 개, ym=2010-12 … ym=2011-12
2011-11만 읽기: 84,711행, 0.02초, ym 타입=category
ym=2010-12부터 ym=2011-12까지 13개 폴더가 생겼고, 2011년 11월 치 84,711행만 0.02초에 읽었습니다. 파티션을 쓰면 특정 월이 잘못 수집됐을 때 그 폴더만 지우고 다시 쓸 수 있어서, 같은 작업을 여러 번 돌려도 결과가 같아지는 자동화에도 유리합니다.
주의할 점이 두 가지 있습니다. 첫째, 다시 읽은 ym의 타입은 문자열이 아니라 category입니다. 파티션 컬럼은 파일 안이 아니라 폴더 이름에서 되살리기 때문입니다. 여기서도 읽은 뒤 타입 확인이 필요합니다. 둘째, 너무 잘게 쪼개면 오히려 느려집니다. 하루 치가 몇 KB뿐인데 일별로 나누면 작은 파일 수천 개를 여닫느라 시간이 다 갑니다. 이 글에서는 하루 수천 행 규모라 월별로 나눠 실험했습니다. 일별과 월별 중 무엇이 나은지는 실제 조회 패턴과 파일 크기를 보고 정합니다.
JSON: 표로 떨어지지 않는 데이터
CSV·Excel·Parquet은 모두 행과 열이 정해진 표입니다. 그런데 API가 주는 데이터는 표가 아닌 경우가 많습니다. PokeAPI에서 1세대 포켓몬 151마리를 받아 보면, 포켓몬마다 타입이 1개일 수도 2개일 수도 있습니다.
import requests
import pandas as pd
rows = []
for i in range(1, 152):
p = requests.get(f"https://pokeapi.co/api/v2/pokemon/{i}", timeout=30).json()
rows.append({
"dex_no": f"{p['id']:04d}", # 0025처럼 네 자리 문자열
"name": p["name"],
"weight": p["weight"], # 단위: 0.1kg
"types": [t["type"]["name"] for t in p["types"]], # 리스트 그대로
**{s["stat"]["name"]: s["base_stat"] for s in p["stats"]},
})
df = pd.DataFrame(rows)
행 수: 151 | 타입 2개: 67 | 타입 1개: 84
리자몽: ['fire', 'flying'] | 피카츄: ['electric']
explode 후 행 수: 218
몸무게 합계: 원래 6,938.7kg → explode 후 10,650.7kg
151마리 중 67마리가 타입이 2개입니다. 여기서 흔한 실수가 나옵니다. 타입별로 분석하려고 explode로 리스트를 행으로 펼치면 151행이 218행이 되고, 타입이 2개인 포켓몬은 두 번 들어갑니다. 그 상태로 몸무게를 합하면 6,938.7kg이 10,650.7kg으로 부풀려집니다. 행 단위를 확인하지 않고 합계를 내면 총매출이 부풀려지던 것과 같은 문제입니다. 펼치는 순간 한 행의 의미가 ‘포켓몬 한 마리’에서 ‘포켓몬의 타입 하나’로 바뀌기 때문입니다. 타입별 분석은 펼친 표에서, 마리 단위 합계는 원래 표에서 합니다.
JSON의 {"stats": {"hp": 35, "attack": 55}} 같은 딕셔너리 중첩은 pd.json_normalize()로 stats.hp, stats.attack 컬럼으로 평평하게 펼 수 있습니다. 결정이 필요한 건 위의 types 같은 리스트, 즉 1:N 관계입니다.
작은 데이터에서는 CSV가 더 작습니다
같은 151마리를 세 형식으로 저장하고 다시 읽었습니다.
gen1.csv 7.7 KB
gen1.json 23.0 KB
gen1.parquet 10.7 KB
CSV로 다시 읽으면 | 피카츄 dex_no: np.int64(25) | 이상해씨 types: "['grass', 'poison']"
Parquet으로 다시 읽으면 | 피카츄 dex_no: '0025' | 이상해씨 types: ndarray ['grass', 'poison']
151행에서는 CSV(7.7KB)가 Parquet(10.7KB)보다 작습니다. Parquet은 파일마다 스키마와 통계 정보를 붙이기 때문에, 데이터가 작으면 그 고정 비용이 더 큽니다. 압축의 이점은 앞의 54만 행처럼 데이터가 커야 드러납니다. 누군가 “Parquet이 항상 더 작다”고 한다면, AI든 사람이든 실제로 재 보지 않고 말한 것입니다.
그래도 기본 옵션으로 다시 읽었을 때 타입을 그대로 돌려주는 건 여전히 Parquet뿐입니다(CSV도 읽을 때 dtype을 지정하면 막을 수 있습니다). CSV로 다시 읽으면 피카츄의 도감 번호 0025가 숫자 25가 되고, 이상해씨의 타입 목록은 "['grass', 'poison']"이라는 문자열 하나가 됩니다. Parquet에서는 '0025'와 ['grass', 'poison']이 그대로 돌아옵니다(정확히는 파이썬 리스트가 아니라 배열로 돌아옵니다).
언제 무엇을 쓰나
| 상황 | 형식 |
|---|---|
| 사람에게 결과를 보내고, 받는 쪽이 어떤 프로그램을 쓸지 모를 때 | CSV |
| 사람이 직접 열어 보고 고치거나 색·수식이 필요할 때 | Excel |
| API 응답을 받거나 항목마다 딸린 개수가 다를 때 | JSON |
| 분석 도중 중간 결과를 저장하고 반복해서 읽을 때, 데이터가 클 때 | Parquet |
| 매일 쌓이는 데이터 | Parquet + 날짜 파티션 |
AI에게 변환을 시킬 때
형식 변환도 AI에게 맡기기 좋은 일입니다. 다만 변환 결과가 원본과 같은지 확인하는 일까지 함께 요청합니다.
이 CSV를 Parquet으로 바꿔 줘. 바꾼 뒤 두 파일의 용량과 읽는 시간을 비교하고, 다시 읽었을 때 행 수, 컬럼 타입, 날짜와 앞자리 0이 있는 코드값이 원본과 같은지 확인해 줘.
결과를 받으면 세 가지를 봅니다. 행 수가 같은가, 컬럼 타입이 날짜는 날짜로 ID는 문자열로 들어왔는가, 샘플 값에서 앞자리 0과 날짜 표기가 그대로인가. 통계 요약만으로는 0025가 25가 된 걸 놓치기 쉬워서, 몇 행은 직접 눈으로 대조합니다.
면접에서 나올 수 있는 질문
CSV와 Parquet의 차이를 설명하고, 언제 Parquet을 쓰는지 말해 보세요. CSV는 행 단위 텍스트라 어디서나 열리지만 타입 정보가 없고 매번 전체를 읽습니다. Parquet은 컬럼 단위 바이너리라 필요한 컬럼만 읽고, 압축이 잘 되고, 타입을 보존합니다. 분석 중간 저장과 대용량에는 Parquet, 사람에게 전달할 때는 CSV를 씁니다. 이 글의 54만 행에서는 45.0MB 대 3.5MB, 두 컬럼만 읽을 때 0.003초였습니다.
Parquet의 단점은요? 사람이 바로 열어 볼 수 없고, 한 행만 고치기 어렵고, 데이터가 작으면 오히려 CSV보다 큽니다. 151행에서 CSV 7.7KB, Parquet 10.7KB였습니다.
중첩 JSON을 표로 만들 때 무엇을 주의하나요? 딕셔너리는 json_normalize로 펴면 되지만, 리스트(1:N)를 explode로 펼치면 한 행의 의미가 바뀌어 합계가 중복됩니다. 먼저 한 행이 무엇인지 정하고, 필요하면 1:N 정보는 별도 테이블로 분리합니다.
파티션과 샤딩의 차이는요? 파티션은 한 저장소 안에서 날짜 같은 기준으로 구획을 나눠 필요한 부분만 읽는 것이고, 샤딩은 사용자 ID 해시 같은 키로 데이터를 여러 서버에 나눠 담아 부하를 분산하는 것입니다. 분석가는 파티션을 자주 만나고, 샤딩은 개념만 알면 충분합니다.
전체 코드
pip install pandas openpyxl pyarrow requests 후 실행합니다. 첫 번째는 온라인몰 데이터 비교 스크립트로, online_retail_II.xlsx와 같은 폴더에서 실행합니다.
import os, time, shutil
import pandas as pd
import pyarrow # 첫 읽기 시간에 import 시간이 섞이지 않게 미리 불러오기
XLSX = os.environ.get("RETAIL_XLSX", "online_retail_II.xlsx")
OUT = "formats"
shutil.rmtree(OUT, ignore_errors=True); os.makedirs(OUT)
def timed(fn):
t = time.perf_counter(); r = fn(); return r, time.perf_counter() - t
# 타입을 지정해서 읽은 원본
df, t_xlsx = timed(lambda: pd.read_excel(
XLSX, sheet_name="Year 2010-2011",
dtype={"Invoice": str, "StockCode": str, "Customer ID": "Int64"}))
df["Customer ID"] = df["Customer ID"].astype("string") # ID는 문자열로
print(f"Excel 읽기: {t_xlsx:.1f}초, {len(df):,}행")
# 1) 세 형식으로 저장
paths = {"CSV": f"{OUT}/retail.csv", "JSON Lines": f"{OUT}/retail.jsonl", "Parquet": f"{OUT}/retail.parquet"}
df.to_csv(paths["CSV"], index=False)
df.to_json(paths["JSON Lines"], orient="records", lines=True, date_format="iso", force_ascii=False)
try:
df.to_parquet(paths["Parquet"], index=False)
except Exception as e:
print("Parquet 저장 실패:", type(e).__name__, "-", str(e)[:90])
odd = df["Description"].map(type).value_counts().to_dict()
print("Description 값의 파이썬 타입:", {t.__name__: n for t, n in odd.items()},
"| 숫자인 예:", df.loc[df["Description"].map(lambda v: isinstance(v, (int, float)) and pd.notna(v)), "Description"].head(3).tolist())
df["Description"] = df["Description"].astype("string")
df.to_parquet(paths["Parquet"], index=False)
print("Description을 문자열로 지정한 뒤 저장 성공")
xlsx_mb = os.path.getsize(XLSX) / 1024**2
print(f"원본 xlsx(시트 2개): {xlsx_mb:.1f} MB")
for k, p in paths.items():
print(f"{k} 용량: {os.path.getsize(p) / 1024**2:.1f} MB")
# 2) 다시 읽는 시간
readers = {
"CSV": lambda: pd.read_csv(paths["CSV"]),
"JSON Lines": lambda: pd.read_json(paths["JSON Lines"], lines=True),
"Parquet": lambda: pd.read_parquet(paths["Parquet"]),
"Parquet(2개 컬럼만)": lambda: pd.read_parquet(paths["Parquet"], columns=["Quantity", "Price"]),
}
back = {}
for k, fn in readers.items():
fn() # 한 번 미리 실행
runs = [timed(fn) for _ in range(3)]
back[k] = runs[0][0]
print(f"{k} 읽기: {min(sec for _, sec in runs):.3f}초 (3회 중 최소)")
# 3) 다시 읽었을 때 타입이 그대로인가
for k in ["CSV", "JSON Lines", "Parquet"]:
b = back[k]
print(f"[{k}] InvoiceDate={b['InvoiceDate'].dtype}, Customer ID={b['Customer ID'].dtype}, "
f"Invoice={b['Invoice'].dtype}, StockCode={b['StockCode'].dtype}, "
f"Customer ID 예시={b['Customer ID'].dropna().iloc[0]!r}, "
f"값 동일(Customer ID)={b['Customer ID'].astype('string').fillna('').equals(df['Customer ID'].fillna(''))}")
# 4) 월별 파티션
df["ym"] = df["InvoiceDate"].dt.strftime("%Y-%m")
df.to_parquet(f"{OUT}/by_month", partition_cols=["ym"], index=False)
folders = sorted(os.listdir(f"{OUT}/by_month"))
print("파티션 폴더:", len(folders), "개,", folders[0], "…", folders[-1])
nov, sec = timed(lambda: pd.read_parquet(f"{OUT}/by_month", filters=[("ym", "==", "2011-11")]))
print(f"2011-11만 읽기: {len(nov):,}행, {sec:.2f}초, ym 타입={nov['ym'].dtype}")
두 번째는 PokeAPI 스크립트입니다. 포켓몬 151마리를 한 번씩, 모두 151번 요청합니다.
import os
import requests
import pandas as pd
rows = []
for i in range(1, 152):
p = requests.get(f"https://pokeapi.co/api/v2/pokemon/{i}", timeout=30).json()
rows.append({
"dex_no": f"{p['id']:04d}",
"name": p["name"],
"weight": p["weight"], # 단위: 0.1kg
"types": [t["type"]["name"] for t in p["types"]],
**{s["stat"]["name"]: s["base_stat"] for s in p["stats"]},
})
df = pd.DataFrame(rows)
print("행 수:", len(df), "| 타입 2개:", (df["types"].str.len() == 2).sum(), "| 타입 1개:", (df["types"].str.len() == 1).sum())
print("리자몽:", df.loc[df["name"] == "charizard", "types"].iloc[0], "| 피카츄:", df.loc[df["name"] == "pikachu", "types"].iloc[0])
# 1:N을 행으로 풀면
long = df.explode("types")
print("explode 후 행 수:", len(long))
print(f"몸무게 합계: 원래 {df['weight'].sum() / 10:,.1f}kg → explode 후 {long['weight'].sum() / 10:,.1f}kg")
# 저장 후 다시 읽기
os.makedirs("poke", exist_ok=True)
df.to_csv("poke/gen1.csv", index=False)
df.to_json("poke/gen1.json", orient="records", force_ascii=False)
df.to_parquet("poke/gen1.parquet", index=False)
for f in ["gen1.csv", "gen1.json", "gen1.parquet"]:
print(f, f"{os.path.getsize('poke/' + f) / 1024:.1f} KB")
c = pd.read_csv("poke/gen1.csv")
q = pd.read_parquet("poke/gen1.parquet")
print("CSV로 다시 읽으면 | 피카츄 dex_no:", repr(c.loc[c["name"] == "pikachu", "dex_no"].iloc[0]), "| 이상해씨 types:", repr(c.loc[c["name"] == "bulbasaur", "types"].iloc[0]))
b = q.loc[q["name"] == "bulbasaur", "types"].iloc[0]
print("Parquet으로 다시 읽으면 | 피카츄 dex_no:", repr(q.loc[q["name"] == "pikachu", "dex_no"].iloc[0]), "| 이상해씨 types:", type(b).__name__, list(b))
참고 자료
- UCI Online Retail II: Chen, D. (2012). UCI Machine Learning Repository. https://doi.org/10.24432/C5CG6D (CC BY 4.0)
- PokeAPI: 포켓몬 데이터 공개 API (2026년 9월 29일 조회)
- Apache Parquet 공식 문서: 컬럼형 형식의 설계 배경
용량과 시간은 macOS, Python 3.9.6, pandas 2.3.3, pyarrow 21.0.0에서 측정했습니다. 시간은 환경에 따라 달라지므로 형식 간 상대적인 차이로 읽어 주세요.