BIGDATA · 1~3장 보충 실습

보충 실습 파일과 실행 안내

주문 CSV를 정리하고 Spark로 합계와 단어 수를 구합니다. Worker 수를 바꾸며 실행 시간을 비교합니다. 코드·입력·예상 결과 파일은 아래에서 받습니다.

빅데이터 홈 · 1장 · 2장 · 3장

01

실행할 폴더부터 준비하기

1~3장 개념을 연습하는 보충 예제로, 강의에서 제공한 실습자료/와 별도로 작성했습니다. 주문과 문장은 개인정보가 없는 합성 데이터입니다.

이 페이지의 파일은 practice/에 있습니다. 제공 실습과 서비스 이름·포트가 다르므로 아래 순서대로 실행합니다.

파일·환경 준비 → 전체 코드 확인 → 주문 CSV → Spark 합계 → 단어 집계 → 성능 비교 · 오류 확인·종료

실습별 준비물과 확인 결과
순서준비물완료 기준
1장 · 주문 CSVPython 3.10 이상, 아래 파일5행 → 주문 3개 · 35,000원, 예상 CSV와 일치
2장 · 첫 Spark 계산Docker Desktop, Compose, Spark 이미지Worker 등록 2개, 합계 5050, 정상 종료
3장 · 단어 집계같은 Docker 환경, words.txt단어 3종의 횟수와 Parquet 재읽기 일치
3장 · 자원 비교같은 Docker 환경, 여유 CPU·메모리같은 정답·조건별 시간·실제 Executor 기록

1. 파일을 폴더 구조대로 저장하기

PC에 bigdata-practice 폴더를 만들고 아래 구조에 맞춰 파일을 저장합니다. 링크의 파일 받기로 내려받거나 전체 코드를 복사하여 UTF-8로 저장하세요. 파일 이름 뒤에 .txt가 추가되지 않았는지 확인합니다.

GitHub 저장소를 내려받았다면 site/BigData/practice에 파일이 있습니다. 로컬 학습 폴더에서는 BigData/practice에 있습니다. 명령은 compose.yaml이 있는 폴더에서 실행합니다.

bigdata-practice/
  compose.yaml
  apps/
    order_pipeline.py
    check_cluster.py
    sum_app.py
    wordcount_app.py
    benchmark_app.py
    submit.sh
  data/
    orders.csv
    words.txt
  expected/
    orders-clean.csv
    wordcounts.csv
  results-template.csv
  output/                 ← 실행 결과를 저장할 빈 폴더

2. Python 확인 · 1장에 사용

실행 위치: 위 폴더의 Windows PowerShell. 버전이 Python 3.10 이상인지 확인합니다. Windows의 python 명령이 Store를 열면 설치된 Python 실행 경로를 확인하거나 Python 공식 배포로 설치합니다.

python --version
New-Item -ItemType Directory -Force output

3. Docker 확인 · 2~3장에 사용

Windows에서는 Docker Desktop 공식 설치 안내에 따라 WSL 2 기반 Linux 컨테이너 환경을 준비합니다. 이미 설치되어 있다면 Docker Desktop을 열고 Engine이 실행될 때까지 기다립니다.

wsl --status
docker version
docker compose version
docker info --format '{{.OSType}}'
docker compose config

보충 예제는 1~3장의 Spark 이미지와 자원 개념을 사용합니다. 서비스 시작 순서: Docker 공식 문서.

02

같은 주문은 한 번만 계산하기

같은 주문이 여러 번 수집될 수 있습니다. 모든 행의 금액을 더하면 중복 주문도 매출에 포함됩니다. 이 예제에서는 A002와 A003의 중복 행으로 중복 제거 규칙을 확인합니다.

학습용 비유 · 영수증 번호 대조

같은 영수증이 두 장 있어도 판매는 한 번입니다. 번호는 같은데 금액이 다르면 한 장을 임의로 버리지 않고 확인해야 합니다.

입력 계약

  • 열: order_id,order_date,amount 순서.
  • 주문 번호: 빈 값은 오류. 양끝 공백은 제거.
  • 날짜: ISO 날짜를 읽어 YYYY-MM-DD로 저장.
  • 금액: 음수가 아닌 정수 원 단위.

중복·실패 규칙

  • 동일 ID·날짜·금액: 한 건으로 합침.
  • 동일 ID·다른 날짜나 금액: 충돌 오류로 종료.
  • 잘못된 입력: 결과 파일을 교체하기 전에 실패.
  • 재실행: 정렬한 동일 결과를 저장.

실행 위치: bigdata-practice 폴더의 PowerShell.

python apps/order_pipeline.py
Get-Content output/orders-clean.csv
Compare-Object (Get-Content expected/orders-clean.csv) (Get-Content output/orders-clean.csv)
$LASTEXITCODE
INPUT_ROWS=5
UNIQUE_ORDERS=3
DUPLICATE_ROWS=2
TOTAL_AMOUNT=35000

위 값은 제공 입력의 예상 출력입니다. 실제 출력에는 결과 파일 위치도 나옵니다. WSL·macOS·Linux에서는 python3 apps/order_pipeline.py 뒤 diff expected/orders-clean.csv output/orders-clean.csv로 비교할 수 있습니다.

코드가 수행하는 네 단계

  1. 수집: csv.DictReader가 열 이름을 키로 가진 행을 읽습니다.
  2. 변환: 날짜·정수 변환과 중복/충돌 검사를 수행하고 주문 ID를 키로 보관합니다.
  3. 저장: ID순으로 임시 파일을 완성한 뒤 결과 파일로 교체합니다. 입력 파일은 읽기만 합니다.
  4. 제공: 정리된 CSV와 행 수·총액을 출력하여 사람이 예상 결과와 대조합니다.

직접 바꿔 확인하기

원본 입력을 보존하고 실험용 사본을 만들어 편집합니다.

Copy-Item data/orders.csv output/orders-test.csv
notepad output/orders-test.csv
python apps/order_pipeline.py --input output/orders-test.csv --output output/orders-test-clean.csv
$LASTEXITCODE
확인 질문

입력이 100배가 되어도 이 코드로 처리할 수 있을까요? 고유 주문을 Python 사전에 모두 보관하므로 고유 주문 수가 늘면 메모리 사용량도 늘어납니다. 큰 입력을 분산 처리하려면 데이터 분할 방법과 중복 제거 비용을 고려해야 합니다.

order_pipeline.py 전체 코드 · 1장 개념으로 돌아가기

03

이미지 → 등록 → 계산 순서로 확인하기

이미지를 받은 뒤 한 컨테이너에서 계산하고, Worker 등록과 클러스터 계산을 차례로 확인합니다.

보충 실습의 서비스·포트 확인하기

이번 compose.yaml의 역할과 경로
항목설정의미
Masterspark-master · 내부 7077Worker 자원을 등록하고 Application에 배정
Workerspark-worker · 복제 가능각 1 Core·1g, 실행 시 2개로 복제
Client / Driverspark-clientsubmit.sh가 여기에서 Driver를 시작
브라우저 UIMaster 28080 · Driver 24041호스트에서 localhost로 접속
앱·입력·정답apps · data · expectedClient에 읽기 전용 연결
출력output → /opt/spark/lab-outputClient에서 쓰면 PC의 output에 보존

이 보충 실습의 프로젝트 이름은 knut-public-spark입니다. 2장 제공 실습의 Worker 1·2 이름과 8080/4040 포트는 사용하지 않습니다. 성능 측정 중에는 다른 학습 환경을 종료해 CPU·메모리 경합을 줄입니다.

1. 이미지와 local[2] 계산

실행 위치: compose.yaml이 있는 폴더의 PowerShell.

docker compose pull
docker compose run --rm --no-deps --entrypoint /opt/spark/bin/spark-submit spark-client --master "local[2]" /opt/spark/apps/sum_app.py
$LASTEXITCODE

예상 결과는 MASTER=local[2], SUM_1_TO_100=5050, CHECK=PASS, APP_STOPPED, 종료 코드 0입니다.

local[2]는 하나의 Java 실행 환경(JVM) 안에서 작업 스레드 두 개를 쓰는 모드입니다. 클러스터 Worker 두 개의 등록 여부는 다음 단계에서 검사합니다.

이 실습은 spark:4.1.3-scala2.13-java21-python3-ubuntu 이미지를 사용합니다. 태그는 Docker 공식 이미지 목록에 등록되어 있습니다. manifest unknown 오류가 나면 태그·레지스트리 주소를 확인합니다. 다른 버전을 쓰면 버전을 기록하고 각 실행 단계를 다시 확인합니다.

2. Worker 두 개 시작·등록 확인

docker compose up -d --scale spark-worker=2
docker compose ps
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 2
$LASTEXITCODE

ALIVE_WORKERS=2 TOTAL_CORES=2와 REGISTRATION_CHECK=PASS를 확인합니다. 검사기는 Master의 상태 JSON을 읽어 등록을 기다립니다. Master UI · localhost:28080의 ALIVE Worker 수도 2개여야 합니다.

healthy는 Master HTTP 응답 여부입니다. 등록 검사가 통과해도 Driver로 되돌아오는 연결과 실제 계산은 다음 단계에서 확인해야 합니다.

3. 클러스터에 앱 제출

docker compose exec spark-client bash /opt/spark/apps/submit.sh sum_app.py --wait-seconds 120
$LASTEXITCODE

MASTER=spark://spark-master:7077와 합계 5050을 확인합니다. UI_WAIT_SECONDS=120이 나오면 별도 브라우저에서 Driver UI · localhost:24041를 엽니다. 터미널은 관찰 대기 중이므로 다음 앱을 겹쳐 실행하지 않습니다.

확인 질문

입력 Partition이 4개인데 Core가 2개라면 네 작업을 모두 동시에 실행할까요? 동시에 실행할 수 있는 작업 수는 배정된 Core에 제한됩니다. 입력 분할 수와 동시에 사용할 자원을 구분해 기록하세요.

4. Worker 하나로 같은 답 확인

docker compose up -d --scale spark-worker=1
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 1
docker compose exec -e TOTAL_EXECUTOR_CORES=1 spark-client bash /opt/spark/apps/submit.sh sum_app.py --wait-seconds 120

같은 입력과 합계를 유지하면서 가용 자원을 줄입니다. 숫자 100개의 실행 시간만으로 성능 차이를 판단하기는 어렵습니다. 다음 절을 진행하거나 종료 절차를 따릅니다.

Compose 전체 설정 · 계산 앱 전체 코드 · 제출 스크립트 전체 코드 · 2장 해설

client 모드와 local[N]의 의미: Spark 4.1.3 공식 제출 안내. Standalone의 Worker·Core 구성: Spark 4.1.3 Standalone 문서.

04

입력 문장이 집계 표로 바뀌는 과정

3장에서 배운 단어 집계 연산을 보충 입력 파일로 실행합니다. 입력은 Client 컨테이너에만 연결하므로 local[2]로 실행합니다. 클러스터의 Worker에는 이 파일이 연결되어 있지 않습니다.

학습용 비유 · 문장 카드를 단어별로 모으기

문장을 단어 카드로 자르고 같은 단어끼리 모아 셉니다. 실제 코드는 문자열 열을 배열로 나눈 뒤, 배열 원소를 여러 행으로 펼치고 집계합니다.

실행 위치: 같은 PowerShell. 앞 앱의 APP_STOPPED 이후. Client가 실행 중인 상태에서 아래를 실행합니다.

docker compose exec spark-client /opt/spark/bin/spark-submit --master "local[2]" /opt/spark/apps/wordcount_app.py
$LASTEXITCODE
Get-ChildItem output/wordcounts

예상 결과는 WORDCOUNT_CHECK=PASS, APP_STOPPED, 종료 코드 0입니다. 출력의 단어 표는 아래와 같습니다.

제공 입력의 예상 집계
wordcount
spark3
data2
pipeline1

Parquet는 같은 열의 값을 모아 저장하는 파일 형식입니다. Spark는 지정 경로에 데이터를 나누어 담은 part 파일과 성공 표시를 만듭니다. Get-ChildItem으로 이 출력 폴더의 파일 목록을 확인합니다.

입력에서 출력까지의 연산

  1. read.text: 한 줄을 문자열 value 한 행으로 읽습니다. 빈 줄도 살펴봅니다.
  2. split: 하나 이상의 공백을 기준으로 단어 배열을 만듭니다.
  3. explode · alias: 배열의 원소를 각각 word 행으로 펼칩니다.
  4. where: 빈 단어를 제외합니다. 대소문자나 문장부호는 바꾸지 않습니다.
  5. groupBy · count: 같은 단어를 모아 횟수를 계산합니다. 재배치인 Shuffle이 필요할 수 있습니다.
  6. orderBy: 횟수 내림차순, 동률이면 단어 오름차순으로 화면을 정렬합니다.
  7. write · read: Parquet로 저장하고 다시 읽어 예상 CSV와 비교합니다. 저장 후 행의 순서가 유지된다고 가정하지 않습니다.

재실행과 입력 변경

기존 결과를 보존하도록 기본 저장 모드는 errorifexists입니다. 같은 실습을 다시 실행하려면 새 출력 경로를 지정합니다.

docker compose exec spark-client /opt/spark/bin/spark-submit --master "local[2]" /opt/spark/apps/wordcount_app.py --output /opt/spark/lab-output/wordcounts-run2

입력을 바꾸면 expected/wordcounts.csv의 예상값도 직접 계산해 바꿉니다. 예를 들어 Spark와 spark는 서로 다른 단어로 집계됩니다. 표기를 통일하려면 먼저 어떤 표기를 같은 단어로 볼지 정합니다.

확인 질문

이 예제에서 collect로 결과를 검사할 수 있는 이유는 무엇일까요? 합성 입력의 단어는 3종뿐입니다. 코드도 최대 101행만 가져와 100종을 넘으면 중단하므로 Driver에 모으는 데이터가 제한됩니다. 큰 결과 전체를 검사할 때는 다른 방법이 필요합니다.

단어 집계 전체 코드 · 3장 연산별 표 해설

텍스트의 한 줄·value 열·UTF-8 읽기: DataFrameReader.text 공식 API.

05

정답을 고정하고 자원을 바꾸기

입력·연산·정답 확인 규칙을 고정하고 성능을 비교합니다. 작은 입력으로 코드와 설정을 확인한 뒤 PC 자원에 맞춰 입력을 늘립니다. 숫자는 spark.range로 생성하므로 큰 입력 파일을 따로 준비하지 않습니다.

1. 작은 입력으로 실행 경로 확인

실행 위치: compose.yaml이 있는 폴더의 PowerShell. 앞 앱의 APP_STOPPED를 확인한 뒤 실행합니다.

docker compose up -d --scale spark-worker=1
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 1
docker compose exec -e TOTAL_EXECUTOR_CORES=1 spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000001 --partitions 128 --repetitions 3
$LASTEXITCODE

1부터 1,000,000까지의 예상 합은 500000500000입니다. end는 포함하지 않는 끝값입니다. 각 실행 시간과 CHECK=PASS를 확인한 뒤 종료까지 기다립니다.

2. 기록할 입력·측정 방법 고정

3. Worker와 Core를 함께 1 → 2 → 4 → 8로 늘리기

PowerShell에서 아래 블록을 조건마다 실행합니다. 먼저 $workers = 1, 다음은 2, 4, 8로 바꿉니다. 앞 앱의 APP_STOPPED를 확인한 뒤 다음 조건을 실행하세요. 실행하지 못한 조건은 기록표에서 미측정으로 남깁니다.

$workers = 1
docker compose up -d --scale "spark-worker=$workers"
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers $workers
docker compose exec -e "TOTAL_EXECUTOR_CORES=$workers" spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000000001 --partitions 128 --repetitions 3
$LASTEXITCODE

등록 검사에 실패하면 제출하지 말고 원인을 먼저 해결합니다. Master UI의 ALIVE Worker와 Total Cores, 앱 출력의 REQUESTED_CORES를 같이 확인하세요. 요청 Core 수는 상한이며 실제 할당량과 항상 같지는 않습니다.

4. Worker 8개에서 Core 상한만 2로 고정

docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 8
docker compose exec -e TOTAL_EXECUTOR_CORES=2 spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000000001 --partitions 128 --repetitions 3 --wait-seconds 120

동일 시점의 Master 사용 Core와 Driver UI의 실제 Executor를 기록합니다. Worker 8개를 준비해도 이 앱의 Executor Core 합은 최대 2입니다. 앞의 2 Worker·2 Core 조건과 비교하면 준비된 Worker 수와 앱이 쓰는 자원을 구분할 수 있습니다.

2/2 조건의 화면을 놓쳤다면 그 조건으로 되돌려 동일 명령에 --wait-seconds 120을 추가해 관찰합니다. 관찰용 재실행의 시간은 앞 측정값과 섞지 않고 따로 표시합니다.

5. 기록표 해석

빈 CSV 기록표에 직접 실행한 결과를 적습니다. 코드가 출력한 시간만 실제 측정값입니다.

계산식과 주의할 점
항목계산해석
중앙값세 시간을 정렬한 가운데 값단일 이상값의 영향을 줄이되 원자료도 보존
Speedup1/1 조건 중앙값 ÷ 비교 조건 중앙값같은 입력에서 얼마나 빨라졌는지
효율Speedup ÷ 사용 Core 수 × 1001 Core 기준 자원 증가와 비교; 실제 할당도 확인
확인 질문

8 Worker가 4 Worker보다 느리면 코드가 반드시 틀린 걸까요? 같은 PC의 CPU·메모리 경합, 통신·스케줄링 비용, 초기 실행 비용을 확인합니다. 정답이 같다는 증거와 성능 이유를 나누어 설명하세요. 큰 차이가 반복되면 조건별 Application을 여러 번 다시 실행하여 변동도 기록합니다.

측정 앱 전체 코드 · 3장 성능 해석으로 돌아가기

06

실패한 단계를 좁히고 종료하기

증상별 확인 순서
증상확인할 것
docker 명령 없음 / Server 오류Desktop 설치, 실행 상태, Linux 컨테이너 모드. 설치 성공과 Engine 시작을 구분합니다.
이미지 pull 실패태그 존재, 인터넷·프록시·로그인 제한. 오류를 기록하고 이미지가 없으면 다음 단계로 진행하지 않습니다.
파일 없음Compose 현재 폴더, apps/data/expected 이름과 확장자, 내려받은 실제 파일 내용.
Worker 등록 실패Master/Worker 로그와 상태, 메모리 부족·종료 여부. healthy만으로 성공 판단하지 않습니다.
Initial job has not accepted any resourcesALIVE Worker, 가용 Core/메모리, 이전 앱 종료 여부, submit.sh의 Driver 호스트 설정.
포트 사용 중28080/24041을 쓰는 기존 프로세스 확인. Compose의 호스트 포트를 바꾸면 안내 URL도 맞춥니다.
Driver UI가 안 열림앱이 이미 끝났는지 확인. wait-seconds를 주고 대기 메시지가 나온 동안 접속합니다.
submit.sh의 $'\r' 오류편집기에서 Bash 파일 줄바꿈을 LF로 저장. 제공 파일의 LF를 유지합니다.
Parquet 경로 이미 존재기존 결과를 남기고 새 --output 경로를 지정합니다.
출력 Permission denied호스트 output의 쓰기 권한과 공유 설정. 컨테이너 사용자와 Linux 파일 소유자가 다를 수 있습니다.

실행 위치: 보충 실습 폴더의 PowerShell. 로그를 확인한 뒤 실습 환경을 종료합니다.

docker compose ps -a
docker compose logs --tail 100 spark-master spark-worker
# 실행 중인 앱의 APP_STOPPED를 확인한 뒤 종료
docker compose down
docker compose ps -a

이 프로젝트의 컨테이너와 기본 네트워크가 제거됩니다. 바인드 마운트한 PC의 output과 원본 입력은 남습니다. 다음에는 같은 폴더에서 up으로 시작한 뒤 등록부터 다시 확인합니다.

예상값과 측정값

합계·단어 수는 합성 입력에서 계산한 예상값입니다. 실행 결과를 예상값과 비교하고, 빈 측정 CSV에는 자신의 환경에서 측정한 시간과 자원 사용을 기록합니다.

07

파일별 전체 내용

파일 이름을 누르면 다운로드 파일과 같은 전체 내용이 펼쳐집니다. 코드 블록의 복사 버튼으로 복사해 UTF-8로 저장할 수 있습니다.

compose.yaml · 서비스·포트·파일 연결
# Independently authored public lab. Run commands from this directory.
name: knut-public-spark

x-runtime: &runtime
  image: spark:4.1.3-scala2.13-java21-python3-ubuntu
  init: true
  environment:
    PYSPARK_PYTHON: python3

services:
  spark-master:
    <<: *runtime
    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:28080: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: 15s

  spark-worker:
    <<: *runtime
    entrypoint: ["/bin/bash", "-lc"]
    command:
      - >-
        exec /opt/spark/bin/spark-class org.apache.spark.deploy.worker.Worker
        --host "$$HOSTNAME" --cores 1 --memory 1g
        --work-dir /tmp/spark-work spark://spark-master:7077
    depends_on:
      spark-master:
        condition: service_healthy

  spark-client:
    <<: *runtime
    hostname: spark-client
    entrypoint: ["/bin/bash", "-lc"]
    command: ["exec sleep infinity"]
    ports:
      - "127.0.0.1:24041:4040"
    volumes:
      - ./apps:/opt/spark/apps:ro
      - ./data:/opt/spark/lab-data:ro
      - ./expected:/opt/spark/lab-expected:ro
      - ./output:/opt/spark/lab-output
    depends_on:
      spark-master:
        condition: service_healthy
apps/order_pipeline.py · CSV 파이프라인 전체
"""Small, synthetic CSV pipeline. Python standard library only."""
import argparse
import csv
from datetime import date
from pathlib import Path
import tempfile

ROOT = Path(__file__).resolve().parents[1]
FIELDS = ["order_id", "order_date", "amount"]


def clean_orders(source):
    unique = {}
    input_rows = 0
    with source.open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        if reader.fieldnames != FIELDS:
            raise ValueError("CSV header must be order_id,order_date,amount")
        for line, row in enumerate(reader, start=2):
            input_rows += 1
            if None in row or any(row[key] is None for key in FIELDS):
                raise ValueError(f"line {line}: expected three columns")
            order_id = row["order_id"].strip()
            if not order_id:
                raise ValueError(f"line {line}: empty order_id")
            order_date = date.fromisoformat(row["order_date"].strip()).isoformat()
            amount = int(row["amount"])
            if amount < 0:
                raise ValueError(f"line {line}: amount must be nonnegative")
            record = (order_date, amount)
            if order_id in unique and unique[order_id] != record:
                raise ValueError(f"line {line}: conflicting order_id {order_id}")
            unique[order_id] = record
    return unique, input_rows


def run(source, output):
    if source.resolve() == output.resolve():
        raise ValueError("input and output must be different files")
    orders, input_rows = clean_orders(source)
    output.parent.mkdir(parents=True, exist_ok=True)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", encoding="utf-8", newline="", dir=output.parent,
            prefix="orders-", suffix=".tmp", delete=False,
        ) as stream:
            temporary = Path(stream.name)
            writer = csv.writer(stream, lineterminator="\n")
            writer.writerow(FIELDS)
            for order_id, (order_date, amount) in sorted(orders.items()):
                writer.writerow([order_id, order_date, amount])
        temporary.replace(output)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
    print(f"INPUT_ROWS={input_rows}")
    print(f"UNIQUE_ORDERS={len(orders)}")
    print(f"DUPLICATE_ROWS={input_rows - len(orders)}")
    print(f"TOTAL_AMOUNT={sum(amount for _, amount in orders.values())}")
    print(f"OUTPUT={output}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=ROOT / "data/orders.csv")
    parser.add_argument("--output", type=Path, default=ROOT / "output/orders-clean.csv")
    args = parser.parse_args()
    try:
        run(args.input, args.output)
    except (ValueError, OSError) as error:
        parser.exit(1, f"ERROR: {error}\n")


if __name__ == "__main__":
    main()
apps/check_cluster.py · Worker 등록 검사 전체
"""Wait for the requested number of live one-core workers; no PySpark needed."""
import argparse
import json
import time
from urllib.error import URLError
from urllib.request import urlopen


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--workers", type=int, required=True)
    parser.add_argument("--timeout", type=int, default=90)
    args = parser.parse_args()
    if args.workers < 1 or args.timeout < 1:
        parser.error("workers and timeout must be positive")
    deadline = time.monotonic() + args.timeout
    observed = "master not reached"
    while time.monotonic() < deadline:
        try:
            with urlopen("http://spark-master:8080/json/", timeout=3) as response:
                status = json.load(response)
            live = [worker for worker in status["workers"] if worker["state"] == "ALIVE"]
            cores = sum(int(worker["cores"]) for worker in live)
            observed = f"ALIVE_WORKERS={len(live)} TOTAL_CORES={cores}"
            if len(live) == args.workers and cores == args.workers:
                print(observed)
                print("REGISTRATION_CHECK=PASS")
                return
        except (URLError, TimeoutError, OSError, ValueError, KeyError) as error:
            observed = str(error)
        time.sleep(1)
    parser.exit(1, f"REGISTRATION_CHECK=FAIL: {observed}\n")


if __name__ == "__main__":
    main()
apps/sum_app.py · 합계 계산 전체
"""Verify a known answer and leave time to inspect the Spark UI."""
import argparse
import time


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--wait-seconds", type=int, default=0)
    args = parser.parse_args()
    if args.wait_seconds < 0:
        parser.error("wait-seconds must be nonnegative")
    from pyspark.sql import SparkSession, functions as F

    spark = SparkSession.builder.appName("Public-First-Sum").getOrCreate()
    try:
        spark.sparkContext.setLogLevel("WARN")
        print(f"MASTER={spark.sparkContext.master}", flush=True)
        print(f"APP_ID={spark.sparkContext.applicationId}", flush=True)
        numbers = spark.range(1, 101, 1, 4)
        numbers.printSchema()
        result = numbers.agg(F.sum("id").alias("total"))
        result.explain()
        total = result.first()["total"]
        if total != 5050:
            raise ValueError(f"expected 5050, received {total}")
        print(f"SUM_1_TO_100={total}", flush=True)
        print("CHECK=PASS", flush=True)
        print(f"UI_WAIT_SECONDS={args.wait_seconds}", flush=True)
        time.sleep(args.wait_seconds)
    finally:
        spark.stop()
    print("APP_STOPPED", flush=True)


if __name__ == "__main__":
    main()
apps/submit.sh · 제출 옵션 전체
#!/usr/bin/env bash
set -euo pipefail

app="${1:-sum_app.py}"
if (( $# > 0 )); then shift; fi
case "$app" in
  sum_app.py|benchmark_app.py) ;;
  *) echo "Choose sum_app.py or benchmark_app.py" >&2; exit 2 ;;
esac
cores="${TOTAL_EXECUTOR_CORES:-2}"
if [[ ! "$cores" =~ ^[1-9][0-9]*$ ]]; then
  echo "TOTAL_EXECUTOR_CORES must be a positive integer" >&2
  exit 2
fi

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 "$cores" \
  --conf spark.dynamicAllocation.enabled=false \
  --conf spark.sql.adaptive.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/wordcount_app.py · 단어 집계 전체
"""Count the supplied small text fixture, save Parquet, then read it back."""
import argparse
import csv
from pathlib import Path


def small_counts(frame):
    rows = frame.limit(101).collect()
    if len(rows) > 100:
        raise ValueError("this teaching check supports at most 100 distinct words")
    return {row["word"]: row["count"] for row in rows}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", default="/opt/spark/lab-data/words.txt")
    parser.add_argument("--expected", default="/opt/spark/lab-expected/wordcounts.csv")
    parser.add_argument("--output", default="/opt/spark/lab-output/wordcounts")
    args = parser.parse_args()
    with Path(args.expected).open(encoding="utf-8-sig", newline="") as stream:
        expected = {row["word"]: int(row["count"]) for row in csv.DictReader(stream)}
    from pyspark.sql import SparkSession, functions as F

    spark = SparkSession.builder.appName("Public-Wordcount").getOrCreate()
    try:
        spark.sparkContext.setLogLevel("WARN")
        if not spark.sparkContext.master.startswith("local["):
            raise ValueError("use --master local[2]: files are mounted only in the client")
        lines = spark.read.text(args.input)
        words = lines.select(
            F.explode(F.split(F.col("value"), r"\s+")).alias("word")
        ).where(F.col("word") != "")
        counts = words.groupBy("word").count()
        ordered = counts.orderBy(F.desc("count"), F.asc("word"))
        print("INPUT_LINES")
        lines.show(truncate=False)
        print("WORD_ROWS")
        words.show(truncate=False)
        print("WORD_COUNTS")
        ordered.show(truncate=False)
        ordered.explain()
        if small_counts(ordered) != expected:
            raise ValueError("word counts differ from the expected CSV")
        ordered.write.mode("errorifexists").parquet(args.output)
        restored = spark.read.parquet(args.output)
        if small_counts(restored) != expected:
            raise ValueError("Parquet round-trip check failed")
        print("WORDCOUNT_CHECK=PASS", flush=True)
        print(f"PARQUET_OUTPUT={args.output}", flush=True)
    finally:
        spark.stop()
    print("APP_STOPPED", flush=True)


if __name__ == "__main__":
    main()
apps/benchmark_app.py · 반복 측정 전체
"""Measure repeated aggregation actions; timings are produced only by real runs."""
import argparse
import statistics
import time


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--end", type=int, default=1_000_001)
    parser.add_argument("--partitions", type=int, default=128)
    parser.add_argument("--repetitions", type=int, default=3)
    parser.add_argument("--wait-seconds", type=int, default=0)
    args = parser.parse_args()
    if not 2 <= args.end <= 4_294_967_296:
        parser.error("end must be 2..4294967296 (keeps the sum within signed 64-bit)")
    if args.partitions < 1 or args.repetitions < 1 or args.wait_seconds < 0:
        parser.error("partitions/repetitions must be positive; wait must be nonnegative")
    from pyspark.sql import SparkSession, functions as F

    spark = SparkSession.builder.appName("Public-Worker-Benchmark").getOrCreate()
    try:
        spark.sparkContext.setLogLevel("WARN")
        context = spark.sparkContext
        print(f"MASTER={context.master}", flush=True)
        print(f"APP_ID={context.applicationId}", flush=True)
        print(f"REQUESTED_CORES={context.getConf().get('spark.cores.max', 'unset')}", flush=True)
        print(f"END_EXCLUSIVE={args.end}", flush=True)
        print(f"PARTITIONS={args.partitions}", flush=True)
        print(f"REPETITIONS={args.repetitions}", flush=True)
        numbers = spark.range(1, args.end, 1, args.partitions)
        expected = (args.end - 1) * args.end // 2
        durations = []
        for repeat in range(1, args.repetitions + 1):
            started = time.perf_counter()
            total = numbers.agg(F.sum("id").alias("total")).first()["total"]
            elapsed = time.perf_counter() - started
            if total != expected:
                raise ValueError(f"expected {expected}, received {total}")
            durations.append(elapsed)
            print(f"RUN_{repeat}_SECONDS={elapsed:.6f}", flush=True)
        print(f"SUM_RESULT={total}", flush=True)
        print(f"MEDIAN_SECONDS={statistics.median(durations):.6f}", flush=True)
        print("CHECK=PASS", flush=True)
        print(f"UI_WAIT_SECONDS={args.wait_seconds}", flush=True)
        time.sleep(args.wait_seconds)
    finally:
        spark.stop()
    print("APP_STOPPED", flush=True)


if __name__ == "__main__":
    main()
data/orders.csv · 합성 주문
order_id,order_date,amount
A001,2026-09-01,10000
A002,2026-09-01,20000
A002,2026-09-01,20000
A003,2026-09-02,5000
A003,2026-09-02,5000
data/words.txt · 합성 문장
spark spark
data spark

data pipeline
expected/orders-clean.csv · 주문 예상 결과
order_id,order_date,amount
A001,2026-09-01,10000
A002,2026-09-01,20000
A003,2026-09-02,5000
expected/wordcounts.csv · 단어 예상 결과
word,count
spark,3
data,2
pipeline,1
results-template.csv · 미측정 기록표
condition,workers,requested_cores,end_exclusive,partitions,repetitions,run1_seconds,run2_seconds,run3_seconds,median_seconds,observed_executors,check,notes
A1,1,1,1000000001,128,3,,,,,,,not_measured
A2,2,2,1000000001,128,3,,,,,,,not_measured
A3,4,4,1000000001,128,3,,,,,,,not_measured
A4,8,8,1000000001,128,3,,,,,,,not_measured
B,8,2,1000000001,128,3,,,,,,,not_measured