PySpark 실무: API · UDF · pandas API on Spark
들어가며
Spark Essential Curriculum의 6단계입니다. 지금까지의 다섯 단계는 Spark의 속을 보는 여정이었습니다 — 아키텍처(1단계)로 무엇이 어떻게 실행되는지, 추상화(2단계)로 데이터의 세 얼굴을, 옵티마이저(3단계)로 왜 빠른지, 셔플·튜닝(4단계)으로 왜 느려지고 어떻게 고치는지, Structured Streaming(5단계)으로 배치를 넘어 실시간까지 다뤘습니다. 이 6단계는 방향을 살짝 틀어 “그래서 실제로 어떤 코드로 쓰는가”를 다룹니다.
이유는 단순합니다 — 현실의 Spark 코드는 압도적으로 PySpark로 쓰입니다. 데이터 엔지니어·분석가 대부분이 Python으로 파이프라인을 짜고, Scala/Java로 내려가는 경우는 드뭅니다. 그런데 여기에 함정이 하나 있습니다. PySpark로 쓴다고 코드가 Python으로 실행되는 것은 아닙니다. 실행은 여전히 JVM에서 일어나고, Python과 JVM 사이에는 넘나드는 데 비용이 드는 경계가 있습니다. PySpark를 잘 쓴다는 것은 이 경계를 언제 넘고 언제 넘지 않는지를 아는 것과 거의 같습니다.
앞 단계들에서 익힌 실행·최적화 지식이 여기서 값을 합니다. 특히 3단계 옵티마이저에서 본 Catalyst는 내장 함수의 표현식을 이해하고 최적화하지만, 여러분이 쓴 Python UDF는 옵티마이저에게 블랙박스입니다 — 이 한 문장이 이 글 전체를 관통합니다. 그리고 이 6단계는 다음 7단계 — Iceberg/Delta 연동으로 이어지며, 거기서 Spark를 레이크하우스 스택에 얹는 것으로 시리즈를 마무리합니다.
📌 이 글에서 다루는 내용
- PySpark API와 JVM 경계: Python 진입점(SparkSession), Py4J로 이어진 Python↔JVM 구조, 왜 코드는 얇은 껍데기이고 실행은 JVM에서 일어나는지, 그리고 왜 대부분 내장 함수만으로 충분한지
- UDF와 직렬화 비용: 행 단위로 pickle 직렬화를 왕복하는 느린 Python UDF vs 배치 단위로 Arrow를 주고받는 벡터화 Pandas UDF, UDF가 옵티마이저의 블랙박스인 이유와 내장 함수 우선 원칙
- pandas API on Spark: 기존 pandas 코드를 최소 수정으로 분산 실행하는
pyspark.pandas, 언제 쓰고 언제 네이티브 DataFrame으로 내려가야 하는지, 연산별 셔플 비용이라는 주의점
한눈에 보기 — 경계를 넘느냐 마느냐
이 글의 스파인을 한 장으로 그리면 이렇습니다. 같은 변환을 표현하는 세 가지 길이 있고, 셋은 Python↔JVM 경계를 얼마나 넘느냐로 갈립니다. 내장 함수는 경계를 아예 넘지 않고(JVM에서 최적화되어 실행), Python UDF는 행마다 넘나들며(직렬화 왕복), Pandas UDF는 배치로 한 번에 넘깁니다(Arrow).
flowchart TD
CODE["PySpark 코드<br/>df.select(...)"]
subgraph JVM["JVM 실행 엔진 (Catalyst·Tungsten)"]
BUILTIN["내장 함수<br/>F.upper · F.col · F.when …"]
end
subgraph PY["Python worker (경계 너머)"]
PUDF["Python UDF<br/>행 단위 · pickle 왕복"]
PANDAS["Pandas UDF<br/>배치 단위 · Arrow"]
end
CODE -->|경계 안| BUILTIN
CODE -->|행마다 경계 넘음| PUDF
CODE -->|배치로 경계 넘음| PANDAS
BUILTIN -.->|Catalyst가 이해·최적화| FAST["빠름 · 최적화됨"]
PANDAS -.->|벡터 연산| MID["빠름 · 그러나 블랙박스"]
PUDF -.->|행마다 직렬화| SLOW["느림 · 블랙박스"]
원칙 하나만 챙겨 두면 됩니다 — 경계를 넘지 않는 코드가 가장 빠르다. 나머지는 이 원칙을 어쩔 수 없이 어겨야 할 때, 그 비용을 얼마나 줄이느냐의 문제입니다. 이제 세 갈래를 차례로 파고듭니다.
PySpark API — Python으로 쓰지만 JVM에서 실행된다
SparkSession — Python 쪽 진입점
PySpark의 모든 것은 SparkSession에서 시작합니다. DataFrame을 만들고, SQL을 실행하고, 설정을 읽는 통합 진입점입니다.
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = (
SparkSession.builder
.appName("daily-sales")
.config("spark.sql.shuffle.partitions", "200") # 3~4단계에서 다룬 셔플 파티션 수
.getOrCreate()
)
# 데이터 읽기 — 반환값은 파이썬 객체지만, 실제 데이터는 JVM 쪽에 있다
df = spark.read.parquet("s3://warehouse/orders/")
# 변환 — 이 체인은 "계획"을 쌓을 뿐, 아직 아무것도 실행되지 않는다 (lazy)
result = (
df.filter(F.col("amount") > 100_000)
.groupBy("region")
.agg(F.sum("amount").alias("total"),
F.countDistinct("customer_id").alias("customers"))
)
result.show() # 액션 — 이 순간에야 JVM에서 실제 실행이 일어난다
여기서 결정적으로 이해해야 할 것은, 위 df나 result가 데이터를 담고 있는 파이썬 객체가 아니라는 점입니다. 그것들은 JVM 쪽에 실재하는 DataFrame을 가리키는 얇은 파이썬 핸들(handle)입니다. df.filter(...)를 호출하면 파이썬은 그 요청을 JVM으로 넘겨 JVM 쪽 논리 계획에 노드를 하나 더 붙일 뿐입니다.
Py4J — Python과 JVM을 잇는 다리
그 “넘김”을 담당하는 것이 Py4J입니다. PySpark 드라이버는 사실 두 개의 프로세스입니다 — 여러분의 파이썬 프로세스, 그리고 그것이 띄운 JVM 프로세스. Py4J는 이 둘 사이를 잇는 다리로, 파이썬에서 df.groupBy("region")를 부르면 그 호출이 소켓을 통해 JVM 쪽 대응 객체의 메서드 호출로 전달됩니다.
이 구조가 주는 통찰이 하나 있습니다. 순수 DataFrame/SQL 코드만 쓰면 파이썬은 “무엇을 하라”는 지시만 JVM으로 넘기고, 실제 데이터 처리는 전부 JVM에서 일어납니다. 즉 파이썬 인터프리터의 느림(GIL, 인터프리터 오버헤드)이 처리 성능에 개입하지 않습니다. “PySpark는 느리다”는 흔한 오해가 여기서 갈립니다 — 순수 DataFrame 코드는 Scala와 사실상 같은 엔진에서 같은 속도로 돕니다. 느려지는 것은 우리가 경계를 넘어 데이터를 파이썬으로 끌고 올 때뿐입니다.
왜 대부분 내장 함수로 충분한가
PySpark의 pyspark.sql.functions(관례적으로 F로 임포트)는 놀라울 만큼 방대합니다. 문자열 처리, 날짜/시간, 수학, 조건 분기, 집계, 윈도, 배열/맵, JSON 파싱까지 — “파이썬으로 한 줄 짜면 될 것 같은” 대부분의 연산에 이미 내장 함수가 있습니다.
from pyspark.sql import functions as F
df = spark.read.parquet("s3://warehouse/events/")
enriched = df.select(
F.col("user_id"),
# 문자열 정규화 — upper/trim
F.upper(F.trim(F.col("country"))).alias("country"),
# 조건 분기 — Python if 대신 when/otherwise
F.when(F.col("amount") > 100_000, "high")
.when(F.col("amount") > 10_000, "mid")
.otherwise("low").alias("tier"),
# 날짜 처리 — 이벤트 시각에서 요일 추출
F.date_format(F.col("event_ts"), "EEEE").alias("weekday"),
# 중첩 JSON 필드 접근
F.col("payload.device.os").alias("os"),
)
이 코드에는 파이썬 함수가 하나도 실행되지 않습니다. F.upper, F.when, F.date_format은 파이썬 함수 호출처럼 보이지만, 실제로는 JVM 쪽 Catalyst 표현식을 만드는 빌더일 뿐입니다. 그래서 이 전부가 3단계에서 본 Catalyst의 최적화 대상이 되고, Tungsten의 whole-stage code generation으로 컴파일됩니다.
내장 함수 우선 원칙의 뿌리가 여기 있습니다 — 내장 함수는 (1) 경계를 넘지 않고, (2) 옵티마이저가 이해하며, (3) JVM 네이티브 코드로 실행됩니다. UDF를 짜기 전에 항상 먼저 물어야 합니다: “이걸 내장 함수 조합으로 표현할 수 없나?” 답이 “된다”인 경우가 생각보다 훨씬 많습니다.
UDF — 편리함의 대가는 직렬화와 블랙박스
UDF가 하는 일과, 그 비용의 정체
내장 함수로 도저히 표현할 수 없는 로직 — 사내 규칙이 복잡한 커스텀 파싱, 외부 파이썬 라이브러리(예: 특정 NLP·지오코딩 패키지)를 써야 하는 변환 — 이 있을 때, UDF(User-Defined Function)로 임의의 파이썬 함수를 컬럼 연산으로 등록할 수 있습니다.
문제는 UDF가 파이썬 코드라는 데 있습니다. 파이썬 코드는 JVM에서 실행될 수 없으므로, Spark는 각 executor 옆에 별도의 파이썬 워커 프로세스를 띄우고 데이터를 그쪽으로 넘겨야 합니다. 이 “넘김”이 정확히 앞에서 말한 경계 넘기이고, UDF의 비용은 거의 전부 여기서 나옵니다.
from pyspark.sql import functions as F
from pyspark.sql.types import StringType
# ❌ 굳이 UDF로 짠 예 — 사실 내장 함수로 충분하다
@F.udf(returnType=StringType())
def classify_amount(amount):
# 이 파이썬 함수는 "행 하나마다" 파이썬 워커에서 호출된다
if amount is None:
return "unknown"
if amount > 100_000:
return "high"
return "low"
df_udf = df.withColumn("tier", classify_amount(F.col("amount")))
# ✅ 같은 로직을 내장 함수로 — 경계를 넘지 않는다
df_builtin = df.withColumn(
"tier",
F.when(F.col("amount").isNull(), "unknown")
.when(F.col("amount") > 100_000, "high")
.otherwise("low"),
)
두 코드는 같은 결과를 내지만 실행은 전혀 다릅니다. 아래(df_builtin)는 JVM에서 끝나고, 위(df_udf)는 모든 행을 파이썬 워커로 보냈다가 결과를 다시 받아 옵니다.
Python UDF — 행 단위 pickle 직렬화
전통적인 Python UDF의 실행을 단계로 풀면 이렇습니다.
- JVM executor가 처리할 행을 하나씩 꺼낸다.
- 그 행을 pickle로 직렬화해 파이썬 워커로 보낸다.
- 파이썬 워커가 UDF를 그 행에 대해 호출한다.
- 결과를 다시 pickle로 직렬화해 JVM으로 돌려보낸다.
- JVM이 역직렬화해 결과 컬럼에 채운다.
핵심은 이 왕복이 행마다 반복된다는 점입니다. 1억 행이면 1억 번의 직렬화/역직렬화가 일어납니다. 직렬화 자체의 CPU 비용에, 파이썬 인터프리터가 행을 하나씩 처리하는(벡터화 없는) 비용이 더해집니다. 그래서 무거운 Python UDF가 낀 잡은 종종 순수 DataFrame 버전보다 수 배에서 수십 배 느립니다.
# Python UDF의 실행 모델을 의사코드로 — 행 단위 루프가 본질이다
# (Spark 내부가 대략 이런 일을 한다)
for row in partition: # 파티션의 모든 행을 하나씩
py_value = pickle_deserialize(row) # JVM → 파이썬: 역직렬화
result = classify_amount(py_value) # 파이썬 함수 호출 (벡터화 없음)
jvm_value = pickle_serialize(result) # 파이썬 → JVM: 직렬화
emit(jvm_value)
Pandas UDF — Arrow 배치로 비용을 줄인다
Pandas UDF(Arrow 기반 벡터화 UDF)는 같은 “파이썬으로 넘긴다”를 훨씬 싸게 해냅니다. 차이는 두 가지입니다.
- 전송 방식: 행 단위 pickle 대신, 여러 행을 Apache Arrow 컬럼 배치로 한 번에 넘깁니다. Arrow는 언어 중립 컬럼 포맷이라 JVM↔Python 사이에서 직렬화/역직렬화 비용이 거의 없습니다(제로카피에 가깝습니다).
- 실행 방식: 함수가 행 하나가 아니라
pandas.Series(또는DataFrame)를 통째로 받습니다. 그래서 내부에서 pandas/NumPy의 벡터 연산을 쓸 수 있습니다 — 파이썬 루프가 아니라 C로 구현된 배열 연산 한 번으로 배치 전체를 처리합니다.
import pandas as pd
from pyspark.sql.functions import pandas_udf
from pyspark.sql.types import StringType
# Pandas UDF — 입력도 출력도 pandas.Series (배치 단위)
@pandas_udf(StringType())
def classify_amount_vec(amount: pd.Series) -> pd.Series:
# amount는 행 하나가 아니라 수천 행짜리 Series다.
# 파이썬 루프 없이 벡터 연산으로 배치 전체를 한 번에 처리한다.
result = pd.Series("low", index=amount.index)
result[amount > 100_000] = "high"
result[amount.isna()] = "unknown"
return result
df_pandas = df.withColumn("tier", classify_amount_vec(F.col("amount")))
같은 UDF라도 이 벡터화 버전은 Python UDF보다 대체로 훨씬 빠릅니다 — 직렬화 비용은 Arrow로 무너지고, 행 단위 파이썬 호출은 배치 벡터 연산으로 사라지기 때문입니다. Pandas UDF에는 여러 변형이 있습니다.
- Series → Series (위 예): 컬럼을 받아 같은 길이의 컬럼을 반환. 대부분의 스칼라 변환에 씀.
- Iterator of Series: 배치들을 반복자로 받아, 무거운 초기화(모델 로딩 등)를 배치마다가 아니라 파티션당 한 번만 하도록.
- Grouped Map (
applyInPandas):groupBy후 각 그룹을 통째 pandas DataFrame으로 받아 처리. 그룹별 회귀·정규화처럼 “그룹 안을 pandas로 다루고 싶은” 경우.
# Grouped Map — 고객별로 그룹을 pandas DataFrame으로 받아 처리
def normalize_amount(pdf: pd.DataFrame) -> pd.DataFrame:
# pdf는 한 customer_id의 모든 행이 담긴 pandas DataFrame
pdf = pdf.copy()
pdf["amount_z"] = (pdf["amount"] - pdf["amount"].mean()) / pdf["amount"].std(ddof=0)
return pdf
# 반환 스키마를 명시해야 한다 (JVM이 결과 구조를 알아야 하므로)
result = df.groupBy("customer_id").applyInPandas(
normalize_amount,
schema="customer_id string, amount double, amount_z double",
)
그래도 UDF는 여전히 옵티마이저의 블랙박스다
Pandas UDF가 직렬화 비용을 줄여 주지만, UDF의 근본적 한계는 성능이 아니라 불투명성입니다. 내장 함수는 Catalyst가 그 의미를 압니다 — F.col("amount") > 100_000을 보고 “이건 조건 푸시다운으로 데이터 소스까지 내려보낼 수 있다”, “이 컬럼만 읽으면 된다(컬럼 프루닝)”고 판단합니다. 하지만 UDF는 Catalyst에게 “뭘 하는지 알 수 없는 함수”입니다.
그래서 UDF 앞에서는 조건 푸시다운도, 컬럼 프루닝도, 표현식 재정렬도 멈춥니다. Pandas UDF로 전송 비용을 줄여도 이 최적화 손실은 그대로 남습니다. 결론은 명확합니다.
- 1순위: 내장 함수(
F.*)와 SQL로 표현할 수 있는지 먼저 찾는다. 대부분 가능하다. - 2순위: 정말 파이썬 로직이 필요하면, Python UDF가 아니라 Pandas UDF를 쓴다.
- 최후: 벡터화도 불가능한 순수 파이썬 로직만 Python UDF로. 그리고 그것이 병목이 아닌지 Spark UI로 확인한다.
UDF는 “금지”가 아니라 “최후의 수단”입니다. 편리하다는 이유로 습관처럼 UDF를 쓰면, 옵티마이저를 스스로 껐다 켰다 하는 코드가 됩니다.
pandas API on Spark — 기존 pandas 코드를 분산으로
왜 필요한가 — pandas의 벽
데이터 분석가·과학자는 대개 pandas로 사고합니다. df.groupby(...).mean(), df["col"].fillna(0) 같은 문법이 손에 익어 있습니다. 문제는 pandas가 단일 머신 메모리에 갇혀 있다는 것입니다 — 데이터가 메모리보다 커지면 그대로 무너집니다. 그렇다고 익숙한 pandas 코드를 PySpark DataFrame API로 전면 재작성하는 것은 비용이 큽니다.
pandas API on Spark(pyspark.pandas, 관례상 ps)가 이 간극을 메웁니다. pandas와 거의 같은 API를 제공하되, 뒤에서는 Spark DataFrame으로 분산 실행합니다. 기존 pandas 코드를 최소 수정으로 대용량에 태울 수 있습니다.
import pyspark.pandas as ps
# pandas를 쓰던 사람에게 익숙한 그 문법 그대로 — 그러나 분산 실행된다
psdf = ps.read_parquet("s3://warehouse/orders/")
# import만 바꾸면 되는 수준의 친숙함
psdf["amount_krw"] = psdf["amount"] * 1300
summary = (
psdf[psdf["amount"] > 100_000]
.groupby("region")["amount"]
.agg(["sum", "mean", "count"])
)
print(summary.head()) # 내부적으로는 Spark 잡이 돈다
언제 쓰고, 언제 네이티브로 내려가야 하는가
pandas API on Spark는 훌륭한 다리이지만, 만능 치환은 아닙니다. 성격을 알고 써야 합니다.
pandas API on Spark가 어울리는 경우
- 이미 pandas로 짜인 코드를 큰 데이터에 최소 수정으로 태워야 할 때
- 팀이 pandas 관용구에 익숙하고 러닝커브를 줄이고 싶을 때
- 탐색적 분석(EDA)처럼 익숙한 문법으로 빠르게 훑어야 할 때
네이티브 PySpark DataFrame으로 내려가야 하는 경우
- 성능이 중요한 프로덕션 파이프라인: pandas API on Spark는 pandas 시맨틱을 맞추기 위해 내부적으로 추가 연산을 넣는 경우가 있어, 같은 로직이라도 네이티브 DataFrame이 더 빠를 수 있습니다.
- 세밀한 제어가 필요할 때: 파티셔닝, 조인 전략(4단계), 캐시 등 성능 손잡이는 네이티브 API에서 훨씬 직접적입니다.
- pandas 특유의 무거운 연산을 피해야 할 때(아래).
주의점 — pandas 시맨틱이 부르는 숨은 셔플
가장 조심할 지점은, pandas의 어떤 연산은 분산 환경에서 본질적으로 비쌉니다. pandas는 “행이 순서를 갖고, 정수 인덱스로 접근 가능한” 단일 머신 자료구조를 전제합니다. 분산 데이터에는 그런 전역 순서가 없으므로, 그걸 흉내 내려면 셔플이나 전역 정렬이 필요합니다.
import pyspark.pandas as ps
psdf = ps.read_parquet("s3://warehouse/orders/")
# ⚠️ 전역 순서를 전제하는 연산 — 분산에선 비싸다
psdf = psdf.sort_values("event_ts") # 전역 정렬 → 셔플
row = psdf.iloc[1000] # 위치 기반 인덱싱 → 전역 순서 계산 필요
# ⚠️ 기본 인덱스 방식에 따라 순차 인덱스 부여가 셔플을 부를 수 있다
# (compute.default_index_type 설정으로 distributed 계열을 쓰면 완화)
# ✅ 반대로, 집계·필터·groupby는 분산에 자연스럽다 — 값싸다
by_region = psdf.groupby("region")["amount"].sum()
기억할 원칙은 이렇습니다 — 집계·필터·groupby처럼 “값 기반”으로 병렬화되는 연산은 값싸지만, sort_values·iloc·순차 인덱스처럼 “전역 순서·위치”를 전제하는 연산은 셔플을 부릅니다. pandas에서 무심코 쓰던 iloc·정렬·reset_index가 대용량에서 갑자기 느려진다면 이 이유입니다.
또 하나. pandas API on Spark 객체와 네이티브 Spark DataFrame은 서로 변환할 수 있어, 필요한 구간만 골라 내려갈 수 있습니다.
# pandas API로 편하게 전처리하다가, 성능이 중요한 구간만 네이티브로 내려간다
sdf = psdf.to_spark() # pyspark.pandas → Spark DataFrame
sdf = sdf.repartition(200, "region") # 네이티브에서 파티셔닝을 직접 제어
# ... 네이티브 DataFrame으로 최적화된 조인/집계 ...
psdf2 = sdf.pandas_api() # 다시 pandas API로 (필요하면)
이 상호 변환 덕분에 “전체를 pandas API로” 또는 “전체를 네이티브로”라는 양자택일이 아니라, 친숙함이 필요한 곳은 pandas API로, 성능이 필요한 곳은 네이티브로 섞어 쓰는 실용적 전략이 가능합니다.
정리
PySpark 실무의 핵심을 정리하면 이렇습니다.
- PySpark로 쓰지만 실행은 JVM에서 일어난다:
SparkSession은 Python 진입점이고, DataFrame 핸들은 JVM 쪽 실체를 가리키는 얇은 참조입니다. Py4J가 둘을 잇고, 순수 DataFrame/SQL 코드는 파이썬 인터프리터의 느림과 무관하게 JVM에서 최적화되어 돕니다 — “PySpark는 느리다”는 경계를 넘을 때만 사실입니다. - 경계를 넘지 않는 코드가 가장 빠르다:
pyspark.sql.functions의 내장 함수는 경계를 넘지 않고, Catalyst가 이해하며, Tungsten이 네이티브로 컴파일합니다. UDF를 짜기 전에 항상 “내장 함수 조합으로 안 되나?”를 먼저 물어야 합니다. - UDF는 최후의 수단, 그중에서도 Pandas UDF: Python UDF는 행 단위 pickle 왕복으로 느리고, Pandas UDF는 Arrow 컬럼 배치 + 벡터 연산으로 그 비용을 크게 줄입니다. 하지만 둘 다 Catalyst에게는 블랙박스라 조건 푸시다운·컬럼 프루닝 같은 최적화가 그 앞에서 멈춥니다 — Pandas UDF도 이 불투명성은 해결하지 못합니다.
- pandas API on Spark는 다리이지 치환이 아니다:
pyspark.pandas로 익숙한 pandas 코드를 최소 수정으로 분산 실행할 수 있지만,sort_values·iloc·순차 인덱스처럼 전역 순서를 전제하는 연산은 셔플을 부릅니다. 성능이 중요한 구간은to_spark()로 네이티브 DataFrame에 내려가 파티셔닝·조인 전략을 직접 제어하는 것이 정답입니다.
이 여섯 단계를 관통하는 한 문장이 있습니다 — PySpark를 잘 쓴다는 것은 Python↔JVM 경계를 언제 넘고 언제 넘지 않는지를 아는 것이다. 그리고 그 판단의 근거는 결국 앞 단계들에서 쌓은 실행·최적화 이해입니다. 셔플이 왜 비싼지(4단계), Catalyst가 무엇을 최적화하는지(3단계)를 알기에, “이 UDF가 그 최적화를 막는다”가 손에 잡히는 것입니다.
이제 남은 것은 마지막 한 걸음입니다. 지금까지 다룬 Spark를 2026년의 레이크하우스 스택에 얹으면 — 오브젝트 스토리지 위에서 ACID·시간여행·업서트를 하는 처리 엔진이 됩니다. 그 결합이 다음 단계의 주제입니다.
다음 학습 (Next Learning)
- Spark Iceberg/Delta 연동 — 레이크하우스 저장 포맷과의 결합 — 7단계(심화): Spark를 레이크하우스 스택에 얹어 시리즈를 완주하기
- Spark Catalyst · Tungsten · AQE — 3단계: UDF가 왜 최적화를 막는지, 내장 함수가 왜 빠른지의 근거
- Spark Essential Curriculum — 시리즈 로드맵으로 돌아가 진행 상황 확인하기