LabHub
배우기 러닝패스 코스

Apache Spark — 느린 잡의 답은 실행 계획과 이벤트 로그에 있다 · 파일로 쓰기 · 이론

쓰기는 되돌릴 수 없으니 모드와 파일 모양을 먼저 정한다

LabHub 에서 이어서 보기

한 줄 요약

Spark 의 쓰기는 저장 모드(이미 있으면 어떻게 할까), 파티션 디렉터리(어떤 모양으로 놓을까), 커밋 프로토콜(다 쓴 것을 어떻게 알릴까) 세 가지로 정해지고, 셋 다 잘못 고르면 오류 없이 자료가 사라지거나 불어난다.

개념 지도: 저장 모드 · 파티션 디렉터리 · 커밋 프로토콜 · 성공으로 끝난다.

왜 쓰기를 따로 배우나

읽기와 변환은 몇 번을 틀려도 원본이 그대로다. 쓰기는 다르다. 덮어쓰기 한 번에 석 달 치 파티션이 지워지고, 재시도한 추가 쓰기가 같은 하루를 두 번 넣는다. 그리고 둘 다 성공으로 끝난다. 잡은 초록불이고 보고서만 틀린다.

[불러오기·저장 문서](https://spark.apache.org/docs/4.2.0/sql-data-sources-load-save-functions.html)는 저장 모드를 설명하면서 이 점을 먼저 경고한다. 저장 모드는 잠금을 쓰지 않고 원자적이지도 않다. 그리고 덮어쓰기는 새 자료를 쓰기 전에 기존 자료를 지운다. 덮어쓰기 도중에 잡이 죽으면 옛 자료도 새 자료도 없는 순간이 생긴다는 뜻이다.

어떻게 동작하나 — 저장 모드 넷

같은 문서가 드는 모드는 넷이다.

기본이 오류라는 것은 좋은 설계다. 무엇을 할지 말하지 않은 쓰기가 남의 자료를 건드리지 못하게 한다. 문제는 사람들이 그 오류를 없애려고 습관처럼 overwrite 를 붙이는 데서 시작한다.

파티션 디렉터리와 파일 수

partitionBy("day") 로 쓰면 값마다 day=2026-01-03/ 같은 디렉터리가 생기고, 그 칼럼은 파일 안이 아니라 경로에 들어간다. 읽는 쪽은 경로에서 값을 되살리고, 그 칼럼에 조건이 걸리면 디렉터리를 통째로 건너뛴다. 같은 문서는 이 방식이 디렉터리 구조를 만들기 때문에 값이 아주 많은 칼럼에는 맞지 않는다고 적는다. 사용자 ID 로 partitionBy 하면 디렉터리가 사용자 수만큼 생긴다.

파일 수는 더 조용한 함정이다. 쓰기 태스크 하나는 자기가 가진 행들의 파티션 값마다 파일 하나를 쓴다. 셔플 뒤 200개 태스크가 모두 30일 치를 조금씩 갖고 있으면 파일은 최대 200 × 30 = 6,000개가 된다. 날마다 수 KB 짜리 파일 200개다.

(df.repartition("day")                 # 같은 날은 한 태스크로 — 날마다 파일 하나   .write.partitionBy("day")   .option("maxRecordsPerFile", 50000) # 너무 큰 날은 5만 줄씩 끊는다   .mode("overwrite")   .option("partitionOverwriteMode", "dynamic")   .parquet("/data/lake/orders"))

쓰기 전에 파티션 칼럼으로 repartition 하면 같은 날의 행이 한 태스크에 모여 날마다 파일 하나가 된다. 대신 큰 날 하나가 거대한 파일 하나가 된다. 그 상한을 거는 것이 [설정 문서](https://spark.apache.org/docs/4.2.0/configuration.html)의 spark.sql.files.maxRecordsPerFile 다. 한 파일에 쓸 최대 레코드 수이고, 기본 0 은 제한이 없다는 뜻이다. 쓰기 옵션으로 줄 수도 있다.

값이 많은 칼럼으로 나누고 싶을 때의 대안이 버킷이다. 같은 문서에 따르면 bucketBy 는 값의 가짓수와 상관없이 정해진 개수의 버킷에 자료를 해시로 나눠 담으므로 고유값이 끝없이 늘어나는 칼럼에도 쓸 수 있다. 대신 버킷과 정렬은 영구 표(saveAsTable)에만 적용된다. 경로에 파일만 쓰는 save() 로는 버킷을 남길 수 없다. 날짜처럼 가짓수가 적고 조회 조건에 늘 들어가는 칼럼은 partitionBy, 사용자 ID 처럼 가짓수가 많고 조인 키로 쓰이는 칼럼은 bucketBy 라고 나눠 기억하면 된다.

정적 덮어쓰기와 동적 덮어쓰기

파티션된 경로에 overwrite 를 하면 무엇이 지워질까. 설정 문서의 spark.sql.sources.partitionOverwriteMode 가 이것을 정하고, 기본은 STATIC 이다. 정적 모드는 쓰기 전에 대상에 해당하는 파티션을 미리 지운다. 경로 전체에 DataFrame 을 덮어쓰면 대상은 경로 전체다. 하루치를 고치려고 그날 행만 담은 DataFrame 으로 덮어썼는데 나머지 날짜가 모두 사라지는 사고가 이것이다.

동적(dynamic) 모드는 미리 지우지 않고, 실제로 자료가 쓰인 파티션만 바꾼다. 하루치 DataFrame 으로 덮어쓰면 그 하루만 바뀌고 다른 날은 그대로다. 문서는 쓰기 옵션 partitionOverwriteMode 가 세션 설정보다 우선한다고 적는다. 그래서 위 예처럼 쓰는 자리에 옵션으로 적어 두는 편이 안전하다. 세션 설정은 다른 사람이 바꿀 수 있지만 코드에 적힌 옵션은 그 쓰기와 함께 다닌다.

커밋 프로토콜과 _SUCCESS

여러 태스크가 한 디렉터리에 동시에 쓰는데, 읽는 쪽은 언제 다 쓴 것을 믿을 수 있을까. 태스크는 최종 위치가 아니라 _temporary/ 아래 자기 자리에 쓴다. 태스크가 성공하면 그 출력이 커밋되고, 모든 태스크가 끝나면 잡 커밋이 출력을 최종 위치로 옮긴다. [Hadoop 의 mapred-default.xml](https://hadoop.apache.org/docs/r3.5.0/hadoop-mapreduce-client/hadoop-mapreduce-client-core/mapred-default.xml) 은 알고리즘 1판의 잡 커밋이 태스크 출력을 합쳐 옮기고 _temporary 를 지운 뒤 _SUCCESS 를 쓴다고 설명한다. 그래서 _SUCCESS 는 "이 디렉터리는 끝까지 쓰였다" 는 표식이다. 실패한 잡의 디렉터리에는 없다.

Spark 는 이 커밋 알고리즘을 설정으로 고른다. 설정 문서에서 spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version 의 기본은 1 이고, 2 는 MAPREDUCE-7282 같은 정확성 문제를 일으킬 수 있다고 적혀 있다. 1판은 옮기기(이름 바꾸기)에 기댄다. [클라우드 연동 문서](https://spark.apache.org/docs/4.2.0/cloud-integration.html)는 객체 저장소에서 이름 바꾸기가 매우 느리고 실패하면 상태를 알 수 없게 된다고 경고한다. 로컬 디스크나 HDFS 에서는 싼 일이 S3 에서는 비싼 일이 되는 이유다.

현장에서 만나는 모습

첫째, 하루를 고치려다 전부를 지운다. 정적 덮어쓰기의 전형이다. 파티션된 레이크를 덮어쓰는 코드에는 동적 모드를 옵션으로 박아 둔다.

둘째, 재시도가 두 배를 만든다. append 로 쓰는 잡이 중간에 실패해 다시 돌면, 앞서 커밋된 부분 위에 같은 자료가 또 들어간다. 하루 단위로 다시 돌 수 있는 잡은 append 보다 그날 파티션의 동적 덮어쓰기가 안전하다. 몇 번을 돌려도 결과가 같기 때문이다.

셋째, 다음 잡이 반쯤 쓴 디렉터리를 읽는다. 뒤따르는 잡이 파일이 보이자마자 읽기 시작하면 쓰는 도중의 상태를 본다. _SUCCESS 를 확인하고 읽는 규칙 하나가 이것을 막는다. 파티션 디렉터리 하나하나가 아니라 쓰기의 맨 위 경로에 하나 생긴다는 점도 기억해 둔다. 읽는 쪽은 날짜 디렉터리가 아니라 레이크 맨 위에서 이 표식을 찾는다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

주문을 날짜로 partitionBy 해서 레이크를 쓰고, 같은 경로에 기본 모드로 다시 쓸 때 나는 오류 조건을 확인한다. overwrite 뒤 append 로 한 번 더 써서 행은 두 배가 되고 고유 주문 번호는 그대로인 것을 세고, 하루치로 정적 덮어쓰기를 했을 때 다른 날짜가 사라지는 것을 본 뒤, 동적 덮어쓰기로 그 하루만 고친다. 마지막으로 클릭을 40 조각으로 흩어 쓴 레이크와 파티션 칼럼으로 다시 나눠 쓴 레이크의 파일 수를 견주고, maxRecordsPerFile 로 파일 하나에 담기는 레코드 수에 상한을 건다.