CHAPTER 06 · PARQUET & DATA LAKE
Parquet과 Data Lake
정상 주문을 파일로 저장해 다시 읽고, 컬럼·조건·날짜에 따라 읽을 범위를 구분합니다.
조회 결과를 저장하고 재사용하기
출처: 2026-bigdata-week06.pdf, 1–5쪽.
5장에서는 같은 주문을 DataFrame API와 SQL로 정제·집계하고 결과를 비교했습니다. 6장에서는 그 정제 결과를 Parquet으로 저장해 다시 읽습니다. Column Pruning, Predicate Pushdown, Partition Pruning이 파일 읽기에 어떤 차이를 만드는지 확인하고 Data Lake의 기본 구성을 배웁니다.
CSV는 구분자로 값을 나눈 텍스트입니다. 헤더의 price라는 이름만으로 decimal(12,2) 자료형이 정해지지 않으므로 읽을 때 Schema를 지정합니다. 필요한 컬럼만 선택하면 불필요한 값의 자료형 변환은 줄일 수 있지만, 같은 줄에 있는 불필요한 컬럼의 파일 바이트까지 읽지 않는다는 뜻은 아닙니다.
정상 주문과 amount를 저장하면 다음 프로그램이 정제와 금액 계산을 반복하지 않아도 됩니다. 이런 결과 재사용은 CSV로도 가능하며, Parquet의 컬럼 기반 저장 이점과는 구분합니다. 원본은 정제 기준 변경과 오류 조사에 사용합니다.
보조 비유: 원본 영수증을 매번 다시 계산하는 대신 정리한 장부를 보관합니다. 장부를 재사용하는 것과 필요한 항목만 빨리 찾는 저장 방식은 별개의 이점입니다.
정제 결과를 재사용할 수 있는 이유는 무엇인가요?
Parquet 파일의 내부 구조
출처: 2026-bigdata-week06.pdf, 6–9쪽.
Parquet은 Schema를 포함하는 분석용 컬럼 기반 파일 형식입니다. 행별 값이 한 줄에 이어지는 CSV와 달리, 같은 컬럼의 값을 모아 저장해 필요 없는 컬럼의 읽기·압축 해제·해석을 줄일 수 있습니다. 실제 시간은 저장장치, 전송량, 요청 수, 압축과 캐시에 따라 달라집니다.
| 구조 | 의미 |
|---|---|
| Row Group | 파일 안에서 같은 행 범위에 속하는 컬럼 묶음입니다. Spark 실행 Partition과 다릅니다. |
| Column Chunk | 한 Row Group의 한 컬럼을 담은 연속 바이트 구간입니다. Page 헤더와 데이터를 포함합니다. |
| Data Page | Column Chunk 안에서 일부 값과 헤더를 담는 단위입니다. 디스크 섹터나 OS 메모리 페이지가 아닙니다. |
| File Metadata | Schema, Chunk의 파일 내 시작 위치·길이, 행 수와 통계 등을 기록합니다. |
File
Row Group 0 (rows 1–3)
order_id Column Chunk -> Data Pages
price Column Chunk -> Data Pages
Row Group 1 (rows 4–6)
order_id Column Chunk -> Data Pages
price Column Chunk -> Data Pages
File Metadata
위 구조는 원본 7–8쪽의 6행·두 컬럼 예시를 텍스트로 옮긴 것입니다. Row Group이 두 개이면 order_id의 Column Chunk도 두 개입니다. Metadata의 위치는 디스크 주소가 아니라 파일 안의 바이트 위치입니다.
인코딩은 값을 표현하는 방법이고 압축은 바이트 수를 줄이는 방법입니다. Dictionary Encoding은 반복 문자열을 작은 번호에 대응시키며, Run-Length Encoding은 반복 값과 횟수 등으로 표현합니다. Snappy 등은 인코딩한 데이터를 압축합니다. uncompressed도 Parquet 인코딩이 없다는 뜻은 아닙니다. 압축 해제에는 CPU 비용이 있어 압축률만으로 속도를 판단하지 않습니다.
Row Group 두 개는 Worker 두 개를 뜻하나요?
저장 모드와 값 보존 확인
출처: 2026-bigdata-week06.pdf, 10–24쪽 · 제공 apps/week06_parquet.py.
정상 주문 9건과 amount를 저장합니다. write는 DataFrameWriter를 만들고 mode와 option은 저장 조건을 지정합니다. parquet(path)는 실제 읽기·정제·계산·기록을 수행하는 Action입니다. 경로는 단일 파일명이 아니라 결과 디렉터리입니다.
clean_orders.write.mode("overwrite").option("compression", "snappy").parquet(clean_path)
parquet_orders = spark.read.parquet(clean_path)
parquet_orders.printSchema()
| mode | 기존 경로가 있을 때 | 사용 상황 |
|---|---|---|
| error / errorifexists | 오류가 납니다. 기본 모드입니다. | 기존 결과를 실수로 덮어쓰지 않아야 할 때 |
| overwrite | 대상 경로의 기존 결과를 교체합니다. 데이터베이스 트랜잭션과는 별개입니다. | 같은 범위의 결과를 다시 계산해 교체할 때 |
| append | 기존 파일을 유지하고 새 파일을 추가합니다. 갱신·중복 제거는 하지 않습니다. | 기존 결과에 새로운 행들을 누적할 때 |
| ignore | 새 DataFrame을 쓰지 않고 기존 결과를 유지합니다. | 이미 만들어 둔 결과를 그대로 유지할 때 |
경로가 없다면 네 모드 모두 새로 저장합니다. 강의 전체 예제의 mode_demo는 1행 저장 → ignore 후 1행 유지 → error의 충돌 오류 확인 → 9행 append 후 10행 → overwrite 후 9행 순서입니다. 단계별 실습의 append_demo와는 다른 경로입니다.
compression에는 snappy, gzip, zstd, uncompressed 등을 지정합니다. snappy는 Spark 4.1.3 Parquet 쓰기의 기본값입니다. maxRecordsPerFile=4는 파일당 최대 네 행이며 파일 수나 바이트 크기를 지정하지 않습니다. 원본 20쪽의 [4, 4, 1]은 원본에 실린 관찰 결과입니다. 같은 파일 수나 배치를 매 실행의 고정값으로 사용하지 않습니다.
part-...parquet는 데이터 파일이고 _SUCCESS는 저장 완료 표시입니다. Worker 수와 파일 수를 같다고 보지 않습니다. 파일 수는 쓰기 Partition, 디렉터리 구분, 파일당 행 수 제한 등의 영향을 받습니다. 저장된 Schema로 다시 읽으면 price는 decimal(12,2), amount는 decimal(23,2)입니다. 행 순서는 orderBy로 지정합니다.
columns = clean_orders.columns
before = clean_orders.select(*columns).orderBy("order_id").collect()
after = parquet_orders.select(*columns).orderBy("order_id").collect()
assert before == after
assert len(after) == 9
위 코드는 원본 23쪽의 비교를 짧게 쓴 해설용 발췌입니다. assert는 조건이 거짓이면 AssertionError를 냅니다. collect는 크기를 아는 9행 비교에만 사용하며 대용량 전체를 Driver로 모으는 방법으로 확대하지 않습니다. 읽은 뒤의 select·filter·groupBy·agg와 SQL은 CSV 때와 같은 방식으로 사용합니다.
append는 같은 주문 번호를 어떻게 처리하나요?
필요한 컬럼만 읽기
출처: 2026-bigdata-week06.pdf, 25–26쪽.
Column Pruning은 조회에 필요 없는 데이터 컬럼을 파일 읽기 대상에서 제외하는 최적화입니다. DataFrame을 만든다는 것은 전체 행을 메모리에 저장한다는 뜻이 아닙니다. 파일 위치와 Schema를 확인한 다음 select로 조회를 정의하고, explain으로 방법을 확인하며 show 등으로 결과를 요청합니다.
projected = parquet_orders.select("user_id", "amount")
projected.explain(mode="formatted")
projected.orderBy("user_id", "amount").show(truncate=False)
Parquet Scan의 ReadSchema에 user_id와 amount만 있는지 확인합니다. 최종 출력에 없는 필터·정렬 컬럼은 추가로 필요할 수 있고 파일 Metadata도 읽습니다. explain은 결과 행을 수집하지 않습니다. 이 예제에는 cache가 없으므로 앞선 show의 결과가 다음 조회에 자동 보관되지 않습니다.
select를 호출한 직후 전체 9행이 메모리에 저장되나요?
읽기 단계에 조건 전달하기
출처: 2026-bigdata-week06.pdf, 27–29쪽.
Predicate는 amount >= 30000 같은 행 선택 조건입니다. Predicate Pushdown은 이 조건을 데이터 읽기 단계로 전달합니다. 예를 들어 가상 Row Group A의 금액 범위가 1000~10000이면 조건을 만족할 수 없어 생략할 수 있습니다. 범위가 24000~35000인 B는 읽고 행별로 추가 검사해야 합니다.
high_orders = (parquet_orders.filter(F.col("amount") >= 30000)
.select("order_id", "user_id", "amount"))
high_orders.explain(mode="formatted")
parquet_orders.createOrReplaceTempView("orders_parquet")
sql_high_orders = spark.sql("""
SELECT order_id, user_id, amount FROM orders_parquet
WHERE amount >= 30000
""")
PushedFilters에서 GreaterThanOrEqual(amount,30000.00)과 같은 조건 전달 표시를 봅니다. DataFrame과 SQL의 예상 결과는 주문 2·5·8입니다. 조건이 표시되어도 실제로 생략 가능한 Row Group이 없을 수 있습니다. 작은 입력의 실행 시간이 줄었다거나 모든 파일을 건너뛰었다고 단정하지 않습니다.
PushedFilters만 있으면 읽기 생략과 가속이 보장되나요?
날짜별 저장과 Partition Pruning
출처: 2026-bigdata-week06.pdf, 30–41쪽 · 제공 apps/week06_parquet.py.
이 절의 입력은 정상 주문 9건과 별개로 코드에서 만든 주문 101~104의 4행입니다. 날짜는 2026-09-01과 2026-09-02 두 개입니다. partitionBy("event_date")는 같은 날짜의 행을 event_date=날짜 디렉터리 아래 저장합니다.
partition_input.write.mode("overwrite").partitionBy("event_date").parquet(partitioned_path)
single_file_orders = spark.read.parquet(str(one_file))
partitioned_orders = spark.read.parquet(partitioned_path)
새 개별 파일에는 order_id·user_id·amount 세 컬럼이 있고 event_date는 디렉터리 이름에 있습니다. 개별 파일을 읽는 위 호출에는 날짜 컬럼이 없지만, 두 날짜 폴더를 포함하는 최상위 partition_demo를 읽으면 경로에서 날짜를 얻어 네 컬럼으로 조회합니다. 입력 DataFrame에서 날짜가 삭제된 것은 아닙니다. 파일과 디렉터리 구조를 함께 관리합니다.
F.col과 F.lit
literal_demo = partitioned_orders.select(
"order_id", F.col("event_date"),
F.lit("2026-09-01").alias("fixed_text"),
F.lit(1).alias("fixed_number"))
daily_high_orders = partitioned_orders.filter(
(F.col("event_date") == F.to_date(F.lit("2026-09-01")))
& (F.col("amount") >= 30000)
).select("order_id", "user_id", "amount")
daily_high_orders.explain(mode="formatted")
F.col은 현재 컬럼을 참조하고 F.lit은 코드에 적은 값을 Column 표현식으로 만듭니다. lit은 숫자 변환 함수가 아닙니다. 위 fixed_text는 모든 행에 같은 문자열, fixed_number는 정수 1이며 원래 event_date는 유지됩니다. to_date는 문자열 표현식을 날짜로 변환합니다. 이 예제의 단순 등호 비교에서는 F.col("event_date") == "2026-09-01"처럼 lit을 생략해도 같은 결과입니다.
날짜가 9월 1일이고 금액이 30000 이상인 주문은 102 한 건입니다. SQL에서는 WHERE event_date = DATE '2026-09-01' AND amount >= 30000으로 같은 조건을 씁니다.
| 계획 항목 | 하는 일 |
|---|---|
| PartitionFilters | 날짜 디렉터리 선택: 9월 2일 파일을 조회 범위에서 제외합니다. |
| PushedFilters | 선택한 파일의 읽기 단계에 amount 조건을 전달합니다. |
| ReadSchema | 파일 안에서 order_id·user_id·amount를 읽습니다. event_date는 경로에서 얻습니다. |
Partition Pruning은 저장 파티션 조건으로 읽을 파일 범위를 줄입니다. Spark 실행 Partition은 한 Task가 처리하는 데이터 구분이며 날짜 디렉터리와 다릅니다. 날짜 디렉터리 두 개가 Task 두 개나 날짜별 Worker 전용 배정을 뜻하지 않습니다.
event_date가 ReadSchema에 없는 이유는 무엇인가요?
파티션 컬럼과 작은 파일
출처: 2026-bigdata-week06.pdf, 42–46쪽.
Cardinality는 서로 다른 값의 개수입니다. 주문 번호처럼 거의 모든 행마다 다른 값을 기준으로 디렉터리를 나누면 작은 파일이 많아집니다. 조회에 자주 쓰는 조건인지, 디렉터리마다 충분한 데이터가 있는지, 특정 값에 쏠리지 않는지를 함께 판단합니다.
Small File Problem은 파일 목록 조회·열기·Metadata 처리·Task 준비 비용이 커지는 문제입니다. 너무 세밀한 디렉터리 구분, 소량의 반복 append, 데이터 양에 비해 많은 쓰기 Task가 원인이 될 수 있습니다. Worker를 늘리는 것만으로 저장 구분의 문제를 해결하지는 못합니다.
일반 Parquet append는 기존 파일 끝에 행을 이어 쓰는 방식이 아니라 새 파일을 추가합니다. 정상 9행에 주문 1을 추가하면 10행이고 주문 1은 두 건입니다. 원본 파일은 유지됩니다. 주문 번호로 partitionBy한 반례는 9행에 날짜가 아닌 주문별 디렉터리 9개와 데이터 파일 9개를 만듭니다.
대응 방법은 조회와 데이터 양에 맞는 컬럼 선택, 쓰기 주기 조절, 쓰기 병렬도 점검, Compaction입니다. Compaction은 여러 작은 파일을 더 큰 파일들로 다시 저장하는 작업입니다. 무조건 파일 하나나 Task 하나로 줄이는 방식은 대용량 처리의 일반 해법이 아닙니다.
주문 번호별 partitionBy의 문제는 무엇인가요?
Data Lake와 객체 스토리지
출처: 2026-bigdata-week06.pdf, 47–50쪽.
Data Lake는 여러 시스템의 원본·정제 데이터를 공통 저장소에 보관해 분석에 사용하는 방식입니다. 이 실습은 orders.csv 원본 → Spark 정제와 amount 계산 → Parquet 정상 9행 → 다른 프로그램의 재조회 흐름입니다. 저장소는 호스트 공유 폴더이고, 파일 형식은 CSV·Parquet이며, 처리 엔진은 Spark입니다.
객체 스토리지는 바이트 내용과 Key·Metadata를 함께 보관합니다. Bucket은 객체를 담는 저장 공간이고 Key는 그 안의 객체 이름입니다. clean/orders/event_date=2026-09-01/part-00000.parquet 같은 Key로 파일 내용을 저장·요청합니다. 파일 두 개를 각각 올리면 보통 데이터 객체 두 개이며, 주문 한 행이나 Python 객체를 뜻하지 않습니다. CSV·JSON·이미지도 저장할 수 있습니다.
실습의 Windows output 연결은 로컬 파일시스템이며 객체 스토리지가 아닙니다. 여러 물리 서버의 로컬 폴더는 자동 공유되지 않으므로 공통 접근 저장소가 필요합니다. 보조 비유로 저장소는 보관 장소, Parquet은 장부 형식, Spark는 장부를 처리하는 도구입니다.
| 형식 | 구조와 용도 |
|---|---|
| CSV | 별도 Schema가 필요한 평면 텍스트 표 교환 |
| JSON | 필드 이름·객체·배열을 표현하는 API 응답과 이벤트 원본 |
| Parquet | Schema·컬럼별 데이터·중첩 구조를 지원하는 반복 분석 |
Catalog는 테이블 이름·Schema·데이터 위치 같은 정보를 관리합니다. 일반 Parquet 파일 모음만으로 동시 쓰기의 일관성, 중복 없는 재실행, 특정 주문의 수정·삭제, 과거 상태 조회가 자동 해결되지는 않습니다. Parquet은 파일 형식이며 트랜잭션·스냅샷을 관리하는 완성된 테이블 시스템이 아닙니다. 원본은 Apache Iceberg 같은 테이블 형식을 후속 학습으로 소개합니다.
Parquet과 Spark의 역할은 무엇인가요?
실습 준비와 전체 코드
출처: 2026-bigdata-week06.pdf, 10–11쪽 · 제공 README.md·실습절차서.md·compose.yaml·apps/.
실습 준비 → 실행 절차 → 코드 해설 → 입력 데이터 → 예상 결과 → 연습문제 순서로 진행합니다. Windows PowerShell과 Docker Desktop을 사용합니다. 이미 준비한 환경은 다시 설치하지 말고 docker version에서 Client·Server 응답을 확인합니다.
실습 ZIP은 week06-lab 폴더 하나로 풀립니다. 그 안에 compose.yaml이 있는 위치가 모든 명령의 기준 폴더입니다. 기존 폴더가 있다면 작성 파일을 따로 보관하고 새 위치에 풉니다.
week06-lab/
README.md
실습절차서.md
EXERCISES.md
compose.yaml
apps/
data/
output/
| 파일·다운로드 | 역할 |
|---|---|
README.md | 실습 파일의 전체 안내 |
실습절차서.md | 준비·단계별 명령·확인 기준 |
EXERCISES.md | 연습문제 요구사항과 제출물 |
compose.yaml | Master·Worker·Client와 공유 경로 |
apps/week06_lab.py | 8단계 실행·assert 검사·종료 처리 |
apps/run_week06.sh | 단계별 프로그램의 spark-submit 옵션 |
apps/week06_parquet.py | 원본 강의 전체 예제; 별도의 week06 경로에 저장 |
apps/exercise_support.py | 입력 읽기·세션·결과 검사; 수정 대상 아님 |
apps/run_exercise.sh | 작성한 연습문제 파일 제출·실행 |
apps/exercise01_store_sales.py | 문제 1의 세 함수를 작성할 파일 |
apps/exercise02_inventory_append.py | 문제 2의 네 함수를 작성할 파일 |
data/orders.csv | 주문 입력 12행 |
data/store_sales.csv | 문제 1 매출 입력 12행 |
data/inventory_initial.csv | 문제 2 최초 재고 변동 6행 |
data/inventory_incoming.csv | 문제 2 추가 재고 변동 4행 |
제공된 output 폴더는 비어 있습니다. 미리 실행한 Parquet 결과가 들어 있는 것이 아닙니다. 실행 후 생성되는 결과는 호스트 output에 남고 docker compose down으로 컨테이너를 종료해도 유지됩니다.
Full Source Code
아래는 제공된 환경 파일과 Python·셸 파일의 전체 코드입니다. 각 파일을 펼쳐 읽거나 위 파일명으로 내려받습니다. 연습문제 파일에는 미구현 함수만 있으며 작성 전 NotImplementedError는 정상입니다.
compose.yaml
name: bigdata-week06
x-spark: &spark
image: spark:4.1.3-scala2.13-java21-python3-ubuntu
environment:
PYSPARK_PYTHON: python3
init: true
stop_grace_period: 30s
services:
spark-master:
<<: *spark
hostname: spark-master
entrypoint: ["/opt/spark/bin/spark-class"]
command:
- org.apache.spark.deploy.master.Master
- --host
- spark-master
- --port
- "7077"
- --webui-port
- "8080"
ports:
- "127.0.0.1:18080:8080"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080', timeout=3)"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
spark-worker:
<<: *spark
entrypoint: ["/bin/bash", "-lc"]
command:
- >-
exec /opt/spark/bin/spark-class
org.apache.spark.deploy.worker.Worker
--host "$$HOSTNAME"
--cores 1
--memory 1g
--webui-port 8081
--work-dir /tmp/spark-work
spark://spark-master:7077
volumes:
- ./data:/opt/spark/data:ro
- ./output:/opt/spark/output
depends_on:
spark-master:
condition: service_healthy
spark-client:
<<: *spark
hostname: spark-client
entrypoint: ["/bin/bash", "-lc"]
command: ["exec sleep infinity"]
ports:
- "127.0.0.1:24040:4040"
volumes:
- ./apps:/opt/spark/apps:ro
- ./data:/opt/spark/data:ro
- ./output:/opt/spark/output
depends_on:
spark-master:
condition: service_healthy
apps/week06_lab.py
import argparse
import hashlib
import time
from datetime import date
from decimal import Decimal
from pathlib import Path
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import (
DecimalType, IntegerType, LongType, StringType,
StructField, StructType, TimestampType,
)
ROOT = Path("/opt/spark/output/week06-lab")
CLEAN = ROOT / "orders_clean"
SCHEMA = StructType([
StructField("order_id", LongType(), True),
StructField("user_id", StringType(), True),
StructField("product_id", StringType(), True),
StructField("quantity", IntegerType(), True),
StructField("price", DecimalType(12, 2), True),
StructField("event_time", TimestampType(), True),
])
def read_csv(spark):
return (spark.read.option("header", True)
.option("timestampFormat", "yyyy-MM-dd HH:mm:ss")
.schema(SCHEMA).csv("/opt/spark/data/orders.csv"))
def normal_orders(raw):
return raw.filter(
F.col("quantity").isNotNull() & F.col("price").isNotNull()
& (F.col("quantity") > 0) & (F.col("price") >= 0)
).withColumn("amount", F.col("quantity") * F.col("price"))
def saved_orders(spark):
if not CLEAN.is_dir():
raise RuntimeError("Run --step clean before this step")
return spark.read.parquet(str(CLEAN))
def clean(spark):
raw = read_csv(spark)
valid = normal_orders(raw)
# CSV input is unchanged; replace only this lab's output directory.
valid.write.mode("overwrite").option("compression", "snappy").parquet(str(CLEAN))
saved = spark.read.parquet(str(CLEAN))
assert raw.count() == 12 and saved.count() == 9
assert valid.orderBy("order_id").collect() == saved.orderBy("order_id").collect()
print("RAW_ROWS=12\nVALID_ROWS=9\nSAVED_ROWS=9", flush=True)
print("OUTPUT=" + str(CLEAN), flush=True)
def inspect(spark):
saved = saved_orders(spark)
saved.printSchema()
saved.select("order_id", "user_id", "amount").orderBy("order_id").show(truncate=False)
print("DATA_FILES=" + str(len(list(CLEAN.glob("part-*.parquet")))), flush=True)
assert saved.schema["amount"].dataType.simpleString() == "decimal(23,2)"
assert [r.order_id for r in saved.orderBy("order_id").collect()] == [1, 2, 3, 5, 7, 8, 10, 11, 12]
print("SCHEMA_AND_VALUES=PASS", flush=True)
def projection(spark):
selected = saved_orders(spark).select("user_id", "amount")
# explain prints a plan; show triggers result calculation.
selected.explain(mode="formatted")
selected.orderBy("user_id", "amount").show(truncate=False)
assert selected.count() == 9
print("SELECTED_COLUMNS=user_id,amount", flush=True)
def pushdown(spark):
selected = saved_orders(spark).filter(F.col("amount") >= 30000).select("order_id", "user_id", "amount")
selected.explain(mode="formatted")
selected.orderBy("order_id").show(truncate=False)
assert [r.order_id for r in selected.orderBy("order_id").collect()] == [2, 5, 8]
print("HIGH_ORDER_IDS=2,5,8", flush=True)
def partition(spark):
# Independent four-row example from lecture chapter 10.
original = spark.createDataFrame([
(101, "U001", Decimal("24000.00"), date(2026, 9, 1)),
(102, "U002", Decimal("35000.00"), date(2026, 9, 1)),
(103, "U003", Decimal("20000.00"), date(2026, 9, 2)),
(104, "U001", Decimal("35000.00"), date(2026, 9, 2)),
], "order_id long, user_id string, amount decimal(12,2), event_date date")
path = ROOT / "partition_demo"
original.write.mode("overwrite").partitionBy("event_date").parquet(str(path))
for file in sorted(path.rglob("*.parquet")):
print("FILE=" + str(file.relative_to(path)), flush=True)
one_file = sorted((path / "event_date=2026-09-01").glob("*.parquet"))[0]
inside_file = spark.read.parquet(str(one_file))
saved = spark.read.parquet(str(path))
print("SINGLE_FILE_COLUMNS=" + ",".join(inside_file.columns), flush=True)
print("DATASET_COLUMNS=" + ",".join(saved.columns), flush=True)
saved.orderBy("order_id").show(truncate=False)
selected = saved.filter(
(F.col("event_date") == "2026-09-01") & (F.col("amount") >= 30000)
).select("order_id", "user_id", "amount")
selected.explain(mode="formatted")
selected.show(truncate=False)
assert "event_date" not in inside_file.columns and "event_date" in saved.columns
assert saved.select(*original.columns).orderBy("order_id").collect() == original.orderBy("order_id").collect()
assert [r.order_id for r in selected.collect()] == [102]
assert len(list(path.glob("event_date=*"))) == 2
print("DATE_DIRECTORIES=2\nPARTITION_ROWS=4\nDAILY_HIGH_ORDER_IDS=102", flush=True)
def append(spark):
valid = saved_orders(spark)
path = ROOT / "append_demo"
# Reset on each invocation, then append one duplicate order deliberately.
valid.write.mode("overwrite").parquet(str(path))
before = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in path.glob("part-*.parquet")}
print("BEFORE_FILES=" + str(len(before)) + "\nBEFORE_ROWS=9", flush=True)
valid.filter(F.col("order_id") == 1).write.mode("append").parquet(str(path))
after = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in path.glob("part-*.parquet")}
saved = spark.read.parquet(str(path))
assert saved.count() == 10 and saved.filter(F.col("order_id") == 1).count() == 2
assert len(after) > len(before) and all(after.get(name) == value for name, value in before.items())
print("AFTER_FILES=" + str(len(after)) + "\nAFTER_ROWS=10\nORDER_1_COUNT=2", flush=True)
print("ORIGINAL_FILES_UNCHANGED=True", flush=True)
def options(spark):
valid = saved_orders(spark)
path = ROOT / "file_limit_demo"
valid.write.mode("overwrite").option("compression", "gzip").option("maxRecordsPerFile", 4).parquet(str(path))
files = sorted(path.glob("part-*.parquet"))
counts = [spark.read.parquet(str(file)).count() for file in files]
for file in files:
print("FILE=" + file.name, flush=True)
assert sum(counts) == 9 and all(0 < n <= 4 for n in counts)
print("ROWS_PER_FILE=" + ",".join(str(n) for n in counts), flush=True)
print("TOTAL_ROWS=9", flush=True)
def small_files(spark):
path = ROOT / "tiny_files_demo"
saved_orders(spark).write.mode("overwrite").partitionBy("order_id").parquet(str(path))
directory_count = len(list(path.glob("order_id=*")))
file_count = len(list(path.rglob("*.parquet")))
assert directory_count == 9 and file_count == 9
print(f"ORDER_DIRECTORIES={directory_count}\nPARQUET_FILES={file_count}", flush=True)
STEPS = {"clean": clean, "inspect": inspect, "projection": projection, "pushdown": pushdown,
"partition": partition, "append": append, "options": options, "small-files": small_files}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--step", choices=[*STEPS, "all"], default="all")
parser.add_argument("--wait-seconds", type=int, default=0)
args = parser.parse_args()
if args.wait_seconds < 0:
parser.error("--wait-seconds must be zero or positive")
spark = SparkSession.builder.appName("Week06-Lab-" + args.step).getOrCreate()
spark.sparkContext.setLogLevel("WARN")
spark.conf.set("spark.sql.session.timeZone", "Asia/Seoul")
try:
for name in STEPS if args.step == "all" else [args.step]:
print("STEP=" + name, flush=True)
STEPS[name](spark)
print("STEP_CHECK=" + name + ",PASS", flush=True)
print("CHECK=PASS", flush=True)
print("UI observation: " + str(args.wait_seconds) + " seconds", flush=True)
time.sleep(args.wait_seconds)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
if __name__ == "__main__":
main()
apps/run_week06.sh
#!/usr/bin/env bash
set -euo pipefail
exec /opt/spark/bin/spark-submit \
--master spark://spark-master:7077 \
--deploy-mode client \
--driver-memory 512m \
--executor-memory 512m \
--executor-cores 1 \
--total-executor-cores 2 \
--conf spark.dynamicAllocation.enabled=false \
--conf spark.driver.host=spark-client \
--conf spark.driver.bindAddress=0.0.0.0 \
--conf spark.driver.port=39000 \
--conf spark.blockManager.port=39001 \
/opt/spark/apps/week06_lab.py "$@"
apps/week06_parquet.py
# DataFrame과 SQL을 사용할 SparkSession 클래스
from pyspark.sql import SparkSession
# 컬럼 계산과 집계에 사용할 내장 함수
from pyspark.sql import functions as F
# CSV 컬럼의 자료형을 지정할 클래스
from pyspark.sql.types import (
DecimalType, IntegerType, LongType, StringType,
StructField, StructType, TimestampType,
)
# Master 주소 등 실행 옵션은 기존처럼 spark-submit에서 지정
spark = SparkSession.builder.appName("Week06-Parquet").getOrCreate()
# 출력에서 INFO 로그를 줄이고 경고·오류는 유지
spark.sparkContext.setLogLevel("WARN")
# 날짜 변환의 기준 시간대를 명시
spark.conf.set("spark.sql.session.timeZone", "Asia/Seoul")
# CSV의 컬럼 이름·자료형 지정; NULL과 잘못된 값은 아래 품질 조건에서 검사
order_schema = StructType([
StructField("order_id", LongType(), True),
StructField("user_id", StringType(), True),
StructField("product_id", StringType(), True),
StructField("quantity", IntegerType(), True),
StructField("price", DecimalType(12, 2), True),
StructField("event_time", TimestampType(), True),
])
# 헤더와 시각 형식을 지정하고 CSV를 읽을 DataFrame 정의
orders = (
spark.read
.option("header", True)
.option("timestampFormat", "yyyy-MM-dd HH:mm:ss")
.schema(order_schema)
.csv("/opt/spark/data/orders.csv")
)
# 수량·가격이 있고, 수량이 양수이며 가격이 음수가 아닌 주문만 선택
valid_orders = orders.filter(
F.col("quantity").isNotNull()
& F.col("price").isNotNull()
& (F.col("quantity") > 0)
& (F.col("price") >= 0)
)
# 주문 금액을 계산한 컬럼 추가; 원본 orders는 변경하지 않음
clean_orders = valid_orders.withColumn(
"amount", F.col("quantity") * F.col("price")
)
# 정제 결과의 주문 번호·수량·가격·금액을 실제로 계산해 출력
clean_orders.select(
"order_id", "user_id", "quantity", "price", "amount"
).orderBy("order_id").show(truncate=False)
# 이번 주 예제 전용 출력 디렉터리
clean_path = "/opt/spark/output/week06/orders_clean"
# 정상 주문과 계산한 amount를 Snappy 압축 Parquet으로 저장
(
clean_orders.write
# 해당 출력 경로에 기존 결과가 있으면 교체; 원본 CSV는 대상이 아님
.mode("overwrite")
# 사용할 압축 방식 지정
.option("compression", "snappy")
# Action: 실제 입력 읽기·정제·금액 계산·파일 기록 수행
.parquet(clean_path)
)
# 예상한 저장 경로 충돌 오류만 처리할 PySpark 예외 클래스
from pyspark.errors import AnalysisException
# 저장 모드 실험 전용 경로
mode_path = "/opt/spark/output/week06/mode_demo"
# 주문 1 한 건만 먼저 저장
clean_orders.filter(F.col("order_id") == 1).write.mode("overwrite").parquet(mode_path)
print("INITIAL_ROWS=", spark.read.parquet(mode_path).count(), sep="")
# 9건을 저장하려 해도 기존 경로가 있으므로 저장 생략; 기존 1건 유지
clean_orders.write.mode("ignore").parquet(mode_path)
print("AFTER_IGNORE=", spark.read.parquet(mode_path).count(), sep="")
try:
# 기존 경로가 있으므로 오류 발생; 이 오류를 아래 except에서 확인
clean_orders.write.mode("error").parquet(mode_path)
except AnalysisException as error:
# Spark가 제공하는 오류 이름 출력
print("ERROR_MODE=", error.getCondition(), sep="")
# 기존 1건에 정상 주문 9건을 추가하므로 10건; 주문 1은 중복
clean_orders.write.mode("append").parquet(mode_path)
print("AFTER_APPEND=", spark.read.parquet(mode_path).count(), sep="")
# 기존 10건을 정상 주문 9건으로 교체
clean_orders.write.mode("overwrite").parquet(mode_path)
print("AFTER_OVERWRITE=", spark.read.parquet(mode_path).count(), sep="")
# 파일 이름을 확인하기 위한 Python의 경로 클래스
from pathlib import Path
# 기본 저장 결과와 구분한 option 실험 경로
option_path = "/opt/spark/output/week06/option_demo"
(
clean_orders.write.mode("overwrite")
# Gzip 압축 사용
.option("compression", "gzip")
# 파일 하나의 행 수를 최대 4건으로 제한
.option("maxRecordsPerFile", 4)
.parquet(option_path)
)
# glob은 이름 규칙에 맞는 파일들을 찾는 Python 메서드
option_files = sorted(Path(option_path).glob("part-*.parquet"))
# 생성한 파일 이름 출력; UUID 등은 실행마다 달라짐
for file in option_files:
print(file.name)
# 파일마다 실제 행 수를 읽어 출력
print("ROWS_PER_FILE=", [spark.read.parquet(str(file)).count() for file in option_files], sep="")
# iterdir는 해당 폴더 안의 항목 목록을 반환; 앞에서 Path를 가져옴
for entry in sorted(Path(clean_path).iterdir()):
# 점으로 시작하는 파일시스템 보조 파일은 출력에서 생략
if not entry.name.startswith("."):
print(entry.name)
# 파일에 저장된 Schema 정보를 이용해 DataFrame 생성
parquet_orders = spark.read.parquet(clean_path)
# 컬럼 이름과 자료형 확인; 입력 CSV의 Schema 추론은 필요하지 않음
parquet_orders.printSchema()
# 값 비교를 위해 주문 식별자로 정렬해 출력
parquet_orders.orderBy("order_id").show(truncate=False)
# 작은 예제 9행을 비교하기 위해 컬럼과 행 순서를 고정
compare_columns = clean_orders.columns
# 저장 전의 정제 결과 계산
before_rows = clean_orders.select(*compare_columns).orderBy("order_id").collect()
# 저장한 Parquet에서 같은 컬럼과 순서로 조회
after_rows = parquet_orders.select(*compare_columns).orderBy("order_id").collect()
# 저장 전후 값과 정상 주문 수 확인
# assert: 두 List가 다르면 AssertionError; 같으면 다음 줄로 진행
assert before_rows == after_rows
# len은 수집한 Python List의 원소 수; 9가 아니면 오류
assert len(after_rows) == 9
# 실제 정상 주문 식별자도 확인
assert [row["order_id"] for row in after_rows] == [1, 2, 3, 5, 7, 8, 10, 11, 12]
# assert가 자체 출력하지 않으므로 통과 여부를 명시적으로 출력
print("ROUND_TRIP=PASS; ROWS=", len(after_rows), sep="")
# 일곱 컬럼 중 사용자와 주문 금액만 필요한 조회 정의
projected = parquet_orders.select("user_id", "amount")
# 실행계획 출력; 이 호출 자체는 결과 행을 수집하지 않음
projected.explain(mode="formatted")
# Action 호출: 계획한 두 컬럼을 실제로 읽어 주문 금액 결과 출력
projected.orderBy("user_id", "amount").show(truncate=False)
# 필터에 amount를 사용하고 필요한 세 컬럼을 선택
high_orders = (
parquet_orders.filter(F.col("amount") >= 30000)
.select("order_id", "user_id", "amount")
)
# 정렬을 추가하기 전 파일 Scan의 조건 전달 여부 확인
high_orders.explain(mode="formatted")
# 같은 조건을 SQL WHERE로 작성
# 먼저 이 DataFrame을 SQL에서 사용할 이름으로 등록
parquet_orders.createOrReplaceTempView("orders_parquet")
sql_high_orders = spark.sql("""
SELECT order_id, user_id, amount
FROM orders_parquet
WHERE amount >= 30000
""")
# 비교할 때만 순서를 명시; 각 조회 결과는 세 행
assert (
high_orders.orderBy("order_id").collect()
== sql_high_orders.orderBy("order_id").collect()
)
# 조건을 만족하는 주문을 실제로 출력
high_orders.orderBy("order_id").show(truncate=False)
# Python에서 날짜 값과 정확한 십진수 금액을 만들 클래스
from datetime import date
from decimal import Decimal
# 이 장의 저장 구조를 관찰할 독립적인 4행 DataFrame 생성
partition_input = spark.createDataFrame(
[
(101, "U001", Decimal("24000.00"), date(2026, 9, 1)),
(102, "U002", Decimal("35000.00"), date(2026, 9, 1)),
(103, "U003", Decimal("20000.00"), date(2026, 9, 2)),
(104, "U001", Decimal("35000.00"), date(2026, 9, 2)),
],
# 각 컬럼의 이름과 자료형을 지정하는 Schema 문자열
"order_id long, user_id string, amount decimal(12,2), event_date date",
)
# 저장 전 입력 컬럼과 네 행 출력
print("INPUT_COLUMNS=", partition_input.columns, sep="")
partition_input.orderBy("order_id").show(truncate=False)
# 6장의 기본 저장 결과와 구분한 새 출력 경로
partitioned_path = "/opt/spark/output/week06/partition_demo"
(
partition_input.write.mode("overwrite")
.option("compression", "snappy")
# event_date 값이 같은 행들을 같은 이름의 날짜 디렉터리에 저장
.partitionBy("event_date")
.parquet(partitioned_path)
)
# rglob은 하위 폴더도 탐색; relative_to는 앞의 partition_demo 경로를 빼고 표시
for file in sorted(Path(partitioned_path).rglob("*.parquet")):
print(file.relative_to(partitioned_path))
# 9월 1일 디렉터리의 데이터 파일 하나 선택; file은 Python Path 객체
one_file = sorted(
(Path(partitioned_path) / "event_date=2026-09-01").glob("*.parquet")
)[0]
# 파일 경로만 지정: 그 파일 내부의 세 컬럼 읽기
single_file_orders = spark.read.parquet(str(one_file))
print("SINGLE_FILE_COLUMNS=", single_file_orders.columns, sep="")
# 데이터셋 최상위 경로 지정: 하위 파일들과 날짜 디렉터리 이름 함께 해석
partitioned_orders = spark.read.parquet(partitioned_path)
print("DATASET_COLUMNS=", partitioned_orders.columns, sep="")
# 경로에서 얻은 날짜를 포함해 네 행 출력
partitioned_orders.orderBy("order_id").show(truncate=False)
# 입력 DataFrame의 컬럼도 그대로인지 확인
print("INPUT_STILL_HAS_DATE=", "event_date" in partition_input.columns, sep="")
# F.col은 기존 컬럼 참조, F.lit은 코드에 적은 값을 각 행에서 그대로 사용
literal_demo = partitioned_orders.select(
"order_id",
F.col("event_date"),
# 문자열을 전달했으므로 결과 자료형도 string
F.lit("2026-09-01").alias("fixed_text"),
# 정수를 전달했으므로 결과는 integer 값 1
F.lit(1).alias("fixed_number"),
)
# 실제 날짜는 행마다 다르지만 fixed_text와 fixed_number는 모든 행에서 같음
literal_demo.orderBy("order_id").show(truncate=False)
print("LITERAL_TYPES=", literal_demo.dtypes, sep="")
# 날짜 컬럼 조건과 금액 컬럼 조건을 함께 적용
daily_high_orders = (
partitioned_orders.filter(
# 문자열 값을 날짜로 변환해 event_date와 비교
(F.col("event_date") == F.to_date(F.lit("2026-09-01")))
& (F.col("amount") >= 30000)
)
.select("order_id", "user_id", "amount")
)
# 파일 읽기 연산과 그 연산에 적용된 조건을 출력
daily_high_orders.explain(mode="formatted")
daily_high_orders.orderBy("order_id").show(truncate=False)
# 5주차와 같은 방식으로 SQL 조회 이름 등록
partitioned_orders.createOrReplaceTempView("partition_orders")
# 같은 날짜·금액 조건을 SQL로 작성
sql_daily_high_orders = spark.sql("""
SELECT order_id, user_id, amount
FROM partition_orders
WHERE event_date = DATE '2026-09-01'
AND amount >= 30000
""")
# 두 조회의 정렬된 결과가 다르면 AssertionError
assert (
daily_high_orders.orderBy("order_id").collect()
== sql_daily_high_orders.orderBy("order_id").collect()
)
# 비교 연산 오른쪽의 문자열은 비교할 값; 컬럼 이름으로 찾지 않음
without_lit = (
partitioned_orders.filter(
(F.col("event_date") == "2026-09-01")
& (F.col("amount") >= 30000)
)
.select("order_id", "user_id", "amount")
)
# lit·to_date를 명시한 코드와 같은 주문 102인지 확인
assert without_lit.collect() == daily_high_orders.collect()
without_lit.show(truncate=False)
print("WITHOUT_LIT=PASS", flush=True)
# append의 파일 생성 동작을 확인할 전용 출력 경로
append_path = "/opt/spark/output/week06/append_files_demo"
# 초기 정상 주문 9건 저장
clean_orders.write.mode("overwrite").parquet(append_path)
# 추가 저장 전의 데이터 파일 목록 보관
files_before_append = set(Path(append_path).glob("part-*.parquet"))
print("BEFORE_FILES=", len(files_before_append), sep="")
print("BEFORE_ROWS=", spark.read.parquet(append_path).count(), sep="")
# 주문 1 한 건을 담은 DataFrame을 추가 저장
clean_orders.filter(F.col("order_id") == 1).write.mode("append").parquet(append_path)
files_after_append = set(Path(append_path).glob("part-*.parquet"))
print("AFTER_FILES=", len(files_after_append), sep="")
print("AFTER_ROWS=", spark.read.parquet(append_path).count(), sep="")
# issubset은 원래 파일 이름들이 추가 저장 후 목록에도 모두 있는지 검사
print("ORIGINAL_FILES_PRESENT=", files_before_append.issubset(files_after_append), sep="")
# 주문 식별자마다 디렉터리를 만드는 반례용 경로
# 이 대입은 경로 문자열만 지정; 디렉터리는 아래 parquet 호출에서 생성
tiny_path = "/opt/spark/output/week06/tiny_files_demo"
(
clean_orders.write.mode("overwrite")
# 주문 식별자는 9개 모두 달라 디렉터리도 9개 생성
.partitionBy("order_id")
.parquet(tiny_path)
)
# 주문 번호별 디렉터리 수와 그 아래 Parquet 파일 수 출력
print("ORDER_DIRECTORIES=", len(list(Path(tiny_path).glob("order_id=*"))), sep="")
print("PARQUET_FILES=", len(list(Path(tiny_path).rglob("*.parquet"))), sep="")
# 예상 금액을 십진수로 작성할 Python 클래스
from decimal import Decimal
# 원본과 각 저장 결과의 전체 행 수 확인
assert orders.count() == 12
assert parquet_orders.count() == 9
assert partitioned_orders.count() == 4
# 4·5주차의 집계 연산으로 저장된 정상 주문의 사용자별 결과를 검사
df_summary_rows = (
parquet_orders.groupBy("user_id")
.agg(
F.count("*").alias("order_count"),
F.sum("amount").alias("total_amount"),
)
.orderBy(F.desc("total_amount"), F.asc("user_id"))
.collect()
)
# 저장 결과를 예상한 사용자별 집계값과 비교
assert [tuple(row) for row in df_summary_rows] == [
("U001", 4, Decimal("108000.00")),
("U002", 3, Decimal("89000.00")),
("U003", 2, Decimal("35000.00")),
]
# 10장의 독립적인 4행 예제가 날짜별 저장 후에도 같은지 확인
assert (
partitioned_orders.select(*partition_input.columns).orderBy("order_id").collect()
== partition_input.orderBy("order_id").collect()
)
# 금액 조건에 맞는 주문 식별자 확인
assert [
row["order_id"] for row in high_orders.orderBy("order_id").collect()
] == [2, 5, 8]
# 10장의 날짜·금액 조건을 모두 만족하는 주문은 102
assert [row["order_id"] for row in daily_high_orders.collect()] == [102]
# 날짜 컬럼의 중복을 제거하고 정렬해 두 날짜 확인
assert [
str(row["event_date"])
for row in partitioned_orders.select("event_date").distinct().orderBy("event_date").collect()
] == ["2026-09-01", "2026-09-02"]
# 모든 검사가 끝났다는 표시
print("CHECK=PASS", flush=True)
# Spark 자원 정리와 Application 종료
spark.stop()
apps/exercise_support.py
import hashlib
from pathlib import Path
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import (
DecimalType, IntegerType, LongType, StringType,
StructField, StructType, TimestampType,
)
DATA = Path("/opt/spark/data")
OUTPUT = Path("/opt/spark/output/exercises")
SALES_SCHEMA = StructType([
StructField("sale_id", LongType(), True),
StructField("branch", StringType(), True),
StructField("quantity", IntegerType(), True),
StructField("unit_price", DecimalType(12, 2), True),
StructField("sold_at", TimestampType(), True),
])
INVENTORY_SCHEMA = StructType([
StructField("event_id", LongType(), True),
StructField("warehouse", StringType(), True),
StructField("delta_quantity", IntegerType(), True),
StructField("event_time", TimestampType(), True),
])
def session(name):
spark = SparkSession.builder.appName(name).getOrCreate()
spark.sparkContext.setLogLevel("WARN")
spark.conf.set("spark.sql.session.timeZone", "Asia/Seoul")
return spark
def read_csv(spark, name, schema):
return (spark.read.option("header", True)
.option("timestampFormat", "yyyy-MM-dd HH:mm:ss")
.schema(schema).csv(str(DATA / name)))
def snapshot(path):
return {str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in path.rglob("*.parquet")}
def run_sales(prepare, write_clean, query_saved):
spark = session("Week06-Exercise01")
try:
raw = read_csv(spark, "store_sales.csv", SALES_SCHEMA)
clean = prepare(raw)
expected_ids = [301, 302, 303, 305, 307, 308, 310, 311, 312]
assert raw.count() == 12
assert clean.columns == [*raw.columns, "amount", "sale_date"], "Keep the five input columns, then amount and sale_date"
assert [r.sale_id for r in clean.orderBy("sale_id").collect()] == expected_ids
assert clean.schema["amount"].dataType.simpleString() == "decimal(23,2)"
assert clean.schema["sale_date"].dataType.simpleString() == "date"
path = OUTPUT / "ex01"
# Repeat the write to check that rerunning replaces, not duplicates, data.
for _ in range(2):
write_clean(clean, str(path))
saved = spark.read.parquet(str(path))
assert saved.select(*clean.columns).orderBy("sale_id").collect() == clean.orderBy("sale_id").collect()
directories = sorted(p.name for p in path.glob("sale_date=*") if p.is_dir())
assert directories == ["sale_date=2026-10-01", "sale_date=2026-10-02"]
files = sorted(path.rglob("*.parquet"))
assert files and all(".snappy.parquet" in p.name for p in files), "Use Snappy compression"
file_columns = spark.read.parquet(str(files[0])).columns
assert len(file_columns) == 6 and "sale_date" not in file_columns
result = query_saved(saved)
assert result.columns == ["sale_id", "branch", "amount"]
rows = result.collect()
assert [(r.sale_id, r.branch, str(r.amount)) for r in rows] == [
(307, "Seoul", "12000.00"), (310, "Incheon", "12000.00"), (311, "Seoul", "12000.00")]
assert int(saved.agg(F.sum("amount")).first()[0]) == 86000
saved.orderBy("sale_id").show(truncate=False)
result.show(truncate=False)
print("RAW_ROWS=12\nSAVED_ROWS=9\nDATE_DIRECTORIES=2", flush=True)
print("SINGLE_FILE_COLUMNS=" + ",".join(file_columns), flush=True)
print("DATASET_COLUMNS=" + ",".join(saved.columns), flush=True)
print("RESULT_IDS=307,310,311\nTOTAL_AMOUNT=86000.00", flush=True)
print("CHECK=PASS", flush=True)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
def run_inventory(prepare, write_initial, append_new, summarize):
spark = session("Week06-Exercise02")
try:
initial = prepare(read_csv(spark, "inventory_initial.csv", INVENTORY_SCHEMA))
incoming = prepare(read_csv(spark, "inventory_incoming.csv", INVENTORY_SCHEMA))
assert initial.count() == 4 and incoming.count() == 3
assert initial.columns == incoming.columns == INVENTORY_SCHEMA.fieldNames()
assert [r.event_id for r in initial.orderBy("event_id").collect()] == [401, 402, 403, 406]
assert [r.event_id for r in incoming.orderBy("event_id").collect()] == [402, 407, 408]
path = OUTPUT / "ex02"
# Each run must reset the initial data, then append exactly one batch.
for attempt in (1, 2):
write_initial(initial, str(path))
before = snapshot(path)
assert spark.read.parquet(str(path)).count() == 4
append_new(incoming, str(path))
after = snapshot(path)
saved = spark.read.parquet(str(path))
assert saved.count() == 7
assert saved.filter(F.col("event_id") == 402).count() == 2
assert len(after) > len(before), "Append must add data files"
assert all(after.get(name) == digest for name, digest in before.items()), "Existing files must remain unchanged"
print(f"RUN={attempt}\nBEFORE_FILES={len(before)}\nAFTER_FILES={len(after)}", flush=True)
print("BEFORE_ROWS=4\nAFTER_ROWS=7\nEVENT_402_COUNT=2", flush=True)
result = summarize(saved)
assert result.columns == ["warehouse", "event_count", "net_quantity"]
assert [tuple(r) for r in result.collect()] == [("A", 3, 13), ("B", 4, 17)]
result.show(truncate=False)
print("ORIGINAL_FILES_UNCHANGED=True\nRERUN_ROWS=7\nCHECK=PASS", flush=True)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
apps/run_exercise.sh
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
exercise01_store_sales.py|exercise02_inventory_append.py) app="$1"; shift ;;
*) printf '%s\n' 'Specify exercise01_store_sales.py or exercise02_inventory_append.py' >&2; exit 2 ;;
esac
exec /opt/spark/bin/spark-submit \
--master spark://spark-master:7077 \
--deploy-mode client \
--driver-memory 512m \
--executor-memory 512m \
--executor-cores 1 \
--total-executor-cores 2 \
--conf spark.dynamicAllocation.enabled=false \
--conf spark.driver.host=spark-client \
--conf spark.driver.bindAddress=0.0.0.0 \
--conf spark.driver.port=39000 \
--conf spark.blockManager.port=39001 \
"/opt/spark/apps/$app" "$@"
apps/exercise01_store_sales.py
from pyspark.sql import functions as F
from exercise_support import run_sales
def prepare(raw):
# 수량·단가가 NULL인 행, 수량이 0 이하인 행, 단가가 음수인 행 제외
# 원본 다섯 컬럼 뒤에 amount와 sale_date 컬럼을 차례로 추가
raise NotImplementedError("prepare 함수를 작성하세요")
def write_clean(clean, path):
# sale_date별 디렉터리로 구분하여 Snappy Parquet 저장, 재실행 시 덮어쓰기
raise NotImplementedError("write_clean 함수를 작성하세요")
def query_saved(saved):
# 2026-10-02 중 amount >= 10000인 행: sale_id, branch, amount 순서로 반환
# sale_id 오름차순 정렬. DataFrame API 또는 SQL 사용
raise NotImplementedError("query_saved 함수를 작성하세요")
if __name__ == "__main__":
run_sales(prepare, write_clean, query_saved)
apps/exercise02_inventory_append.py
from pyspark.sql import functions as F
from exercise_support import run_inventory
def prepare(raw):
# delta_quantity가 NULL이거나 0인 행 제외. 음수는 출고이므로 유지
raise NotImplementedError("prepare 함수를 작성하세요")
def write_initial(clean, path):
# 최초 데이터로 결과 디렉터리를 덮어써서 매 실행을 같은 상태에서 시작
raise NotImplementedError("write_initial 함수를 작성하세요")
def append_new(clean, path):
# 기존 파일을 유지하면서 신규 데이터를 Parquet으로 추가 저장
raise NotImplementedError("append_new 함수를 작성하세요")
def summarize(saved):
# warehouse별 event_count(행 수), net_quantity(수량 합계) 계산
# warehouse, event_count, net_quantity 컬럼을 warehouse 오름차순으로 반환
raise NotImplementedError("summarize 함수를 작성하세요")
if __name__ == "__main__":
run_inventory(prepare, write_initial, append_new, summarize)
실습 실행 절차
출처: 2026-bigdata-week06.pdf, 11쪽 · 제공 실습절차서.md 1–13절 · compose.yaml · apps/run_week06.sh.
1. 폴더와 클러스터 준비
이전 실습 클러스터가 실행 중이면 그 실습 폴더에서 docker compose down으로 종료합니다. 이후 새 실습 ZIP이 Downloads에 있다는 조건에서 다음 명령을 실행합니다. C:\bigdata\week06-lab이 이미 있다면 기존 폴더에 덮어쓰지 않습니다.
실습 폴더는 C:\bigdata처럼 동기화 프로그램이 관리하지 않는 경로를 권장합니다. 파일 삭제·교체 오류가 나면 동기화 프로그램 등이 파일을 사용 중인지, 쓰기·삭제 권한이 있는지 확인합니다.
docker version
New-Item -ItemType Directory -Force C:\bigdata
Expand-Archive -LiteralPath "$HOME\Downloads\week06-lab.zip" -DestinationPath C:\bigdata
Set-Location C:\bigdata\week06-lab
Get-ChildItem
New-Item -ItemType Directory -Force .\output
docker compose config --quiet
$LASTEXITCODE
docker compose config --services
docker compose pull --policy missing
docker compose up -d --scale spark-worker=2
docker compose ps
이후 모든 명령은 compose.yaml이 있는 폴더의 PowerShell에서 실행합니다. config 종료 코드는 0이고 서비스는 spark-master·spark-worker·spark-client입니다. 컨테이너는 Master 1개·Worker 2개·Client 1개입니다. 지정 이미지는 spark:4.1.3-scala2.13-java21-python3-ubuntu이며 pull --policy missing은 같은 태그가 이미 있으면 재사용합니다.
Master UI http://localhost:18080에서 ALIVE Worker 2개를 확인합니다. Master 컨테이너의 healthy는 웹 응답 확인이며 Worker 등록과는 별개입니다. apps·data는 읽기 전용, output은 Client와 모든 Worker가 같은 호스트 폴더를 읽고 쓰도록 연결합니다.
Get-Content .\data\orders.csv
docker compose exec spark-client ls /opt/spark/apps
docker compose exec spark-client ls /opt/spark/data
docker compose exec spark-client bash -lc 'test -w /opt/spark/output && echo CLIENT_OUTPUT_WRITABLE'
docker compose exec --index 1 spark-worker bash -lc 'test -w /opt/spark/output && echo WORKER_1_OUTPUT_WRITABLE'
docker compose exec --index 2 spark-worker bash -lc 'test -w /opt/spark/output && echo WORKER_2_OUTPUT_WRITABLE'
CSV는 헤더를 제외하고 12행인지 확인합니다.
2. 저장·조회 단계를 차례로 실행
아래 각 명령 직후의 $LASTEXITCODE가 0인지 확인합니다. 먼저 clean을 실행해야 inspect, projection, pushdown, append, options, small-files가 저장된 주문을 읽을 수 있습니다. 단계 partition은 별도 4행 예제입니다. 결과는 output\week06-lab 아래에 생성됩니다.
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step clean
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step inspect
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step projection
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step pushdown
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step partition
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step append
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step options
$LASTEXITCODE
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step small-files
$LASTEXITCODE
각 단계가 끝나면 단계별 통과 표시(예: STEP_CHECK=clean,PASS)와 CHECK=PASS, APP_STOPPED를 확인합니다. WARN만으로 실패로 판단하지 않습니다. Traceback·AssertionError·통과 표시 누락 또는 종료 코드가 0이 아니면 다음 단계로 진행하지 않습니다. clean 결과가 없으면 clean부터, 출력 권한 오류이면 위 세 쓰기 검사를 다시 확인합니다.
Get-ChildItem .\output\week06-lab\orders_clean
Get-ChildItem .\output\week06-lab\partition_demo -Recurse -Filter *.parquet
Get-ChildItem .\output\week06-lab\tiny_files_demo -Recurse -Filter *.parquet
정제 저장 후에는 part-...snappy.parquet와 _SUCCESS를 확인합니다. 파일 이름의 임의 문자열과 파일 수는 실행마다 달라질 수 있습니다. Schema 검사 출력에서 SCHEMA_AND_VALUES=PASS와 CHECK=PASS를 확인합니다.
추가 저장 동작을 비교하려면 append 명령을 한 번 더 실행합니다.
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step append
$LASTEXITCODE
다시 실행한 출력도 BEFORE_ROWS=9와 AFTER_ROWS=10인지 확인합니다. 이 함수는 매 실행마다 9행으로 덮어쓴 뒤 한 행을 추가하므로 같은 결과가 됩니다. append 자체가 중복 누적을 방지하는 것은 아닙니다.
3. Spark UI 관찰과 종료
docker compose exec spark-client bash /opt/spark/apps/run_week06.sh --step projection --wait-seconds 120
$LASTEXITCODE
출력에 UI observation: 120 seconds가 나오면 http://localhost:24040을 엽니다. SQL / DataFrame → Completed Queries → showString at ... → Plan Details에서 Scan parquet의 ReadSchema를 확인합니다. Executors에서는 Driver와 Executor를 봅니다. 120초 대기 후 종료되면 UI 접속도 끝납니다. 동시에 다른 실습 프로그램을 실행하지 않습니다.
run_week06.sh는 client 모드에서 Executor당 1코어·512m, 전체 Executor 코어 상한 2개를 요청합니다. Worker는 각각 1코어·1g를 제공합니다. 이 설정값은 계산 중 실제 사용량이나 고정 Job·Stage 수를 뜻하지 않습니다. 측정 시간과 Task 수는 자신의 UI에서 관찰합니다.
실습을 마치면 다음 명령으로 컨테이너와 실습 네트워크를 종료합니다.
docker compose down
다음 실습 때는 같은 폴더에서 아래 명령으로 다시 시작합니다.
docker compose up -d --scale spark-worker=2
종료 후에도 호스트 output은 남습니다. 단계 clean의 재실행은 orders_clean을 교체합니다. 단계 append는 매번 append_demo를 9행으로 초기화한 뒤 한 행을 추가합니다. 이는 추가 저장 자체의 중복 방지가 아닙니다. 연습문제 결과는 output\exercises에 따로 저장됩니다.
단계별 코드 해설
출처: 제공 apps/week06_lab.py·week06_parquet.py·exercise_support.py.
전체 코드는 앞 절에서 확인합니다. 여기서는 week06_lab.py의 실제 함수와 단계별 읽기·쓰기 관계를 연결합니다. 아래 함수는 제공 코드 발췌이며, 명령은 앞 절의 run_week06.sh로 실행합니다.
정제와 저장
def clean(spark):
raw = read_csv(spark)
valid = normal_orders(raw)
# CSV input is unchanged; replace only this lab's output directory.
valid.write.mode("overwrite").option("compression", "snappy").parquet(str(CLEAN))
saved = spark.read.parquet(str(CLEAN))
assert raw.count() == 12 and saved.count() == 9
assert valid.orderBy("order_id").collect() == saved.orderBy("order_id").collect()
print("RAW_ROWS=12\nVALID_ROWS=9\nSAVED_ROWS=9", flush=True)
print("OUTPUT=" + str(CLEAN), flush=True)
read_csv는 6컬럼 Schema와 시각 형식으로 CSV를 읽습니다. normal_orders는 NULL·수량·가격 조건을 적용하고 amount를 추가합니다. clean은 Snappy overwrite 후 다시 읽어 12행 → 9행과 저장 전후 값을 assert로 검사합니다.
저장 Schema와 컬럼 선택
def projection(spark):
selected = saved_orders(spark).select("user_id", "amount")
# explain prints a plan; show triggers result calculation.
selected.explain(mode="formatted")
selected.orderBy("user_id", "amount").show(truncate=False)
assert selected.count() == 9
print("SELECTED_COLUMNS=user_id,amount", flush=True)
inspect는 저장된 amount가 decimal(23,2)인지와 주문 식별자를 검사합니다. projection은 두 컬럼을 선택하고 explain을 출력합니다. show·count가 결과 계산을 요구합니다. 뒤의 orderBy는 표시 순서를 정합니다.
읽기 조건 전달
def pushdown(spark):
selected = saved_orders(spark).filter(F.col("amount") >= 30000).select("order_id", "user_id", "amount")
selected.explain(mode="formatted")
selected.orderBy("order_id").show(truncate=False)
assert [r.order_id for r in selected.orderBy("order_id").collect()] == [2, 5, 8]
print("HIGH_ORDER_IDS=2,5,8", flush=True)
금액 조건을 적용한 뒤 세 컬럼만 선택합니다. 계획에서 조건 전달을 확인하고, 정렬된 주문 식별자가 2·5·8인지 검사합니다. PushedFilters와 실제 읽은 바이트 감소량은 별도 관찰 대상입니다.
날짜별 파일 구조
def partition(spark):
# Independent four-row example from lecture chapter 10.
original = spark.createDataFrame([
(101, "U001", Decimal("24000.00"), date(2026, 9, 1)),
(102, "U002", Decimal("35000.00"), date(2026, 9, 1)),
(103, "U003", Decimal("20000.00"), date(2026, 9, 2)),
(104, "U001", Decimal("35000.00"), date(2026, 9, 2)),
], "order_id long, user_id string, amount decimal(12,2), event_date date")
path = ROOT / "partition_demo"
original.write.mode("overwrite").partitionBy("event_date").parquet(str(path))
for file in sorted(path.rglob("*.parquet")):
print("FILE=" + str(file.relative_to(path)), flush=True)
one_file = sorted((path / "event_date=2026-09-01").glob("*.parquet"))[0]
inside_file = spark.read.parquet(str(one_file))
saved = spark.read.parquet(str(path))
print("SINGLE_FILE_COLUMNS=" + ",".join(inside_file.columns), flush=True)
print("DATASET_COLUMNS=" + ",".join(saved.columns), flush=True)
saved.orderBy("order_id").show(truncate=False)
selected = saved.filter(
(F.col("event_date") == "2026-09-01") & (F.col("amount") >= 30000)
).select("order_id", "user_id", "amount")
selected.explain(mode="formatted")
selected.show(truncate=False)
assert "event_date" not in inside_file.columns and "event_date" in saved.columns
assert saved.select(*original.columns).orderBy("order_id").collect() == original.orderBy("order_id").collect()
assert [r.order_id for r in selected.collect()] == [102]
assert len(list(path.glob("event_date=*"))) == 2
print("DATE_DIRECTORIES=2\nPARTITION_ROWS=4\nDAILY_HIGH_ORDER_IDS=102", flush=True)
코드 안의 Decimal·date로 별도 4행을 만듭니다. 개별 파일에는 날짜가 없고 전체 경로에서는 복원되는지 검사합니다. event_date와 amount의 두 조건을 모두 만족하는 102만 남습니다.
추가 저장과 기존 파일 보존
def append(spark):
valid = saved_orders(spark)
path = ROOT / "append_demo"
# Reset on each invocation, then append one duplicate order deliberately.
valid.write.mode("overwrite").parquet(str(path))
before = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in path.glob("part-*.parquet")}
print("BEFORE_FILES=" + str(len(before)) + "\nBEFORE_ROWS=9", flush=True)
valid.filter(F.col("order_id") == 1).write.mode("append").parquet(str(path))
after = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in path.glob("part-*.parquet")}
saved = spark.read.parquet(str(path))
assert saved.count() == 10 and saved.filter(F.col("order_id") == 1).count() == 2
assert len(after) > len(before) and all(after.get(name) == value for name, value in before.items())
print("AFTER_FILES=" + str(len(after)) + "\nAFTER_ROWS=10\nORDER_1_COUNT=2", flush=True)
print("ORIGINAL_FILES_UNCHANGED=True", flush=True)
먼저 정상 9행을 overwrite해 같은 시작 상태를 만듭니다. 기존 파일의 SHA-256을 보관한 뒤 주문 1을 append합니다. 10행·주문 1 두 건·기존 파일 바이트 유지·파일 수 증가를 검사합니다.
파일당 행 수와 작은 파일
def options(spark):
valid = saved_orders(spark)
path = ROOT / "file_limit_demo"
valid.write.mode("overwrite").option("compression", "gzip").option("maxRecordsPerFile", 4).parquet(str(path))
files = sorted(path.glob("part-*.parquet"))
counts = [spark.read.parquet(str(file)).count() for file in files]
for file in files:
print("FILE=" + file.name, flush=True)
assert sum(counts) == 9 and all(0 < n <= 4 for n in counts)
print("ROWS_PER_FILE=" + ",".join(str(n) for n in counts), flush=True)
print("TOTAL_ROWS=9", flush=True)
options는 Gzip과 maxRecordsPerFile=4를 지정해 각 파일의 행 수가 4 이하이고 합계가 9인지 검사합니다. small_files는 order_id별 저장으로 디렉터리·파일이 각각 9개인지 검사하는 반례입니다.
main은 --step 이름을 STEPS 함수에 연결하고 --wait-seconds를 처리합니다. 모든 단계는 --step all로도 순서대로 호출되지만 처음에는 개별 실행으로 출력과 경로를 확인합니다. finally에서 spark.stop을 호출하고 APP_STOPPED를 출력하므로 종료 표시만으로 성공을 판단하지 않고 CHECK=PASS와 종료 코드도 확인합니다.
week06_parquet.py는 강의 전체 예제로 저장 모드 4종, F.lit, SQL 비교도 포함하며 output/week06을 사용합니다. 단계별 실습과 경로·검사 출력이 다릅니다. exercise_support.py는 문제 함수에 입력 DataFrame과 출력 경로를 전달하고 두 번 저장·재실행·파일 해시·예상값을 검사합니다. 학습자가 작성할 대상은 두 exercise 파일의 미구현 함수입니다.
입력 데이터와 Schema
출처: 2026-bigdata-week06.pdf, 3·12–14·30쪽 · 제공 data/*.csv·EXERCISES.md.
입력 CSV 네 개는 읽기 전용입니다. orders.csv와 store_sales.csv는 각각 12행이며 inventory_initial.csv는 6행, inventory_incoming.csv는 4행입니다. 빈 수량은 NULL로 읽습니다. 아래 표시는 입력 파일 전체이며 행 수에는 헤더를 포함하지 않습니다.
orders.csv
order_id,user_id,product_id,quantity,price,event_time
1,U001,P001,2,12000.00,2026-09-01 09:00:00
2,U002,P002,1,35000.00,2026-09-01 09:05:00
3,U001,P003,3,8000.00,2026-09-01 09:10:00
4,U003,P001,0,12000.00,2026-09-01 09:15:00
5,U002,P004,2,15000.00,2026-09-01 09:20:00
6,U004,P002,1,-100.00,2026-09-01 09:25:00
7,U003,P005,4,5000.00,2026-09-01 09:30:00
8,U001,P002,1,35000.00,2026-09-01 09:35:00
9,U004,P003,,8000.00,2026-09-01 09:40:00
10,U002,P001,2,12000.00,2026-09-01 09:45:00
11,U003,P004,1,15000.00,2026-09-01 09:50:00
12,U001,P005,5,5000.00,2026-09-01 09:55:00
store_sales.csv
sale_id,branch,quantity,unit_price,sold_at
301,Seoul,2,4500.00,2026-10-01 09:00:00
302,Busan,3,4000.00,2026-10-01 09:05:00
303,Seoul,1,6500.00,2026-10-01 09:10:00
304,Incheon,0,4000.00,2026-10-01 09:15:00
305,Busan,2,5500.00,2026-10-01 09:20:00
306,Incheon,1,-100.00,2026-10-01 09:25:00
307,Seoul,4,3000.00,2026-10-02 09:00:00
308,Busan,1,7000.00,2026-10-02 09:05:00
309,Incheon,,5000.00,2026-10-02 09:10:00
310,Incheon,3,4000.00,2026-10-02 09:15:00
311,Seoul,2,6000.00,2026-10-02 09:20:00
312,Busan,1,4500.00,2026-10-02 09:25:00
inventory_initial.csv
event_id,warehouse,delta_quantity,event_time
401,A,10,2026-10-03 09:00:00
402,B,8,2026-10-03 09:05:00
403,A,-3,2026-10-03 09:10:00
404,B,0,2026-10-03 09:15:00
405,A,,2026-10-03 09:20:00
406,B,5,2026-10-03 09:25:00
inventory_incoming.csv
event_id,warehouse,delta_quantity,event_time
407,A,6,2026-10-04 10:00:00
402,B,8,2026-10-03 09:05:00
408,B,-4,2026-10-04 10:05:00
409,A,0,2026-10-04 10:10:00
orders.csv의 price는 decimal(12,2), quantity는 integer, event_time은 timestamp입니다. store_sales.csv는 quantity·unit_price·sold_at을 같은 역할로 사용합니다. 재고 변동의 delta_quantity는 양수 입고·음수 출고이며, 0과 NULL만 제외하므로 음수도 유효합니다. incoming의 event_id=402는 initial에도 있는 동일 기록입니다.
날짜 저장용 독립 4행
order_id,user_id,amount,event_date
101,U001,24000.00,2026-09-01
102,U002,35000.00,2026-09-01
103,U003,20000.00,2026-09-02
104,U001,35000.00,2026-09-02
이 4행은 CSV 파일을 읽은 결과가 아니라 partition 함수가 만드는 입력입니다. 앞의 정상 주문 9건에 합치지 않습니다.
예상 결과와 해석
출처: 2026-bigdata-week06.pdf, 20–23·29·40–46·51–52쪽 · 제공 실습절차서.md·apps/.
아래는 제공 입력과 프로그램의 조건으로 구한 예상 기준입니다. 자신의 실행 결과와 비교합니다. 파일의 임의 이름·개수, Job·Stage 수, 실행 시간은 고정값으로 비교하지 않습니다.
| 단계 | 예상 결과와 해석 |
|---|---|
clean / inspect | 출력: RAW_ROWS=12, VALID_ROWS=9, SAVED_ROWS=9. 주문 4·6·9 제외, amount는 decimal(23,2). 저장 검사: SCHEMA_AND_VALUES=PASS. |
projection | 9행·2컬럼입니다. 출력: SELECTED_COLUMNS=user_id,amount. ReadSchema에서도 두 컬럼을 확인합니다. |
pushdown | 출력: HIGH_ORDER_IDS=2,5,8. 세 행이며 조건 전달과 실제 속도는 구분합니다. |
partition | 출력: PARTITION_ROWS=4, DATE_DIRECTORIES=2, DAILY_HIGH_ORDER_IDS=102. 파일 3컬럼·전체 4컬럼입니다. |
append | 출력: BEFORE_ROWS=9 → AFTER_ROWS=10. 중복 주문 수: ORDER_1_COUNT=2. 원본 파일 보존: ORIGINAL_FILES_UNCHANGED=True. |
options | 파일별 출력 ROWS_PER_FILE의 각 값은 4 이하입니다. 행 합계는 TOTAL_ROWS=9이며 파일 수를 3으로 고정하지 않습니다. |
small-files | 출력: ORDER_DIRECTORIES=9, PARQUET_FILES=9. 한 행짜리 파일을 만드는 반례입니다. |
| user_id | 주문 수 | 총액 예상값 |
|---|---|---|
| U001 | 4 | 108000.00 |
| U002 | 3 | 89000.00 |
| U003 | 2 | 35000.00 |
금액 예상값은 U001: 24000+24000+35000+25000=108000, U002: 35000+30000+24000=89000, U003: 20000+15000=35000입니다. 총액은 232000이며 정제 주문은 9행입니다. 저장 전후에는 금액 합계만이 아니라 컬럼·자료형·식별자·행 수·정렬한 값을 함께 비교합니다.
이 입력은 기능과 구조를 확인하는 작은 예제입니다. 형식 간 성능 우열이나 압축별 가속 배수를 판단하는 측정값으로 사용하지 않습니다. 최적화는 ReadSchema·PushedFilters·PartitionFilters의 역할과 결과 값으로 확인합니다.
다음 학습: 집계·Join의 실행 Partition과 Shuffle을 살펴봅니다.
총액만 같으면 저장 전후 검사를 끝내도 될까요?
연습문제 두 개
출처: 제공 EXERCISES.md · apps/exercise01_store_sales.py·exercise02_inventory_append.py·exercise_support.py.
제공 함수의 NotImplementedError를 지우고 DataFrame API 또는 SQL로 구현합니다. Python 행별 반복, Pandas, UDF, Window Function은 사용하지 않습니다. SparkSession·입력 읽기·검사는 exercise_support.py가 담당하므로 CSV·support·실행 스크립트는 수정하지 않습니다. 구현 정답 코드는 제공하지 않으며 요구사항과 예상 결과로 확인합니다.
문제 1. 매장 매출의 날짜별 저장
| 작성 함수 | 요구사항 |
|---|---|
| prepare(raw) | 수량·단가 NULL, 수량 0 이하, 단가 음수 제외. 원본 5컬럼 뒤에 amount와 sale_date를 추가해 9행·7컬럼 반환. |
| write_clean(clean, path) | 전달 경로에 overwrite·Snappy·partitionBy("sale_date")로 저장. |
| query_saved(saved) | 2026-10-02이고 amount >= 10000인 sale_id·branch·amount를 sale_id 오름차순 반환. |
출력 경로는 support가 /opt/spark/output/exercises/ex01로 전달합니다. amount는 Decimal, sale_date는 Date입니다. 예상 조회는 307·310·311이며 각각 12000.00입니다. 전체 저장 총액 86000.00, 날짜 디렉터리 두 개, 개별 파일 6컬럼·전체 경로 7컬럼을 확인합니다. 검사는 저장 함수를 두 번 호출해도 9행인지 확인합니다.
notepad .\apps\exercise01_store_sales.py
docker compose exec spark-client bash /opt/spark/apps/run_exercise.sh exercise01_store_sales.py
$LASTEXITCODE
Get-ChildItem .\output\exercises\ex01 -Recurse -Filter *.parquet
문제 1의 출력에서 RESULT_IDS=307,310,311, SAVED_ROWS=9, CHECK=PASS, APP_STOPPED와 종료 코드 0을 확인합니다.
완성 코드·실행 결과 캡처·날짜 디렉터리 목록을 제출합니다. 개별 파일에 없는 sale_date를 전체 경로에서 조회할 수 있는 이유를 2~3문장으로 설명합니다.
문제 2. 재고 변동의 추가 저장
| 작성 함수 | 요구사항 |
|---|---|
| prepare(raw) | NULL과 0만 제외하고 음수를 유지합니다. 원본 4컬럼 반환. |
| write_initial(clean, path) | 최초 정상 4행을 전달 경로에 overwrite 저장. |
| append_new(clean, path) | 추가 정상 3행을 같은 경로에 append 저장. 중복 제거·갱신 없음. |
| summarize(saved) | 창고별 행 수 event_count와 수량 합계 net_quantity를 구해 warehouse·event_count·net_quantity를 창고순 반환. |
경로는 /opt/spark/output/exercises/ex02입니다. 최초 4행과 추가 3행으로 최종 7행이며 event_id=402가 두 건입니다. 예상 집계는 A: 3건·13, B: 4건·17입니다. net_quantity는 중복도 포함한 입력 기록 합계이므로 정확한 현재 재고를 보장하지 않습니다.
notepad .\apps\exercise02_inventory_append.py
docker compose exec spark-client bash /opt/spark/apps/run_exercise.sh exercise02_inventory_append.py
$LASTEXITCODE
Get-ChildItem .\output\exercises\ex02 -Filter *.parquet
검사는 최초 덮어쓰기 → 추가 저장을 두 번 반복합니다. 매 반복의 출력: BEFORE_ROWS=4, AFTER_ROWS=7, EVENT_402_COUNT=2. 파일 수 증가와 ORIGINAL_FILES_UNCHANGED=True를 확인합니다. 마지막 출력은 RERUN_ROWS=7, CHECK=PASS, APP_STOPPED이고 종료 코드는 0인지 확인합니다.
완성 코드와 실행 캡처를 제출합니다. append 후 파일과 중복 기록이 늘어나는 이유, 전체 재실행은 7행인데 append만 반복하면 같은지 각각 2~3문장으로 설명합니다. 두 문제의 서술 답변은 answers.md 또는 answers.txt로, 결과는 results.pdf 또는 이미지로 제출합니다. CSV·support·실행 스크립트·전체 Parquet 결과는 제출 대상이 아닙니다.