[DuckDB] 06 Real Data Exercise
- DuckDB 06 - Real Data Exercise
- 종합 실습 — NYC Taxi 실제 데이터
DuckDB 06 - Real Data Exercise
Created: September 4, 2026 4:12 PM Class: DuckDB Jupyter Notebook: duckdb06.ipynb
종합 실습 — NYC Taxi 실제 데이터
실습 파일:
duckdb06.ipynb학습 로드맵 8단계(실습). 1~7단계에서 배운 것을 실제 공개 데이터 하나에 전부 적용합니다.
지금까지는 제가 만든 깨끗한 예제 데이터였습니다. 여기서는 진짜 데이터의 지저분함을 마주합니다 — 음수 요금, 2002년 날짜, 13분 만에 31만 마일을 달린 택시.
| 단계 | 내용 | 활용하는 앞선 학습 |
|---|---|---|
| 1 | 첫 탐색 | duckdb00(파일 직접 쿼리), duckdb01(DESCRIBE) |
| 2 | 데이터 품질 조사 | — (실제 데이터에서만 배울 수 있는 것) |
| 3 | 정제 규칙과 뷰 | duckdb01(SQL), duckdb02(View) |
| 4 | 분석 | duckdb01(JOIN, GROUP BY) |
| 5 | 성능 개선 | duckdb02(row group), duckdb04(EXPLAIN, zonemap) |
| 6 | 결과 내보내기 | duckdb02(COPY TO) |
데이터 출처: NYC TLC 공식 배포 — yellow_tripdata_2024-01.parquet(47.65MB, 296만 행), taxi_zone_lookup.csv(12KB, 265개 구역)
1. 첫 탐색
파일을 로드하지 않고 바로 조회합니다.
import duckdb
TRIPS = 'data/parquet/yellow_tripdata_2024-01.parquet'
ZONES = 'data/csv/taxi_zone_lookup.csv'
duckdb.sql(f"DESCRIBE SELECT * FROM '{TRIPS}'").show(max_rows=25)
┌───────────────────────┬─────────────┐
│ column_name │ column_type │
├───────────────────────┼─────────────┤
│ VendorID │ INTEGER │
│ tpep_pickup_datetime │ TIMESTAMP │
│ tpep_dropoff_datetime │ TIMESTAMP │
│ passenger_count │ BIGINT │
│ trip_distance │ DOUBLE │
│ RatecodeID │ BIGINT │
│ store_and_fwd_flag │ VARCHAR │
│ PULocationID │ INTEGER │
│ DOLocationID │ INTEGER │
│ payment_type │ BIGINT │
│ fare_amount │ DOUBLE │
│ ... (총 19개 컬럼) │ │
└───────────────────────┴─────────────┘
total_rows: 2,964,624
파일 크기: 47.65 MB
296만 행이 47.65MB입니다. CSV였다면 수백 MB였을 데이터 — duckdb02.md의 컬럼 지향 압축 효과입니다.
SUMMARIZE — 한 방에 전체 분포 보기
duckdb.sql(f"""
SUMMARIZE SELECT
trip_distance, fare_amount, tip_amount, total_amount, passenger_count
FROM '{TRIPS}'
""").show()
┌─────────────────┬─────────┬──────────┬───────────────┬────────┬─────────────────┐
│ column_name │ min │ max │ approx_unique │ q50 │ null_percentage │
├─────────────────┼─────────┼──────────┼───────────────┼────────┼─────────────────┤
│ trip_distance │ 0.0 │ 312722.3 │ 3376 │ 1.67 │ 0.00 │
│ fare_amount │ -899.0 │ 5000.0 │ 7091 │ 12.80 │ 0.00 │
│ tip_amount │ -80.0 │ 428.0 │ 3551 │ 2.70 │ 0.00 │
│ total_amount │ -900.0 │ 5000.0 │ 19162 │ 20.04 │ 0.00 │
│ passenger_count │ 0 │ 9 │ 11 │ 1 │ 4.73 │
└─────────────────┴─────────┴──────────┴───────────────┴────────┴─────────────────┘
여기서 이미 이상한 게 보입니다:
trip_distance최댓값 312,722 마일 — 달까지 거리보다 멉니다fare_amount최솟값 899달러,tip_amount80달러 — 음수passenger_count의 NULL이 4.73%- 여기서 짚고 넘어갈 부분:
SUMMARIZE는 처음 보는 데이터에서 가장 먼저 쳐야 할 명령입니다.min/max만 봐도 “이 데이터를 그냥 믿으면 안 되겠다”가 3초 만에 드러납니다. 참고로median(q50)은 12.8인데max가 5000인 걸 보면, 평균보다 중앙값과 분위수를 봐야 하는 데이터라는 것도 알 수 있습니다.
2. 데이터 품질 조사
“이 데이터를 믿어도 되는가”를 정량적으로 따지는 단계입니다. 이 단계를 건너뛰면 틀린 결론을 자신 있게 보고하게 됩니다.
2-1. 파일 이름을 믿지 마라
duckdb.sql(f"""
SELECT min(tpep_pickup_datetime) AS 가장_이른_승차,
max(tpep_pickup_datetime) AS 가장_늦은_승차
FROM '{TRIPS}'
""").show()
┌─────────────────────┬─────────────────────┐
│ 가장_이른_승차 │ 가장_늦은_승차 │
├─────────────────────┼─────────────────────┤
│ 2002-12-31 22:59:39 │ 2024-02-01 00:01:15 │
└─────────────────────┴─────────────────────┘
2024-01 파일에 2002년 12월 31일 데이터가 들어있습니다.
┌───────────┬───────────┬─────────┐
│ 기간_이전 │ 기간_이후 │ 전체 │
├───────────┼───────────┼─────────┤
│ 15 │ 3 │ 2964624 │
└───────────┴───────────┴─────────┘
296만 건 중 18건(0.0006%). 무시해도 될 것 같지만, min/max 같은 극값 집계는 이 18건이 통째로 망칩니다. 방금 본 “가장 이른 승차 = 2002년”이 그 예입니다.
2-2. 값의 유효성
┌─────────┬───────────┬──────────┬────────────┬─────────┬──────────────────────┐
│ 전체 │ 거리0이하 │ 요금음수 │ 승객수_NULL│ 승객수0 │ 하차가_승차보다_빠름 │
├─────────┼───────────┼──────────┼────────────┼─────────┼──────────────────────┤
│ 2964624 │ 60371 │ 37448 │ 140162 │ 31465 │ 870 │
└─────────┴───────────┴──────────┴────────────┴─────────┴──────────────────────┘
| 문제 | 건수 | 어떻게 볼 것인가 | | — | — | — | | 거리 0 이하 | 60,371 | 승차 직후 취소? 미터기 오류? — 제외 대상 | | 요금 음수 | 37,448 | 환불/조정 기록으로 추정 — 분석 목적에 따라 판단 | | 승객수 NULL | 140,162 | 미입력. 승객수 분석이 아니면 살려둘 수 있음 | | 하차 ≤ 승차 | 870 | 시간을 거스름 — 명백한 오류, 제외 |
- 여기서 짚고 넘어갈 부분: NULL과 이상치를 무조건 지우는 게 정답이 아닙니다. 승객수가 NULL이라고 그 행을 버리면, 요금 분석에 쓸 수 있었을 14만 건(전체의 4.7%)을 날리는 셈입니다. 어떤 컬럼을 쓸 분석인지에 따라 정제 규칙이 달라져야 합니다.
2-3. 극단적 이상치 직접 보기
┌─────────────────────┬─────────────────────┬────────┬───────────┬────────┐
│ 승차 │ 하차 │ 소요분 │ 거리_마일 │ 요금 │
├─────────────────────┼─────────────────────┼────────┼───────────┼────────┤
│ 2024-01-30 06:37:00 │ 2024-01-30 06:50:00 │ 13 │ 312722.3 │ 14.46 │
│ 2024-01-25 08:39:00 │ 2024-01-25 09:06:00 │ 27 │ 97793.92 │ 29.71 │
│ 2024-01-20 08:01:00 │ 2024-01-20 08:26:00 │ 25 │ 82015.45 │ 16.56 │
└─────────────────────┴─────────────────────┴────────┴───────────┴────────┘
13분 만에 312,722 마일(시속 약 144만 마일), 요금은 14.46달러.
- 여기서 짚고 넘어갈 부분: 요금이 정상 범위라는 게 힌트입니다. 거리 센서만 오작동했고 요금은 시간 기반으로 정상 계산된 것으로 보입니다. 즉 이 행은 통째로 버릴 게 아니라
trip_distance컬럼만 못 믿는 경우입니다. 이상치를 발견하면 “지운다/남긴다”를 바로 정하지 말고 왜 그런 값이 나왔는지 추측해보세요 — 원인에 따라 처리가 달라집니다.
3. 정제 규칙 세우고 뷰 만들기
중요한 건 규칙 자체보다 왜 그 규칙인지 기록해두는 것입니다.
duckdb.sql("DROP VIEW IF EXISTS trips")
duckdb.sql(f"""
CREATE VIEW trips AS
SELECT *,
date_diff('minute', tpep_pickup_datetime, tpep_dropoff_datetime) AS duration_min
FROM '{TRIPS}'
WHERE
-- 규칙 1: 분석 대상 기간(2024년 1월) 밖 데이터 제외
tpep_pickup_datetime >= TIMESTAMP '2024-01-01'
AND tpep_pickup_datetime < TIMESTAMP '2024-02-01'
-- 규칙 2: 하차가 승차보다 빠른 건 명백한 오류
AND tpep_dropoff_datetime > tpep_pickup_datetime
-- 규칙 3: 거리는 0 초과 100마일 미만
AND trip_distance > 0 AND trip_distance < 100
-- 규칙 4: 요금은 양수, 500달러 미만
AND fare_amount > 0 AND fare_amount < 500
""")
┌───────────┬───────────┬─────────┬───────────────┐
│ 정제_전 │ 정제_후 │ 제거됨 │ 유지율_퍼센트 │
├───────────┼───────────┼─────────┼───────────────┤
│ 2964624 │ 2869508 │ 95116 │ 96.79 │
└───────────┴───────────┴─────────┴───────────────┘
96.79%를 남기고 3.2%를 걸러냈습니다. 이 숫자는 반드시 확인해야 합니다:
- 유지율 99% 이상이면 규칙이 너무 느슨한 건 아닌지
- 유지율 90% 미만이면 정상 데이터까지 버리는 건 아닌지
3% 제거는 실제 운영 데이터에서 흔한 수준이고, 제거한 3%가 무엇이었는지 보고서에 적어두는 것이 실무 관행입니다.
4. 분석
4-1. 시간대별 수요와 요금
┌────────┬──────────┬──────────┬──────────┬────────────┐
│ 시간대 │ 운행건수 │ 평균요금 │ 평균거리 │ 평균소요분 │
├────────┼──────────┼──────────┼──────────┼────────────┤
│ 18 │ 206255 │ 17.01 │ 2.86 │ 15.0 │
│ 17 │ 200278 │ 18.12 │ 3.05 │ 16.8 │
│ 16 │ 184944 │ 19.46 │ 3.4 │ 17.8 │
│ 15 │ 183966 │ 19.11 │ 3.27 │ 17.6 │
│ 19 │ 178784 │ 17.63 │ 3.16 │ 14.5 │
└────────┴──────────┴──────────┴──────────┴────────────┘
운행이 가장 많은 시간은 18시(20.6만 건)인데, 평균 요금이 가장 높은 시간은 16시(19.46달러)입니다.
- 18시: 건수 최다 + 요금 17.01 + 소요 15.0분 → 짧은 퇴근 이동이 몰림
- 16시: 건수는 적지만 요금 19.46 + 소요 17.8분 → 더 긴 이동
“피크 시간”을 건수로 볼지 매출로 볼지에 따라 답이 달라집니다.
4-2. 요일별 패턴
┌───────────┬──────────┬──────────┬──────────┐
│ 요일 │ 운행건수 │ 평균거리 │ 평균요금 │
├───────────┼──────────┼──────────┼──────────┤
│ Wednesday │ 480773 │ 3.21 │ 18.45 │
│ Tuesday │ 449088 │ 3.26 │ 18.61 │
│ Thursday │ 416190 │ 3.22 │ 18.6 │
│ Saturday │ 407189 │ 2.99 │ 17.24 │
│ Friday │ 396363 │ 3.2 │ 18.19 │
│ Monday │ 393226 │ 3.7 │ 19.7 │
│ Sunday │ 326679 │ 3.55 │ 18.69 │
└───────────┴──────────┴──────────┴──────────┘
수요일 최다, 일요일 최소. 그런데 평균 거리는 월요일(3.7)·일요일(3.55)이 가장 깁니다 — 주중엔 짧은 업무 이동, 주말·월요일엔 공항 이동 같은 장거리가 상대적으로 많다는 해석이 가능합니다.
4-3. 지역 조인 — 스타 스키마 실전
duckdb01.md에서 개념으로 본 fact table(운행) + dimension table(지역) 구조가 그대로 있습니다.
duckdb.sql(f"""
SELECT z.Borough AS 자치구, z.Zone AS 승차지역,
count(*) AS 운행건수,
round(avg(t.fare_amount), 2) AS 평균요금,
round(avg(t.trip_distance), 2) AS 평균거리
FROM trips t
JOIN '{ZONES}' z ON t.PULocationID = z.LocationID
GROUP BY z.Borough, z.Zone
ORDER BY 운행건수 DESC
LIMIT 10
""").show()
┌───────────┬──────────────────────────────┬──────────┬──────────┬──────────┐
│ 자치구 │ 승차지역 │ 운행건수 │ 평균요금 │ 평균거리 │
├───────────┼──────────────────────────────┼──────────┼──────────┼──────────┤
│ Manhattan │ Midtown Center │ 140138 │ 15.5 │ 2.33 │
│ Manhattan │ Upper East Side South │ 140115 │ 12.38 │ 1.71 │
│ Queens │ JFK Airport │ 138416 │ 62.77 │ 15.84 │
│ Manhattan │ Upper East Side North │ 133961 │ 12.86 │ 1.87 │
│ Manhattan │ Midtown East │ 104342 │ 15.07 │ 2.26 │
│ Manhattan │ Times Sq/Theatre District │ 102958 │ 17.9 │ 2.97 │
│ Manhattan │ Penn Station/Madison Sq West │ 102151 │ 16.09 │ 2.3 │
│ Manhattan │ Lincoln Square East │ 101794 │ 13.62 │ 2.12 │
│ Queens │ LaGuardia Airport │ 87689 │ 42.33 │ 9.71 │
│ Manhattan │ Upper West Side South │ 86465 │ 13.61 │ 2.12 │
└───────────┴──────────────────────────────┴──────────┴──────────┴──────────┘
- 여기서 짚고 넘어갈 부분: JFK 공항은 건수 3위인데 평균 요금 62.77달러로 다른 지역(12~18달러)의 4~5배입니다. 오류가 아니라 뉴욕 택시의 JFK 정액요금 제도 때문입니다. LaGuardia(42.33달러)도 마찬가지로 높습니다. 데이터에서 튀는 값을 발견하면 도메인 규칙을 의심해봐야 하는 좋은 예입니다.
4-4. 결제수단별 팁 — 데이터 함정 ①
payment_type: 1=신용카드, 2=현금, 3=무료, 4=분쟁, 0=미상
┌──────────┬──────────┬────────┬───────────────┐
│ 결제수단 │ 운행건수 │ 평균팁 │ 요금대비_팁비율│
├──────────┼──────────┼────────┼───────────────┤
│ 1 │ 2298319 │ 4.15 │ 26.3 │
│ 2 │ 422701 │ 0.0 │ 0.0 │
│ 0 │ 115174 │ 1.84 │ 9.1 │
│ 4 │ 22756 │ 0.0 │ 0.0 │
│ 3 │ 10558 │ 0.01 │ 0.0 │
└──────────┴──────────┴────────┴───────────────┘
⚠️ 함정 ① — 0이 “값이 0”인가 “기록 안 됨”인가
현금 결제(2)의 평균 팁이 정확히 0원입니다. 42만 건 전부.
“뉴욕 승객은 현금으로 낼 때 팁을 안 준다”고 결론 내리면 틀립니다. 현금 팁은 기사에게 직접 건네므로 미터기에 기록되지 않을 뿐입니다.
결제수단을 구분하지 않고 전체 평균 팁을 냈다면, 실제보다 낮은 값이 나오고 그 원인이 “현금 결제 비중”이라는 걸 모른 채 보고하게 됩니다. 팁 분석은
payment_type = 1로 한정해야 합니다.
추가 발견 — 데이터 함정 ②: 비율의 평균 vs 총합의 비율
(노트북 실행 결과를 보다가 발견한 것으로, 노트북에는 아직 없는 내용입니다.)
함정 ①을 피해 신용카드만으로 시간대별 팁 비율을 냈더니 이런 결과가 나왔습니다:
┌────────┬──────────┬───────────────┐
│ 시간대 │ 운행건수 │ 팁비율_퍼센트 │
├────────┼──────────┼───────────────┤
│ 3 │ 17069 │ 49.0 │ ← 새벽 3시가 49%?
│ 8 │ 91100 │ 39.3 │
│ 18 │ 169182 │ 27.3 │
│ 19 │ 147916 │ 27.2 │
│ 17 │ 163652 │ 26.9 │
└────────┴──────────┴───────────────┘
새벽 3시의 팁 비율이 49%로 튑니다. “심야에 관대해지나?”라고 해석하기 전에 확인해봤습니다.
duckdb.sql("""
SELECT hour(tpep_pickup_datetime) AS 시간대, count(*) AS 건수,
round(avg(fare_amount), 2) AS 평균요금,
round(avg(tip_amount), 2) AS 평균팁,
round(100.0*avg(tip_amount/fare_amount), 1) AS 비율의_평균,
round(100.0*sum(tip_amount)/sum(fare_amount), 1) AS 총합의_비율
FROM trips WHERE payment_type = 1 AND fare_amount > 0
AND hour(tpep_pickup_datetime) IN (3, 8, 18)
GROUP BY 시간대 ORDER BY 시간대
""").show()
┌────────┬────────┬──────────┬────────┬─────────────┬─────────────┐
│ 시간대 │ 건수 │ 평균요금 │ 평균팁 │ 비율의_평균 │ 총합의_비율 │
├────────┼────────┼──────────┼────────┼─────────────┼─────────────┤
│ 3 │ 17069 │ 17.3 │ 3.81 │ 49.0 │ 22.0 │
│ 8 │ 91100 │ 17.45 │ 3.72 │ 39.3 │ 21.3 │
│ 18 │ 169182 │ 16.79 │ 4.09 │ 27.3 │ 24.4 │
└────────┴────────┴──────────┴────────┴─────────────┴─────────────┘
새벽 3시의 평균 요금(17.30)과 평균 팁(3.81)은 18시(16.79 / 4.09)와 거의 같습니다. 오히려 팁 금액은 더 적습니다. 그런데 비율만 49% vs 27%로 두 배 가까이 벌어집니다.
원인은 집계 방식이었습니다:
┌────────┬────────┬────────┬──────────┬──────────────────────┐
│ 중앙값 │ p90 │ p99 │ 최대 │ 팁이_요금보다_큰건수 │
├────────┼────────┼────────┼──────────┼──────────────────────┤
│ 26.5 │ 37.5 │ 60.4 │ 400000.0 │ 75 │
└────────┴────────┴────────┴──────────┴──────────────────────┘
실제 극단 사례:
┌────────┬────────┬────────────┬────────┐
│ 요금 │ 팁 │ 비율퍼센트 │ 거리 │
├────────┼────────┼────────────┼────────┤
│ 0.01 │ 40.0 │ 400000.0 │ 2.1 │ ← 요금 1센트에 팁 40달러
│ 5.1 │ 99.0 │ 1941.0 │ 0.53 │
│ 5.1 │ 80.0 │ 1569.0 │ 0.6 │
└────────┴────────┴────────────┴────────┘
- 중앙값은 26.5% — 18시(27.3%)와 사실상 같습니다. 심야에 팁을 더 주는 게 아니었습니다.
- 요금 $0.01에 팁 $40 같은 건이 비율 400,000%로 계산되어 평균을 통째로 끌어올렸습니다.
⚠️ 함정 ② —
avg(a/b)와sum(a)/sum(b)는 다른 질문에 답한다
집계 의미 특성 avg(tip/fare)평균적인 승객은 몇 % 팁을 주나 (운행마다 동일 가중치) 극단 비율에 취약 sum(tip)/sum(fare)전체 요금 대비 전체 팁은 몇 % 인가 (금액에 가중치) 이상치에 강건 둘 다 틀린 게 아니라 다른 질문입니다. 다만 비율 데이터에서
avg(a/b)를 쓸 땐 분모가 작은 행이 결과를 지배할 수 있으므로, 중앙값이나 총합 기준을 함께 확인해야 합니다.더 근본적인 교훈: 3장에서 세운 정제 규칙(
fare_amount > 0)이 이 분석에는 부족했습니다. 운행 건수를 세는 데는 충분했지만, 비율을 계산하려면 분모에 대한 규칙이 더 엄격해야 합니다(예:fare_amount >= 2.5— 뉴욕 택시 기본요금). 정제 규칙은 분석 목적마다 다시 점검해야 합니다.
5. 성능 개선 — 파일을 직접 고치기
duckdb04.md에서 배운 도구를 실제 파일에 적용합니다.
5-1. 이 파일에 통계(zonemap)가 있는가?
duckdb.sql(f"""
SELECT path_in_schema AS 컬럼명,
count(*) AS row_group수,
count(stats_min) AS 통계있는_row_group수
FROM parquet_metadata('{TRIPS}')
WHERE path_in_schema IN ('tpep_pickup_datetime', 'fare_amount', 'trip_distance')
GROUP BY 컬럼명
""").show()
┌──────────────────────┬─────────────┬──────────────────────┐
│ 컬럼명 │ row_group수 │ 통계있는_row_group수 │
├──────────────────────┼─────────────┼──────────────────────┤
│ fare_amount │ 3 │ 0 │
│ trip_distance │ 3 │ 0 │
│ tpep_pickup_datetime │ 3 │ 0 │
└──────────────────────┴─────────────┴──────────────────────┘
통계가 하나도 없습니다
19개 컬럼 전부
통계있는_row_group수가 0입니다. 이 파일을 만든 도구가 컬럼 통계를 기록하지 않았습니다.
duckdb04.md의 실습에서는 제가 DuckDB로 만든 파일이라 통계가 항상 있었습니다. 하지만 실제로 받는 파일은 이렇지 않을 수 있습니다.결과적으로 이 파일에서는 row group 프루닝이 원천적으로 불가능합니다. 날짜로 필터링해도 3개 row group을 전부 읽습니다.
교훈: “Parquet는 통계 기반 프루닝을 지원한다”는 건 포맷의 능력이지 모든 파일의 속성이 아닙니다. 남이 준 파일은
parquet_metadata()로 직접 확인해야 합니다.
5-2. 필터 pushdown은 되는가? (통계와 별개 문제)
┌───────────────────────────┐
│ PARQUET_SCAN │
│ ──────────────────── │
│ Projections: │
│ fare_amount │
│ │
│ Filters: │
│ tpep_pickup_datetime>= │
│ '2024-01-15 00:00:00' │
│ AND < 01-16 │
│ │
│ ~592,924 rows │
└───────────────────────────┘
Filters:가 스캔 안에 있으니 필터 pushdown 자체는 동작합니다.
- 필터 pushdown = 스캔하면서 거른다 → 통계 없어도 동작
- row group 프루닝 = 아예 안 읽는다 → 통계가 있어야 동작
이 둘은 다른 개념입니다. 지금 이 파일은 앞쪽만 되고 뒤쪽은 안 됩니다.
5-3. 직접 고쳐보기
duckdb02.md(row group 크기)와 duckdb04.md(정렬이 프루닝을 좌우함)에서 배운 걸 적용합니다.
duckdb.sql(f"""
COPY (
SELECT * FROM '{TRIPS}'
WHERE tpep_pickup_datetime >= TIMESTAMP '2024-01-01'
AND tpep_pickup_datetime < TIMESTAMP '2024-02-01'
ORDER BY tpep_pickup_datetime -- 필터 컬럼으로 정렬
) TO '{SORTED}' (FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 100000)
""")
재작성 시간: 0.75 초
원본: 47.65 MB
재작성: 38.97 MB
┌─────────────┬──────────────────────┐
│ row_group수 │ 통계있는_row_group수 │
├─────────────┼──────────────────────┤
│ 30 │ 30 │
└─────────────┴──────────────────────┘
┌───────────┬─────────────────────┬─────────────────────┐
│ row_group │ 최소_승차시각 │ 최대_승차시각 │
├───────────┼─────────────────────┼─────────────────────┤
│ 0 │ 2024-01-01 00:00:00 │ 2024-01-02 11:08:03 │
│ 1 │ 2024-01-02 11:08:03 │ 2024-01-03 15:40:39 │
│ 2 │ 2024-01-03 15:40:41 │ 2024-01-04 17:38:09 │
│ 3 │ 2024-01-04 17:38:10 │ 2024-01-05 16:53:30 │
└───────────┴─────────────────────┴─────────────────────┘
row group 30개 전부에 통계가 생겼고, 각 row group이 날짜순으로 깔끔하게 나뉘었습니다. 이제 “1월 15일치만” 필터를 걸면 해당 row group 1~2개만 읽고 나머지 28개는 건너뜁니다.
5-4. 개선 효과 측정
원본 (통계 없음, row group 3개) : 0.0159초
재작성 (통계 있음, row group 30개): 0.0010초
개선 배수: 15.9배
결과: 15.9배 빨라지고, 파일은 오히려 18% 작아졌습니다
원본 재작성 파일 크기 47.65 MB 38.97 MB row group 3개 30개 컬럼 통계 없음 있음 하루치 조회 0.0159초 0.0010초 참고: 개선 배수는 실행 환경과 OS 캐시 상태에 따라 달라집니다(제가 사전 검증했을 때는 3.5배였습니다). 절대 수치보다 “통계가 생기면 읽지 않아도 되는 row group이 생긴다”는 방향이 핵심입니다.
파일이 작아진 이유도 정렬 때문입니다. 컬럼 지향 포맷은 비슷한 값이 인접할수록 압축이 잘 됩니다 — 시각순 정렬로 델타가 작아지고 RLE 런이 길어진 결과입니다.
즉 정렬 한 번으로 속도·용량을 동시에 얻었습니다. 실무에서 큰 Parquet를 “받은 그대로 쓰지 말고 한 번 정리해서 저장”하는 이유입니다.
6. 결과 내보내기
# 1) 시간대별 요약 → CSV (사람이 열어볼 용도)
# 2) 지역별 요약 → Parquet (다음 분석 단계로 넘길 용도)
output/analysis/hourly_summary.csv: 516 bytes
output/analysis/zone_summary.parquet: 6,528 bytes
목적에 따라 포맷을 나눠 쓰는 게 실무 관행입니다 — 사람이 열어볼 건 CSV, 파이프라인으로 넘길 건 Parquet.
정리 — 8단계에서 무엇이 남았나
실제 데이터가 가르쳐준 것
앞의 1~7단계는 깨끗한 데이터로 문법과 도구를 배우는 과정이었습니다. 이번 단계에서만 배울 수 있었던 것:
- 파일 이름을 믿지 마라 —
2024-01파일에 2002년 데이터가 있었습니다 - 0과 NULL을 구분하라 — 현금 결제의 팁 0원은 “안 줬다”가 아니라 “기록 안 됐다”였습니다
- 비율의 평균을 조심하라 — 요금 $0.01 한 건이 시간대 전체의 팁 비율을 49%로 부풀렸습니다
- 튀는 값은 도메인 규칙을 의심하라 — JFK의 높은 평균요금은 오류가 아니라 정액요금제였습니다
- 이상치의 원인을 추측하라 — 31만 마일 기록은 행 전체가 아니라 거리 컬럼만 오류였습니다
- 포맷의 능력 ≠ 파일의 속성 — Parquet가 통계를 지원해도 그 파일엔 없을 수 있습니다
- 정제 규칙은 분석마다 재점검하라 — 건수 집계엔 충분했던 규칙이 비율 분석엔 부족했습니다
앞 단계들이 실제로 쓰인 지점
| 앞선 학습 | 여기서 쓰인 곳 |
|---|---|
duckdb00 파일 직접 쿼리 | 47MB Parquet을 로드 없이 바로 조회 |
duckdb01 JOIN, GROUP BY | 지역 조인(star schema), 시간대·요일 집계 |
duckdb02 View, row group, COPY TO | 정제 뷰, 재작성, 결과 내보내기 |
duckdb04 EXPLAIN, zonemap | 통계 부재 진단 → 재작성으로 15.9배 개선 |
다음에 할 것
- 여러 달로 확장: 파일명만 바꿔 12개월치를 받고
FROM 'data/parquet/yellow_tripdata_2024-*.parquet'glob 조회(duckdb02.md),filename가상 컬럼으로 월 구분 - GA4로 옮겨가기: 입사 후 실제
events_YYYYMMDD테이블에서duckdb05.md의events_flat뷰를 만드는 것부터 시작하면, 여기서 한 정제 → 분석 → 검증 흐름이 그대로 적용됩니다
로드맵 1~8단계를 모두 마쳤습니다. 수고하셨습니다.
