Back to Blog
SparkRDDTransformationActionLazy EvaluationDAG

0x01. Apache Spark 기초 - RDD와 분산 데이터 처리

Apache Spark의 아키텍처와 핵심 추상화인 RDD를 이해하고, Transformation과 Action의 차이를 알아본다.

대용량 데이터를 처리해야 하는 상황을 생각해 보자. 수십 TB의 로그 데이터를 분석하거나, 수억 건의 거래 기록에서 패턴을 추출해야 한다면? 한 대의 컴퓨터로는 한계가 있다. 여러 대의 컴퓨터에 데이터를 나누어 동시에 처리하는 분산 데이터 처리가 필요한 이유다.

Apache Spark는 이 분산 데이터 처리를 빠르고 간결하게 수행할 수 있도록 설계된 오픈소스 엔진이다. Hadoop MapReduce의 뒤를 이어 사실상 대규모 데이터 처리의 표준으로 자리 잡았다.


MapReduce의 한계, 그리고 Spark의 등장

Spark를 이해하려면 먼저 그 이전의 접근 방식을 알아야 한다.

Hadoop MapReduce는 대규모 데이터를 분산 처리하기 위한 최초의 대중적 프레임워크였다. 데이터를 Map 단계에서 변환하고, Reduce 단계에서 집계하는 단순한 모델이다. 하지만 이 방식에는 근본적인 한계가 있었다.

  • 디스크 I/O 병목: MapReduce는 각 단계의 중간 결과를 디스크에 기록한다. Map의 출력을 디스크에 쓰고, Reduce가 다시 디스크에서 읽는다. 복잡한 파이프라인에서는 이 디스크 읽기/쓰기가 수십 번 반복되어 심각한 성능 저하를 유발한다.
  • 단순한 실행 모델: Map과 Reduce 두 단계만 존재하기 때문에, 복잡한 데이터 처리 로직을 표현하려면 여러 MapReduce 잡을 연쇄적으로 실행해야 한다. 각 잡 사이마다 디스크 I/O가 발생한다.
  • 반복 연산에 취약: 머신러닝처럼 동일 데이터를 반복 처리하는 작업에서는 매번 디스크에서 데이터를 다시 읽어야 하므로 극도로 비효율적이다.

Spark는 이 문제들을 인메모리(In-Memory) 처리로 해결한다. 중간 결과를 디스크가 아닌 메모리에 유지하기 때문에, 반복적인 디스크 I/O를 제거할 수 있다. 또한 MapReduce처럼 Map-Reduce 두 단계로 제한되지 않고, DAG(Directed Acyclic Graph) 기반의 유연한 실행 계획을 수립하여 복잡한 워크플로를 효율적으로 처리한다.

결과적으로 Spark는 메모리 기반 처리에서 MapReduce보다 최대 100배, 디스크 기반에서도 약 10배 빠른 성능을 보인다.


Spark 아키텍처

Spark 애플리케이션은 여러 컴포넌트가 협력하여 동작한다. 전체 구조를 택배 물류 시스템에 비유하면 이해하기 쉽다.

Driver Program

Driver는 Spark 애플리케이션의 main() 함수를 실행하는 프로세스다. 택배 물류 시스템의 본사 관제 센터와 같다. 사용자가 작성한 코드를 분석하여 실행 계획(DAG)을 수립하고, 이를 작은 단위의 Task로 분할하여 Executor에 배분한다.

Driver의 핵심 역할은 다음과 같다.

  • 사용자의 Spark 코드를 DAG(Directed Acyclic Graph) 로 변환
  • Cluster Manager에 리소스 요청
  • Task를 Executor에 전송하고 진행 상황 모니터링
  • 최종 결과 수집

Executor

Executor는 실제 데이터 처리를 수행하는 워커 프로세스다. 택배 시스템의 각 지역 물류 센터와 같다. 클러스터의 워커 노드(Worker Node)에서 실행되며, Driver가 보낸 Task를 병렬로 처리하고 결과를 반환한다.

각 Executor는 할당된 CPU와 메모리 리소스 내에서 작업을 수행하며, 데이터를 메모리나 디스크에 캐싱할 수 있다. 하나의 애플리케이션에 여러 Executor가 할당되고, 각 Executor는 여러 Task를 동시에 실행한다.

Cluster Manager

Cluster Manager는 클러스터 전체의 리소스를 관리하는 외부 서비스다. 택배 시스템의 차량 배차 시스템에 해당한다. Driver가 리소스를 요청하면, Cluster Manager가 적절한 워커 노드에 Executor를 할당한다.

Spark는 다음 세 가지 Cluster Manager를 지원한다.

Cluster Manager설명
StandaloneSpark에 내장된 간단한 클러스터 매니저. 별도 설치 없이 바로 사용 가능
YARNHadoop 생태계의 리소스 매니저. 기존 Hadoop 클러스터에 Spark를 통합할 때 적합
Kubernetes컨테이너 오케스트레이션 플랫폼. 클라우드 네이티브 환경에서 주로 사용

SparkSession과 SparkContext

Spark 애플리케이션의 진입점(Entry Point) 은 버전에 따라 다르다.

Spark 1.x에서는 SparkContext가 유일한 진입점이었다. RDD를 생성하고 클러스터와 통신하는 모든 기능이 SparkContext에 집중되어 있었다. Spark 2.0부터는 SparkSession이 도입되어, SparkContext의 기능에 더해 DataFrame, SQL 등 고수준 API까지 통합된 인터페이스를 제공한다.

from pyspark.sql import SparkSession

# SparkSession 생성 (현재 권장 방식)
spark = SparkSession.builder \
    .appName("MyApp") \
    .master("local[*]") \
    .getOrCreate()

# SparkContext는 SparkSession 내부에 포함되어 있다
sc = spark.sparkContext

내부적으로 SparkSession은 SparkContext를 생성하고 관리한다. 따라서 RDD를 직접 다룰 때도 spark.sparkContext를 통해 접근할 수 있다.


RDD: Spark의 핵심 추상화

RDD란?

RDD(Resilient Distributed Dataset) 는 Spark의 가장 기본적인 데이터 추상화다. 이름에 모든 특성이 담겨 있다.

  • Resilient(탄력적): 노드 장애 시 자동으로 복구할 수 있다. 데이터 자체를 복제하는 대신, 데이터를 생성한 연산 과정(Lineage) 을 기록하여 장애 시 해당 파티션만 재계산한다.
  • Distributed(분산): 데이터가 클러스터의 여러 노드에 걸쳐 파티션(Partition) 단위로 분산 저장된다.
  • Dataset(데이터셋): 구조화/비구조화 데이터를 담는 불변(Immutable) 컬렉션이다.

RDD를 쉽게 표현하면, 여러 컴퓨터에 나뉘어 저장된 읽기 전용 배열이다. 한 번 생성된 RDD는 변경할 수 없으며, 변환 연산을 적용하면 항상 새로운 RDD가 생성된다.

RDD 생성 방법

RDD를 만드는 방법은 크게 두 가지다.

1. 기존 컬렉션을 병렬화 (parallelize)

Driver 프로그램의 기존 리스트나 배열을 분산 데이터셋으로 변환한다.

data = [1, 2, 3, 4, 5]
rdd = sc.parallelize(data)

# 파티션 수를 직접 지정할 수도 있다
rdd = sc.parallelize(data, numSlices=4)

테스트나 프로토타이핑에 유용하지만, 전체 데이터가 Driver 메모리에 올라가야 하므로 대용량 데이터에는 적합하지 않다.

2. 외부 데이터 소스에서 읽기

로컬 파일시스템, HDFS, S3 등 다양한 스토리지에서 데이터를 읽어 RDD를 생성한다.

# 텍스트 파일 읽기 ( 줄이 하나의 요소)
rdd = sc.textFile("hdfs://path/to/data.txt")

# 디렉토리  여러 파일 읽기 (파일명, 내용 )
rdd = sc.wholeTextFiles("hdfs://path/to/directory/")

textFile은 파일의 각 줄을 하나의 문자열 요소로 만들며, 두 번째 인자로 파티션 수를 지정할 수 있다.


Transformation: 게으른 변환

Transformation이란?

Transformation(변환) 은 기존 RDD에서 새로운 RDD를 생성하는 연산이다. 원본 RDD는 변경되지 않으며, 변환된 결과를 담은 새 RDD가 반환된다.

가장 중요한 특성은 Lazy Evaluation(지연 평가) 이다. Transformation을 호출해도 즉시 실행되지 않는다. Spark는 어떤 변환을 적용해야 하는지만 기록해 두고, 나중에 Action이 호출될 때 비로소 실제 연산을 수행한다.

왜 이렇게 설계했을까? 즉시 실행하면 각 변환마다 중간 결과를 생성해야 한다. 하지만 지연 평가를 사용하면 Spark가 전체 변환 체인을 미리 파악하고, 최적화된 실행 계획을 세울 수 있다. 예를 들어 map 다음에 filter가 오면, 두 연산을 하나로 합쳐서 데이터를 한 번만 순회하도록 최적화할 수 있다.

주요 Transformation

map

각 요소에 함수를 적용하여 새로운 요소로 변환한다. 입력과 출력이 1:1 관계다.

rdd = sc.parallelize([1, 2, 3, 4, 5])
squared = rdd.map(lambda x: x ** 2)
# 결과: [1, 4, 9, 16, 25]

filter

조건을 만족하는 요소만 걸러낸다.

rdd = sc.parallelize([1, 2, 3, 4, 5])
evens = rdd.filter(lambda x: x % 2 == 0)
# 결과: [2, 4]

flatMap

각 요소에 함수를 적용하되, 하나의 입력에서 0개 이상의 출력을 생성할 수 있다. map과 달리 결과를 평탄화(flatten) 한다.

rdd = sc.parallelize(["hello world", "hi spark"])
words = rdd.flatMap(lambda line: line.split(" "))
# 결과: ["hello", "world", "hi", "spark"]

map을 사용하면 [["hello", "world"], ["hi", "spark"]]처럼 중첩 리스트가 되지만, flatMap은 이를 하나의 리스트로 펼쳐 준다.

reduceByKey

키-값 쌍(Key-Value Pair) RDD에서, 동일한 키를 가진 값들을 하나의 함수로 병합한다.

rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2), ("b", 3)])
result = rdd.reduceByKey(lambda x, y: x + y)
# 결과: [("a", 3), ("b", 4)]

reduceByKey는 각 노드에서 로컬 병합을 먼저 수행한 뒤 네트워크로 전송하므로, 네트워크 트래픽을 크게 줄인다.

groupByKey

동일한 키를 가진 모든 값을 그룹화하여 (Key, Iterable[Value]) 형태로 반환한다.

rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2), ("b", 3)])
result = rdd.groupByKey()
# 결과: [("a", [1, 2]), ("b", [1, 3])]

주의: groupByKey는 모든 값을 네트워크를 통해 셔플(Shuffle)하므로, 단순 집계 목적이라면 reduceByKey가 훨씬 효율적이다. groupByKeyreduce를 하는 것보다 처음부터 reduceByKey를 사용하는 것이 좋다.

Narrow vs Wide Transformation

Transformation은 데이터 이동 범위에 따라 두 종류로 나뉜다.

구분Narrow TransformationWide Transformation
특징하나의 입력 파티션이 하나의 출력 파티션에만 기여하나의 입력 파티션이 여러 출력 파티션에 기여
셔플불필요필요 (네트워크 전송 발생)
예시map, filter, flatMap, unionreduceByKey, groupByKey, join, sortByKey, repartition
성능빠름 (파이프라이닝 가능)느림 (디스크 I/O, 네트워크 I/O)

셔플(Shuffle) 은 데이터를 키 기준으로 재분배하는 과정으로, 디스크 I/O, 직렬화, 네트워크 전송을 수반한다. Spark 작업에서 가장 비용이 큰 연산이므로, Wide Transformation은 최소화하는 것이 성능 최적화의 핵심이다.


Action: 실행을 촉발하다

Action이란?

Action(액션) 은 RDD의 연산 결과를 실제로 계산하고 반환하는 연산이다. Transformation이 "무엇을 할지" 기록하는 것이라면, Action은 "지금 실행하라"는 명령이다.

Action이 호출되면 Spark는 그동안 기록된 모든 Transformation의 DAG를 분석하고, 최적화된 실행 계획을 세운 뒤 클러스터에서 실제 연산을 수행한다.

rdd = sc.parallelize([1, 2, 3, 4, 5])
mapped = rdd.map(lambda x: x * 2)      # Transformation (아직 실행  )
filtered = mapped.filter(lambda x: x > 4)  # Transformation (아직 실행  )

result = filtered.collect()  # Action!  시점에 위의 모든 변환이 실행된다
# 결과: [6, 8, 10]

주요 Action

Action설명반환 타입
collect()모든 요소를 Driver로 가져온다리스트
count()요소의 개수를 반환한다정수
first()첫 번째 요소를 반환한다단일 요소
take(n)처음 n개 요소를 반환한다리스트
reduce(func)함수를 사용하여 모든 요소를 하나로 집계한다단일 값
saveAsTextFile(path)요소를 텍스트 파일로 저장한다없음
countByKey()키별 요소 수를 반환한다 (Key-Value RDD)딕셔너리
foreach(func)각 요소에 함수를 적용한다 (부수 효과용)없음
rdd = sc.parallelize([1, 2, 3, 4, 5])

rdd.count()     # 5
rdd.first()     # 1
rdd.take(3)     # [1, 2, 3]
rdd.reduce(lambda a, b: a + b)  # 15

주의: collect()는 모든 데이터를 Driver의 메모리로 가져오므로, 대용량 데이터에 사용하면 OutOfMemoryError가 발생할 수 있다. 대용량 데이터에서는 take()이나 saveAsTextFile()을 사용하는 것이 안전하다.


Lazy Evaluation과 DAG

Spark의 실행 모델을 하나의 그림으로 정리하면 다음과 같다.

  1. 사용자가 Transformation을 호출하면, Spark는 이를 DAG(Directed Acyclic Graph) 에 기록한다
  2. Action이 호출되면, Spark는 DAG를 분석하여 최적화된 실행 계획을 수립한다
  3. DAG는 Stage로 분할된다. Narrow Transformation은 하나의 Stage로 묶이고, Wide Transformation(셔플)이 발생하는 지점에서 Stage가 나뉜다
  4. 각 Stage는 여러 Task로 분할되어 Executor에서 병렬 실행된다
# 예시: 단어 빈도 카운트 (Word Count)
text_rdd = sc.textFile("hdfs://path/to/logs.txt")          # RDD 생성
words = text_rdd.flatMap(lambda line: line.split(" "))      # Transformation
pairs = words.map(lambda word: (word, 1))                   # Transformation
counts = pairs.reduceByKey(lambda a, b: a + b)              # Transformation (셔플 발생)
counts.saveAsTextFile("hdfs://path/to/output")              # Action -> 실행!

이 예시에서 flatMapmap은 Narrow Transformation이므로 하나의 Stage로 파이프라이닝된다. reduceByKey에서 셔플이 발생하여 새로운 Stage가 시작된다. 최종적으로 saveAsTextFile Action이 호출되는 순간, 전체 DAG가 실행된다.

캐싱(Persistence)

동일한 RDD를 여러 Action에서 반복 사용한다면, 매번 처음부터 다시 계산하는 것은 낭비다. persist() 또는 cache()를 사용하면 첫 번째 Action에서 계산된 결과를 메모리에 유지하여 이후 Action에서 재활용할 수 있다.

counts = pairs.reduceByKey(lambda a, b: a + b)
counts.cache()  # 메모리에 캐싱 예약

counts.count()              #  실행: 전체 DAG 계산 + 결과 캐싱
counts.saveAsTextFile(...)  #  번째 실행: 캐시된 데이터 사용 (빠름)

이것이 Spark가 반복 연산에서 MapReduce보다 압도적으로 빠른 핵심 이유다.


정리

Apache Spark는 인메모리 처리와 DAG 기반 실행 모델로 MapReduce의 한계를 극복한 분산 데이터 처리 엔진이다. 핵심 개념을 다시 정리하면 다음과 같다.

  • 아키텍처: Driver가 실행 계획을 수립하고, Executor가 실제 연산을 수행하며, Cluster Manager가 리소스를 관리한다
  • RDD: 불변(Immutable)이며 분산 저장되는 탄력적 데이터셋. Lineage를 통해 장애 복구가 가능하다
  • Transformation: 새로운 RDD를 생성하는 지연 평가 연산. Narrow(셔플 없음)과 Wide(셔플 있음)로 구분된다
  • Action: 실제 계산을 촉발하여 결과를 Driver에 반환하거나 외부에 저장하는 연산
  • Lazy Evaluation: 모든 Transformation을 DAG로 기록해 두었다가, Action 시점에 최적화하여 실행한다

RDD는 Spark의 가장 기본적인 API이며, 실무에서는 이를 기반으로 한 DataFrameDataset API가 더 많이 사용된다. 다음 글에서는 DataFrame API와 Spark SQL을 통한 구조화된 데이터 처리를 다룰 예정이다.