RDD·DataFrame·Dataset API 비교
세 가지 API가 공존하는 이유
Apache Spark에는 같은 분산 데이터를 다루는 세 가지 API가 있다. RDD(Resilient Distributed Dataset), DataFrame, Dataset. 세 개가 동시에 존재하는 이유는 Spark가 버전을 거치며 발전했기 때문이다.
- Spark 1.0 (2014): RDD만 존재. 최대한의 유연성, 최소한의 최적화.
- Spark 1.3 (2015): DataFrame 도입. 구조적 API + Catalyst 옵티마이저.
- Spark 1.6 (2016): Dataset 도입. DataFrame + 컴파일 타임 타입 안전성.
- Spark 2.0+: DataFrame =
Dataset[Row]. 두 API가 통합됨.
오늘날 실무에서는 세 API를 모두 접하게 된다. 어떤 상황에서 무엇을 써야 하는지, 그리고 왜 DataFrame/Dataset이 RDD보다 일반적으로 빠른지를 이해하는 것이 핵심이다.
RDD: 저수준의 자유
RDD는 파티션으로 분산된 변경 불가능한(Immutable) JVM 객체 컬렉션이다. Spark의 모든 고수준 API는 내부적으로 RDD 위에 구현된다.
val rdd = sc.textFile("logs/*.txt") // RDD[String]
val errors = rdd.filter(_.contains("ERROR")) // RDD[String]
val counts = errors.map(line => (extractIp(line), 1)) // RDD[(String,Int)]
.reduceByKey(_ + _)
counts.saveAsTextFile("output/")RDD의 특성은 챕터 1에서 다뤘다(분산, 불변, 내구성, Lineage 기반 복구). 그런데 RDD는 치명적인 한계가 있다. Spark는 RDD 연산의 내용을 모른다. rdd.map(line => doSomething(line))에서 doSomething이 무엇을 하는지 Catalyst 옵티마이저가 볼 수 없다. 코드는 JVM 직렬화(또는 Kryo)로 처리되어 성능이 낮고, 옵티마이저는 개입할 여지가 없다.
RDD를 여전히 써야 할 때:
- 비정형 데이터 처리 (스키마가 없는 로우 바이트·텍스트)
- 커스텀 파티션 분할 로직이 필요할 때
- 저수준 그래프 알고리즘·ML 반복 연산의 파티션별 제어
- 기존 RDD 기반 레거시 코드 유지
DataFrame: 구조적 API의 시작
DataFrame은 Named Column이 있는 분산 테이블이다. Python pandas의 DataFrame과 개념이 비슷하지만, 수십 TB를 수백 노드에 분산해 처리한다. Spark 2.0부터 DataFrame은 Dataset[Row]의 타입 별칭이다.
df = spark.read.csv("logs/*.csv", header=True, inferSchema=True)
errors = df.filter(df.level == "ERROR") \
.groupBy("source_ip") \
.count() \
.orderBy("count", ascending=False)
errors.show(20)DataFrame의 열 참조(df.level == "ERROR")는 JVM 람다가 아니라 표현식 트리(Expression Tree)다. Catalyst 옵티마이저가 이 트리를 분석하고 최적화한다. SQL로도 완전히 동일하게 쓸 수 있다.
df.createOrReplaceTempView("logs")
spark.sql("SELECT source_ip, COUNT(*) FROM logs WHERE level='ERROR' GROUP BY 1")DataFrame API와 SQL은 동일한 실행 계획으로 컴파일된다. 결과적으로 성능 차이가 없다.
DataFrame의 한계: Python/R에서는 컴파일 타임 타입 검사가 없다. df.select("typo_column")처럼 잘못된 컬럼 이름을 지정해도 df.show()를 실행하기 전까지 오류를 알 수 없다(런타임 오류).
Dataset: 타입 안전성 + 최적화
Dataset은 DataFrame에 JVM 타입 안전성을 더한 API다. Scala와 Java에서만 지원한다. Python/R에서 DataFrame은 이미 Dataset[Row]이므로, Python 개발자에게 별도의 Dataset API는 없다.
case class LogEntry(timestamp: String, level: String, sourceIp: String, message: String)
val ds: Dataset[LogEntry] = spark.read.csv("logs/*.csv")
.as[LogEntry] // Row → LogEntry 타입으로 바인딩
val errors = ds.filter(_.level == "ERROR") // 컴파일 타임 타입 체크
val byIp = errors.groupByKey(_.sourceIp).count()_.level처럼 직접 케이스 클래스 필드를 참조하므로, 오타나 타입 불일치는 컴파일 단계에서 잡힌다. RDD처럼 유연하면서도 Catalyst 최적화와 Tungsten 직렬화를 모두 누린다.
Dataset의 주의점: filter(_.level == "ERROR")에서 람다 내부는 Catalyst가 볼 수 없다. 이 경우 열 표현식(filter({{BODY}}quot;level" === "ERROR"))보다 최적화 여지가 줄어들 수 있다. 가능하면 열 표현식 API를 섞어 쓰는 것이 좋다.
Catalyst 옵티마이저: 쿼리 최적화 4단계
DataFrame/Dataset의 핵심 성능 비밀은 Catalyst 옵티마이저다. Catalyst는 SQL 쿼리나 DataFrame 변환을 받아 4단계로 최적화한 물리 실행 계획을 만든다.
② 논리 최적화에서 가장 중요한 두 가지 규칙:
- Predicate Pushdown:
df.join(other).filter(df.year == 2025)처럼 Join 후에 필터를 쓰면, Catalyst가 자동으로 필터를 Join 전에 적용한다. 처리하는 데이터 양이 크게 줄어든다. - Projection Pruning: 실제로 사용하는 컬럼만 읽는다. Parquet 같은 컬럼형 포맷과 결합하면 I/O가 극적으로 감소한다.
③ 물리 계획에서 Join 전략을 자동 선택한다. 작은 테이블이 Join에 참여하면 Broadcast Hash Join(작은 쪽을 모든 Executor에 복사 → 셔플 없음), 둘 다 크면 Sort Merge Join(정렬 후 병합)으로 전환한다. Spark 3.x의 Adaptive Query Execution(AQE)은 실행 중 실제 파티션 크기를 측정해 전략을 런타임에 재조정한다.
Tungsten: 실행 엔진의 핵심
Tungsten은 Catalyst가 만든 물리 계획을 실제로 실행하는 엔진이다. 두 가지 방식으로 JVM의 한계를 극복한다.
① Off-Heap 메모리 관리: Tungsten은 JVM Heap 대신 직접 OS 네이티브 메모리를 관리한다. GC(Garbage Collection) 압박이 없고, 캐시-친화적인 컴팩트 바이너리 포맷(UnsafeRow)으로 데이터를 저장한다. UnsafeRow는 고정 오프셋 기반 레이아웃이라 null 비트맵 + 고정 필드 + 가변 필드를 한 줄로 직렬화한다.
② Whole-Stage Code Generation: DataFrame 연산 체인을 하나의 JVM 메서드로 합쳐 바이트코드를 직접 생성한다. 일반 해석(interpreted) 실행에서는 연산마다 가상 메서드 호출이 발생하지만, whole-stage codegen은 인라인 루프 하나로 모든 연산을 처리한다. 결과적으로 CPU 캐시 적중률이 높아지고 해석 오버헤드가 사라진다.
RDD는 이 두 이점을 모두 받지 못한다. JVM 직렬화(기본값) 또는 Kryo로 객체를 직렬화하며, Tungsten 코드 생성도 적용되지 않는다. 이것이 DataFrame/Dataset이 동일한 연산에서 RDD보다 수 배~수십 배 빠른 근본 이유다.
Encoder: Dataset의 직렬화 비밀
Dataset은 케이스 클래스와 UnsafeRow 사이를 Encoder가 변환한다. Encoder는 런타임에 바이트코드로 생성되는 변환기다. JVM Reflection 기반 직렬화(Kryo/Java)보다 빠르고, 직렬화된 형태가 Tungsten UnsafeRow와 호환되어 역직렬화 없이 바로 쿼리 엔진이 처리할 수 있다.
API 선택 가이드
세 API의 특성을 한 표로 정리한다.
| 특성 | RDD | DataFrame | Dataset |
|---|---|---|---|
| 언어 | Scala/Java/Python/R | 모두 | Scala/Java만 |
| 타입 안전성 | 컴파일 타임 | 런타임만 | 컴파일 타임 |
| Catalyst 최적화 | ✗ | ✓ | ✓ (람다 제외) |
| Tungsten 직렬화 | ✗ | ✓ | ✓ |
| API 수준 | 저수준 | 고수준 | 중간 |
| 스키마 | 없음 | 있음 | 있음 |
언제 무엇을 쓸까:
- DataFrame: 기본값. Python/R/Scala/Java 모두. SQL 친화적. 대부분의 ETL, 집계, 조인.
- Dataset: Scala/Java에서 타입 안전성이 필요할 때. 도메인 객체와 Spark 연산을 강하게 결합하고 싶을 때.
- RDD: 비정형 데이터(바이너리, 텍스트 파싱 전 단계), 커스텀 파티셔닝, Spark API로 표현 불가능한 저수준 제어가 필요할 때. 새 코드에서 RDD를 기본으로 쓰는 것은 피한다.
Python 개발자를 위한 정리: Python에서 Dataset은 없다. df.as[MyClass]가 없고, spark.read.*(...) 결과는 항상 DataFrame이다. Spark는 Python UDF를 직렬화할 때 Pickle을 쓰므로 성능이 낮다. Pandas UDF(Arrow 기반)나 Spark SQL 함수(functions.*)를 최대한 활용해 JVM 경계 오버헤드를 줄이는 것이 중요하다.
한 줄 정리
RDD는 저수준 유연성을 제공하지만 최적화가 없고, DataFrame은 Catalyst + Tungsten으로 고성능 구조적 처리를 가능케 하며, Dataset은 Scala/Java에서 컴파일 타임 타입 안전성과 최적화를 동시에 제공한다. 새 코드의 기본값은 DataFrame이다.
References
- https://www.databricks.com/blog/2016/07/14/a-tale-of-three-apache-spark-apis-rdds-dataframes-and-datasets.html
- https://data-flair.training/blogs/apache-spark-rdd-vs-dataframe-vs-dataset/
- https://medium.com/@tejprakashbk1996/rdd-vs-dataframe-vs-dataset-in-apache-spark-which-one-should-you-use-and-why-c79495f33484
- https://raysachin.medium.com/understanding-spark-rdd-dataframe-and-dataset-eb95fe81d107
- https://luminousmen.com/post/spark-tips-dataframe-api/
- https://medium.com/@swayampravapanda686/sparks-catalyst-optimizer-how-it-boosts-performance-in-dataframes-7728dcf84048
- https://rharshad.com/spark-sql-dataframes-datasets/
- https://algoscale.com/blog/apache-spark-rdd-vs-dataframe/