Now Loading ...
-
[6기] 데브코스 DE WIL 14 | 대용량 데이터 처리 Spark & SparkML
이번 주 학습 목표
Broadcast Variable로 셔플(Shuffle)을 최소화하고(Closure 대비 차이 이해 포함) 룩업/조인 성능을 개선한다.
리소스·스케줄링·메모리 관리(Driver/Executor, Unified/Off-Heap, OOM)를 이해하고 핵심 튜닝 포인트를 정리한다.
캐싱, Pushdown, 파티션 재조정(repartition/coalesce), 힌트, AQE(스큐 조인 포함)를 통해 실행 계획 최적화를 적용한다.
Spark 기타 기능과 메모리 관리
대규모 데이터 처리 환경에서 성능 병목의 가장 흔한 원인 중 하나는 셔플(Shuffle)이다. Spark에서는 이를 줄이기 위한 다양한 최적화 기법을 제공하며, 그중 대표적인 방식이 Broadcast Variable이다. Broadcast Variable은 특히 머신러닝 파이프라인이나 룩업 테이블 처리에서 매우 중요한 역할을 한다.
Broadcast Variable란
Broadcast Variable은 작은 크기의 데이터를 모든 Executor에 미리 전달하여 공유하는 방식이다. 이를 통해 각 태스크가 해당 데이터를 직접 가져오기 위해 셔플을 발생시키는 상황을 방지할 수 있다.
이 방식은 브로드캐스트 조인(Broadcast Join)에서 사용되는 기법과 동일한 원리이며, 보통 룩업 테이블이나 디멘션 테이블을 다룰 때 사용된다. 많은 데이터 웨어하우스 환경에서는 스타 스키마 형태로 팩트 테이블과 디멘션 테이블이 분리되어 있는데, 디멘션 테이블은 크기가 상대적으로 작기 때문에 브로드캐스트에 적합하다. 일반적으로 10~20MB 정도의 데이터가 그 기준이 된다.
Spark에서는 spark.sparkContext.broadcast를 사용해 Broadcast Variable을 생성한다.
Closure 방식과 Broadcast 방식의 차이
Spark에서 UDF 내부에서 외부 데이터를 사용하는 방식은 크게 두 가지로 나뉜다. 하나는 Closure를 사용하는 방식이고, 다른 하나는 Broadcast Variable을 사용하는 방식이다.
Closure 방식에서는 파이썬 데이터 구조가 태스크 단위로 직렬화된다. 즉, 각 태스크마다 동일한 데이터가 반복적으로 전송되며, 이는 네트워크와 메모리 측면에서 비효율적이다. UDF 내부에서 일반 파이썬 변수나 컬렉션을 참조할 경우 이 방식이 사용된다.
반면 Broadcast 방식에서는 데이터가 Worker Node 단위로 한 번만 직렬화되어 전달된다. 이후 해당 데이터는 Executor 내에서 캐싱되며, 여러 태스크가 이를 공유해서 사용한다. 따라서 UDF 안에서 브로드캐스트된 데이터를 참조하는 방식은 훨씬 효율적이다.
Broadcast 데이터셋은 몇 가지 특징을 가진다. Worker Node로 공유되는 데이터는 변경이 불가능하며, 노드별로 한 번만 전송되어 캐싱된다. 단, 이 데이터는 Executor의 Task Memory에 적재되어야 하므로 크기에 제한이 있다.
Broadcast Variable 활용 예제
Broadcast Variable의 활용을 이해하기 위해, 간단한 예제를 살펴본다. 특정 코드에 해당하는 이름을 찾아야 하는 상황을 가정한다. 이때 룩업 테이블을 DataFrame으로 로딩한 뒤 조인을 수행할 수도 있지만, 룩업 테이블이 작다면 브로드캐스트하여 UDF 안에서 사용하는 방식이 더 효율적일 수 있다.
아래 예제에서는 CSV 파일로부터 룩업 테이블을 읽어 Map 형태로 변환한 뒤, 이를 Broadcast Variable로 생성한다. 이후 UDF에서 브로드캐스트된 데이터를 참조하여 코드를 이름으로 변환한다.
from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from pyspark.sql.types import *
def my_func(code: str) -> str:
return bdData.value.get(code)
if __name__ == '__main__':
spark = SparkSession \
.builder \
.appName("Demo") \
.master("local[3]") \
.getOrCreate()
prdCode = spark.read.csv("data/lookup.csv")/rdd.collectAsMap()
bdData = spark.sparkContext.broadcast(prdCode)
data_list = [("98312", "2021-01-01", "1200", "01"),
("01056", "2021-01-02", "2345", "01"),
("98312", "2021-02-03", "1200", "02"),
("01056", "2021-02-04", "2345", "02"),
("02845", "2021-02-05", "9812", "02")]
df = spark.createDataFrame(data_list) \
.toDF("code", "order_date", "price", "qty")
spark.udf.register("my_udf", my_func, StringType())
df.withColumn("Product", expr("my_udf(code)")) \
.show()
해당 방식은 셔플을 발생시키지 않으며, 룩업 테이블을 반복적으로 전송하지 않기 때문에 대규모 데이터 처리 환경에서는 성능상 큰 이점을 가진다.
실행 및 리소스 관리(Accumulators & Speculative Execution)
Spark로 대규모 데이터를 처리하다 보면, 단순한 변환 로직뿐 아니라 모니터링, 성능 안정화, 리소스 활용 방식까지 함께 고려해야 한다. Accumulators와 Speculative Execution, 그리고 리소스 할당 방식은 이러한 운영 관점에서 중요한 개념들이다.
Accumulators
Accumulators는 Spark에서 특정 이벤트의 수나 합계를 기록하기 위한 전역 변수이다. 개념적으로는 Hadoop의 Counter와 매우 유사하며, 예를 들어 비정상적인 값을 가진 레코드 수를 집계하는 데 자주 사용된다.
Accumulators는 드라이버에 위치한 변경 가능한 전역 변수이며, Executor에서 값을 누적한 뒤 드라이버로 전달된다. 스칼라 타입으로 생성한 경우에만 이름을 지정할 수 있고, 이름이 지정된 Accumulator만 Spark Web UI에서 확인할 수 있다.
Accumulators 사용 시 주의 사항
Accumulators는 레코드별 카운트나 합계 계산에 사용할 수 있지만, 어디에서 사용하느냐에 따라 값의 정확도가 달라진다.
Transformation 내부에서 Accumulator를 사용하는 경우, 태스크 재시도나 Speculative Execution으로 인해 값이 중복 반영될 수 있다. 따라서 이 방식에서는 Accumulator 값이 부정확해질 수 있다.
반면 DataFrame이나 RDD의 foreach와 같은 액션 단계에서 사용하는 경우에는 정확한 값이 보장된다. 이 방식이 Accumulator 사용 시 권장되는 접근 방식이다.
Speculative Execution
Speculative Execution은 느리게 실행되는 태스크를 다른 Executor에서 중복 실행하는 기능이다. 특정 Worker Node의 하드웨어 문제나 일시적인 성능 저하로 인해 태스크가 늦어질 경우, 전체 잡의 완료 시간을 줄이기 위한 목적을 가진다.
하지만 태스크 지연의 원인이 데이터 스큐(Data Skew)인 경우에는 도움이 되지 않으며, 오히려 리소스만 낭비하게 될 수 있다. 이 때문에 Speculative Execution은 상황에 따라 신중하게 사용해야 한다.
Speculative Execution 제어
Speculative Execution은 spark.speculation 옵션으로 제어할 수 있으며, 기본값은 비활성화(false)다. 이 기능은 Hadoop MapReduce 시절부터 존재해왔으며, 다양한 환경 변수를 통해 세밀하게 조정할 수 있다.
예를 들어 태스크 실행 시간 기준, 상위 지연 태스크 비율, 최소 실행 시간 등을 조정함으로써 Speculative Execution의 민감도를 제어할 수 있다. 대규모 클러스터 환경에서는 기본값보다 보수적인 설정을 사용하는 경우도 많다.
Spark 리소스 할당 방식
Spark에서는 두 가지 수준에서 리소스 할당이 이루어진다.
첫 번째는 Spark Application 간의 리소스 할당이며, 이는 YARN과 같은 리소스 매니저가 담당한다. YARN은 FIFO, FAIR, CAPACITY와 같은 스케줄링 방식을 지원한다.
두 번째는 하나의 Spark Application 내부에서 잡(Job) 간 리소스 할당이다. 기본적으로는 FIFO 방식으로, 먼저 실행된 잡이 필요한 만큼 리소스를 우선 사용한다.
리소스 요구와 해제 방식
Spark Application의 리소스 사용 방식에는 두 가지가 있다.
Static Allocation은 기본 동작 방식으로, Spark Application이 리소스 매니저로부터 할당받은 Executor를 애플리케이션 종료 시점까지 유지한다. 이 방식은 단순하지만, 클러스터 전체의 리소스 사용률을 떨어뜨릴 수 있다.
Dynamic Allocation은 실행 상황에 따라 Executor를 요청하거나 반환하는 방식이다. 여러 Spark Application이 하나의 리소스 매니저를 공유하는 환경에서는 Dynamic Allocation을 활성화하는 것이 일반적으로 유리하다.
리소스 설정은 spark-submit 명령어를 통해 --num-executors, --executor-cores,
--executor-memory와 같은 옵션으로 제어할 수 있다.
Spark의 리소스 할당 전략과 스케줄링
Spark 애플리케이션의 성능과 클러스터 활용 효율은 리소스를 어떻게 할당하고 스케줄링하느냐에 크게 좌우된다. Spark는 실행 환경과 워크로드 특성에 따라 다양한 리소스 관리 전략을 제공하며, 그중 핵심이 Static Allocation과 Dynamic Allocation, 그리고 Spark Scheduler다.
Static Allocation과 Dynamic Allocation
Spark의 기본 리소스 할당 방식은 Static Allocation이다. 이 방식에서는 spark-submit 시점에 지정한 Executor 수와 리소스를 애플리케이션 종료 시점까지 유지한다.
spark-submit —num-executors 100 —executor-cores 4 —executor-memory 32G
Static Allocation은 설정이 단순하고 예측 가능하지만, 작업 부하가 줄어들어도 리소스를 반환하지 않기 때문에 클러스터 전체 관점에서는 리소스 낭비로 이어질 수 있다.
이에 비해 Dynamic Allocation은 실행 상황에 따라 Executor를 동적으로 요청하거나 반환하는 방식이다. 작업이 몰릴 때는 Executor를 늘리고, 유휴 상태가 지속되면 Executor를 릴리스함으로써 리소스 사용 효율을 높인다. 여러 Spark Application이 하나의 클러스터를 공유하는 환경에서는 Dynamic Allocation이 특히 효과적이다.
Dynamic Resource Allocation 제어 옵션
Dynamic Allocation은 여러 환경 변수를 통해 세밀하게 제어할 수 있다.
spark.dynamicAllocation.enabled를 true로 설정하면 기능이 활성화되며,
spark.dynamicAllocation.shuffleTracking.enabled를 통해
셔플 파일 추적 기반 동적 할당을 사용할 수 있다.
Executor가 유휴 상태일 때 얼마 후에 반환할지를 결정하는 옵션이
spark.dynamicAllocation.executorIdleTimeout이며,
반대로 새 Executor를 요청하는 시점을 제어하는 옵션이
spark.dynamicAllocation.schedulerBacklogTimeout이다.
또한 최소·최대·초기 Executor 수를 각각
spark.dynamicAllocation.minExecutors, spark.dynamicAllocation.maxExecutors,
spark.dynamicAllocation.initialExecutors로 지정할 수 있다.
spark.dynamicAllocation.executorAllocationRatio는
Executor 증가 속도를 조절하는 역할을 한다.
Spark Scheduler
Spark Scheduler는 하나의 Spark Application 내부에서 여러 Job 간에 리소스를 분배하는 정책이다. Spark Application들 간의 리소스 분배는 YARN과 같은 리소스 매니저가 담당하지만, Application 내부의 스케줄링은 Spark Scheduler의 역할이다.
Spark Scheduler에는 두 가지 모드가 존재한다. 기본값은 FIFO 방식으로, 먼저 제출된 Job이 리소스를 우선적으로 할당받는다. 이 방식은 단순하지만, 뒤에 들어온 Job이 오래 대기해야 할 수 있다.
FAIR Scheduler
FAIR Scheduler는 라운드 로빈 방식으로 Job 간에 리소스를 고르게 분배한다. 이를 통해 여러 Job이 동시에 진행되며, 특정 Job이 전체 리소스를 독점하는 상황을 방지할 수 있다.
FAIR Scheduler에서는 Pool이라는 개념을 사용해 Job들을 그룹화할 수 있다. Pool 단위로 리소스를 분배하며, 각 Pool 내부에서도 FIFO 또는 FAIR 정책을 적용할 수 있다. 이를 통해 우선순위를 고려한 보다 정교한 리소스 관리가 가능하다.
Scheduler를 활용한 병렬성 증대
Spark에서 병렬성을 높이기 위해서는 Thread 활용과 함께 적절한 스케줄링 전략이 필요하다. 특히 FAIR Scheduler를 사용하는 경우, 여러 Job을 동시에 실행하면서도 리소스를 균형 있게 사용할 수 있어 병렬성 증대 효과가 크다.
이를 위해 spark.scheduler.mode를 FIFO 대신 FAIR로 설정할 수 있으며, FAIR 모드에서는 spark.scheduler.allocation.file을 통해 Pool 설정 파일을 정의해야 한다.
Spark Application에서 Driver의 역할
Spark 애플리케이션은 기본적으로 1개의 Driver와 1개 이상의 Executor로 구성된다. 이 중 Driver는 애플리케이션의 시작부터 종료까지 전체 실행을 총괄하는 핵심 컴포넌트이다.
Driver는 main함수를 실행하여 SparkSession과 SparkContext를 생성하고, 사용자가 작성한 코드를 분석하여 Task 단위로 분해한 뒤 DAG(Directed Acyclic Graph) 를 생성한다. 이후 DAG는 Logical Plan, Physical Plan, Execution Plan으로 변환되며, 이 과정에서 리소스 매니저와 협력하여 Executor에 Task를 분배하고 실행 상태를 관리한다.
또한 Job, Stage, Task의 실행 정보는 Spark Web UI(기본 4040 포트)를 통해 확인할 수 있다.
다만 Task 수가 과도하게 많아질 경우, 메타데이터를 관리하는 Driver의 메모리 사용량이 증가하면서 Driver OOM이 발생할 수 있다는 점은 주의해야 한다.
Executor 메모리 관리 방식의 변화
Spark 초기 버전에서는 Execution과 Storage 메모리를 고정된 비율로 나누는 Static Memory Management 방식을 사용했다. 이 방식은 구조는 단순하지만, 한쪽 메모리가 남아도 다른 쪽에서 사용할 수 없어 메모리 활용 효율이 낮았다.
이를 개선하기 위해 Spark 1.6 이후부터는 Unified Memory Manager가 도입되었다.
Unified Memory Management의 동작 원리
Unified Memory Manager는 Execution 메모리와 Storage 메모리를 유동적으로 공유한다. 기본적으로 실행 중인 Task를 기준으로 메모리를 공정하게 할당하며, 필요 시 한 영역의 남는 메모리를 다른 영역에서 사용할 수 있다.
Execution 메모리가 부족해지면 Storage Memory Pool의 여유 공간을 사용하고, 반대로 DataFrame이나 RDD 캐싱을 위한 Storage 메모리가 부족할 경우 Execution 메모리의 일부를 활용한다. 이때 spark.memory.storageFraction은 초기 경계 비율로 사용되며, 메모리가 모두 차기 시작하면 해당 경계를 기준으로 eviction이 발생한다.
메모리 부족 상황과 Spill
Executor 내에서 더 이상 사용할 수 있는 메모리가 없을 경우, Spark는 데이터를 메모리에서 디스크로 spill하여 처리한다. 이는 Job 실패를 방지할 수 있지만, 디스크 I/O로 인해 성능 저하를 유발한다.
만약 디스크 spill조차 불가능한 상황이 되면, 결국 OOM(Out Of Memory) 이 발생하며 Executor 또는 Job이 실패하게 된다.
Off-Heap Memory
Spark는 기본적으로 JVM Heap(On-Heap) 메모리에서 가장 잘 동작하도록 설계되어 있다. 하지만 JVM Heap은 Garbage Collection(GC)의 대상이 되며, Heap 크기가 커질수록 GC로 인한 성능 비용 역시 증가한다는 한계를 가진다.
이러한 문제를 완화하기 위해 Spark는 JVM 외부 메모리, 즉 Off-Heap 메모리를 함께 사용할 수 있도록 지원한다. Off-Heap 메모리는 GC의 영향을 받지 않기 때문에, 메모리 사용량이 큰 워크로드에서 성능 안정성을 높이는 데 유리하다.
Spark에서 Off-Heap 메모리를 사용하려면 다음 설정이 필요하다.
spark.memory.offHeap.enabled = true
spark.memory.offHeap.size에 사용할 메모리 크기 지정
이 외에도 Executor 프로세스가 사용하는 Overhead 메모리 역시 JVM Heap 외부에서 관리된다.
Spark 3.x의 Off-Heap Memory 구조
Spark 3.x부터는 Off-Heap 메모리 활용이 더욱 적극적으로 최적화되었다.
대표적인 예가 Project Tungsten을 기반으로 한 메모리 관리 방식이다.
Spark 3.x는 JVM에 의존하지 않고 직접 메모리를 관리할 수 있으며, 이 Off-Heap 메모리를 주로 DataFrame 연산에 사용한다. 이를 통해 GC 발생 빈도를 줄이고, 대규모 데이터 처리 시 성능을 보다 안정적으로 유지할 수 있다.
Spark 3.x 기준으로 Executor가 사용하는 Off-Heap 관련 메모리는 다음과 같이 정리할 수 있다.
spark.executor.memoryOverhead
spark.memory.offHeap.size
즉, Executor Heap 메모리를 늘리지 않고도 spark.memory.offHeap.size 설정을 통해 Off-Heap 메모리만 독립적으로 확장할 수 있다.
Spark에서 발생하는 메모리 이슈
Spark에서 발생하는 메모리 문제는 크게 두 가지로 나뉜다.
Driver OOM
Executor OOM
두 경우는 원인과 대응 방식이 전혀 다르기 때문에, 구분해서 이해하는 것이 중요하다.
Driver OOM이 발생하는 대표적인 경우
Driver는 모든 메타데이터와 실행 계획을 관리하기 때문에, 특정 패턴에서 메모리 사용량이 급증할 수 있다.
대표적인 Driver OOM 케이스는 다음과 같다.
대규모 데이터셋에 대해 collect() 호출
큰 데이터셋을 대상으로 한 Broadcast Join
Python, R 등 JVM 외 언어로 작성된 코드
지나치게 많은 Task 생성
특히 collect()나 Broadcast Join은 데이터를 Driver로 직접 가져오는 구조이기 때문에, 데이터 크기에 대한 고려 없이 사용하면 매우 위험하다.
Executor OOM이 발생하는 대표적인 경우
Executor OOM은 주로 데이터 분포와 병렬성 설정 문제에서 발생한다.
대표적인 원인은 다음과 같다.
spark.executor.cores 값이 지나치게 큰 경우
→ 하나의 Executor에서 동시에 너무 많은 Task 실행 (High Concurrency)
Data Skew로 인해 특정 Partition이 과도하게 커진 경우
→ 일부 Task에서 메모리 집중 사용 발생
이러한 상황에서는 Executor 메모리 증설보다는,
Partition 전략, Join 방식, Executor 자원 설정을 함께 점검하는 것이 효과적이다.
JVM과 Python 간의 통신
Pyspark Driver의 구조
PySpark 애플리케이션의 Driver는 단일 프로세스가 아닌 두 개의 프로세스로 구성된다.
Python 프로세스
JVM 프로세스
Spark 자체는 JVM 기반 애플리케이션이지만, PySpark는 Python 코드를 실행해야 하므로 JVM과 Python 프로세스가 병렬적으로 동작한다. 이 구조로 인해 PySpark는 순수 Spark(Scala/Java)와는 다른 메모리 특성을 가진다.
Pyspark 메모리 구조
Spark는 JVM 위에서 동작하지만, PySpark의 Python 코드는 JVM 내부에서 직접 실행되지 않는다. 따라서 Python 코드는 JVM Heap 메모리를 직접 사용할 수 없으며, 별도의 메모리 영역을 사용하게 된다.
이를 위해 Spark는 PySpark 전용 메모리 설정을 제공한다.
spark.executor.pyspark.memory
→ Python 프로세스가 사용하는 메모리
spark.python.worker.memory
→ JVM과 Python 간 통신을 담당하는 Py4J가 사용하는 메모리
이 두 설정은 PySpark 성능과 안정성에 직접적인 영향을 미친다.
PySpark는 기본적으로 Executor의 overhead memory를 사용한다.
spark.executor.pyspark.memory가 설정되면, Python 프로세스가 사용할 수 있는 메모리 크기는 해당 값으로 고정된다. 다만 이 설정은 주로 외부 Python 라이브러리나 사용자 정의 Python 함수를 사용하는 경우에 필요하며, 기본적으로는 명시적으로 설정되지 않는다.
기본값: 512MB (512m)
JVM과 Python 프로세스 간 통신을 담당하는 Py4J가 사용할 수 있는 최대 메모리
해당 크기를 초과하면 Disk Spill 발생
spark.executor.pyspark.memory
→ Python 프로세스 자체가 사용할 수 있는 메모리 크기
spark.python.worker.memory
→ JVM 내부에서 관리되는 Python 오브젝트의 최대 메모리 크기
Spark와 Python 간의 통신 방식
Spark와 Python은 Py4J라는 프레임워크를 통해 데이터를 주고받는다.
Py4J는 Python과 JVM 간의 데이터 교환을 담당하며, PySpark의 핵심 구성 요소 중 하나이다.
DataFrame이나 RDD 연산 중 Python 코드가 사용되면, 해당 로직은 별도의 Python 프로세스에서 실행된다. 이 과정에서 Partition 단위의 데이터가 Python 프로세스로 전달되며, 이 데이터 이동 비용이 PySpark 성능에 영향을 줄 수 있다.
Spark와 UDF(User Defined Function)
Spark에서 UDF는 작성 언어와 방식에 따라 성능 특성이 크게 달라진다:
Java / Scala UDF
→ JVM 내부에서 실행되어 성능상 가장 유리
Python UDF
→ Py4J를 통한 데이터 직렬화/역직렬화 비용 발생
Pandas UDF (Vectorized UDF)
→ PyArrow 기반, 컬럼 단위 처리로 성능 개선
특히 Pandas UDF는 Vectorized 방식으로 동작하며, PyArrow를 활용해 JVM ↔ Python 간 데이터 전송 비용을 줄인다. PySpark 환경에서 Python UDF를 사용해야 한다면, 가능하다면 Pandas UDF를 우선 고려하는 것이 바람직하다.
Caching
Caching은 자주 사용되는 DataFrame을 메모리에 유지하여 반복 연산 시 처리 속도를 향상시키는 기법이다. 동일한 DataFrame을 여러 번 사용하는 경우, 매번 계산을 다시 수행하는 대신 캐시된 데이터를 재사용함으로써 성능을 크게 개선할 수 있다.
다만, 캐싱했다고 해서 항상 성능이 좋아지는 것은 아니다. 실제로 해당 DataFrame이 메모리에 유지되고 있는지 확인해야 하며, 경우에 따라서는 다시 계산하는 것이 더 빠른 상황도 존재한다. 또한 캐싱은 메모리 사용량을 증가시키므로, 모든 DataFrame을 무분별하게 캐싱하는 것은 바람직하지 않다.
DataFrame을 캐싱하는 방법 (1)
Spark에서 DataFrame을 캐싱하는 방법은 크게 두 가지가 있다.
cache()
persist()
두 방법 모두 DataFrame을 메모리, 디스크, 또는 Off-Heap 영역에 보관할 수 있으며, Lazy Execution 방식으로 동작한다. 즉, 실제 액션이 실행되기 전까지는 캐싱이 수행되지 않는다.
또한 캐싱은 항상 Partition 단위로 이루어지며, 하나의 파티션이 부분적으로만 캐싱되는 일은 없다.
DataFrame을 캐싱하는 방법 (2)
persist()는 인자를 통해 캐싱 방식을 보다 세밀하게 제어할 수 있다.
useMemory : 메모리 사용 여부
useDisk : 디스크 사용 여부
useOffHeap : Off-Heap 사용 여부 (사전 설정 필요)
deserialized
True : CPU 연산 감소, 메모리 사용 증가
False : 메모리 절약, CPU 연산 증가
메모리 캐싱에서만 사용 가능
replication
서로 다른 Executor에 저장할 복제본 개수
이 설정을 통해 메모리와 CPU 자원 간의 트레이드오프를 조절할 수 있다.
DataFrame을 캐싱하는 방법 (3)
persist()에서 자주 사용되는 설정 조합은 상수 형태로 제공된다.
DISK_ONLY
MEMORY_ONLY
MEMORY_AND_DISK
MEMORY_ONLY_SER
MEMORY_AND_DISK_SER
OFF_HEAP
MEMORY_ONLY_2
MEMORY_ONLY_3
상수를 사용하면 캐싱 전략을 간단하면서도 명확하게 표현할 수 있다.
DataFrame을 캐싱하는 방법 (4)
기본적으로 persist()는 캐싱된 DataFrame을 메모리와 디스크에 저장하며, 필요 시 복제도 수행한다.
cache()는 persist()의 단순화된 버전으로, 내부적으로 다음과 같은 설정을 사용한다.
useDisk = false
useMemory = true
useOffHeap = false
deserialized = true
replication = 1
즉, cache()는 메모리 기반 캐싱을 간단히 적용하고 싶을 때 적합하다.
Spark SQL을 이용한 Caching
DataFrame API 외에도 Spark SQL을 통해 테이블 단위 캐싱이 가능하다.
CACHE TABLE table_name
CACHE LAZY TABLE table_name
UNCACHE TABLE table_name
spark.sql("cache table table_name")
spark.sql("cache lazy table table_name")
spark.sql("uncache table table_name")
이를 통해 SQL 기반 워크플로우에서도 캐싱 전략을 일관되게 적용할 수 있다.
Caching을 해제하는 방법
캐싱된 데이터는 필요 없어졌을 때 반드시 해제하는 것이 중요하다.
DataFrame.unpersist()
→ LRU(Least Recently Used) 정책 기반
UNCACHE TABLE table_name
spark.catalog.isCached(“table_name”)
spark.catalog.clearCache()
DataFrame.unpersist (LRU - Least Recently Used)
spark.sql("uncache table table_name")
spark.catalog.isCached("table_name")
spark.catalog.clearCache()
적절한 캐시 해제는 전체 애플리케이션의 메모리 안정성을 높인다.
Caching 관련 Best Practices
캐싱을 효과적으로 사용하기 위해서는 몇 가지 원칙을 지키는 것이 좋다.
캐싱된 DataFrame이 명확하게 재사용되도록 변수로 분리
컬럼 수가 많다면 필요한 컬럼만 선택해서 캐싱
더 이상 사용하지 않는 경우 즉시 uncache
Parquet 등 컬럼 기반 포맷의 대규모 데이터셋은
→ 매번 다시 읽는 것이 캐싱보다 빠를 수 있음
캐싱 대상은 소수의 핵심 DataFrame으로 제한
대형 DataFrame 캐싱은 지양
캐싱을 만능 해결책으로 신뢰하지 말 것
Filter (Predicate) Pushdown
Spark에서 대용량 데이터를 처리할 때 성능을 좌우하는 가장 중요한 요소 중 하나는 얼마나 적은 데이터를 읽느냐이다. 이를 위해 Spark는 여러 최적화 기법을 제공하는데, 그중 대표적인 것이 Filter(Predicate) Pushdown과 Partition Pruning를 사용한다.
Filter Pushdown은 데이터를 모두 읽은 뒤 필터링하는 방식이 아니라, 데이터 소스에서 읽는 시점에 필터 조건을 적용하여 불필요한 데이터를 아예 로드하지 않는 최적화 기법이다.
이 방식은 모든 데이터 소스에서 지원되지는 않으며, 대표적으로 Parquet 포맷에서 컬럼 통계 정보(min/max 등)가 존재하는 경우에만 효과적으로 동작한다. Filter Pushdown이 적용되면 I/O 자체가 줄어들기 때문에 성능 개선 효과가 크다.
Partition Pruning이란?
Partition Pruning은 Spark Optimizer가 필요한 파티션만 선택적으로 읽도록 하는 최적화 기법이다. Optimizer는 쿼리를 분석해 실제로 필요한 데이터가 들어 있는 파티션과 그렇지 않은 파티션을 구분하고, 불필요한 파티션은 스캔 대상에서 제외한다.
이 최적화는 Spark의 Logical Plan Optimization 단계에서 수행된다.
Static Partition Pruning
Static Partition Pruning은 쿼리 실행 전에 어떤 파티션을 읽을지 명확하게 알 수 있는 경우에 적용된다. 주로 테이블이 특정 컬럼을 기준으로 파티셔닝되어 있고, 쿼리의 필터 조건에 해당 파티션 컬럼이 직접 사용되는 경우이다.
하지만 현실적인 데이터 모델에서는 파티셔닝이 보통 Fact 테이블에 적용되어 있고, 필터 조건은 Dimension 테이블에 걸리는 경우가 많아 Static 방식만으로는 한계가 있다.
Dynamic Partition Pruning
Dynamic Partition Pruning은 이러한 한계를 보완하기 위한 기법이다. 파티션되지 않은 테이블(Dimension)에 적용된 필터 조건을 실행 시점에 파티션 테이블(Fact)에 동적으로 전달하여 필요한 파티션만 읽도록 한다.
특히 Dimension 테이블이 작아 Broadcast Join까지 활용된다면 성능 개선 효과는 더욱 커진다.
Dynamic Partition Pruning은 기본적으로 활성화되어 있으며, 다음 설정으로 확인할 수 있다.
Spark Shuffling 최적화
Repartition을 사용하는 이유
repartition은 말 그대로 데이터프레임의 파티션을 다시 나누는 작업이다. 이 연산이 필요한 대표적인 이유는 다음과 같다.
첫째, 병렬성을 높이기 위해서다. 파티션 수가 너무 적으면 각 태스크가 처리해야 할 데이터 양이 커지고, 클러스터의 리소스를 충분히 활용하지 못한다. 이럴 때 파티션 수를 늘려주면 병렬 처리가 가능해진다.
둘째, 지나치게 큰 파티션이나 Skew(쏠림) 파티션을 조정하기 위해서다. 특정 키에 데이터가 몰려 있는 경우 일부 태스크만 오래 걸리게 되는데, repartition을 통해 이를 완화할 수 있다.
셋째, 분석 패턴에 맞게 데이터를 재분배하기 위해서다. 예를 들어 어떤 DataFrame을 특정 컬럼 기준으로 자주 그룹핑하거나 필터링한다면, 그 컬럼 기준으로 미리 파티션을 나눠 저장해두는 것이 효율적이다. 이를 “Write once, read many” 패턴이라고 부르며, 이와 유사한 개념이 바로 Bucketing이다.
Repartition 방식의 특징과 주의점
Spark에서는 repartition 방식으로 크게 두 가지를 제공한다.
repartition
repartitionByRange
이 둘의 공통점은 항상 Shuffling이 발생한다는 것이다. 즉, 네트워크 I/O와 디스크 사용이 뒤따르며, 비용이 상당하다. 따라서 repartition은 “그럴듯해 보이니까” 쓰는 연산이 아니라, 명확한 이유가 있을 때만 사용해야 한다.
실무에서는 종종 repartition이 아무 근거 없이 사용되어 오히려 전체 처리 시간이 늘어나고 비용만 증가하는 경우를 본다. 불필요한 count(), distinct(), 중복 제거 연산이 성능을 악화시키는 것과 비슷한 맥락이다.
또 하나 주의할 점은, 컬럼을 기준으로 repartition한다고 해서 항상 균등한 파티션 크기가 보장되지는 않는다는 것이다. 데이터 분포 자체가 불균형하다면, 결과 파티션 역시 Skew를 가질 수 있다.
마지막으로, repartition은 파티션 수를 줄이는 용도로는 적합하지 않다. 줄이고 싶다면 반드시 coalesce를 사용해야 한다.
repartition(numPartitions, *cols)
repartition은 Hash 기반 파티셔닝을 사용한다. 사용 예시는 다음과 같다.
repartition(5)
repartition(5, "city")
repartition(5, "city", "zipcode")
repartition("city")
repartition("city", "zipcode")
컬럼을 지정하지 않으면 전체 데이터를 랜덤하게 섞어 파티션을 나누고, 컬럼을 지정하면 해당 컬럼의 해시 값을 기준으로 파티션이 결정된다. 다만 앞서 언급했듯, 해시 기반이라고 해서 파티션 크기가 항상 균등해지는 것은 아니다.
repartitionByRange(numPartitions, *cols)
repartitionByRange는 지정한 컬럼 값의 범위(range)를 기준으로 파티션을 나눈다. 내부적으로는 데이터 샘플링을 통해 경계를 정하기 때문에, 실행할 때마다 결과가 달라질 수 있는 비결정적(Nondeterministic) 연산이다.
사용법은 repartition과 거의 동일하지만, 값의 범위가 의미 있는 컬럼(예: 날짜, 숫자 ID 등)에 특히 적합하다. 다만 이 역시 Shuffling이 발생하므로, 사용 전 반드시 비용 대비 효과를 고민해야 한다.
Coalesce가 필요한 경우
coalesce는 repartition과 목적이 다르다. 이 연산은 파티션 수를 줄이는 데에만 사용한다.
가장 큰 특징은 Shuffling을 발생시키지 않는다는 점이다. 기존의 로컬 파티션들을 그대로 병합하기 때문에 비용은 적지만, 그만큼 Skew 파티션이 생길 가능성도 높다.
또한 coalesce 역시 컬럼 기반으로 사용할 수는 있지만, 이 경우에도 균등한 파티션 크기는 보장되지 않는다. 따라서 대규모 후처리 단계에서 “결과 파일 수를 줄이고 싶을 때”처럼 명확한 목적이 있을 때 사용하는 것이 바람직하다.
DataFrame 힌트에 대한 간단한 정리
마지막으로, Spark에서는 DataFrame 힌트(hint)를 통해 Spark SQL Optimizer에게 실행 계획에 대한 “제안”을 할 수 있다. 이는 기본 최적화 전략을 완전히 바꾸기보다는, 특정 상황에서 더 적합한 실행 계획을 유도하기 위한 장치다.
힌트는 크게 두 가지로 나뉜다.
Partitioning 관련 힌트
Join 관련 힌트
복잡한 조인이나 대규모 데이터 처리에서, 힌트를 적절히 활용하면 Spark가 더 효율적인 Execution Plan을 선택하도록 도울 수 있다.
DataFrame Partitioning 관련 힌트들
Spark는 파티션 전략과 관련된 여러 힌트를 제공한다. 이 힌트들은 Execution Plan을 생성하는 과정에서 파티션을 어떻게 다룰지에 대한 방향성을 Optimizer에게 전달한다.
대표적인 파티셔닝 관련 힌트는 다음과 같다.
COALESCE
REPARTITION
REPARTITION_BY_RANGE
REBALANCE
이 힌트들은 특히 DataFrame을 테이블이나 파일 형태로 저장할 때 매우 유용하다. 저장 단계에서 파티션 전략을 잘 잡아두면, 이후 반복적인 조회(Read)가 훨씬 효율적으로 이루어진다.
그중 REBALANCE는 파일 크기를 최대한 비슷하게 맞춰 저장하는 데 목적이 있다. 다만 이 기능은 AQE(Adaptive Query Execution)가 활성화되어 있어야 효과적으로 동작한다. AQE를 통해 실행 시점에 파티션을 재조정하면서, 결과 파일들이 특정 파티션에 몰리지 않도록 균형을 맞춘다.
예를 들어, 조인 이후 결과 파티션 수를 줄이고 싶다면 다음과 같이 힌트를 줄 수 있다.
df1.join(df2, "id", "inner")
.hint("COALESCE", 3)
이 코드는 조인 결과를 3개의 파티션으로 병합하라는 힌트를 Optimizer에게 전달한다. 중요한 점은, 이는 강제(force)가 아니라 제안(hint)이라는 점이다. Spark는 전체 실행 계획과 비용을 고려해 힌트를 무시할 수도 있다.
DataFrame Join 관련 힌트들
힌트가 가장 많이 사용되는 영역은 단연 Join이다. Spark는 데이터 크기와 통계 정보를 기반으로 자동으로 조인 전략을 선택하지만, 사용자가 데이터 특성을 더 잘 알고 있는 경우 힌트를 통해 이를 유도할 수 있다.
Broadcast 계열 힌트
BROADCAST
BROADCASTJOIN
MAPJOIN
이 힌트들은 Broadcast Join 사용을 제안한다. 한쪽 테이블이 충분히 작을 경우, 이를 모든 Executor에 복제해 네트워크 Shuffle을 피하는 전략이다. 소규모 Dimension 테이블과의 조인에서 매우 효과적이다.
Merge 계열 힌트
MERGE
SHUFFLE_MERGE
MERGEJOIN
이 힌트들은 Shuffle Merge Join 사용을 제안한다. 이는 Spark의 기본 조인 전략이기도 하며, 양쪽 데이터가 모두 크고 조인 키 기준으로 정렬이 가능한 경우에 적합하다.
Shuffle Hash Join 힌트
SHUFFLE_HASH
Shuffle Hash Join을 사용하도록 제안하는 힌트다. 다만 제약이 명확한데, Full Outer Join에서는 사용할 수 없다. 특정 상황에서는 Merge Join보다 빠를 수 있지만, 메모리 사용량이 늘어날 수 있어 주의가 필요하다.
Shuffle Replicate NL 힌트
SHUFFLE_REPLICATE_NL
이 힌트는 Shuffle-and-replicate 방식의 Nested Loop Join, 즉 사실상 Cross Join을 유도한다. 조인 조건이 없거나 매우 특수한 경우에만 사용해야 하며, 데이터 크기가 크면 비용이 폭발적으로 증가한다.
한 가지 중요한 규칙은, 여러 개의 조인 힌트가 동시에 사용될 경우 우선순위가 존재한다는 점이다. 일반적으로 위에서 아래로 갈수록 우선순위가 낮아지며, Spark는 가장 우선순위가 높은 힌트를 먼저 고려한다.
예를 들어 Spark SQL에서는 다음과 같이 조인 힌트를 명시할 수 있다.
SELECT /*+ MERGE(df2) */ *
FROM df1
JOIN df2
ON df1.order_month = df2.year_month
이 쿼리는 df2와의 조인에서 Merge Join을 우선적으로 고려하라는 의미다.
DataFrame 힌트 사용법 정리
힌트는 Spark SQL과 DataFrame API 양쪽에서 모두 사용할 수 있다.
Spark SQL에서의 힌트
Spark SQL에서는 주석 형태로 힌트를 작성한다.
/*+ hint [, … ] */
대표적인 예시는 다음과 같다.
SELECT /*+ REPARTITION(3) */ *
FROM table
또는 조인 시 Broadcast Join을 유도하는 경우,
SELECT /*+ BROADCAST(table1) */ *
FROM table1
JOIN table2
ON table1.key = table2.key
DataFrame API에서의 힌트
DataFrame API에서는 .hint() 메소드를 사용한다.
val joinDf =
df1.join(df2, "id", "inner")
.hint("COALESCE", 3)
또는 조인 대상 중 하나에만 Broadcast 힌트를 주고, 결과 파티션까지 함께 제어할 수도 있다.
val joinDf =
df1.join(df2.hint("broadcast"), "id", "inner")
.hint("COALESCE", 3)
Spark Optimization의 역사
Spark 1.x: Catalyst Optimizer와 Tungsten Project
Spark 1.x 시절의 최적화는 크게 두 축으로 이루어져 있었다.
먼저 Catalyst Optimizer다. Catalyst는 규칙 기반(rule-based) 최적화 엔진로, SQL이나 DataFrame 연산을 논리적으로 변환하는 역할을 했다. 대표적인 최적화로는 조건절을 최대한 아래로 밀어내는 Predicate Pushdown, 필요한 컬럼만 읽도록 하는 Projection Pushdown 등이 있다. 이 단계에서는 “어떤 순서로 연산을 수행할 것인가”에 초점이 맞춰져 있었다.
두 번째는 Tungsten Project다. 이는 성능의 발목을 잡던 JVM 특유의 한계, 특히 GC 비용을 줄이기 위한 시도였다. Spark는 Tungsten을 통해 Off-Heap 메모리 관리를 직접 수행하고, 코드 생성(Code Generation)을 통해 CPU 친화적인 실행을 지향했다. 즉, “어떻게 빠르게 실행할 것인가”에 대한 해답이었다.
Spark 2.x: CBO (Cost-Based Optimizer)
Spark 2.x에 들어서면서 한 단계 더 진화한 개념이 등장한다. 바로 CBO(Cost-Based Optimizer)다.
CBO는 DataFrame 및 테이블의 통계 정보를 기반으로, 여러 실행 계획 중 가장 비용이 낮을 것으로 예상되는 plan을 선택한다. 여기에는 다음과 같은 정보들이 활용된다.
전체 데이터 크기
레코드 수
컬럼별 최소값 / 최대값
히스토그램 등 분포 정보
즉, Spark는 더 이상 “항상 같은 규칙”이 아니라, 데이터의 특성에 따라 다른 실행 계획을 선택할 수 있게 되었다. 다만 이 방식에는 한계가 있었는데, 통계 정보는 대부분 실행 전(parsing time)에 수집된다는 점이다.
AQE 이전의 세계
다음과 같은 단순한 GROUP BY 쿼리를 생각해보자.
SELECT sku, SUM(price) AS sales
FROM order
GROUP BY sku;
AQE가 없던 시절, Spark는 이 쿼리를 실행하면서 고정된 실행 계획을 만든다.
이 쿼리는 보통 두 개의 Stage를 생성한다:
데이터를 읽고 Shuffle을 수행하는 Stage
Shuffle 결과를 받아 최종 Aggregation을 수행하는 Stage
이때 Shuffle 이후 생성되는 파티션 수는 spark.sql.shuffle.partitions 설정 값에 의해 고정된다. 문제는, 이 시점에서는 실제 데이터 크기나 분포를 정확히 알기 어렵다는 점이다.
spark.sql.shuffle.partitions의 한계
spark.sql.shuffle.partitions는 Spark 성능 튜닝에서 가장 자주 언급되는 설정 중 하나다. MapReduce 시절의 mapreduce.job.reduces와 거의 동일한 역할을 한다.
하지만 이 값 하나로 모든 상황을 커버하기는 매우 어렵다.
파티션 수가 너무 적으면,
→ 병렬성이 낮아지고, OOM이나 디스크 스필 가능성이 커진다.
파티션 수가 너무 많으면,
→ Task 생성과 스케줄링 오버헤드가 증가하고, 잦은 네트워크 I/O로 병목이 발생한다.
결국 핵심 질문은 이것이다. “Spark가 알아서 상황에 맞게 파티션 수를 결정해줄 수는 없을까?”
이 문제를 해결하기 위한 접근
이 문제는 Spark만의 고민은 아니었다. 대용량 데이터베이스 분야에서는 이미 오래전부터 연구된 문제다.
Spark 3.0 이전, Intel Big Data 팀이 이 문제에 대한 프로토타입을 개발했고, 이후 Databricks와 협업하면서 Spark에 본격적으로 도입된다.
핵심 아이디어는 단순하다.
Parsing time(실행 전) 최적화만으로는 충분하지 않다.
Runtime(실행 중) 정보까지 활용해야 한다.
특히 UDF가 많이 사용되는 경우, 실행 전에는 비용을 예측하기가 거의 불가능하기 때문에 이 문제는 더 심각해진다.
AQE란 무엇인가
AQE(Adaptive Query Execution)는 다음과 같이 정의된다.
“실행 중(Runtime)에 수집한 통계 정보를 기반으로, 쿼리 실행 도중에 동적으로 최적화를 수행하는 방식”
즉, AQE는 모든 최적화 결정을 실제 실행 중에 관측한 정확한 통계 정보에 기반한다.
그렇다면 중요한 질문이 하나 남는다.
언제 실행 중 통계를 수집하고, 언제 실행 계획을 바꾸는 것이 가장 좋을까?
Spark의 실행 단계를 떠올려보면 답이 보인다.
Query → Job → Stage → Task
왜 Stage가 최적의 변경 시점인가
Spark에서 Stage는 Shuffle이나 Broadcast를 기준으로 나뉜다. 그리고 Stage 경계에서는 다음과 같은 일이 발생한다.
중간 결과가 materialize 된다.
실제 파티션의 수와 크기를 정확히 알 수 있다.
즉, 이 시점은 데이터 분포를 추측이 아니라 사실로 알 수 있는 최초의 지점이다.
앞서 본 GROUP BY 쿼리를 다시 보면,
Stage 0: Scan → Shuffle → 중간 SUM
Stage 1: Shuffle 결과 → 최종 SUM
바로 이 두 번째 Stage가 시작되는 시점이, 최적화 방식을 바꾸기에 가장 이상적인 타이밍이다.
AQE 이후의 세계
AQE가 활성화된 환경에서는, GROUP BY 쿼리의 두 번째 Stage 시작 시점에 AQEShuffleRead라는 새로운 메커니즘이 개입한다.
이를 통해 Spark는 다음과 같은 결정을 실행 중에 다시 내릴 수 있다.
AQE가 특히 필요한 경우들
AQE는 단순한 파티션 조정 기능이 아니다. 다음과 같은 고급 최적화를 가능하게 만든다.
Shuffle 이후 파티션을 동적으로 병합(Coalescing)
→ Spark 3에서 도입
조인 전략을 실행 중에 전환
→ Spark 3.2에서 강화
Skew Join을 동적으로 감지하고 최적화
→ Spark 3에서 도입
이제 Spark는 더 이상 “처음에 세운 계획을 끝까지 밀어붙이는 엔진”이 아니다.
실행하면서 보고, 판단하고, 전략을 바꾸는 엔진으로 진화했다.
Spark Partition 학습
Dynamically Optimizing Skew Joins란 무엇인가?
Spark 환경에서 대규모 데이터를 처리하다 보면 데이터 스큐(skew) 문제는 성능 저하의 가장 흔한 원인 중 하나이다.
Dynamically Optimizing Skew Joins는 이러한 스큐 문제를 Spark AQE(Adaptive Query Execution)가 런타임에 감지하고, 자동으로 최적화해주는 기능이다.
왜 Skew Join 최적화가 필요한가?
Skew Partition이 만드는 병목 현상
조인 연산 시 특정 파티션에 데이터가 과도하게 몰리면 다음과 같은 문제가 발생한다.
대부분의 태스크는 빠르게 끝나지만, 소수의 태스크만 유독 오래 실행한다
이 몇 개의 태스크 때문에 전체 Job / Stage 종료가 지연된다.
스큐 파티션이 메모리를 초과하면 Disk Spill 발생한다.
디스크 I/O로 인해 성능이 급격히 저하된다.
즉, 클러스터 자원은 충분한데도 불구하고 불균형한 데이터 분포 하나 때문에 전체 성능이 무너지는 상황이 발생한다.
AQE가 제시하는 해법
Spark의 AQE는 이러한 문제를 사전에 추측하지 않고, 실행 중에 관찰한 통계 정보를 기반으로 해결한다.
AQE 기반 Skew Join 최적화는 다음과 같은 전략을 따릅니다.
Skew Partition 존재 여부를 런타임에 탐지한다.
Skew Partition을 더 작은 여러 파티션으로 분할된다.
조인 대상 반대편 파티션을 중복 생성한다.
분할된 파티션 단위로 조인을 병렬 수행한다.
이 방식은 태스크 실행 시간을 고르게 분산시켜, 특정 태스크가 병목이 되는 상황을 제거한다.
Dynamically Optimizing Skew Joins 동작 방식
Leaf Stage 실행
먼저 각 테이블의 Leaf Stage가 실행된다. 이 단계에서 Spark는 각 파티션의 실제 데이터 크기를 수집한다.
Skew Partition 감지 및 분할
AQE는 파티션 크기를 비교하여 비정상적으로 큰 파티션(skew partition) 을 감지한다.
감지된 skew partition은 Skew Reader를 통해 여러 개의 작은 파티션으로 재구성된다.
Skew Partition 감지 및 분할
AQE는 파티션 크기를 비교해 비정상적으로 큰 파티션(skew partition) 을 감지한다.
감지된 skew partition은 Skew Reader를 통해 여러 개의 작은 파티션으로 재구성됩니다.
병렬 조인 수행
결과적으로 하나의 거대한 태스크가 아닌, 여러 개의 균등한 태스크로 구성된다.
이에 전체 Stage 실행 시간이 단축되고, Disk Spill 가능성도 크게 감소한다.
주요 설정 파라미터
Skew Join 최적화는 기본 설정으로도 동작하지만, 워크로드 특성에 따라 조정이 필요할 수 있다.
spark.sql.adaptive.skewJoin.skewedPartitionFactor
스큐 여부를 판단하는 비율 기준으로, 평균 파티션 크기 대비 몇 배 이상 크면 skew로 판단할지 결정한다. 기본값은 5이다.
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes
절대 크기의 기준으로, 해당 바이트 크기를 초과해야 skew partition으로 간주한다. 기본값은 256MB이다.
이 두 조건을 모두 만족해야 skew partition으로 인식된다.
-
-
[6기] 데브코스 DE WIL 12 | Kafka & Spark Streaming 기반 스트리밍 처리
이번 주 학습 목표
이벤트 중심 아키텍처의 관점에서 실시간 데이터 처리 흐름과 Kafka 기반 스트리밍 파이프라인의 구조를 이해한다.
Kafka의 핵심 구성 요소(Topic, Partition, Broker, Consumer Group, Connect 등)와 메시지 처리 보장 방식을 이해하고, 실무 환경에서의 설계 선택 기준을 정리한다.
Spark Structured Streaming의 마이크로 배치 처리 모델과 Source–Sink 구조를 이해하고, Kafka와 연계한 실시간 데이터 처리 파이프라인을 설계할 수 있다.
데이터 처리의 발전 단계
데이터 파이프라인의 데이터 처리는 보통 세 가지 단계로 구성된다. 데이터 수집(Data Collection), 데이터 저장(Data Storage), 데이터 처리(Data Processing)이다.
먼저, 데이터 수집 단계에서는 서비스나 시스템에서 발생하는 다양한 데이터를 모은다. 사용자 행동 로그, 서버 로그, 결제 데이터, 외부 API 등이 여기에 해당된다. 이 단계에서는 얼마나 빠르고 안정적으로 데이터를 수집할 수 있는지가 중요하다.
수집된 데이터는 이후 데이터 저장(Data Storage) 단계로 넘어간다. 데이터 성격과 사용 목적에 따라 저장소는 달라진다. 트랜잭션 처리가 중요한 데이터는 데이터베이스에 저장되고, 대규모 분석을 위한 데이터는 데이터 레이크나 데이터 웨어하우스에 적재된다.
시대별 데이터 저장 시스템의 변천
1980년대
2000년대 후반
2010년 중반
2021년
Data Warhouse(Top-down)
Data Lake(Bottom-up)
Cloud Data Platform Messing Queue(Kafka/Kinesis)
Data Mesh
중앙 시스템
중앙 시스템
중앙 시스템
분산 시스템
마지막으로 데이터 처리 단계에서는 저장된 데이터를 정제하고 변환하며, 필요한 형태로 가공한다. 이 과정을 통해 서비스 효율을 높이거나, 데이터 기반의 의사결정을 보다 과학적으로 수행할 수 있게 된다. 데이터 분석, 추천 시스템, 모니터링 대시보드 등 대부분의 데이터 활용 사례는 이 단계에서 만들어진다.
데이터 처리 방식의 고도화
초기 시스템은 대부분 배치 처리로 시작된다. 배치 처리는 일정 주기로 데이터를 모아 한 번에 처리하며, 한 번에 처리할 수 있는 데이터의 양이 중요하다. 이러한 방식은 대규모 분석이나 리포트 생성에 적합하다.
서비스가 성장함에 따라 데이터의 즉각적인 활용이 요구되면서 실시간 처리 또는 준실시간 처리가 필요해진다. 실시간 처리는 데이터 발생 즉시 처리하는 방식을, 준실시간 처리는 약간의 지연을 허용하는 처리 방식이다. 또한 하나의 데이터가 여러 시스템에서 동시에 사용되는 경우가 늘어나면서, 다수의 데이터 소비자를 고려한 구조가 중요해진다.
처리량(Throught) vs 지연시간(Latency)
데이터 처리 성능을 이해하기 위해서 처리량과 지연시간의 차이를 명확히 이해한다.
처리량은 단위 시간당 처리할 수 있는 데이터 양으로, 배치 시스템에서 중요한 지표이다. 반면 지연시간은 데이터가 처리되기까지 걸리는 시간으로, 실시간 시스템에서 더 중요하다.
해당 두 개념은 대개 트레이드오프(trade-off) 관계에 있으며, 이를 함께 설명하는 개념으로 대역폭(Bandwidth)이 있다. 대역폭은 처리량과 지연시간의 곱으로 표현된다.
SLA(Service Level Agreement)
SLA는 서비스 제공자와 사용자 간에 합의된 서비스 품질 기준을 의미한다. 이 계약에는 서비스의 성능, 가용성, 신뢰성 등에 대한 최소 기준이 포함되며, 통신, 클라우드 컴퓨팅 등 다양한 산업에서 널리 사용된다.
SLA는 외부 고객뿐만 아니라 사내 시스템 간에도 정의된다. 이 경우 주로 지연시간(Latency)이나 업타입이 기준이 된다.
데이터 시스템에서는 여기에 더해 데이터의 시의성(Freshness)이 중요한 SLA 지표가 된다. 데이터가 얼마나 최신 상태로 제공되는지가 분석과 의사결정의 품질에 직접적인 영향을 주기 때문이다.
배치 처리의 특징과 구조
배치 처리는 데이터를 주기적으로 모아서 한 번이 이동하거나 처리하는 방식이다. 이 방식에서는 처리량(Thoughput)이 가장 중요한 성능 지표이며, 처리 주기는 보통 분, 시간, 일 단위로 설정된다.
배치 처리 시스템은 일반적으로 분산 파일 시스템과 분산 처리 엔진을 중심으로 구성된다. 데이터는 HDFS나 S3와 같은 분산 스토리지에 저장되고, MapReduce, Hive, Presto, Spark(DataFrame, SQL) 등을 통해 처리된다.
이러한 작업을 정해진 주기로 안정적으로 실행하기 위해서 Airflow와 같은 워크플로 스케줄러가 사용되는 경우가 많다.
실시간 데이터 처리로의 발전
배치 처리 이후의 고도화 단계가 바로 실시간 데이터 처리이다. 실시간 처리는 시스템 복잡도가 증가하지만, 초 단위로 지속적으로 발생하는 데이터를 즉시 처리할 수 있다는 장점이 있다.
이때 처리되는 데이터로는 되게 이벤트(Event)라고 부르며, 이벤트의 가장 큰 특징은 생성 이후이 값이 변경되지 않는 불변성(Immutable)이다. 이러한 이벤트들이 지속적으로 발생하며 흐름을 이루는 것을 이벤트 스트림(Event Stream)이라고 한다.
실시간 처리 시스템의 구성 요소
배치 처리만으로는 이러한 이벤트 스트림을 효과적으로 다루기 어렵기 때문에, 실시간 처리 환경에서는 새로운 유형의 시스템들이 필요해진다.
먼저 이벤트 데이터를 안정적으로 저장하고 전달하기 위해 메시지 큐 시스템이 사용된다. Kafka, Kinesis, Pub/Sub 등이 대표적인 예이며, 이들은 대량의 이벤트 스트림을 안정적으로 보관하고 전달하는 역할을 한다.
그 위에서 이벤트를 실제로 처리하기 위한 스트리밍 처리 엔진이 동작한다. Spark Streaming, Samza, Flink와 같은 시스템은 이벤트 스트림을 읽어 실시간 집계, 변환, 계산을 수행한다. 처리된 결과를 다시 저장되거나 Druid와 같은 실시간 분석 및 대시보드 시스템으로 전달되어 즉각적인 분석에 활용된다.
실시간 처리 시스템의 구조
실시간 데이터 처리 아키텍처는 일반적으로 Producer -> Message Queue -> Consumer 구조를 따른다.
먼저, Producer(또는 Publisher)는 이벤트를 생성하는 주체로, 애플리케이션이나 서비스가 여기에 해당한다. 생성된 이벤트는 Kafka나 Kinesis와 같은 메시지 큐 시스템에 저장된다. 이때 데이터는 스트림 단위로 관리되며, Kafka에서는 이를 토픽(Topic)이라고 부른다. 각 스트림에는 데이터 보유 기간이 설정되어 있어, 일정 시간이 지나면 자동으로 삭제된다.
Consumer(또는 Subscriber)는 메시지 큐로부터 이벤트를 읽어 처리하는 주체다. 각 Consumer는 자신만의 처리 위치를 관리하며, 하나의 스트림을 여러 Consumer가 동시에 읽는 것도 가능하다. 이를 통해 동일한 이벤트 데이터를 여러 목적의 시스템에서 동시에 활용할 수 있다.
데이터 실시간 처리의 장/단점
서비스가 고도화될수록 데이터는 단순한 분석의 대상이 아닌, 즉시 반응해야 하는 신호가 된다. 이러한 요구를 충족하기 위해서 등장한 것이 데이터 실시간 처리이며, 이는 분명한 장점과 단점을 가지고 있다.
데이터 실시간 처리의 장점
실시간 처리의 가장 큰 장점은 즉각적인 인사이트 확보다. 데이터가 생성되는 순간 바로 분석이 가능하기 때문에, 서비스 운영자는 현재 상황을 빠르게 파악할 수 있다. 이는 운영 효율성 향상으로 이어지며, 장애나 사고와 같은 이벤트에 대해서도 신속하게 대응할 수 있게 한다.
또한 실시간 데이터 처리는 사용자 행동을 즉시 반영할 수 있어, 개인화된 사용자 경험을 더욱 정교하게 만든다. 추천 시스템, 실시간 알림, 동적 UI 변화 등이 대표적인 사례다.
이와 함께 IoT 및 센서 데이터 활용, 사기 탐지 및 보안 시스템, 실시간 협업과 커뮤니케이션 기능 등에서도 실시간 처리는 중요한 역할을 한다.
데이터 실시간 처리의 단점
반면 실시간 처리는 시스템 전반의 복잡도를 크게 증가시킨다. 배치 시스템은 주기적으로 동작하며 대부분 사용자에게 직접 노출되지 않지만, 실시간 시스템은 실제 사용자 경험과 밀접하게 연결되는 경우가 많다. 이로 인해 장애 발생 시 즉각적인 대응이 필수적이며, 시스템 안정성이 매우 중요해진다.
예를 들어 배치 기반 추천 시스템과 달리 실시간 추천 시스템은 장애가 곧바로 서비스 품질 저하로 이어진다. 이 시점부터 데이터 처리는 단순한 데이터 엔지니어링을 넘어 DevOps 영역과 맞닿게 된다.
또한 운영 비용 역시 증가한다. 배치 처리에서는 오류가 발생하더라도 데이터 유실 가능성이 상대적으로 낮지만, 실시간 처리에서는 데이터 유실 위험이 커진다. 따라서 지속적인 백업과 모니터링이 필요하며, 이는 곧 인프라 및 운영 비용 증가로 이어진다.
Realitme vs Semi-Realtime
실시간 데이터 처리는 다시 Realtime과 Semi-Realtime으로 나눌 수 있다.
Realtime 처리는 매우 짧은 지연시간을 목표로 하며, 연속적인 데이터 스트림을 기반으로 동작한다. 이벤트 중심 아키텍처를 사용하여, 데이터가 수신되는 즉시 작업이나 계산이 트리거된다. 이러한 구조는 데이터 변화에 동적으로 반응하며 실시간 분석, 모니터링, 의사결정을 가능하게 한다.
반면, Semi-Realtime 처리는 완전한 즉시성을 약간 포기하고, 합리적인 지연시간을 선택한다. 보통 마이크로 배치(Micro-batch) 방식으로 동작하며, 배치 처리와 유사한 구조를 가진다. 이는 처리 용량과 리소스 활용 효율을 높이기 위한 선택으로, 적시성과 효율성 사이의 균형을 추구한다. 데이터는 짧은 주기로 업데이트되며, 많은 실무 환경에서 현실적인 대안으로 사용된다.
실시간 데이터 종류와 사용 사례
실시간 데이터 처리를 이해하기 위해 가장 먼저 받아들여야 할 관점은 “이벤트는 어디에나 존재한다(Events are Everywhere)”는 사실이다. 현대의 서비스와 비즈니스 환경에서는 대부분의 변화와 행동이 이벤트의 형태로 발생하며, 데이터 실시간 처리는 이러한 이벤트를 빠짐없이 포착하고 활용하는 데서 시작된다.
Case 01: 온라인 서비스에서의 이벤트
온라인 서비스에서는 사용자의 거의 모든 행동이 이벤트로 기록될 수 있다. 대표적인 예가 퍼널 데이터(Funnel Data)다. 상품 노출, 클릭, 구매와 같은 일련의 사용자 행동은 클릭 스트림 형태로 수집되며, 회원가입 역시 버튼 클릭부터 정보 입력, 최종 등록까지 여러 이벤트의 연속으로 표현된다.
또한 페이지 뷰와 성능 데이터 역시 중요한 이벤트다. 페이지별 렌더링 시간을 기록해두면, 서비스 장애나 성능 저하가 발생했을 때 원인을 빠르게 추적할 수 있다. 이때 데스크탑과 모바일 등 디바이스 타입별로 이벤트를 구분하면 분석의 정확도가 높아진다. 페이지 에러가 발생하는 경우에도 에러 이벤트를 별도로 수집하여 문제를 체계적으로 관리할 수 있다.
이 밖에도 사용자 등록, 로그인, 방문자 발생 등 기본적인 사용자 행동 모두 이벤트에 해당한다. 이러한 데이터가 늘어나면서, 이벤트 데이터의 모델을 정의하고 정확히 수집하는 작업의 중요성이 점점 커지고 있다. 데이터가 올바르게 수집되어야 이후 저장과 소비가 가능하기 때문에, 이벤트 수집만을 전담하는 팀이 생기기도 한다.
Case 02: 리테일 비즈니스에서의 이벤트
리테일 환경에서도 이벤트는 핵심적인 역할을 한다. 재고 추가나 품절과 같은 재고 수준 변화는 모두 이벤트로 기록되며, 이를 통해 실시간 재고 관리가 가능해진다.
Case 03: IoT 환경에서의 이벤트
IoT 환경에서는 이벤트의 비중이 더욱 커진다. 센서에서 수집되는 온도, 습도, 압력과 같은 측정값은 대표적인 이벤트 데이터다. 여기에 더해 장치의 온라인/오프라인 상태나 배터리 잔량과 같은 장치 상태 변화 역시 이벤트로 관리된다.
이벤트 데이터가 필요한 주요 유스 케이스
이벤트 데이터는 다양한 실시간 유스 케이스의 기반이 된다. 실시간 리포팅 영역에서는 A/B 테스트 분석, 마케팅 캠페인 대시보드, 인프라 모니터링 등이 대표적이다. 또한 실시간 알림 영역에서는 사기 탐지, 실시간 입찰, 원격 환자 모니터링과 같은 사례가 존재한다.
나아가 이벤트 데이터는 실시간 예측에도 활용된다. 머신러닝 모델을 통해 사용자 행동을 즉시 반영한 개인화 추천 시스템은 이벤트 기반 데이터 처리의 대표적인 결과물이라고 볼 수 있다.
실시간 데이터 처리 단계
실시간 데이터 처리는 단일 기술이나 시스템으로 완성되지 않는다. 이벤트가 정의되는 시점부터 전송, 처리 그리고 운영까지 여러 단계를 체계적으로 설계해야 실시간 시스템을 구축할 수 있다.
실시간 데이터 처리 과정은 크게 네 단계로 나눌 수 있다. 먼저 이벤트 데이터 모델을 결정하고, 이를 전송 및 저장한 뒤, 실제로 데이터를 처리한다. 마지막으로 전체 과정에서 발생할 수 있는 관리 이슈를 모니터링하고 해결하는 단계가 필요하다.
이 네 단계는 서로 강하게 연결되어 있으며, 앞단의 선택이 뒤 단계의 복잡도와 안정성에 직접적인 영향을 미친다.
1단계: 이벤트 데이터 모델 결정
이벤트 데이터 모델을 설계할 때 가장 최소한의 필요 요소는 Primary Key와 Timestamp이다. 이를 통해 이벤트의 식별과 시간 순서가 보장된다.
유스케이스에 따라 사용자 정보가 포함될 수도 있고, 이벤트 자체에 대한 세부 정보가 필요할 수도 있다. 예시로 클릭 이벤트의 경우, 어떤 페이지에서 어떤 버튼이 클릭되었는가와 같은 정보가 이벤트 데이터에 포함된다.
이 단계에서의 모델 설계는 이후 저장 방식과 처리 방식까지 좌우하게 되므로 중요하다고 볼 수 있다.
2단계: 이벤트 데이터 전송/저장
이벤트 데이터를 전닿하고 저장하는 방식은 크게 Point-to-Point 방식과 Message Queue 방식으로 나뉜다.
Point-to-Point 방식은 Producer와 Consumer가 직접 연결되는 구조이다. 처리량도 중요하지만, 무엇보다 지연 시간이 매우 중요한 시스템에서 사용된다. 많은 API 레이어가 이와 같은 방식으로 동작한다. 다만 Consumer가 여러 개인 경우 동일한 데이터를 반복해서 전송해야 하며, 확장성이 떨어진다는 단점이 있다.
Nessage Queue 방식은 Producer와 Consumer 사이에 Kafka와 같은 메시지 큐를 두는 구조이다. 중간 저장소를 통해 생산자와 소비자가 분리되며, 시스템 간 결합도가 낮아진다. 이 방식은 실시간 데이터 처리에서 가장 일반적으로 사용된다.
(번외) Backpressure(배압) 이슈
실시간 처리 시스템에서 반드시 고려해야 할 문제가 Backpressure이다. 이벤트 데이터는 일반적으로 일정한 속도로 생성되지만, 특정 상황에서는 데이터 생산량이 급격히 증가할 수 있다.
문제는 Consumer가 이 속도를 따라가지 못할 때 발생한다. 처리 지연이 누적되면 메모리 사용량이 증가하고, 이는 잠재적인 시스템 장애로 이어질 수 있다. 이를 Backpressure 이슈라고 부른다.
Backpressure를 완화하는 대표적인 방법 중 하나가 메시지 큐를 중간에 두는 것이다. 메시지 큐는 데이터 폭증 상황에서 완충 역할을 하지만, 이 문제는 완전히 제거할 수는 없다. Point-to-Point 구조에서도 Consumer 쪽에 작은 버퍼가 존재하지만, 버퍼 크기는 제한적이기 때문에 결국 오버플로우 문제가 발생할 수도 있다.
3단계: 이벤트 데이터 처리
이벤트 데이터를 어떻게 처리할지는 앞서 선택한 전송·저장 모델과 활용 목적에 따라 결정된다.
Point-to-Point 방식에서는 Consumer의 부담이 크며, 데이터가 들어오는 즉시 처리되어야 한다. 이로 인해 Backpressure에 취약하고 데이터 유실 가능성도 상대적으로 높다. 일반적으로 Low Throughput, Low Latency 특성을 가진다.
Messaging Queue를 사용하는 경우에는 보통 마이크로 배치(Micro-batch) 형태로 아주 짧은 주기 동안 데이터를 모아 처리한다. Spark Streaming이 대표적인 예다. 이 방식은 다수의 Consumer를 쉽게 구성할 수 있고, Point-to-Point 방식에 비해 운영이 훨씬 수월하다는 장점이 있다.
4단계: 이벤트 데이터 관리 이슈 모니터링 및 해결
이벤트 데이터 처리는 구현보다도 운영 단계에서의 안정성 관리가 핵심이다. 실시간 시스템은 지속적으로 동작하며 사용자 경험과 직접 연결되기 때문에, 작은 문제도 빠르게 장애로 이어질 수 있다.
가장 중요한 모니터링 대상은 데이터 지연(Lag)이다. Producer에서 생성된 이벤트가 Consumer에서 처리되기까지의 지연이 지속적으로 증가한다면, Backpressure나 처리 성능 저하를 의심해야 한다. 메시지 큐 기반 시스템에서는 Consumer Lag이 대표적인 지표로 사용된다.
또한 이벤트가 정상적으로 수집·저장·처리되고 있는지 확인하여 데이터 유실 가능성을 최소화해야 한다. 이를 위해 오프셋 관리, 재처리 가능 여부, 데이터 보존 기간 설정이 중요하다.
문제가 발생하면 Consumer 확장, 처리 로직 최적화, 파티션 조정 등 구조적인 개선을 통해 대응한다. 경우에 따라서는 완전한 실시간 처리 대신 Semi-Realtime 방식이 더 적절한 선택이 될 수도 있다.
Kafka 소개
Kafka는 실시간 데이터 처리를 위해 설계된 오픈소스 분산 스트리밍 플랫폼이다. 단순한 메시지 큐를 넘어, 데이터를 일정 기간 저장하고 다시 읽을 수 있는 분산 커밋 로그(Distibuted Commit Log) 개념을 기반으로 한다. 이로 인해 Kafka는 대규모 실시간 데이터 파이프라인의 중심 역할을 수행한다.
Kafka는 Publish-Subscribe 모델을 따르는 메시징 시스템으로, Producer와 Consumer가 명확히 분리되어 있다. 이를 통해 시스템은 높은 처리량과 지연시간을 동시에 만족할 수 있으며, 실시간 데이터 처리에 최적화된 구조를 가진다.
Kafka는 분산 아키텍처를 기반으로 하며, 서버를 추가하는 Scale-Out 방식으로 확장된다. 이때 각 서버는 브로커(Broker)라고 불리며, 브로커를 추가함으로써 자연스럽게 처리량과 저장 용량을 확장할 수 있다.
Kafka는 메시지를 일정 기간 동안 저장한다. 이 보유 기간(retention period) 동안 데이터는 삭제되지 않으며, 기본 설정은 약 일주일이다. 이 특성 덕분에 Consumer가 일시적으로 중단되더라도 데이터를 다시 읽을 수 있어 내구성과 내결함성이 보장된다.
기존 메시징 시스템 및 데이터베이스와의 비교
기존 메시징 시스템은 메시지를 소비하면 바로 제거되는 경우가 많았지만, Kafka는 메시지를 보유 기간 동안 유지한다. 따라서 Consumer가 오프라인 상태여도 데이터 유실 없이 복구가 가능하다.
또한 Kafka는 메시지 생산과 소비를 완전히 분리한다. Producer와 Consumer는 서로의 처리 속도에 영향을 주지 않고 독립적으로 동작할 수 있으며, 이는 시스템 안정성을 크게 향상시킨다.
(번외) Eventual Consistency란
분산 시스템에서 하나의 레코드를 여러 서버에 복제해 저장하는 경우, 데이터 변경 사항이 모든 서버에 즉시 반영되기는 어렵다. 데이터를 쓸 때 복제가 완료될 때까지 기다리는 방식은 Strong Consistency를 제공하지만, 지연시간이 증가한다.
반면 Kafka와 같은 시스템은 데이터를 쓰자마자 응답을 반환하고, 복제는 비동기적으로 진행된다. 이 경우 일부 서버에서는 아직 최신 데이터가 보이지 않을 수 있으며, 시간이 지나면 결국 일관된 상태에 도달한다. 이를 Eventual Consistency라고 한다.
Kafka 주요 기능과 이점
kafka는 처음부터 스트림 처리를 목적으로 설계된 플랫폼이다. ksqlDB를 활용하면 SQL 형태로 이벤트 데이터를 처리할 수 있다.
또한 kafka는 초당 수백만 건의 데이터를 처리할 수 있을 정도로 높은 처리량을 제공한다. 데이터 복제와 분산 커밋 로그 구조를 통해 내결함성(Fault Tolerance)을 확보하며, 브로커 추가만으로 손쉽게 확장 가능한 확장성(Scalability)을 갖추고 있다.
마지막으로 Kafka는 풍부한 생태계를 가진다. Kafka Connect를 통해 다양한 데이터 시스템과 연동할 수 있고, Schema Registry를 통해 이벤트 데이터의 스키마 관리도 가능하다. 이러한 점들이 Kafka를 실시간 데이터 처리의 표준 플랫폼 중 하나로 만들고 있다.
Kafka 아키텍처
Kafka를 이해하는 핵심은 데이터를 어떻게 스트림으로 관리하고, 이를 어떻게 확장성과 안정성을 갖춘 구조로 처리하는지를 파악하는 데 있다. Kafka는 이벤트 데이터를 단순히 전달하는 것이 아니라, 구조화된 스트림 형태로 저장하고 소비할 수 있도록 설계되어 있는지 알아본다.
데이터 이벤트 스트림
Kafka에서 데이터 이벤트 스트림은 Topic이라는 단위로 관리된다. Topic은 이벤트가 지속적으로 쌓이는 논리적인 스트림이며, Producer는 특정 Topic에 이벤트를 기록하고 Consumer는 해당 Topic으로부터 데이터를 읽어들인다.
하나의 Topic은 다수의 Consumer가 동시에 읽을 수 있다. 이를 통해 동일한 이벤트 스트림을 분석, 모니터링, 추천 시스템 등 여러 목적의 시스템에서 동시에 활용할 수 있다. 이 구조는 Kafka가 내부 데이터 버스로 사용될 수 있는 중요한 이유 중 하나다.
Message (Event) 구조: Key, Value, Timestamp
Kafka에서 처리되는 메시지, 즉 이벤트는 기본적으로 Key, Value, Timestamp로 구성된다. 메시지의 크기는 최대 1MB까지 허용되며, Timestamp는 보통 해당 이벤트가 Topic에 기록된 시점을 의미한다.
Key는 단순한 문자열이 아니라, 복잡한 구조를 가질 수도 있다. 이 Key는 이후 메시지가 어느 Partition에 저장될지를 결정하는 데 사용되며, 데이터 파티셔닝 전략의 핵심 요소가 된다.
또한 Header는 선택적인 구성 요소로, 경량의 메타데이터를 key-value 형태로 저장할 수 있다. 이는 메시지의 본문과 분리된 추가 정보를 전달할 때 유용하다.
Kafka 아키텍처 - Topic과 Partition
Kafka는 확장성을 확보하기 위해 하나의 Topic을 여러 개의 Partition으로 나누어 저장한다. Partition은 Kafka의 병렬 처리 단위이며, Partition의 수가 많을수록 더 높은 처리량을 기대할 수 있다.
메시지가 어떤 Partition에 저장될지는 Key의 유무에 따라 달라진다. Key가 존재하는 경우, Key의 해시 값을 Partition 수로 나눈 결과를 기준으로 Partition이 결정된다.
이를 통해 동일한 Key를 가진 메시지는 항상 같은 Partition에 저장되며, 메시지 순서가 보장된다. 반면 Key가 없는 경우에는 라운드 로빈 방식으로 Partition이 선택되는데, 이는 순서 보장이 어렵기 때문에 일반적으로 권장되지 않는다.
Kafka 아키텍처 - Topic과 Partition과 복제본
각 Partition은 장애 대응을 위해 복제본(Replica)을 가진다. 이 구조를 통해 하나의 브로커에 장애가 발생하더라도 데이터 유실 없이 서비스를 유지할 수 있다.
Partition마다 하나의 Leader와 하나 이상의 Follower가 존재한다. 쓰기 작업은 Leader를 통해서만 수행되며, 읽기 작업은 설정에 따라 Leader 또는 Follower에서 수행될 수 있다.
Kafka는 Partition 단위로 일관성 수준을 설정할 수 있으며, 이는 in-sync replica(ACK 설정)를 통해 제어된다. 이를 통해 처리 지연과 데이터 안정성 사이의 균형을 시스템 요구사항에 맞게 조정할 수 있다.
Kafka 아키텍처 - Broker: 실제 데이터를 저장하는 서버
Kafka 클러스터는 기본적으로 여러 대의 Broker로 구성된다. 이 Broker들은 실제로 Topic의 Partition 데이터를 저장하고, Producer와 Consumer의 요청을 처리한다. 하나의 Kafka 클러스터는 이론적으로 최대 약 20만 개의 Partition을 관리할 수 있으며, 이는 Broker들이 분산되어 Partition을 나눠 관리하기 때문에 가능한 구조다.
각 Broker는 최대 약 4,000개의 Partition을 처리할 수 있다. Broker는 물리 서버 또는 가상 머신 위에서 동작하며, Partition 데이터는 해당 서버의 디스크에 직접 기록된다. 따라서 Broker 수를 늘리면 자연스럽게 저장 용량과 처리량이 함께 증가하는 Scale-Out 구조를 갖는다.
이러한 수치적 제약은 Zookeeper를 사용하는 전통적인 Kafka 구성에서의 한계이며, 이를 개선하기 위해 Zookeeper를 대체하는 새로운 방식도 등장했다.
Kafka 아키텍처 - Broker와 Partition
Kafka Broker는 Kafka Server 혹은 Kafka Node라고도 불린다. Broker의 핵심 역할은 Topic을 구성하는 Partition들을 실제로 관리하는 것이다. 각 Partition은 특정 Broker에 할당되어 저장되며, 해당 Broker가 Partition의 Leader 또는 Follower 역할을 수행한다.
이처럼 Partition과 Broker의 매핑 정보는 Kafka 클러스터 전체에서 공유되어야 하며, 이를 위해 메타데이터 관리가 필수적이다.
Kafka 아키텍처 - 메타 정보 관리를 어떻게 할 것인가?
Kafka는 단순히 메시지를 저장하는 시스템이 아니라, 다양한 메타정보를 함께 관리한다. 예를 들어 어떤 Broker들이 클러스터에 참여하고 있는지에 대한 Broker 리스트(Broker Membership), 그리고 그중 어떤 Broker가 클러스터의 상태를 관리하는 Controller인지에 대한 정보가 필요하다.
또한 Topic 리스트와 Topic 설정 정보, 각 Topic을 구성하는 Partition과 그 Replica의 상태 역시 관리 대상이다. 이때 Partition과 Replica 관리는 Controller가 담당하며, Controller는 Broker 중 하나가 맡는다.
이 외에도 Topic별 접근 권한을 제어하기 위한 ACL(Access Control Lists), 그리고 클라이언트의 과도한 사용을 제한하기 위한 Quota 관리 역시 중요한 메타데이터다.
Kafka 아키텍처: Zookeeper와 Controller
Kafka 0.8.2 버전(2015년)부터는 Controller 개념이 도입되었다. Controller는 Broker이면서 동시에 Partition 관리와 리더 선출을 담당하는 역할을 수행한다. Kafka의 장기적인 목표는 Zookeeper 의존도를 줄이거나 완전히 제거하는 것이며, 현재는 두 가지 운영 모드가 공존한다.
첫 번째는 Zookeeper 모드이다. 이 방식에서는 3대, 5대, 혹은 7대의 서버로 Zookeeper Ensemble을 구성한다. Zookeeper는 메타데이터 저장과 Controller 선출을 담당하며, 클러스터 내에는 항상 하나의 Controller만 존재한다.
두 번째는 KRaft 모드이다. KRaft 모드에서는 Zookeeper를 완전히 배제하고, Kafka 자체가 메타데이터 관리와 컨트롤 기능을 수행한다. 이 방식에서는 다수의 Controller가 존재하며, 이들이 Zookeeper의 역할을 대체한다. 일반적으로 Controller들은 Broker 역할도 함께 수행한다.
(번외) Zookeeper란
Zookeeper는 분산 시스템에서 널리 사용되어 온 Distributed Coordination Service다. 여러 노드로 구성된 시스템에서 동기화, 설정 관리, 리더 선출과 같은 작업을 중앙에서 조율하기 위한 목적으로 설계되었다. 분산 환경에서 각 노드가 동일한 상태를 인식하고 협력할 수 있도록 돕는 역할을 한다.
Zookeeper는 원래 Yahoo!의 Hadoop 프로젝트 일부로 자바 기반으로 개발되었으며, 이후 Apache 오픈소스 프로젝트로 발전했다. 이로 인해 오랫동안 다양한 분산 시스템의 핵심 구성 요소로 사용되어 왔다.
Zookeeper의 한계와 문제점
Zookeeper는 분산 시스템의 조율을 담당하는 데 효과적이지만, 구조적인 한계도 분명하다. 먼저 Zookeeper는 지원하는 데이터 크기가 매우 작고, 동기 방식으로 동작한다. 이로 인해 처리 속도가 느리고, 일정 규모 이상으로 확장할 경우 병목이 발생하기 쉽다.
또한 설정과 운영이 복잡하다는 점도 문제로 지적된다. 안정적인 운영을 위해 여러 대의 서버로 Ensemble을 구성해야 하며, 장애 대응과 설정 관리에 상당한 부담이 따른다. 이러한 이유로 점차 많은 서비스들이 Zookeeper 의존도를 줄이거나, 아예 다른 방식으로 대체하기 시작했다.
Kafka 역시 이러한 흐름 속에서 Zookeeper를 제거하려는 방향으로 발전하고 있으며, ElasticSearch 또한 Zookeeper를 사용하다가 자체적인 메타데이터 관리 방식으로 전환한 대표적인 사례다.
Zookeeper의 주요 사용 사례
그럼에도 불구하고 Zookeeper는 오랫동안 다양한 분산 시스템에서 핵심 역할을 수행해왔다. 대표적으로 메시지 큐 시스템인 Apache Kafka, 분산 데이터베이스 조정을 위한 Apache HBase, 분산 스트림 처리 시스템인 Apache Storm 등에서 사용되었다.
이들 시스템에서 Zookeeper는 노드 간 상태 공유, 리더 선출, 설정 정보 관리와 같은 기능을 담당하며 분산 환경의 안정성을 유지하는 데 기여했다.
Kafka 주요 개념
Kafka는 메시지를 단순히 전달하는 시스템이 아니라, 로그 기반 스토리지 모델을 중심으로 설계된 스트리밍 플랫폼이다. 이를 이해하기 위해서는 Topic, Partition, Segment로 이어지는 계층 구조와 데이터 저장 방식의 특성을 함께 살펴볼 필요가 있다.
Topic의 기본 동작 방식
Kafka에서 Topic은 Consumer가 데이터를 읽는 단위지만, Consumer가 데이터를 읽는다고 해서 메시지가 사라지지는 않는다. Topic에 저장된 데이터는 설정된 보유 기간(retention period) 동안 유지되며, 여러 Consumer가 동일한 데이터를 각자의 속도로 읽을 수 있다.
Kafka는 Consumer별로 어디까지 데이터를 읽었는지에 대한 위치 정보(offset)를 유지한다. 이 정보는 장애 상황에서도 복구가 가능하도록 중복 저장되며, Kafka의 내결함성을 구성하는 중요한 요소다.
Topic, Partition, Replication 구조
하나의 Topic은 확장성을 위해 여러 개의 Partition으로 나뉜다. Partition은 Kafka의 병렬 처리 단위이며, Partition 수를 늘리면 처리량을 수평적으로 확장할 수 있다.
각 Partition은 장애 대응을 위해 복제본(Replication Partition)을 가진다. Partition마다 하나의 Leader와 하나 이상의 Follower가 존재하며, 쓰기 작업은 Leader를 통해서만 이루어진다. 읽기 작업은 설정에 따라 Leader 또는 Follower를 통해 수행될 수 있다. 이 구조를 통해 Kafka는 장애 발생 시에도 빠르게 Fail-over가 가능하다.
Partition과 Segment의 관계
하나의 Partition은 다시 여러 개의 Segment로 구성된다. Segment는 변경되지 않고 데이터가 계속 추가되는 Append-Only 로그 파일로, Kafka의 커밋 로그(Commit Log)를 구성하는 기본 단위다.
각 Segment는 디스크 상의 하나의 파일이며, 크기에 제한이 있다. Segment 파일이 최대 크기에 도달하면 새로운 Segment가 생성된다. 이로 인해 각 Segment는 자신만의 데이터 오프셋 범위를 가지게 된다.
로그 파일의 특성
Kafka의 로그 파일, 정확히는 Segment의 특성은 매우 단순하면서도 강력하다. 데이터는 항상 파일의 끝에만 추가되며(Append Only), 한 번 기록된 데이터는 변경되지 않는다(Immutable).
데이터는 설정된 Retention Period에 따라 일정 시간이 지나면 삭제되며, 각 메시지에는 순서를 나타내는 Offset 번호가 부여된다. 이 Offset을 기준으로 Consumer는 자신이 어디까지 데이터를 처리했는지를 정확히 추적할 수 있다.
Broker의 역할
Kafka에서 Topic은 시간 순서대로 정렬된 메시지들의 집합이다. Producer는 먼저 Topic을 생성하고 필요한 속성을 지정한 뒤, 메시지를 Broker로 전송한다. Broker는 수신한 메시지를 Topic에 속한 Partition으로 나누어 저장하며, 이때 장애 대응을 위해 Replication Factor에 따라 Leader와 Follower 복제본을 함께 관리한다.
Consumer는 직접 Producer와 통신하지 않고, 항상 Broker를 통해 메시지를 읽는다. 이 구조를 통해 생산과 소비가 분리되며, 시스템 전체의 안정성이 확보된다.
Kafka 클러스터는 여러 개의 Broker로 구성된다. 하나의 Broker는 여러 Partition을 동시에 관리하며, 각 Partition 데이터는 Broker가 실행 중인 서버의 디스크에 저장된다. Topic에 속한 메시지들은 스케일 아웃을 위해 여러 Partition에 분산 저장되고, 이 Partition과 Replica들의 전체적인 배치는 Controller가 관리한다.
개념적으로 하나의 Partition은 하나의 로그 파일로 볼 수 있으며, 각 메시지는 고유한 위치 정보인 Offset을 가진다. 메시지의 저장 기간은 Retention Policy에 의해 제어된다.
Producer와 Partition 선택
하나의 Topic은 여러 Partition으로 구성되며, 어떤 Partition에 메시지를 저장할지는 Producer가 결정한다. Partition은 두 가지 목적을 가진다. 첫째는 로드 밸런싱을 통한 처리량 확장이고, 둘째는 특정 Key를 기준으로 메시지를 묶는 의미적 파티셔닝(Semantic Partitioning)이다.
Producer는 기본적으로 hash(key) % partition 수 방식으로 Partition을 선택한다. Key가 없는 경우에는 라운드 로빈 방식이 사용되며, 필요에 따라 커스텀 Partition 로직을 구현할 수도 있다. Partition 전략은 메시지 순서 보장과 처리 성능에 직접적인 영향을 미친다.
Consumer의 기본 개념
Consumer는 Topic을 구독(Subscription)하여 메시지를 읽는다. Consumer는 자신이 마지막으로 읽은 메시지의 Offset을 관리하며, 이를 통해 중단 이후에도 정확한 위치에서 다시 처리할 수 있다. Kafka는 이를 지원하기 위한 커맨드라인 Consumer 유틸리티도 제공한다.
Consumer는 Consumer Group이라는 개념을 통해 수평 확장이 가능하다. 하나의 Consumer Group 내에서 Partition은 Consumer들에게 나뉘어 할당되며, 이를 통해 처리량을 늘리고 Backpressure 문제를 완화할 수 있다.
실무에서는 하나의 프로세스가 Consumer이면서 동시에 Producer 역할을 수행하는 경우도 매우 흔하다. 이 방식으로 데이터를 가공한 뒤 새로운 Topic으로 다시 발행하는 구조는 Kafka 기반 데이터 파이프라인의 대표적인 패턴이다.
Kafka 기타 기능
Kafka가 실시간 데이터 파이프라인의 중심 역할을 하게 되면서, Kafka 자체뿐 아니라 Kafka를 둘러싼 주변 생태계 구성 요소의 중요성도 커졌다. 그중 대표적인 것이 Kafka Connect와 Kafka Schema Registry다. 이 두 구성 요소는 Kafka를 기존 데이터 시스템과 안전하게 연결하는 역할을 담당한다.
Kafka Connect
Kafka Connect는 Kafka 위에 구축된 중앙 집중형 데이터 통합 허브이다. Kafka를 단순한 메시징 시스템이 아닌, 데이터 시스템 간의 데이터 버스(Data Bus)로 활용할 수 있도록 돕는 별도의 오픈소스 프로젝트이다. Kafka Connect는 Kafka Broker와는 별도로 동작하며, 이를 위해 전용 서버들이 필요하다.
Kafka Connect는 두 가지 실행 모드를 제공하는데, Standalone 모드는 주로 개발과 테스트 용도로 사용되며, Distributed 모드는 실제 운영 환경에서 사용된다. 운영 환경에서는 장애 대응과 확장성을 위해 Distributed 모드가 일반적이다.
Kafka Connect의 핵심 목적은 다양한 데이터 시스템 간의 데이터를 주고받는 것이다. 데이터베이스, 파일 시스템, 키-값 저장소, 검색 인덱스 등과 Kafka를 연결하여, 외부 데이터를 Kafka로 가져오거나 Kafka의 데이터를 외부 시스템으로 지속적으로 전달할 수 있다. 이때 데이터를 가져오는 쪽을 Source, 내보내는 쪽을 Sink라고 부른다.
Kafka Connect의 내부 구조
Kafka Connect는 Broker 중 일부 서버나, 완전히 별도의 서버들로 구성될 수 있다. Kafka Connect 클러스터 내부에서는 Worker들이 동작하며, 실제 데이터 이동 작업은 Worker가 수행하는 Task 단위로 나뉜다.
Task는 역할에 따라 Source Task와 Sink Task로 구분된다. Source Task는 외부 데이터 소스로부터 데이터를 읽어 Kafka의 이벤트 스트림으로 변환하고, Sink Task는 Kafka에 저장된 데이터를 외부 시스템으로 전달한다. 예를 들어 Kafka의 데이터를 S3 버킷에 지속적으로 저장하는 작업은 Kafka Connect를 통해 매우 쉽게 구성할 수 있다.
Kafka Schma Registry
Kafka 기반 시스템에서 이벤트 데이터가 많아질수록, 메시지 구조의 일관성과 변경 관리가 중요한 문제가 된다. Kafka Schema Registry는 Topic에 저장되는 메시지 데이터의 스키마를 중앙에서 관리하고 검증하기 위한 서비스다.
Producer와 Consumer는 Schema Registry를 통해 스키마를 공유하며, 스키마 변경이 발생하더라도 안전하게 처리할 수 있다. 이를 통해 데이터 포맷 불일치로 인한 장애를 사전에 방지할 수 있다.
Schema Registry는 Schema ID와 버전을 기반으로 스키마 진화(Schema Evolution)를 지원한다. 일반적으로 AVRO 포맷이 많이 사용되며, Protobuf나 JSON도 함께 사용된다.
스키마 변경 시에는 호환성 전략을 선택해야 한다. Producer를 먼저 변경하고 Consumer를 점진적으로 수정하는 방식은 Forward Compatibility, Consumer를 먼저 변경하는 방식은 Backward Compatibility라고 한다. 양쪽 모두를 동시에 고려하는 방식은 Full Compatibility다.
(번외) Serialization and Deserialization (직렬화 & 역직렬화)
메시지를 Kafka로 전송하기 위해서는 데이터를 직렬화(Serialization)해야 한다. 직렬화는 객체의 상태를 저장하거나 전송 가능한 형태로 변환하는 과정으로, 이때 데이터 압축이 함께 이루어지기도 하며, 스키마 정보가 포함될 수 있다.
Consumer 측에서는 역직렬화(Deserialization) 과정을 통해 직렬화된 데이터를 다시 사용할 수 있는 형태로 변환한다. 이 과정에서 압축 해제와 함께 스키마 검증이 수행된다. 이러한 직렬화와 역직렬화 작업은 보통 Kafka 관련 라이브러리들이 담당한다.
Kafka 아키텍처 - REST Proxy
Kafka REST Proxy는 HTTP API를 통해 Kafka를 사용할 수 있도록 해주는 컴포넌트다. 이를 통해 클라이언트는 Kafka 전용 라이브러리를 사용하지 않고도 메시지를 생성하거나 소비하고, 토픽을 관리할 수 있다. 표준화된 REST API를 제공하기 때문에 언어와 환경에 대한 제약이 크게 줄어든다.
REST Proxy는 메시지의 직렬화와 역직렬화를 대신 수행하며, 내부적으로 로드 밸런싱 역할도 담당한다. 특히 사내 네트워크 외부에서 Kafka에 접근해야 하는 경우에 유용하며, Kafka 클러스터를 직접 노출하지 않고도 안전한 접근 지점을 제공할 수 있다.
Kafka 아키텍처 - Streams와 KSQL
Kafka Streams는 Kafka Topic을 소비하고 다시 Kafka Topic으로 결과를 생성하는 실시간 스트림 처리 라이브러리다. 별도의 클러스터를 구성할 필요 없이 애플리케이션 라이브러리 형태로 사용 가능하다는 점이 특징이다.
Spark Streaming으로 Kafka 데이터를 처리하는 경우가 마이크로 배치 기반에 가깝다면, Kafka Streams는 레코드 단위 처리를 중심으로 하여 보다 실시간에 가까운 처리를 제공한다. 이로 인해 지연시간이 짧고, 비교적 단순한 스트림 처리 로직에 적합하다.
Kafka 아키텍처 - ksqlDB
KSQL은 Confluent에서 개발한 Kafka용 오픈소스 SQL 엔진으로, 스트리밍 데이터를 SQL로 처리할 수 있도록 설계되었다. 이를 통해 사용자는 연속적으로 유입되는 데이터를 대상으로 Continuous Query를 작성하고, 실시간 분석과 변환을 수행할 수 있다.
이후 KSQL은 ksqlDB로 발전했다. ksqlDB는 Kafka Streams를 기반으로 구현된 스트림 처리 데이터베이스로, SQL과 유사한 쿼리 언어를 제공한다. 필터링, 집계, 조인, 윈도우 연산 등 일반적인 SQL 작업을 실시간 스트림 데이터에 적용할 수 있다.
ksqlDB의 중요한 특징은 연속 쿼리와 지속적으로 업데이트되는 뷰를 지원한다는 점이다. 데이터가 도착하는 즉시 결과가 갱신되며, 이를 통해 실시간 집계와 변환을 손쉽게 구현할 수 있다. 이는 Spark 환경에서 SQL 기반 분석이 대세가 된 흐름과도 유사하다.
Kafka 프로그래밍 with Python
Kafka를 사용하기 위한 각 프로그래밍 언어들의 옵션은 다음과 같다:
Java:
Apache Kafka Java Client: 아파치 카프카의 공식 Java 클라이언트 라이브러리
Spring Kafka: 스프링 프레임워크와 Kafka를 통합하기 위한 라이브러리
Python:
Confluent Kafka Python: Confluent에서 개발한 공식 Kafka Python 클라이언트 라이브러리
Kafka-Python: 또다른 파이썬 기반 라이브러리
.NET:
Confluent Kafka .NET Client: Confluent에서 개발한 공식 Kafka .NET 클라이언트 라이브러리
GO:
Sarama: Go 언어용 Kafka 클라이언트 라이브러리
Node.js:
node-rdkafka: librdkafka를 기반으로 한 Node.js용 Kafka 클라이언트 라이브러리
kafka-node: Node.js용 Kafka 클라이언트 라이브러리
Kafka Python 프로그래밍 기본
Kafka 설치 - Docker Compose 사용
$ git clone https://github.com/conduktor/kafka-stack-docker-compose.git
$ cd kafka-stack-docker-compose
$ docker-compose -f full-stack.yml up
Docker Compose yaml 파일은 다음과 같은 구조로 이루어진디:
version: '2.1'
services:
zoo1:
kafka1:
kafka-schema-registry:
kafka-connect:
ksqldb-server:
conduktor-platform:
volumes:
...
Python 모듈 설치
pip3 install kafka-python
간단한 Producer 만들기
from time import sleep
from json import dumps
from kafka import KafkaProducer
# 로컬 Kafka 인스턴스를 연결하는 KafkaProducer 객체 생성
# 전송하려는 데이터를 json 문자열로 변환한 뒤,
# UTF-8로 인코딩하여 직렬화 방법 정의
producer = KafkaProducer(
# Broker들 중 하나 이상을 지정
bootstrap_servers = ['localhost:9092'],
value_serializer = lambda x: dumps(x).encode('utf-8')
)
# 0.5초마다 "topic_test"라는 토픽과 반복 카운터를 데이터로 포함하는 이벤트를 전송
# 키-값 데이터로 'counter'라는 키와 정수를 값으로 갖도록 구성함
for j in range(999):
print("Iteration", j)
data = {'counter': j}
# key와 headers는 지정되어 있지 않음
producer.send('topic_test', value=data)
sleep(0.5)
Consumer 객체 만들기
from time import sleep
from json import loads
from kafka import KafkaConsumer
# 로컬 Kafka 인스턴스를 연결하는 KafkaConsumer 객체 생성
# "topic_test" 트픽에서 가장 먼저 생긴 데이터를 읽고,
# 오프셋 정보는 계속해서 업데이트 후,
# my-group-id라는 이름의 consumer group에 조인하도록 설정
consumer = KafkaConsumer(
'topic_test',
bootstrap_servers = ['localhost:9092'],
auto_offset='earliest'
# 만약 False일 경우, commit 함수를 명시적으로 offset 위치를 commit해야 함
enable_auto_commit=True,
group_id='my_group_id',
# Producer에서 수행한 value_serializer의 반대 작업 수행
value-deserializer=lambda x: loads(x.decode('utf-8'))
)
# 2초마다 "topic-test"라는 토픽에서 카운터 값을 읽도록 구성
for event in consumer:
event_data = event.value
print(event_data)
sleep(2)
Kafka CLI Tools 접근
docker ps를 통해 Broker의 Container ID 혹은 Container 이름을 파악하여, 해당 컨테이너로 로그인한다.
docker exec -it Broker_Container_ID sh
해당 shell에서 다양한 Kafka 관련 클라이언트 툴을 사용할 수 있다.
$ kafka-topics --bootstrap-server kafka1:9092 --list
$ kafka-topics --bootstrap-server kafka1:9092 --delete --topic topic_test
Command line을 통해 Topic을 만들고, Message 생성이 가능하다.
$ kafka-console-producer --bootstrap-server kafka1:9092 --topic test_console
Command line을 통해 Topic에서 Message 읽기가 가능하다. 만약 --from-beginning 옵션이 있다면, 처음부터 읽음 (earliest). 아니면 latest로 동작하게 된다.
kafka-console-consumer --bootstrap-server kafka1:9092 --topic test_console --from-beginning
Topic 파라미터 설정
Topic 생성시 다수의 Partition이나 Replica를 주려면, 먼저 KafkaAdminClient 오브젝트를 생성하고 create_topics 함수로 Topic을 추가하고, create_topics의 인자로는 NewTopic 클래스의 오브젝트를 지정한다.
clinet = KafkaAdminClient(bootstrap_servers=bootstrap_servers)
topic = NewTopic(
name=name,
num_partitions=partitions,
replication_factor=replica
)
client.create_topics([topic])
Kafka Producer 동작 파라미터
Kafka Producer의 동작을 위한 파라미터들은 다음과 같이 구성된다:
파라미터
의미
기본 값
bootstrap_servers
메시지를 보낼 때 사용할 브로커 리스트 (host:port)
localhost:9092
client_id
Kafka Producer의 이름
kafka-python-{version}
key_serializer, value_serializer
메시지의 키와 값을 직렬화(serialize)하는 방법을 지정하는 함수
enable_idempotence
중복 메시지 전송을 막을 것인지 여부
False
acks (0, 1, all)
Consistency level (0: 바로 리턴, 1: Leader에 기록 시 리턴, all: 모든 Replica에 기록될 때까지 대기)
0
retries, delivery.timeout.ms
메시지 실패 시 재시도 횟수 / 메시지 전송 최대 시간(ms)
2147483647, 120000
linger_ms, batch_size
배치 전송 설정 (송신 전 대기 시간(ms), 송신 전 데이터 크기(bytes))
0, 16384
max_in_flight_requests_per_connection
Broker 응답을 기다리지 않고 전송 가능한 최대 메시지 수
5
각 파라미터들은 다음과 같은 흐름으로 동작하게 된다:
Kafka Consumer 동작 파라미터
Kafka Consumer의 동작을 위한 파라미터들은 다음과 같이 구성된다:
파라미터
의미
기본 값
bootstrap_servers
메시지를 받을 때 사용할 브로커 리스트 (host:port)
localhost:9092
client_id
Kafka Consumer의 이름
kafka-python-{version}
group_id
Kafka Consumer Group의 이름
key_deserializer, value_deserializer
메시지의 키와 값을 역직렬화(deserialize)하는 방법을 지정하는 함수
auto_offset_reset
초기 오프셋이 없을 때 읽기 시작 위치 (earliest 또는 latest)
latest
enable_auto_commit
True이면 소비자의 오프셋을 백그라운드에서 주기적으로 커밋, False이면 명시적으로 커밋 필요 (오프셋은 별도 리셋 가능)
True
Consumer는 여러 Partition을 어떻게 읽는가
하나의 Consumer가 있고, 해당 Consumer가 다수의 Partition으로 구성된 Topic을 읽어야 하는 상황을 가정해보자. 이 경우 Consumer는 각 Partition으로부터 라운드 로빈 방식으로 하나씩 메시지를 읽게 된다.
이 구조에서는 병렬성이 떨어질 수 있다. 데이터 생산 속도가 빠를수록 Consumer가 이를 따라가지 못하게 되고, 결과적으로 Backpressure가 심해질 가능성이 커진다. 이러한 문제를 해결하기 위해 등장한 개념이 바로 Consumer Group이다.
한편, 하나의 프로세스에서 여러 Topic을 읽는 것도 가능하다. 이 경우 Topic 수만큼 KafkaConsumer 인스턴스를 생성해야 하며, 각각 다른 Group ID와 Client ID를 지정하는 것이 일반적이다.
Consumer Group이란 무엇인가
Consumer Group은 Kafka에서 데이터 소비의 병렬성과 장애 대응을 동시에 해결하기 위한 핵심 개념이다. Consumer가 Topic을 읽기 시작하면, 해당 Topic의 Partition들이 Consumer Group 내의 Consumer들에게 자동으로 할당된다.
Partition 수가 Consumer 수보다 많다면, Partition들은 라운드 로빈 방식으로 Consumer들에게 분배된다. 이때 중요한 제약은 하나의 Partition은 하나의 Consumer에게만 할당된다는 점이다. 이를 통해 데이터 중복 처리 없이 병렬 소비가 가능해진다.
이 구조의 장점은 명확하다. 데이터 소비 병렬성이 증가하여 Backpressure를 완화할 수 있고, 일부 Consumer가 중단되더라도 나머지 Consumer들이 계속해서 데이터를 처리할 수 있다.
Consumer Group Rebalancing
Consumer Group 내에서 Consumer의 수가 변하면, Partition 할당을 다시 조정해야 한다. 기존 Consumer가 장애로 사라지거나, 새로운 Consumer가 Group에 참여하는 경우가 이에 해당한다.
이러한 재할당 과정을 Consumer Group Rebalancing이라고 하며, Kafka가 이를 자동으로 수행한다. Rebalancing은 편리하지만, 그 동안 일시적인 처리 중단이 발생할 수 있기 때문에 Consumer 설계 시 이를 고려해야 한다.
메시지 처리 보장 방식 (Delivery Semantics)
실시간 메시지 처리 시스템에서는 메시지가 얼마나 정확하게 전달되고 처리되는지를 정의하는 보장 방식이 중요하다. 일반적으로 세 가지 방식이 존재한다.
At Most Once
At Most Once는 메시지가 최대 한 번만 처리됨을 보장한다. 중복은 없지만, 메시지 손실 가능성이 존재한다. 가장 단순한 방식이며, 일부 로그 처리나 중요도가 낮은 데이터에 사용된다.
At Least Once
At Least Once는 모든 메시지가 적어도 한 번 이상 처리됨을 보장한다. 대신 메시지 중복 가능성이 있으며, Consumer는 중복 처리를 방지하기 위한 멱등성(Idempotency) 로직을 직접 구현해야 한다. 보통 Consumer가 오프셋을 직접 커밋하는 경우 이 방식이 사용된다.
Exactly Once
Exactly Once는 각 메시지가 정확히 한 번만 처리됨을 보장한다. 가장 이상적인 방식이지만, 네트워크 장애나 재시도 상황까지 고려해야 하므로 구현 난이도가 매우 높다. Kafka에서는 Producer 측에서 enable.idempotence를 활성화하고, Producer와 Consumer 모두 Transaction API를 사용하는 방식으로 이를 지원한다.
ksqlDB 사용 예제
RESTAPI나 ksql 클라이언트 툴을 사용하여 Topic을 테이블처럼 SQL(ksql)로 조작하는 간단한 예제를 진행한다.
# confluentinc/cp-ksqldb-server의 Container ID 복사를 위해 실행 중인 docker container list 출력
$ docker ps
$ docker exec -it ContainerID sh
ksql 실행 후 두 개의 명령어를 실행한다:
CREATE STREAM my_stream (id STRING, name STRING, title STRING) with (kafka_topic='fake_people', value_format='JSON');
SELECT * FROM my_stream;
Spark Streaming 소개
Spark Streaming은 실시간 데이터 스트림 처리를 위한 Spark의 API다. Kafka, Kinesis, Flume, TCP 소켓 등 다양한 소스로부터 유입되는 데이터를 처리할 수 있으며, 배치 처리에서 사용하던 Join, Map, Reduce, Window와 같은 고급 연산을 그대로 사용할 수 있다.
이로 인해 기존 배치 처리 로직을 크게 변경하지 않고도 실시간 처리로 확장할 수 있다는 장점이 있다.
Spark Streaming의 동작 방식
Spark Streaming은 데이터를 완전히 실시간으로 처리하기보다는, 마이크로 배치(Micro-batch) 방식으로 처리한다. 일정 시간 동안 수집된 데이터를 하나의 작은 배치로 묶어 처리하고, 이 과정을 반복적으로 수행한다.
각 배치마다 데이터의 시작과 끝 위치를 관리하며, 이전 배치에서 처리된 데이터와 병합해 전체 스트림 상태를 유지한다. 장애가 발생할 경우에는 데이터를 다시 처리할 수 있도록 설계되어 있어, Fault Tolerance와 데이터 재처리가 가능하다.
Spark Streaming의 내부 동작
내부적으로 Spark Streaming은 실시간 입력 스트림을 여러 개의 배치로 나눈 뒤, 이를 Spark Engine에서 처리한다. 처리 결과는 다시 스트림 형태로 이어져 최종 결과를 만들어낸다.
Spark Streaming에는 두 가지 주요 추상화가 존재한다. 초기 방식인 DStream과, 이후 등장한 Structured Streaming이다.
DStream
Structured Streaming
RDD 기반 스트리밍 처리
DataFrame 기반 스트리밍 처리
Spark SQL 엔진의 최적화 기능 사용 불가
Catalyst 기반 최적화 혜택을 가져감
이벤트 발생 시간 기반 처리 불가
이벤트 발생 시간 기반 처리 가능
개발이 중단된 상태
계속해서 기능이 추가되고 있음
Structured Streaming은 더 선언적인 API와 강력한 최적화 기능을 제공하며, 현재 Spark 실시간 처리의 중심 모델로 자리 잡고 있다.
Source & Sink
Spark Structured Streaming에서 스트리밍 처리를 이해하는 핵심은 Source와 Sink 개념이다. Source와 Sink는 외부 시스템과 Spark 사이에서 데이터를 주고받는 역할을 하며, 스트리밍 파이프라인의 시작과 끝을 구성한다.
Source
Source는 외부 시스템에서 발생하는 스트리밍 데이터를 Spark Structured Streaming으로 수집할 수 있도록 해주는 구성 요소다. Kafka, Amazon Kinesis, Apache Flume, TCP/IP 소켓, HDFS, 파일 시스템 등 다양한 데이터 소스를 지원한다.
Structured Streaming에서 Source를 통해 읽어온 데이터는 결국 Spark DataFrame으로 변환된다. 이 덕분에 스트리밍 데이터라 하더라도 배치 처리와 동일한 DataFrame API를 사용할 수 있다. 예를 들어 Kafka에 저장된 데이터를 Spark Structured Streaming으로 수집하려는 경우, Kafka Source를 사용해 하나 이상의 Topic에서 데이터를 읽어 DataFrame 형태로 변환할 수 있다.
배치 처리와의 가장 큰 차이점은 read가 아닌 readStream API를 사용한다는 점이다. 이를 통해 Spark는 해당 DataFrame이 지속적으로 갱신되는 스트리밍 데이터임을 인식하게 된다.
lines_df = spark.readStream \
.format("socket") \
.option("host", "localhost") \
.option("port", "9999") \
.load()
Sink
Sink는 Spark Structured Streaming에서 처리된 데이터를 외부 시스템이나 스토리지로 출력하는 역할을 한다. 즉, 스트리밍 파이프라인의 결과가 어디로 전달되고, 어떻게 소비될지를 정의한다.
Sink 역시 Source와 마찬가지로 다양한 대상 시스템을 지원한다. Kafka, HDFS, Amazon S3, Apache Cassandra, JDBC 기반 데이터베이스 등이 대표적인 예다. 예를 들어 Kafka Sink를 사용하면 Spark Structured Streaming에서 처리된 데이터를 다시 Kafka Topic으로 전송할 수 있다.
word_count_query = counts_df.writeStream \
.format("console") \
.outputMode("complete") \
.option("checkpointLocation", "chk-point-dir") \
.start()
(추가) Output Mode
Sink로 데이터를 출력할 때는 Output Mode를 통해 현재 마이크로 배치의 결과가 어떻게 반영될지를 결정한다.
Append 모드: 새로운 데이터만 추가하는 방식으로, 변경되지 않는 결과에 적합하다.
Update 모드: 기존 결과를 갱신하는 방식으로, UPSERT와 유사한 동작을 한다.
Complete 모드: 전체 결과를 매번 다시 쓰는 방식으로, 일종의 FULL REFRESH에 해당한다.
Output Mode 선택은 처리 로직과 Sink의 특성에 따라 신중하게 결정해야 한다.
-
[6기] 데브코스 DE WIL 11 | 빅데이터 처리 시스템 Hadoop & Spark
이번 주 학습 목표
Hadoop과 Spark의 등장 배경 및 아키텍처(HDFS, YARN, MapReduce, Spark Core)를 이해하고, 대용량 데이터를 분산 환경에서 처리해야 하는 이유를 설명할 수 있다.
Spark의 실행 모델과 데이터 처리 방식(DataFrame, Partition, Shuffle, Job·Stage·Task)을 이해하고, 성능에 영향을 주는 핵심 요소를 파악할 수 있다.
Parquet, Partitioning, Bucketing과 같은 저장 및 처리 최적화 기법을 이해하고, 실무 관점에서 효율적인 대규모 데이터 처리 파이프라인을 설계할 수 있다.
빅데이터 정의와 예
빅데이터는 흔히 단일 서버로 처리할 수 없는 규모의 데이터로 정의된다. 이 정의는 2012년 아마존 클라우드 컨퍼런스에서 아마존의 데이터 사이언티스트 존 라우저(John Rauser)가 제시한 개념으로, 핵심은 데이터의 크기 자체보다 분산 환경이 필요한가에 있다.
예시로, Pandas와 같은 단일 머신 기반 도구로 데이터를 처리하려고 할 때, 메모리나 성능의 한계로 작업이 불가능하다면, 이는 빅데이터 문제로 볼 수 있다. 이 시점부터는 여러 대의 서버를 활용하는 분산 처리 접근이 필요해진다.
또 다른 관점에서의 빅데이터는 기존의 소프트웨어로 처리할 수 없는 데이터를 의미한다. 여기서 기존 소프트웨어란 Oracle이나 MySQL과 같은 전통적인 관계형 데이터베이스를 말한다.
이러한 시스템은 기본적으로 분산 환경을 두고 설계되지 않았으며, 성능 향상을 위해 Scale-Up 방식을 사용하였다. 즉, 메모리, CPU, 디스크를 추가하는 방식으로 한계를 극복하려 한다. 반면 빅데이터 환경에서는 여러 노드를 추가하는 Scale-Out 방식이 필요하다.
빅데이터의 4V
빅데이터는 흔히 4V로 설명되곤 한다:
Volume: 데이터의 크기가 매우 큰가?
Velocity: 데이터가 빠른 속도로 생성·처리되어야 하는가?
Variety: 정형·반정형·비정형 데이터를 모두 포함하는가?
Veracity: 데이터의 품질과 신뢰성이 확보되어 있는가?
이 네 가지 특성은 빅데이터 처리 시스템이 단순 저장을 넘어, 대규모 분산 처리와 품질 관리까지 고려해야 함을 의미한다.
빅데이터의 대표적 사례
디바이스 데이터
빅데이터는 다양한 디바이스에서 지속적으로 생성되는데, 대표적으로 모바일 디바이스에서 위치 정보와 같은 실시간성 데이터가 수집된다. 스마트 TV나 각종 IoT 센서에서는 사용 로그와 환경 데이터가 끊임없이 발생한다.
웹 데이터
웹은 가장 대표적인 빅데이터 환경 중 하나로, 전 세계에서는 수십 조 개 이상의 웹 페이지가 존재하며, 이는 사실상 방대한 지식의 집합체로 볼 수 있다.
웹 검색 엔진 개발은 대규모 데이터 처리의 전형적 사례로, 웹 페이지를 크롤링하여 수집한 후, 중요도를 계산(PageRank)하고, 이를 인덱싱하여 사용자 요청에 빠르게 응답한다. 이러한 과정 속에서 구글은 빅데이터 기술 발전에 결정적인 역할을 기여해왔다.
또한 사용자 검색어와 클릭 로그 자체로도 매우 큰 데이터셋을 형성한다. 해당 데이터를 분석함으로써 개인화 서비스, 검색 트랜드 분석, 통계 기반 번역과 같은 다양한 부가 서비스를 개발할 수 있다. 최근에는 웹 데이터를 기반으로 한 자연어 처리(NLP) 대규모 모델의 학습 데이터로 활용되며, 그 중요성이 더욱 커지고 있다.
빅데이터 처리가 갖는 특징
빅데이터를 다루기 위해선 기존 데이터 처리 방식과는 다른 접근이 필요하다. 최우선적으로, 대용량 데이터를 손실 없이 저장할 수 있는 스토리지가 필수적이다. 데이터 규모가 단일 서버의 디스크 용량을 넘어서는 경우가 많기 때문이다.
또한 빅데이터 처리는 처리 시간이 오래 걸리는 경우가 많아 병렬 처리가 요구된다. 하나의 프로세스로 데이터를 순차 처리하는 방식으로는 현실적인 시간 안에 결과를 얻기 어렵다.
더불어 빅데이터는 비구조화 또는 반구조화 데이터인 경우가 많다. 예를 들어 웹 로그 파일과 같은 데이터는 전통적인 SQL 기반 처리만으로는 한계가 있으며, 보다 유연한 처리 방식이 필요하다.
해결 방안
이러한 문제를 해결하기 위해서는 몇 가지 핵심 요소가 필요하다.
먼저, 대규모 데이터를 안정적으로 저장하기 위해 분산 파일 시스템이 요구된다. 여러 서버에 데이터를 분산 저장함으로써 용량 한계를 극복할 수 있다.
두 번째로, 대량의 데이터를 효율적으로 처리하기 위해 병렬 처리가 가능한 분산 컴퓨팅 시스템이 필요하다. 이를 통해 작업을 여러 노드에 분산시켜 처리 시간을 단축할 수 있다.
마지막으로, 비구조화 데이터를 다룰 수 있는 유연한 데이터 처리 모델이 필요하다. 이러한 요구사항을 종합하면, 결국 다수의 컴퓨터로 구성된 분산 처리 프레임워크가 필수적임을 알 수 있다.
대용량 분산 시스템의 특징
대용량 분산 시스템은 기본적으로 분산 환경을 전제로 설계되며, 하나 이상의 서버로 구성된다. 이 환경에서는 분산 파일 시스템과 분산 컴퓨팅 시스템이 함께 동작한다.
또한 일부 서버에 장애가 발생하더라도 전체 시스템이 정상적으로 동작해야 하는 Fault Tolerance가 중요하다. 마지막으로, 데이터 증가에 따라 서버를 추가하는 방식으로 손쉽게 확장할 수 있는 Scale-out 구조를 갖추는 것이 필수적이다.
하둡의 등장과 소개
Hadoop은 Doug Cutting이 구글 연구소에서 발표한 논문들을 기반으로 개발한 오픈소스 분산 처리 프로젝트이다. 그 출발점은 대규모 웹 데이터를 처리하기 위해 구글이 제안한 두 가지 핵심 기술이었다.
2003년에 발표된 The Google File System과 2004년에 공개된 MapReduce: Simplified Data Processing on Large Clusters 논문은 이후 분산 시스템 설계의 표준이 되었다.
초기 Hadoop은 오픈소스 검색 엔진 프로젝트인 Nutch의 하부 컴포넌트로 시작되었으며, 이름은 Doug Cutting의 아들이 가지고 있던 코끼리 인형에서 유래했다. 이후 2006년 Apache 재단의 톱레벨 프로젝트로 분리되며 본격적인 생태계를 형성하게 된다.
Hadoop이란?
Hortonworks는 Hadoop을 범용 하드웨어로 구성된 클러스터에서 대규모 데이터를 분산 저장하고 처리하기 위한 오픈소스 플랫폼으로 정의한다. Hadoop 클러스터는 여러 대의 노드로 구성되며, 사용자 관점에서는 마치 하나의 거대한 컴퓨터처럼 동작한다.
실제로는 다수의 독립된 서버들이 복잡한 분산 소프트웨어에 의해 통제되며, 저장과 연산을 나누어 수행한다. 이를 통해 대용량 데이터를 효율적으로 처리할 수 있다.
Hadoop의 발전 과정
Hadoop 1.0은 HDFS 위에서 MapReduce가 직접 동작하는 구조를 가지고 있었다. 이 환경 위에서 다양한 분산 컴퓨팅 언어와 프레임워크가 MapReduce 기반으로 개발되었다.
이후 Hadoop 2.0에서는 아키텍처에 큰 변화가 발생했다. MapReduce는 더 이상 유일한 실행 엔진이 아니게 되었고, Hadoop은 YARN이라는 범용 분산 자원 관리 시스템 위에서 동작하는 플랫폼으로 전환되었다. 이로 인해 Spark와 같은 다양한 분산 처리 엔진이 YARN 위에서 애플리케이션 레이어로 실행될 수 있는 구조가 마련되었다.
HDFS: 분산 파일 시스템
HDFS(Hadoop Distributed File System)는 Hadoop의 핵심 구성 요소로, 대용량 데이터를 분산 환경에서 안정적으로 저장하기 위한 파일 시스템이다. HDFS는 데이터를 일정 크기의 블록 단위로 나누어 저장하며, 기본 블록 크기는 128MB이다.
각 블록은 복제(Replication) 방식으로 여러 노드에 분산 저장된다. 기본적으로 하나의 블록은 세 개의 서로 다른 노드에 중복 저장되며, 이는 일부 노드에 장애가 발생하더라도 데이터 접근이 가능하도록 하는 Fault Tolerance를 보장한다. 블록들은 장애 상황을 고려한 배치 정책에 따라 저장된다.
Hadoop 2.0부터는 NameNode 이중화가 지원된다. Active와 Standby NameNode 구조를 통해 단일 장애 지점을 제거했으며, 두 NameNode는 공유된 Edit Log를 통해 메타데이터 상태를 동기화한다. 이와 별도로 Secondary NameNode는 여전히 존재하며, 메타데이터 병합 작업을 담당한다.
MapReduce
MapReduce는 Hadoop 1.0에서 사용되던 분산 컴퓨팅 모델이다.
구조적으로는 하나의 JobTracker와 다수의 TaskTracker로 구성된다. JobTracker는 전체 작업을 관리하며, 작업을 여러 태스크로 분할해 TaskTracker에 분배한다. 각 TaskTracker는 할당된 태스크를 병렬로 처리한다.
MapReduce는 이름 그대로 Map과 Reduce 단계로 작업을 수행하며, 대규모 배치 처리에 적합하다. 다만 MapReduce만을 지원하는 구조로 인해 범용 분산 컴퓨팅 시스템으로는 한계가 있었고, 이러한 제약이 이후 Hadoop 2.0과 YARN, 그리고 Spark 등장으로 이어지게 된다.
YARN의 동작 방식
Hadoop 2.0: 범용 분산 컴퓨팅 프레임워크
Hadoop 2.0에서는 기존 Hadoop 1.0의 한계를 해결하기 위해 YARN(Yet Another Resource Negotiator)이 도입되었다. YARN은 세부적인 자원 관리가 가능한 범용 분산 컴퓨팅 프레임워크로, Hadoop을 단순한 MapReduce 플랫폼에서 다양한 분산 애플리케이션을 실행할 수 있는 환경으로 확장했다.
YARN의 주요 구성 요소는 다음과 같다.
클러스터 전체 자원을 관리하는 Resource Manager(RM), 각 노드에서 자원 사용을 담당하는 Node Manager(NM), 그리고 실제 작업 단위가 실행되는 Container가 있다. 각 애플리케이션마다 하나씩 할당되는 Application Master(AM)는 해당 애플리케이션의 실행을 총괄하며, 태스크 관리와 리소스 요청을 담당한다. Spark 역시 이러한 YARN 위에서 실행되는 대표적인 애플리케이션이다.
YARN의 동작 방식
YARN에서 애플리케이션 실행은 다음과 같은 흐름으로 이루어진다.
먼저 실행할 코드와 환경 정보가 Resource Manager에 제출된다. 이때 실행에 필요한 파일들은 애플리케이션 ID에 해당하는 HDFS 디렉토리로 미리 복사된다.
Resource Manager는 Node Manager를 통해 Application Master를 실행한다. Application Master는 각 애플리케이션마다 하나씩 생성되며, 실행 로직을 관리하는 중심 역할을 한다. 이후 Application Master는 입력 데이터를 처리하는 데 필요한 리소스를 Resource Manager에 요청하고, Resource Manager는 데이터 로컬리티를 고려해 적절한 컨테이너를 할당한다.
할당된 리소스는 Node Manager를 통해 컨테이너로 실행되며, 컨테이너 내부에서 실제 태스크가 수행된다. 이 과정에서 필요한 파일들은 HDFS에서 해당 노드로 전달된다. 각 태스크는 실행 상태를 주기적으로 Application Master에 heartbeat 형태로 보고하며, 태스크 실패나 응답 지연이 발생하면 다른 컨테이너에서 재실행된다.
Hadoop 1.0과 Hadoop 2.0의 차이
Hadoop 1.0에서는 MapReduce가 자원 관리와 작업 실행을 모두 담당했지만, Hadoop 2.0에서는 자원 관리 역할이 YARN으로 분리되었다. 이를 통해 Hadoop은 MapReduce에 종속되지 않는 구조가 되었고, 다양한 분산 처리 엔진을 수용할 수 있는 플랫폼으로 발전했다.
Hadoop 3.0의 주요 특징
Hadoop 3.0은 기존 Hadoop 2.x 아키텍처를 기반으로 하면서, 자원 관리와 스토리지 확장성 측면에서 개선된 버전이다. 핵심 변화는 YARN과 파일 시스템 영역에서 확인할 수 있다.
YARN 2.0 기반 자원 관리
Hadoop 3.0에서는 YARN 2.0이 사용된다. YARN은 애플리케이션들을 논리적인 그룹 단위로 묶어 관리할 수 있으며, 이러한 그룹을 Flow라고 부른다. 이를 통해 데이터 수집 파이프라인과 데이터 서빙 파이프라인처럼 성격이 다른 워크로드를 분리해 자원을 할당하고 관리할 수 있다.
또한 YARN의 타임라인 서버는 기본 스토리지로 HBase를 사용한다. 이는 애플리케이션 실행 이력과 메트릭을 보다 안정적으로 저장하고 조회하기 위한 개선 사항이다.
파일 시스템 확장
파일 시스템 측면에서도 확장성이 강화되었다. NameNode는 다수의 Standby NameNode를 지원함으로써 고가용성이 더욱 향상되었다.
또한 Hadoop 3.0은 기존 HDFS뿐만 아니라 S3, Azure Storage, Azure Data Lake Storage 등 다양한 외부 스토리지 시스템을 지원한다. 이를 통해 온프레미스 환경뿐 아니라 클라우드 기반 아키텍처에서도 유연하게 활용할 수 있다.
맵리듀스 프로그래밍 소개
MapReduce는 대규모 데이터를 처리하기 위한 분산 프로그래밍 모델로, 몇 가지 명확한 제약과 특징을 가진다. 먼저 MapReduce에서 다루는 데이터셋은 Key-Value 쌍의 집합이며, 한 번 생성된 데이터는 변경할 수 없는 Immutable 구조를 가진다.
데이터 조작은 오직 Map과 Reduce 두 가지 오퍼레이션을 통해서만 이루어진다. 이 두 오퍼레이션은 항상 하나의 쌍으로 연속 실행되며, 개발자는 각 단계에서 수행할 로직만 구현하면 된다. Map 작업의 결과는 시스템에 의해 자동으로 Reduce 단계로 전달된다.
이 과정에서 Map 결과를 Reduce로 전달하기 위해 셔플링(Shuffle) 단계가 발생하며, 네트워크를 통한 대량의 데이터 이동이 수반된다. 이는 MapReduce 성능에 큰 영향을 미치는 요소 중 하나이다.
Hadoop 내에서의 Map과 Reduce의 역할
Map 단계는 입력 데이터를 변환하는 역할을 한다. 입력은 시스템에 의해 제공되며, 지정된 HDFS 파일로부터 Key–Value 형태로 전달된다. Map 함수는 (k, v) 형태의 입력을 받아, 새로운 Key–Value 쌍의 리스트 [(k', v')...]로 변환한다. 이 과정에서 입력을 그대로 출력할 수도 있고, 특정 조건에 따라 출력이 없을 수도 있다.
Reduce 단계는 Map 결과를 집계하는 역할을 한다. 시스템은 Map 출력 중 같은 키를 가진 값들을 자동으로 묶어 Reduce 함수의 입력으로 전달한다. Reduce 함수는 (k', [v1', v2', ...]) 형태의 입력을 받아 새로운 (k'', v'') 쌍으로 변환한다. 이는 SQL의 GROUP BY 연산과 매우 유사하며, 최종 결과는 HDFS에 저장된다.
Shuffling과 Sorting
MapReduce에서 Shuffling은 Mapper의 출력 데이터를 Reducer로 전달하는 과정을 의미한다. 이 단계에서는 대량의 데이터가 네트워크를 통해 이동하게 되며, 전송되는 데이터 크기가 클수록 네트워크 병목이 발생하고 전체 처리 시간이 크게 증가한다.
Reducer는 전달받은 모든 Mapper의 출력을 키 기준으로 Sorting한 뒤, Reduce 로직을 수행한다. 이 과정 역시 추가적인 연산 비용을 발생시키며, 대규모 데이터 환경에서는 성능에 큰 영향을 미친다.
Data Skew 문제
MapReduce에서 자주 발생하는 문제 중 하나는 Data Skew이다. 이는 각 태스크가 처리하는 데이터 양에 불균형이 존재하는 상황을 의미한다. 병렬 처리 환경에서는 가장 느린 태스크가 전체 작업의 완료 시간을 결정하기 때문에, Data Skew가 발생하면 병렬 처리의 효과가 크게 감소한다.
특히 Group By나 Join 연산과 같이 특정 키에 데이터가 집중되는 경우, Reducer에 전달되는 데이터 크기에 큰 차이가 발생할 수 있다. 이는 메모리 부족 오류로 이어질 수 있으며, 빅데이터 시스템 전반에서 데이터 엔지니어가 반복적으로 마주치는 문제이다.
MapReduce 프로그래밍의 한계
MapReduce는 단순한 프로그래밍 모델을 제공하지만, 그만큼 생산성이 낮다는 한계를 가진다. Map과 Reduce 두 가지 연산만 제공하기 때문에 복잡한 데이터 처리 로직을 표현하기 어렵고, 데이터 분포가 균등하지 않은 경우 성능 튜닝과 최적화도 쉽지 않다.
또한 MapReduce는 기본적으로 배치 처리 중심의 시스템으로 설계되어 있다. 이는 낮은 지연 시간보다는 높은 처리량(Throughput)에 초점이 맞춰져 있어, 실시간성이나 인터랙티브 분석에는 적합하지 않다.
MapReduce 대안의 등장
이러한 한계를 극복하기 위해 보다 범용적인 대용량 데이터 처리 프레임워크들이 등장했다. 대표적으로 YARN 기반의 다양한 처리 엔진과 Spark가 있다.
또한 SQL 기반 분석에 대한 수요가 다시 증가하면서 Hive와 Presto 같은 엔진들이 등장했다. Hive는 MapReduce 위에서 동작하며 대용량 ETL 처리에 적합한 반면, Presto는 메모리 기반 처리로 Low Latency 쿼리에 초점을 맞추며 Ad-hoc 분석에 적합하다. AWS Athena는 Presto를 기반으로 한 대표적인 서비스이다.
하둡 설치와 맵리듀스 프로래밍 실습
WordCount 실행 흐름
MapReduce의 대표적인 예제는 WordCount(단어 수 세기)이다. Hadoop은 예제 JAR을 기본 제공하며, 다음과 같이 실행할 수 있다.
bin/hadoop jar hadoop-*-examples.jar wordcount input output (환경에 따라 bin/hadoop은 내부적으로 bin/yarn과 동일한 실행 엔트리로 동작한다.)
실행 이후에는 HDFS에 생성된 입력/출력 경로를 확인할 수 있다.
bin/hdfs dfs -ls input
bin/hdfs dfs -ls output
또한 실행 상태 및 결과는 Hadoop Web UI(Resource Manager)에서 잡 단위로 확인 가능하다. 이 과정을 통해 Map 태스크와 Reduce 태스크가 어떻게 분산 실행되는지, 리소스가 어떻게 할당되는지를 관찰할 수 있다.
MapReduce의 구조적 문제
WordCount 같은 단순 작업에서는 MapReduce가 직관적이지만, 실무 관점에서는 한계가 분명하다. 우선 생산성이 낮다. 데이터 모델이 Key–Value로 고정되어 있고, 연산 역시 Map/Reduce 두 단계로 제한되기 때문에 복잡한 처리 로직을 작성하기가 어렵다.
또한 MapReduce는 모든 입출력이 디스크(HDFS)를 중심으로 발생한다. 이는 대규모 배치 처리에는 적합하지만, 반복 연산이나 인터랙티브 분석에는 비효율적이다.
마지막으로 Shuffling 이후에는 Data Skew가 발생하기 쉽고, Reducer 태스크 수 또한 개발자가 직접 지정해야 한다. 데이터 분포가 균등하지 않은 경우 특정 Reducer로 데이터가 몰리면서 병목이 생기고, 태스크 수 설정에 따라 성능과 안정성이 크게 달라질 수 있다.
Spark 소개
Spark는 단순한 분산 처리 엔진을 넘어, 대용량 데이터 처리 전반을 아우르는 데이터 시스템으로 활용된다. 대표적으로 배치 처리, 스트림 처리, 머신러닝 모델 빌딩 영역에서 널리 사용된다.
활용 사례 개요
Spark 데이터 시스템은 다음과 같은 시나리오에서 강점을 가진다:
대용량 데이터의 배치 처리 및 스트림 처리
머신러닝 모델 학습에 사용되는 대규모 피처 데이터 처리
Spark ML을 활용한 대규모 학습 데이터 기반 모델 훈련
이러한 활용 사례들은 Spark의 메모리 기반 처리와 다양한 고수준 API 덕분에 효율적으로 구현할 수 있다.
활용 사례 1: 대용량 비구조화 데이터 처리
Spark는 로그 파일, 이벤트 데이터와 같은 비구조화 또는 반구조화 데이터를 처리하는 데 적합하다. 기존에는 Hive가 이러한 대용량 ETL/ELT 처리의 주요 수단이었으나, Spark는 더 빠른 처리 성능과 유연한 API를 제공함으로써 이를 대체하거나 보완하는 역할을 한다.
Spark SQL과 DataFrame API를 활용하면 대규모 데이터를 효율적으로 변환하고 정제할 수 있으며, 결과를 데이터 웨어하우스나 데이터 레이크로 적재하는 ETL 또는 ELT 파이프라인을 구성할 수 있다.
활용 사례 2: 머신러닝 피처 처리
머신러닝 모델 학습에서는 대량의 피처 데이터를 생성하고 가공하는 과정이 필수적이다. Spark는 배치와 스트림 환경 모두에서 대규모 피처 엔지니어링을 지원한다.
여러 데이터 소스를 조합해 피처를 생성하고, 이를 주기적으로 업데이트하거나 실시간으로 처리하는 작업에 Spark가 활용된다. 이러한 피처 데이터는 이후 Spark ML 또는 외부 머신러닝 플랫폼으로 전달되어 모델 학습에 사용된다.
Spark 프로그램 실행 옵션
Spark 애플리케이션은 크게 Driver와 Executor로 구성된다. Driver는 애플리케이션의 마스터 역할을 수행하며, Executor는 실제 연산을 수행하는 워커 역할을 담당한다. YARN 환경에서는 Driver가 Application Master에 해당하고, Executor는 컨테이너(Container)로 실행된다.
Driver의 역할
Driver는 사용자가 작성한 Spark 코드를 실행하는 주체로, 실행 모드에 따라 위치가 달라진다. Client 모드에서는 클러스터 외부에서 실행되고, Cluster 모드에서는 클러스터 내부에서 실행된다.
Driver는 애플리케이션 실행에 필요한 리소스를 지정하며, 대표적으로 --num-executors, --executor-cores, --executor-memory와 같은 옵션을 통해 리소스를 설정한다. 또한 SparkSession을 생성해 Spark 클러스터와 통신하며, 클러스터 매니저(YARN의 경우 Resource Manager)와 Executor(YARN의 경우 Container)를 제어한다.
사용자 코드는 Driver에 의해 Spark 태스크 단위로 변환되고, 클러스터 전체에 분산 실행된다.
Executor의 역할
Executor는 실제 태스크를 실행하는 프로세스(JVM)이다. Transformations과 Actions가 Executor에서 수행되며, 데이터 처리의 실질적인 작업이 이루어진다. YARN 환경에서는 각 Executor가 하나의 Container로 매핑된다.
Spark 클러스터 매니저 옵션
Spark는 다양한 클러스터 매니저를 지원한다. 대표적으로 local[n], YARN, Kubernetes, Mesos, Standalone 모드가 있다.
local[n] 모드
local[n] 모드는 개발 및 테스트 용도로 주로 사용된다. Spark Shell, IDE, 노트북 환경에서 실행할 때 적합하며, 하나의 JVM이 클러스터처럼 동작한다. 이 환경에서는 Driver와 하나의 Executor가 함께 실행된다.
여기서 n은 사용할 CPU 코어 수를 의미하며, Executor의 스레드 수로 사용된다. local[*]는 현재 머신에서 사용 가능한 모든 코어를 사용한다는 의미이다.
YARN 모드
YARN에서는 두 가지 실행 모드가 제공된다:
Client 모드: Driver가 Spark 클러스터 외부에서 실행된다. YARN 기반 Spark 클러스터를 활용해 개발이나 테스트를 수행할 때 주로 사용된다.
Cluster 모드: Driver가 Spark 클러스터 내부에서 실행되며, 하나의 Container 슬롯을 차지한다. 이 모드는 실제 프로덕션 환경에서 주로 사용된다.
Spark 클러스터 매니저와 실행 모델 요약
클러스터 매니저
실행 모드(deployed mode)
프로그램 실행 방식
local[n]
Client
Spark Shell, IDE, 노트북
YARN
Client
Spark Shell, 노트북
YARN
Cluster
spark-submit
Spark 데이터 처리
Spark 데이터 시스템 아키텍처
데이터 병렬처리를 위한 전제 조건
데이터 병렬 처리가 가능하려면, 가장 먼저 데이터가 분산되어 있어야 한다. Hadoop과 Spark 모두 데이터를 나누어 처리하는 구조를 전제로 한다.
Hadoop MapReduce에서는 데이터 처리의 최소 단위가 HDFS 블록이며, 기본 크기는 128MB이며, 이 값은 hdfs-site.xml의 dfs.block.size 설정에 의해 결정된다. 하나의 파일이 여러 블록으로 나뉘면, 각 블록마다 Map 태스크가 실행된다.
Spark에서는 이 개념을 파티션(Partition)이라 부른다. Spark 역시 기본 파티션 크기는 128MB이며, 파일을 읽을 때는 spark.sql.files.maxPartitionBytes 설정이 적용된다. 분산된 데이터는 파티션 단위로 메모리에 로드되고, 각 파티션이 Executor에 할당되어 병렬 처리된다.
Spark 데이터 처리 흐름
Spark에서 DataFrame은 여러 개의 작은 파티션으로 구성된 논리적 데이터 집합이다. DataFrame은 생성 이후 수정할 수 없는 Immutable 구조를 가지며, 이는 분산 처리 환경에서 일관성과 안정성을 보장한다.
Spark의 데이터 처리는 입력 DataFrame을 시작으로, 원하는 결과가 나올 때까지 연속적인 변환(Transformation)을 수행하는 방식으로 이루어진다. 예를 들어 filter, map, groupBy, join, sort와 같은 연산들이 단계적으로 적용되며, 각 단계는 새로운 DataFrame을 생성한다.
셔플링(Shuffling)
셔플링은 파티션 간 데이터 이동이 필요한 경우에 발생한다. 대표적인 예는 파티션 수를 명시적으로 변경하는 경우나, 시스템 내부적으로 데이터 재배치가 필요한 연산이다.
예를 들어 groupBy, aggregation, sort와 같은 연산은 동일한 키를 가진 데이터를 한 파티션으로 모아야 하므로, 네트워크를 통해 데이터가 이동하는 셔플링이 발생한다. 이 과정은 Spark 성능에 큰 영향을 미친다.
셔플 이후 생성되는 파티션 수는 spark.sql.shuffle.partitions 설정에 의해 결정되며, 기본값은 200이다. 이는 최대 파티션 수를 의미하며, 실제 파티션 수는 연산 방식에 따라 달라질 수 있다. Spark는 랜덤 파티셔닝, 해시 파티셔닝, 레인지 파티셔닝 등을 사용하며, 정렬 연산의 경우 주로 레인지 파티셔닝을 사용한다.
셔플링이 발생하는 시점에서는 Data Skew가 발생할 가능성도 함께 존재한다. 특정 키에 데이터가 집중될 경우 일부 파티션이 과도한 데이터를 처리하게 되어 전체 작업 성능을 저하시킬 수 있다.
셔플링: hashing partition
Spark에서 Hash Partition은 셔플링이 발생하는 대표적인 파티셔닝 방식 중 하나이다. 주로 Aggregation 연산에서 사용되며, 동일한 키를 가진 데이터가 반드시 같은 파티션으로 모이도록 보장한다.
Hash Partition은 레코드의 키 값을 해시 함수에 입력하고, 그 결과를 파티션 수로 나눈 값을 기준으로 파티션을 결정한다. 이 방식은 groupBy, count, sum과 같은 집계 연산에서 자연스럽게 사용된다.
Aggregation 연산에서는 동일 키에 대한 모든 레코드가 한 Reducer(또는 Spark의 경우 하나의 파티션)에서 처리되어야 하므로, 셔플 단계에서 네트워크를 통한 데이터 재배치가 발생한다. 이 과정은 데이터 규모가 클수록 비용이 커지며, Spark 성능 튜닝의 주요 고려 대상이 된다.
Hash Partition은 구현이 단순하고 균등 분산을 기대할 수 있지만, 키 분포가 불균형한 경우 Data Skew가 발생할 수 있다는 단점이 있다. 따라서 대규모 Aggregation 작업에서는 파티션 수 조정이나 키 설계에 대한 사전 고려가 필요하다.
Data Skewness
Data Skewness는 분산 데이터 처리에서 데이터가 파티션 간에 균등하게 분포되지 않는 현상을 의미한다. 데이터 파티셔닝은 병렬 처리를 가능하게 해 성능을 향상시키지만, 데이터 분포가 치우친 경우에는 오히려 성능 저하의 원인이 된다.
이 문제는 주로 셔플링 이후에 발생한다. groupBy, join, aggregation과 같은 연산에서 특정 키에 데이터가 집중되면, 일부 파티션이 과도한 데이터를 처리하게 된다. 그 결과 가장 느린 태스크가 전체 작업 시간을 결정하게 되어 병렬 처리의 이점이 크게 감소한다.
따라서 분산 처리 환경에서는 셔플링을 최소화하는 것이 매우 중요하며, 불가피하게 셔플이 발생하는 경우에는 파티션 수 조정이나 키 설계와 같은 파티션 최적화 전략이 필요하다. Data Skew를 인지하고 이를 완화하는 설계는 Spark 성능 튜닝의 핵심 요소 중 하나이다.
Spark 데이터 구조: RDD, DataFrame, Dataset
Spark는 분산 환경에서 데이터를 처리하기 위해 Immutable Distributed Data 구조를 사용한다. 대표적인 데이터 구조로는 RDD, DataFrame, Dataset이 있으며, 이들은 모두 내부적으로 여러 개의 파티션으로 분할되어 병렬 처리된다.
2016년 이후 Spark에서는 DataFrame과 Dataset이 하나의 통합된 API로 정리되었으며, 사용 언어에 따라 노출되는 방식만 달라졌다.
RDD(Resilient Distributed Dataset)
RDD는 Spark의 가장 기본적인 데이터 구조로, 클러스터 내 여러 서버에 분산 저장된 로우레벨 데이터 집합을 의미한다. 각 레코드는 독립적으로 존재하며, 스키마 정보가 없다는 것이 특징이다. 이로 인해 구조화된 데이터와 비구조화된 데이터 모두를 처리할 수 있다.
RDD는 여러 개의 파티션으로 구성되며, map, filter, flatMap과 같은 로우레벨 함수형 변환을 지원한다. 일반적인 파이썬 컬렉션은 parallelize 함수를 통해 RDD로 변환할 수 있고, 반대로 collect를 사용하면 로컬 파이썬 데이터로 가져올 수 있다.
DataFrame과 Dataset
DataFrame과 Dataset은 RDD 위에 구축된 고수준 데이터 구조로, RDD와 달리 명확한 필드(컬럼) 정보를 가진다. 개념적으로는 관계형 데이터베이스의 테이블이나 Pandas DataFrame과 매우 유사하다.
Dataset은 컬럼에 대한 타입 정보를 포함하며, 이는 컴파일 언어인 Scala와 Java에서만 사용할 수 있다. 반면 PySpark에서는 DataFrame API만 제공된다.
DataFrame은 HDFS, Hive, 외부 데이터베이스, 기존 RDD 등 다양한 데이터 소스로부터 생성할 수 있으며, Scala, Java, Python 등 여러 언어에서 동일한 추상화로 사용할 수 있다.
프로그램 구조
Spark 프로그램의 시작점은 SparkSession을 생성하는 것이다. SparkSession은 하나의 애플리케이션당 하나만 생성되는 Singleton 객체로, Spark 클러스터와의 모든 통신을 담당한다. 이 개념은 Spark 2.0부터 도입되었다.
SparkSession을 통해 Spark가 제공하는 다양한 기능을 사용할 수 있다. DataFrame과 SQL 처리뿐만 아니라 Streaming, ML API 역시 모두 SparkSession을 통해 접근한다. 환경 설정은 config 메서드를 사용해 지정할 수 있으며, RDD와 관련된 작업을 수행할 때는 SparkSession 하위의 sparkContext 객체를 사용한다.
SparkSession이란?
Spark 프로그램의 시작점은 SparkSession을 생성하는 것이다. SparkSession은 하나의 애플리케이션당 하나만 생성되는 Singleton 객체로, Spark 클러스터와의 모든 통신을 담당한다. 이 개념은 Spark 2.0부터 도입되었다.
SparkSession을 통해 Spark가 제공하는 다양한 기능을 사용할 수 있다. DataFrame과 SQL 처리뿐만 아니라 Streaming, ML API 역시 모두 SparkSession을 통해 접근한다. 환경 설정은 config 메서드를 사용해 지정할 수 있으며, RDD와 관련된 작업을 수행할 때는 SparkSession 하위의 sparkContext 객체를 사용한다.
SparkSession 주요 환경 변수
SparkSession을 생성할 때는 다양한 환경 변수를 설정할 수 있다. 대표적인 예는 다음과 같다.
spark.executor.memory: Executor 당 메모리 크기 (기본값 1g)
spark.executor.cores: Executor 당 CPU 코어 수 (YARN 기준 기본값 1)
spark.driver.memory: Driver 메모리 크기 (기본값 1g)
spark.sql.shuffle.partitions: 셔플 이후 생성되는 파티션 수 (기본값 최대 200)
실제로 사용할 수 있는 환경 변수는 매우 다양하며, 사용하는 리소스 매니저(YARN, Kubernetes 등)에 따라 설정 가능한 옵션도 달라진다.
from pyspark.sql import SparkSession
# SparkSession은 싱글턴
spark = SparkSession.builder\
.maaster("local[*]")\
.appName('PySpark Tutorial')\
.getOrCreate()
spark.stop
SparkSession 환경 설정 방법
Spark 환경 설정은 여러 방식으로 적용할 수 있다.
첫째, 환경 변수를 통해 전역 설정이 가능하다.
둘째, $SPARK_HOME/conf/spark-defaults.conf 파일에 기본값을 정의할 수 있다.
셋째, spark-submit 명령 실행 시 커맨드라인 파라미터로 설정할 수 있으며, 이는 실행 단위의 설정에 유용하다.
마지막으로 SparkSession을 생성할 때 코드 레벨에서 직접 설정할 수도 있다.
이러한 다양한 설정 방식을 통해 개발 환경과 운영 환경에 맞는 Spark 실행 구성을 유연하게 관리할 수 있다.
from pyspark.sql import SparkSession
spark = SparkSession.builder\
.maaster("local[*]")\
.appName('PySpark Tutorial')\
.config("spark.some.config.option1", "some-value")\
.config("spark.some.config.option2", "some-value")\
.getOrCreate()
from pyspark.sql import SparkSession
from pyspark import SparkConf
conf = SparkConf()
conf.set("spark.app.name", "PySpark Tutorial")
conf.set("spark.master", "local[*]")
# SparkSession은 싱글턴
spark = SparkSession.builder\
.config(conf=conf) \
.getOrCreate()
Spark 데이터 처리의 전체적인 흐름
Spark 애플리케이션은 일정한 처리 흐름을 따른다. 가장 먼저 SparkSession을 생성하며, 이를 통해 Spark 클러스터와 통신을 시작한다. SparkSession은 애플리케이션의 진입점 역할을 한다.
다음으로 입력 데이터를 로딩한다. 이 단계에서 데이터는 DataFrame 형태로 로드되며, 이후 모든 처리는 이 DataFrame을 중심으로 이루어진다.
데이터 로딩 이후에는 데이터 조작 작업이 수행된다. 이 과정은 Pandas와 매우 유사하며, DataFrame API나 Spark SQL을 사용해 filter, groupBy, join 등의 연산을 적용한다. Spark의 데이터 구조는 Immutable하기 때문에, 각 연산은 기존 DataFrame을 수정하는 것이 아니라 새로운 DataFrame을 생성한다. 원하는 결과가 나올 때까지 이러한 변환을 반복한다.
마지막으로 최종 결과를 저장한다. 결과 데이터는 파일 시스템이나 데이터베이스 등 다양한 저장소로 출력될 수 있다.
SparkSession이 지원하는 데이터 소스
SparkSession은 다양한 데이터 소스를 통합적으로 지원한다. 데이터 로딩 시에는 spark.read(DataFrameReader)를 사용해 DataFrame으로 불러오고, 저장 시에는 DataFrame.write(DataFrameWriter)를 사용한다.
Spark에서 자주 사용되는 데이터 소스는 다음과 같다:
HDFS 파일: CSV, JSON, Parquet, ORC, Text, Avro 등의 포맷을 지원한다. 이 중 Parquet, ORC, Avro와 같은 컬럼 기반 포맷은 대규모 데이터 처리에 특히 유리하다.
Hive 테이블: 메타스토어를 통해 Hive 테이블을 직접 DataFrame으로 로드할 수 있다.
JDBC 기반 관계형 데이터베이스: MySQL, PostgreSQL 등 전통적인 RDBMS와 연동 가능하다.
클라우드 기반 데이터 시스템: S3, GCS, Azure Storage 등과 같은 클라우드 스토리지를 지원한다.
스트리밍 시스템: Kafka와 같은 스트리밍 데이터 소스를 통해 실시간 데이터 처리도 가능하다.
Spark 데이터베이스
카탈로그(Catalog)
Spark에서는 카탈로그(Catalog)를 통해 테이블과 뷰에 대한 메타데이터를 관리한다. 기본적으로 Spark는 메모리 기반 카탈로그를 제공하며, 이는 세션 단위로 유지되어 SparkSession이 종료되면 함께 사라진다.
보다 영속적인 메타데이터 관리를 위해 Spark는 Hive와 호환되는 카탈로그를 지원한다. 이 경우 메타데이터는 외부 메타스토어에 저장되며, 세션 종료 이후에도 유지된다.
데이터베이스와 테이블 관리 방식
Spark에서 테이블은 데이터베이스(Database)라 불리는 논리적 단위로 관리된다. 데이터베이스는 파일 시스템 상의 폴더와 유사한 역할을 하며, 테이블과 뷰를 계층적으로 관리하는 2단계 구조를 가진다.
메모리 기반 테이블과 뷰
메모리 기반 테이블과 뷰는 임시 테이블로, 주로 세션 내에서만 사용된다. 앞서 사용한 임시 뷰들이 이에 해당하며, SparkSession이 종료되면 자동으로 제거된다. 빠른 실험이나 중간 결과를 확인하는 용도로 적합하다.
스토리지 기반 테이블
스토리지 기반 테이블은 실제 데이터가 파일 시스템에 저장되는 테이블이다. 기본적으로 HDFS와 Parquet 포맷을 사용하며, 메타데이터는 Hive와 호환되는 메타스토어를 통해 관리된다.
스토리지 기반 테이블은 Hive와 동일하게 두 가지 유형으로 구분된다.
Managed Table은 Spark가 데이터와 메타데이터를 모두 관리하는 테이블이며, 테이블 삭제 시 실제 데이터도 함께 제거된다.
반면 Unmanaged(External) Table은 Spark가 메타데이터만 관리하며, 실제 데이터는 외부에서 관리된다. 이 경우 테이블을 삭제해도 데이터 파일은 유지된다.
Spark 파일 포맷
Parquet: Spark의 기본 파일 포맷
Parquet는 Spark에서 기본적으로 사용되는 컬럼 지향(Columnar) 파일 포맷이다. 트위터와 클라우데라가 공동으로 개발했으며, Hadoop 생태계 전반에서 표준 포맷으로 자리 잡았다. Parquet는 컬럼 단위 저장 방식을 통해 디스크 I/O를 최소화하고, 압축 효율을 높이며, 대규모 분석 쿼리에 최적화된 성능을 제공한다.
Spark 실행 단위: Job, Stage, Task
Spark에서 코드 실행은 Action을 기점으로 실제 수행된다. 하나의 Action은 하나의 Job을 생성하며, Job은 하나 이상의 Stage로 구성된다.
Stage는 셔플링이 발생하는 지점을 기준으로 분리된다. 즉, 셔플이 없는 연산들은 하나의 Stage로 묶이고, 셔플이 발생하면 새로운 Stage가 생성된다. 각 Stage는 DAG 형태로 구성된 여러 Task를 포함하며, 이 Task들은 병렬로 실행된다.
Task는 Spark 실행의 가장 작은 단위로, 각 Executor에 의해 실제로 수행된다. 전체 실행 성능은 Task 수, 파티션 수, 그리고 셔플 구조에 크게 영향을 받는다.
Execution Plan
Bucketing과 File System Partitioning
Spark에서는 데이터를 저장할 때 이후의 반복 처리 성능을 고려한 저장 최적화 전략을 사용할 수 있다. 대표적인 방법이 Bucketing과 File System Partitioning이며, 두 방식 모두 Hive 메타스토어를 사용하는 테이블(saveAsTable)에서 활용된다.
spark.read.option("header", True). \
csv(“test.csv”). \
where("gender <> 'F'"). \
select("name", "gender"). \
groupby("gender"). \
count(). \
show()
Bucketing과 Partitioning
Bucketing
Bucketing은 DataFrame을 특정 컬럼(ID 등)을 기준으로 해시 분할하여 테이블로 저장하는 방식이다. 주로 Aggregation, Window 함수, Join에서 자주 사용되는 컬럼이 있을 때 효과적이다.
버킷 수와 기준 컬럼을 지정해 데이터를 저장하면, 이후 동일한 조건의 연산에서 데이터 재분배 비용이 줄어들어 반복 처리 성능이 향상된다. Spark에서는 DataFrameWriter.bucketBy() 함수를 사용해 Bucketing을 적용한다. 이 방식은 데이터 특성을 잘 알고 있는 경우에 특히 유용하다.
File System Partitioning
File System Partitioning은 Hive에서 널리 사용되던 방식으로, 특정 컬럼 값을 기준으로 디렉토리 구조를 나누어 데이터를 저장한다. 이때 사용되는 컬럼을 Partition Key라고 부른다.
이 방식은 조건절에 Partition Key가 포함될 경우 불필요한 데이터 스캔을 줄여주며, 대규모 테이블 조회 성능을 크게 개선한다
Partitioning의 예와 장점
예를 들어 매우 큰 로그 데이터를 자주 조회하는 상황에서, 데이터 생성 시간을 기준으로 데이터를 읽는 경우가 많다면 연도–월–일과 같은 디렉토리 구조로 데이터를 저장하는 것이 효과적이다. 실제로 많은 로그 데이터는 이미 이러한 형태로 수집·저장된다.
이와 같은 파티셔닝 구조를 사용하면, 조회 시 조건에 해당하는 폴더만 스캔하게 되어 불필요한 데이터 읽기 비용이 크게 줄어든다. 그 결과 쿼리 성능이 개선되며, 데이터 스캔 자체가 발생하지 않는 경우도 있다. 또한 기간 단위로 데이터를 관리할 수 있어 Retention Policy 적용과 같은 운영 작업도 수월해진다.
Partitioning 적용 시 주의사항
Spark에서는 DataFrameWriter.partitionBy()를 사용해 File System Partitioning을 적용한다. 다만 Partition Key를 잘못 선택할 경우, 파티션 수가 과도하게 늘어나 매우 많은 작은 파일들이 생성될 수 있다. 이는 메타데이터 관리 부담과 성능 저하로 이어질 수 있으므로, 파티션 키는 데이터 접근 패턴과 카디널리티를 고려해 신중하게 선택해야 한다.
-
[6기] 데브코스 DE WIL 10 | DBT, 데이터 디스커버리
이번 주 학습 목표
dbt의 핵심 개념과 구성 요소(models, materialization, tests, snapshots, docs)를 이해하고, ELT 기반 데이터 파이프라인을 구조적으로 설계할 수 있다.
Fact·Dimension 모델링, 증분 로드, 변경 이력 관리(SCD Type 2), 데이터 품질 테스트를 통해 신뢰성 있는 분석용 데이터셋을 구축할 수 있다.
dbt 문서화와 데이터 카탈로그 개념을 활용해 데이터 리니지, 메타데이터, 거버넌스를 체계적으로 관리하는 방법을 이해한다.
ELT의 미래는?
ETL을 하는 이유는 결국 ELT를 하기 위함이며 이때 데이터 품질 검증이 중요해진다.
데이터 품질의 중요성 증대
입출력 체크
더 다양한 품질 검사
리니지 체크
데이터 히스토리 파악
따라서 데이터 품질을 유지하는 것은, 비용/노력 감소와 생산성 증대의 지름길이다. 이러한 문제를 해결하기 위해서 나온 tool이 DBT(Data Build Tool)이다.
Database Normalization
데이터 정규화(Data Normalization)을 하는 이유는 데이터베이스를 좀 더 조직적이고 일관된 방법으로 디자인함으로써 유지보수를 더 쉽게 하기 위해서 진행한다. 이는 데이터베이스의 정합성을 쉽게 유지하고, 레코드들을 수정/적재/삭제를 용이하게 하는 것을 의미한다.
Normalization에 사용되는 개념
Primary Key (기본키)
Composite Key (복합키)
Foreign Key (외래키)
제 1 정규형(1NF; First Normal Form)
제 1 정규형은 한 셀에 하나의 값만 있어야 하는 원자성(Atomicity)을 만족하는 정규형이다. Primary Key가 있어야 하며, 중복된 키나 레코드들이 없어야 한다. 결국, 해당 정규형의 목표는 중복을 제거하고 원자성을 만족하는 것이다.
1NF 테이블 예제 - Employee
EMPLOYEE_ID
NAME
JOB_CODE
JOB
STATE_CODE
HOME_STATE
E001
Alice
J01
Chef
26
Michigan
E002
Bob
J02
Waiter
66
California
E003
Tom
J02
Waiter
51
Oregon
제 2 정규형(2NF; Second Normal Form)
제 2 정규형은 1NF을 만족하면서, Primary Key를 중심으로 의존 결과를 알 수 있어야 한다. 부분적인 의존도가 없어야 하는데, 즉 모든 부가 속성들은 Primary Key를 가지고 찾을 수 있어야 한다. 해당 정규형의 목표는 중복을 제거하고 원자성을 만족하는 것이다.
2NF 테이블 예제 - Employees & Jobs
EMPLOYEE_ID
NAME
STATE_CODE
HOME_STATE
E001
Alice
26
Michigan
E002
Bob
56
Wyoming
JOB_CODE
JOB
J01
Chef
J02
Waiter
J02
Bartender
제 3 정규형(3NF; Second Normal Form)
제 3 정규형은 2NF를 만족하면서, 전이적 부분 종속성이 없어야 한다.
3NF 테이블 예제 - Employees & Jobs & States
EMPLOYEE_ID
NAME
STATE_CODE
E001
Alice
26
E002
Bob
56
E003
Alice
39
JOB_CODE
JOB
J01
Chef
J02
Waiter
J02
Bartender
STATE_CODE
HOME_STATE
26
Michigan
56
Wyoming
SCD(Slowly Changing Dimension)
데이터 웨어하우스(DW)나 모든 테이블들의 히스토리를 유지하는 것이 중요하다. 보통 두 개의 timestamp 필드를 갖는 것이 좋다.
create_at(생성 시간으로 한 번 만들어지면 고정됨)
update_at(꼭 필요 마지막 수정 시간을 나타냄)
이 경우에 컬럼의 성격에 따라 어떻게 유지할 지 방법이 달라진다.
SCD Type 0
SCD Type 1
SCD Type 2
SCD Type 3
SCD Type 4
일부 속성들은 시간을 두고 변하게 되는데 DW Table 쪽에 어떻게 반영을 해야 하나? 현재 데이터만을 유지 or 처음부터 지금까지 히스토리도 유지
SCD Type 0
해당 타입은 한 번 쓰고 나면 바꿀 이유가 없는 경우에서 사용한다. 관련된 경우로는 한 번 정해지면 갱신되지 않고 고정되는 필드들로, 예시로 고객 테이블의 회원 등록일, 제품 첫 구매일 등이 있다.
SCD Type 1
해당 타입은 데이터가 새로 생기면 덮어쓰면 되는 컬럼들의 경우에서 사용된다. 처음 레코드 생성 시에는 존재하지 않았지만 나중에 생기면서 채우는 경우로, 예시로는 고객 테이블의 연간 소득 필드 등이 있다.
SCD Type 2
특정 Enity에 대한 데이터가 새로운 레코드로 추가되어야 하는 경우로, 예시로는 고객 테이블의 고객 등급 등이 있다. tier라는 컬럼의 값이 “Regular”에서 “vip”로 변화할 때 변경 시간도 같이 기록되어야 한다.
customer_id
tier
100
vip
101
regular
SCD Type 3
SCD Type 2의 대안으로 특정 Entity 데이터가 새로운 컬럼으로 추가되는 경우에 사용하는 타입으로, Type2와 다른 점이라면 이전의 tier 컬럼의 값을 기억하기 위한 새로운 컬럼 previous_tier라는 컬럼을 생성한다. 변경 시간 또한 별도 컬럼으로 존재해야 한다.
customer_id
tier
previous_tier
100
vip
regular
101
regular
regular
SCD Type 4
특정 Entity에 대한 데이터를 새로운 Dimension 테이블에 저정하는 경우로, 별도의 테이블로 저장하고 이 경우에는 일반화할 수도 있다.
dbt(Data Build Tool) 소개
dbt란 Data Build Tool의 약어로, ELT를 수행하기 위한 오픈소스이다. 데이터 웨어하우스 내에 존재하는 데이터를 변환(In-warehouse data transformation)하기 위해서 사용되며, 해당 tool은 Analytics Engineer라는 말을 만들어 내기도 하였다.
앞서 말했듯이 데이터 웨어하우스와 긴밀한 관계에 있는 만큼, Redshift, Snowflake, Bigquery, Spark 등의 다양한 웨어하우스를 지원한다.
dbt Cloud라는 클라우드 버전도 존재한다.
dbt를 구성하는 컴포넌트
dbt는 데이터 웨어하우스 환경에서 SQL 기반 데이터 변환을 구조화하기 위한 도구로, 데이터 모델링과 품질 관리 기능을 함께 제공한다. 주요 구성 요소는 데이터 모델(models), 테스트(tests), 스냅샷(snapshots)이다.
데이터 모델 (Models)
dbt의 데이터 모델은 SELECT 문으로 정의되며, 실행 결과는 Table 또는 View로 물리화된다. 모델은 데이터 처리 단계에 따라 여러 개의 티어로 구분해 관리하는 것이 일반적이다. 대표적인 티어 구조는 다음과 같다:
Bronze / Raw Table
소스 시스템에서 수집한 원본 데이터를 그대로 적재한 레이어이다. 최소한의 가공만 수행하며, 데이터 정합성 이슈 발생 시 재처리의 기준점 역할을 한다.
Staging Table
Raw 데이터를 분석에 적합한 형태로 정제하는 단계이다. 컬럼명 표준화, 타입 변환, 기본적인 필터링과 같은 전처리 로직이 적용된다.
Core Table
비즈니스 로직이 반영된 핵심 데이터 레이어로, 여러 Staging 테이블을 조합하거나 계산 로직을 적용해 분석 및 리포팅에 바로 사용할 수 있는 형태로 구성된다.
이러한 티어 분리는 데이터 흐름을 명확히 하고, dbt의 Lineage 기능을 통해 모델 간 의존 관계를 체계적으로 관리할 수 있게 한다.
데이터 품질 검증 (Tests)
dbt는 모델 단위로 데이터 품질 테스트를 정의할 수 있다. 특정 컬럼의 NULL 여부, 유일성, 참조 무결성 등을 검증함으로써 데이터 모델의 신뢰도를 지속적으로 확보할 수 있다.
스냅샷 (Snapshots)
스냅샷은 테이블의 변경 이력을 관리하기 위한 기능이다. 값이 변경되는 레코드를 시간 축 기준으로 저장함으로써, 과거 상태 기반 분석이나 변경 추적이 가능하다.
dbt 사용 시나리오
데이터 파이프라인에서 요구되는 핵심조건을 만족하기 위해선, 단순히 데이터를 적재하는 것만으로는 불충분하다.
우선, 데이터 변경 사항을 쉽게 이해할 수 있어야 하며, 필요할 경우 이전 상태로의 롤백이 가능해야 한다. 이는 데이터 변환 로직과 변경 이력이 명확하게 관리되어야 함을 의미한다.
또한 데이터 간 리니지(Lineage)를 확인할 수 있어야 한다. 특정 테이블이나 컬럼이 어떤 소스 데이터와 변환 과정을 거쳐 생성되었는지 추적할 수 있어야, 변경 영향도를 빠르게 파악할 수 있다.
데이터 품질 테스트와 에러 보고 역시 필수 요소이다. 데이터 누락, 중복, 무결성 오류 등을 사전에 검증하고, 문제가 발생했을 때 이를 명확하게 인지할 수 있어야 한다.
대용량 데이터 환경에서는 Fact 테이블의 증분 로드(Incremental Update)가 중요하다. 전체 데이터를 매번 재처리하지 않고 변경된 데이터만 반영함으로써, 성능과 비용을 효율적으로 관리할 수 있다.
반면, Dimension 테이블은 변경 이력을 추적할 수 있어야 한다. 값이 변경되는 시점을 기록하는 히스토리 테이블을 통해, 과거 기준의 분석과 이력 관리가 가능해진다.
마지막으로, 데이터 구조와 변환 로직에 대한 문서화가 용이해야 한다. 문서는 단순한 부가 기능이 아니라, 협업과 유지보수를 위한 핵심 요소이며, 코드 기반으로 자동 생성될 수 있을수록 운영 효율이 높아진다.
Fact 테이블과 Dimension 테이블
데이터 웨어하우스 모델링에서 Fact 테이블과 Dimension 테이블은 분석 구조의 핵심을 이룬다. 두 테이블은 역할과 성격이 명확히 구분되며, 함께 사용할 때 분석 효율을 극대화할 수 있다.
Fact 테이블
Fact 테이블은 분석의 중심이 되는 정량적 지표를 저장하는 테이블이다. 일반적으로 매출, 수익, 판매량 이익과 같은 측정 가능한 값들이 포함되며, 비즈니스 의사결정에 직접적으로 활용된다.
Fact 테이블은 여러 Dimension 테이블과 외래키(Foreign Key)를 통해 연결되며, 이를 통해 다양한 관점에서 데이터를 분석할 수 있다. 특성상 이벤트 단위의 데이터가 누적되기 때문에, 테이블 크기가 상대적으로 매우 큰 경우가 많다.
Dimension 테이블
Dimesion 테이블은 Fact 테이블에 대한 맥락(Context)을 제공하는 테이블이다. 고객, 제품, 지역과 같은 정보가 여기에 해당하며, Fact 데이터가 어떤 대상과 조건에서 발생했는지를 설명해준다.
각 Dimension 테이블은 Primary Key를 가지며, 이 키는 Fact 테이블에서 Foreign Key로 참조된다. Dimension 데이터를 기준으로 Fact 데이터를 그룹핑하거나 필터링함으로써, 다양한 형태의 분석이 가능해진다. 일반적으로 Dimension 테이블은 Fact 테이블에 비해 데이터 크기가 훨씬 작다.
ELT 작업 예제
AWS Redshift의 데이터 웨어하우스를 사용하여 AB 테스트 분석을 쉽게 진행하기 위한 ELT 테이블을 만든다.
입력 테이블: user_event, user_variant, user_metadata
출력 테이블: Variant별 사용자별 일별 요약 테이블
variant_id, user_id, datastamp, age, gender
총 imporession, 총 clink, 총 purchase, 총 revenue
입력 데이터들
Production DB에 저장되는 정보들을 사용할 Redshift에 적재했다고 가정한다.
raw_data.user_event: 사용자/날짜/아이템별로 impression이 있는 경우 그 정보를 기록하고
impression으로부터 클릭, 구매, 구매시 금액을 기록. 실제 환경에서는 이런
aggregate 정보를 로그 파일등의 소스
raw_data.user_variant: 사용자가 소속한 AB test variant를 기록한 파일 (control vs. test)
raw_data.user_metadata: 사용자에 관한 메타 정보가 기록된 파일 (성별, 나이 등등)
{% raw %}
CREATE TABLE raw_data.user_event(
user_id int,
datastamp timestamp,
item_id int,
clicked int,
purchased int,
paidamount int
);
CREATE TABLE raw_data.user_variant (
user_id int,
variant_id varchar(32) -- control vs. test
);
CREATE TABLE raw_data.user_metadata (
user_id int,
age varchar(16),
);
{% endraw %}
최종 생성 데이터(ELT 테이블)
SELECT로 표현한 ELT 테이블은 다음과 같다:
{% raw %}
SELECT
variant_id,
ue.user_id,
datestamp,
age,
gender,
COUNT(DISTINCT item_id) num_of_items, -- 총 impression
COUNT(DISTINCT CASE WHEN clicked THEN item_id END) num_of_clicks, -- 총 click
SUM(purchased) num_of_purchases, -- 총 purchase
SUM(paidamount) revenue -- 총 revenue
FROM raw_data.user_event ue
JOIN raw_data.user_variant uv ON ue.user_id = uv.user_id
JOIN raw_data.user_metadata um ON uv.user_id = um.user_id
GROUP by 1, 2, 3, 4, 5;
{% endraw %}
dbt Models: Input
Model
우선 Model이란 ELT 기반 데이터 파이프라인에서 테이블을 생성하기 위한 가장 기본적인 빌딩 블록이다. dbt에서 Model은 SQL로 정의되며, 실행 결과는 테이블, 뷰, 혹은 CTE 형태로 물리화된다.
Model은 데이터 흐름에 따라 입력, 중간, 최종 테이블을 정의하는 역할을 하며, 일반적으로 티어 구조로 관리된다. 예를 들어 raw → staging → core 와 같은 단계적 구조를 통해 원본 데이터에서 분석용 데이터까지 점진적으로 변환한다. 이러한 구조는 데이터 처리 책임을 명확히 하고, 변환 로직의 가독성과 유지보수성을 높인다.
Model의 구성 요소
dbt의 Model은 입력 데이터로부터 최종 결과 테이블까지의 변환 과정을 정의하는 단위이며, 크게 Input과 Output으로 구분할 수 있다.
Input
Input은 변환의 시작점이 되는 데이터로, raw 및 staging(src) 단계의 데이터를 의미한다. raw 데이터는 일반적으로 CTE 형태로 정의되며, 소스 시스템에서 적재된 원본 데이터를 그대로 참조한다. staging 단계는 raw 데이터를 정제하고 구조를 표준화하는 역할을 하며, 보통 View로 물리화된다.
Output
Output은 분석에 직접 사용되는 최종(core) 데이터를 의미한다. core 모델은 비즈니스 로직이 적용된 결과물로, 일반적으로 Table 형태로 생성된다. 이 단계의 데이터는 Fact 및 Dimension 테이블의 기반이 된다.
Model 관리 방식
이러한 모든 Model은 models 디렉토리 하위에 SQL 파일 형태로 관리된다. 각 SQL 파일은 기본적으로 SELECT 문으로 구성되며, dbt의 Jinja 템플릿과 매크로를 함께 사용해 재사용성과 가독성을 높일 수 있다.
또한 Model 내에서는 다른 테이블이나 Model을 직접 참조(reference)할 수 있으며, 이를 통해 dbt는 모델 간 의존 관계와 리니지를 자동으로 추적한다. 이는 데이터 변경 시 영향 범위를 빠르게 파악하는 데 중요한 역할을 한다.
View
여기서 잠깐 View란 SELECT 쿼리의 결과를 기반으로 생성되는 가상의 테이블이다. 하나의 테이블 일부 데이터만 노출하거나, 여러 테이블을 조인한 결과를 하나의 논리적 테이블처럼 제공할 수 있다. 일반적으로 CREATE VIEW view_name AS SELECT ... 형태로 정의된다.
View를 사용함으로써 얻을 수 있는 장점으로는 데이터 접근에 대한 추상화 계층을 제공한다는 점이다. 사용자는 View를 통해 필요한 데이터에만 접근하면 되며, 원본 테이블의 구조나 복잡한 조인 로직을 알 필요가 없다. 또한 View를 활용하면 사용자에게 필요한 데이터만 노출할 수 있어 보안 측면에서도 유리하다. 반복적으로 사용되는 복잡한 쿼리를 View로 정의함으로써, 쿼리 자체를 단순화하는 효과도 있다.
반면, View는 쿼리가 실행될 때마다 원본 데이터를 조회하기 때문에 성능 저하가 발생할 수 있다는 단점이 있다. 또한 원본 테이블 구조가 변경되었음을 인지하지 못한 상태에서 View를 실행하면 오류가 발생할 수 있다는 점도 고려해야 한다.
CTE(Common Table Expression)
CTE는 SQL 쿼리 내에서 임시 결과 집합을 정의하고 재사용하기 위한 구조이다. WITH절을 사용하여 하나 이상의 서브 쿼리를 정의한 뒤, 이후 메인 쿼리에서 이를 참조할 수 있다.
일반적인 CTE 구조는 다음과 같다. 여러 개의 CTE를 정의할 수 있으며, 각 CTE는 논리적으로 분리된 변환 단계를 표현한다:
{% raw %}
WITH temp1 AS (
SELECT k1, k2
FROM t1
JOIN t2 ON t1.id = t2.foreign_id
),
temp2 AS (
...
)
SELECT *
FROM temp1 t1
JOIN temp2 t2 ON ...
{% endraw %}
CTE를 사용하면 복잡한 쿼리를 단계별로 나누어 작성할 수 있어 가독성과 유지보수성이 크게 향상된다.
dbt에서 CTE 활용
dbt에서는 CTE를 활용해 raw 데이터를 논리적으로 분리된 입력 소스(src)로 정의하는 패턴을 자주 사용한다. 예를 들어 원본 테이블을 직접 참조하는 대신, CTE로 한 번 감싸서 이후 변환 로직의 기준점으로 삼는다.
{% raw %}
-- models/src - src_user_event.sql
WITH src_user_event AS (
SELECT *
FROM raw_data.user_event
)
SELECT
user_id,
datestamp,
item_id,
clicked,
purchased,
paidamount
FROM src_user_event
-- models/src - src_user_variant.sql
WITH src_user_variant AS (
SELECT * FROM raw_data.user_variant
)
SELECT
user_id,
variant_id
FROM
src_user_variant
-- models/src - src_user_metadata.sql
WITH src_user_metadata AS (
SELECT * FROM raw_data.user_metadata
)
SELECT
user_id,
age,
gender,
updated_at
FROM
src_user_metadata
{% endraw %}
Model 빌딩
dbt run
이와 같은 방식은 원본 데이터 접근을 명확히 하고, 이후 컬럼 선택이나 변환 로직을 단계적으로 확장하기에 용이하다. 특히 dbt 모델 내에서 CTE는 Input 레이어를 구성하는 핵심 요소로 활용된다.
dbt Models: Output
Materialization
Materialization은 입력 데이터(테이블 또는 모델)를 기반으로 새로운 데이터 모델을 실제로 어떻게 생성할 것인지를 정의하는 방식이다. dbt에서는 여러 입력 모델을 연결해 하나의 결과를 만들며, 이 과정에서 추가적인 변환 로직이나 데이터 클린업이 수행된다.
Materialization은 모델 단위로 설정할 수 있으며, 파일 레벨 또는 프로젝트 레벨에서 지정 가능하다. 설정된 Materialization 방식은 dbt run 실행 시 적용되며, 실행 옵션에 따라 동작을 제어할 수도 있다.
dbt에서 제공하는 Materialization 유형
dbt에서는 기본적으로 네 가지의 Materialization 방식을 제공한다:
View: 데이터를 자주 사용하지 않거나, 항상 최신 상태를 유지해야 하는 경우에 적합하다. 저장 공간을 거의 사용하지 않지만, 조회 시마다 원본 쿼리가 실행된다.
Table: 데이터를 반복적으로 자주 사용하는 경우에 적합하다. 실행 시 결과를 물리적인 테이블로 생성하므로, 조회 성능이 안정적이다.
Incremental: 기존 테이블에 새로운 데이터만 추가하는 방식으로, 주로 Fact 테이블에 사용된다. 과거 레코드를 수정할 필요가 없는 경우에 적합하며, 대용량 데이터 처리 시 성능과 비용 측면에서 효과적이다.
Ephemeral: 물리적인 테이블이나 뷰를 생성하지 않고, CTE 형태로 쿼리 내에 인라인된다. 하나의 SELECT 문에서 반복적으로 사용되는 로직을 모듈화하는 데 주로 활용된다.
Jinja 템플릿
Jinja는 Python 기반의 템플릿 엔진으로, Flask와 같은 웹 프레임워크에서 널리 사용되며 Airflow에서도 활용된다. dbt에서는 Jinja를 활용하여 SQL을 동적으로 생성한다.
Jinja 템플릿은 입력 파라미터를 기반으로 SQL을 유연하게 구성할 수 있으며, 조건문, 반복문, 필터와 같은 기능을 제공한다. 이를 통해 중복되는 SQL 로직을 줄이고, 유지보수성을 높일 수 있다.
Core 모델 디렉터리 구성
dbt 프로젝트에서는 분석에 직접 사용되는 core 테이블을 별도의 디렉토리로 분리해 관리하는 것이 일반적이다. 이를 통해 Fact와 Dimension 모델의 역할을 명확히 구분할 수 있다.
models 디렉토리 하위에는 core 레이어를 위한 폴더를 생성하고, 그 아래에 dim과 fact 폴더를 각각 구성한다.
dim 폴더에는 Dimension 테이블에 해당하는 모델을 정의한다. 예를 들어 사용자 관련 정보를 담는 dim_user_variant.sql, dim_user_metadata.sql과 같은 파일을 생성한다.
fact 폴더에는 Fact 테이블에 해당하는 모델을 정의한다. 사용자 이벤트 데이터를 저장하는 fact_user_event.sql과 같이 분석의 중심이 되는 지표 데이터를 관리한다.
이렇게 정의된 core 모델들은 모두 물리적인 Table 형태로 생성되며, 최종적으로 리포팅과 분석에 사용된다. 디렉토리 구조를 통해 모델의 성격을 명확히 구분함으로써, 프로젝트 전반의 가독성과 유지보수성을 높일 수 있다.
{% raw %}
-- models/dim - dim_user_variant.sql
WITH src_user_variant AS (
SELECT * FROM {{ ref('src_user_variant')}}
)
SELECT
user_id,
variant_id
FROM
src_user_variant
-- models/dim - dim_user_metadata.sql
WITH src_user_metadata AS (
SELECT * FROM {{ ref('src_user_metadata') }}
)
SELECT
user_id,
age,
gender,
updated_at
FROM
src_user_metadata
-- models/fact - fact_user_event.sql
{{
config(
materialized = 'incremental',
on_schema_change='fail'
)
}}
WITH src_user_event AS (
SELECT * FROM {{ ref("src_user_event") }}
)
SELECT
user_id,
datastamp,
item_id,
clicked,
purchased,
paidamount
FROM
src_user_event
{% endraw %}
Model 빌딩
dbt run
ddbt compile vs dbt run
dbt compile은 SQL 코드까지만 생성하고 실행하지 않지만, dbt run은 생성된 코드를 실제 실행에 옮긴다.
dbt Seeds
dbt Seeds는 데이터 웨어하우스에서 사용되는 Dimension 테이블 중에는 데이터 크기가 작고 변경이 거의 없는 경우가 많다. dbt의 Seeds 기능은 이러한 데이터를 파일 형태로 웨어하우스에 로드하기 위한 방법이다.
Seeds는 보통 CSV 파일로 관리되며, 코드와 함께 버전 관리가 가능하다. dbt seed 명령을 실행하면 해당 파일이 테이블로 생성되어, 소규모 기준 데이터나 고정된 매핑 테이블을 관리하는 데 유용하다.
입력 테이블 변경과 Sources의 필요성
Staging 테이블을 생성할 때 입력 테이블 구조나 이름이 자주 변경된다면, models 디렉토리 하위의 SQL 파일을 일일이 수정해야 하는 문제가 발생한다. 이러한 번거로움을 해결하기 위해 dbt는 Sources 개념을 제공한다.
Sources는 ETL 단계에서 최초로 유입되는 테이블을 대상으로 하며, 입력 테이블에 별칭(alias)을 부여하고 이를 staging 모델에서 참조하도록 한다. 이를 통해 소스 테이블명이 변경되더라도 downstream 모델에는 영향을 최소화할 수 있다.
dbt Sources
Sources 개념
Sources는 원본 테이블에 대한 추상화 계층이다. 실제 테이블 이름 대신 source 이름과 테이블 이름의 조합으로 참조하며, 예를 들어 raw_data.user_metadata 테이블을 username.metadata와 같은 형태로 정의할 수 있다.
이 방식은 ETL 단의 변경 사항을 효과적으로 캡슐화하고, 모델 간 의존성을 낮춰 유지보수를 용이하게 한다. 또한 Sources는 단순한 별칭 제공을 넘어, 소스 테이블에 새로운 레코드가 존재하는지 여부를 체크하는 기능도 함께 제공한다.
Sources 최신성 (Freshness)
dbt는 소스 데이터의 최신성(Freshness)을 확인할 수 있는 기능을 제공한다. 이는 특정 소스 테이블의 데이터가 기준 시점 대비 얼마나 오래되었는지를 검증하는 기능이다.
dbt source freshness 명령을 통해 실행할 수 있으며, 이를 위해 models/sources.yml 파일에서 각 Source 테이블에 대한 최신성 기준을 정의해야 한다. 이 기능을 활용하면 ETL 지연이나 데이터 수집 문제를 조기에 감지할 수 있다.
version: 2
sources:
- name: username
schema: raw_data
tables:
- name: metadata
identifier: user_metadata
- name: event
identifier: user_event
- name: variant
identifier: user_variant
{% raw %}
WITH src_user_event AS (
SELECT * FROM raw_data.user_event
)
SELECT
user_id,
datastamp,
item_id,
clicked,
purchased,
paidamount
FROM
src_user_event
{% endraw %}
dbt Snapshots
데이터 웨어하우스에서 Dimension 테이블은 특성상 값 변경이 자주 발생할 수 있다. dbt에서의 스냅샷은 이러한 테이블의 변화를 지속적으로 기록함으로써, 과거 특정 시점의 데이터 상태를 다시 조회할 수 있도록 하는 기능을 의미한다.
스냅샷을 활용하면 데이터에 문제가 발생했을 경우 과거 데이터 기준으로 롤백이 가능하며, 값 변경 이력을 기반으로 다양한 데이터 이슈를 보다 쉽게 디버깅할 수 있다.
SCD Type 2와 dbt
SCD(Slowly Changing Dimension) Type 2는 Dimension 테이블에서 특정 엔티티의 값이 변경될 때, 기존 레코드를 유지한 채 새로운 레코드를 추가하는 방식이다.
예를 들어 employee_jobs 테이블에서 특정 employee_id의 job_code가 변경되는 경우, 기존 데이터는 종료 시점을 기록하고 새로운 데이터에는 변경된 값과 함께 변경 시점을 추가한다. 이를 통해 시간에 따른 상태 변화를 추적할 수 있다.
dbt에서는 이러한 SCD Type 2 패턴을 스냅샷 테이블을 통해 구현한다. 변경이 발생할 때마다 새로운 레코드를 생성하는 별도의 히스토리 테이블을 유지함으로써, 과거 상태 분석이 가능해진다.
dbt의 스냅샷 처리 방식
dbt에서 스냅샷은 snapshots 디렉토리 하위에 정의된다. 스냅샷을 적용하기 위해서는 대상 데이터 소스가 몇 가지 조건을 만족해야 한다.
우선, Primary Key가 반드시 존재해야 하며, 레코드 변경 시점을 판단할 수 있는 타임스탬프 컬럼(updated_at, modified_at 등)이 필요하다. dbt는 Primary Key를 기준으로 변경 시간을 비교해, 현재 데이터 웨어하우스에 저장된 시점보다 최신인 경우 변경으로 판단한다.
스냅샷 테이블에는 변경 이력을 관리하기 위해 총 네 개의 주요 컬럼이 생성된다.
dbt_scd_id와 dbt_updated_at은 dbt 내부 관리용 컬럼이며, valid_from과 valid_to는 해당 레코드가 유효한 기간을 나타낸다.
{% raw %}
{% snapshot scd_user_metadata %}
{{
config(
target_schema='keeyong',
unique_key='user_id',
strategy='timestamp',
updated_at='updated_at',
invalidate_hard_deletes=True
)
}}
SELECT * FROM {{ source('keeyong', 'metadata') }}
{% endsnapshot %}
{% endraw %}
dbt snapshot 실행
dbt snapshot
dbt Tests
dbt는 데이터 변환 과정에서 데이터 품질을 검증하기 위한 테스트 기능을 제공한다. 테스트는 모델이 기대한 규칙을 만족하는지 확인하는 역할을 하며, 데이터 파이프라인의 신뢰성을 유지하는 핵심 요소이다.
dbt의 테스트는 크게 Generic 테스트와 Singular 테스트 두 가지로 구분된다.
Generic Tests
Generic 테스트는 dbt에서 기본적으로 제공하는 내장 테스트로, 자주 사용되는 데이터 품질 검증 패턴을 손쉽게 적용할 수 있다. 대표적으로 unique, not_null, accepted_values, relationships와 같은 테스트를 지원한다.
이러한 테스트는 주로 models 디렉토리 내의 YAML 파일에서 정의되며, 컬럼 단위로 간단하게 설정할 수 있다. 반복적인 품질 검증 로직을 표준화하는 데 적합하다.
Singular Tests
Singular 테스트는 사용자가 직접 정의하는 커스텀 테스트이다. 기본적으로 하나의 SELECT 쿼리로 작성되며, 쿼리 실행 결과가 한 행이라도 반환되면 테스트는 실패로 간주된다.
Singular 테스트는 tests 디렉토리 하위에 SQL 파일로 관리되며, 복잡한 비즈니스 규칙이나 Generic 테스트로 표현하기 어려운 검증 로직을 구현할 때 활용된다.
dbt Documentations
dbt의 문서화 기능은 문서와 소스 코드를 최대한 가깝게 배치한다는 철학을 기반으로 한다. 데이터 모델과 그에 대한 설명을 분리하지 않고 함께 관리함으로써, 코드 변경과 문서 갱신이 자연스럽게 동기화되도록 한다.
dbt에서 문서를 작성하는 방법은 크게 두 가지가 있다. 첫 번째는 기존의 YAML 파일에 문서 내용을 함께 작성하는 방식으로, 모델과 컬럼 정의 옆에 설명을 추가할 수 있다. 이 방식은 코드와 문서의 일관성을 유지하기 쉬워 일반적으로 선호된다. 두 번째 독립적인 Markdown 파일을 생성하는 방식으로, 보다 자유로운 형태의 설명이나 개념 정리에 적합하다.
작성된 문서는 dbt에서 제공하는 경량 웹 서버를 통해 서빙할 수 있다. 이때 overview.md 파일은 문서 사이트의 기본 홈 페이지 역할을 하며, 이미지와 같은 정적 자산도 함께 포함할 수 있다. 이를 통해 dbt 프로젝트 전반에 대한 구조와 맥락을 한눈에 파악할 수 있는 문서 환경을 구축할 수 있다.
version: 2
models:
- name: dim_user_metadata
description: A dimension table with user metadata
columns:
- name: user_id
description: The Primary key of the table
tests:
- unique
- not null
models 문서 만들기
사용자 권한이 있다면 Redshift로부터 더 많은 정보를 가져다가 보여준다. 결과 파일은 target/catalog.json 파일로 출력된다.
dbt docs generate
dbt Expectations
dbt Expectations는 Greate Expectations에서 영감을 받아 개발된 dbt용 확장 패키지로, dbt의 기본 테스트 기능을 보다 풍부하게 확장해준다. dbt 환경에서 데이터 품질 규칙을 선언적으로 정의할 수 있도록 돕는 라이브러리이다.
패키지는 Github에서 제공되며, 설치 후 packages.yml 파일에 등록하여 사용한다. 특정 버전 범위를 명시함으로써 프로젝트 환경에 맞는 안정적인 테스트 구성이 가능하다.
packages:
- package: calogica/dbt_expectations
version: [">=0.7.0", "<0.8.0"]
dbt Expectations는 일반적으로 dbt에서 기본 제공하는 테스트들과 함께 사용되며, 테스트 정의는 주로 models/schema.yml 파일에서 관리된다. 이를 통해 기본 테스트로는 표현하기 어려운 보다 정교한 데이터 품질 규칙을 적용할 수 있다.
dbt Expectations 주요 함수
dbt Expectations는 다양한 테스트 함수를 제공한다. 예를 들어 컬럼 존재 여부를 검증하는 expect_column_to_exist, 최근 데이터가 정상적으로 유입되고 있는지를 확인하는 expect_row_values_to_have_recent_data와 같은 테스트가 있다.
또한 컬럼 값의 NULL 여부, 유일성, 데이터 타입, 값의 범위나 허용 집합 여부 등을 검증하는 함수들도 제공된다. 이러한 함수들을 활용하면 데이터 품질 규칙을 코드로 명확히 정의하고, 운영 환경에서 일관되게 검증할 수 있다.
(번외) 데이터 카탈로그
데이터 카탈로그는 조직 내에 존재하는 데이터 자산의 메타데이터를 중앙에서 관리하는 저장소이다. 이는 데이터 거버넌스를 위한 출발점으로, 많은 기업에서는 데이터 카탈로그 자체를 거버넌스 도구로 활용하거나, 이를 기반으로 커스텀 기능을 확장해 사용한다.
데이터 카탈로그의 핵심 기능 중 하나는 메타데이터의 (반)자동 수집이다. 이를 통해 데이터 자산의 구조, 사용 현황, 연관 관계를 효율적으로 관리할 수 있다. 또한 일반적으로 실제 데이터가 아닌 메타데이터만 접근하기 때문에, 데이터 보안 측면에서도 중요한 역할을 한다.
데이터 자산의 종류
데이터 카탈로그에서 관리되는 자산은 데이터베이스 테이블에 국한되지 않는다. 분석에 활용되는 대시보드, 문서 및 메시지 도구(Slack, Jira, GitHub 등), 머신러닝 피처, 데이터 파이프라인, 그리고 사용자 정보(HR 시스템)까지 조직 내 다양한 데이터 관련 자산이 관리 대상이 된다.
데이터 카탈로그의 역할
데이터 카탈로그는 데이터 자산을 다양한 관점에서 조직적으로 관리하기 위한 프레임워크이다. 비즈니스 용어와 데이터 용어를 구분해 관리하거나, 태그 기반 분류를 통해 데이터 탐색성을 높일 수 있다.
또한 각 데이터 자산에는 비즈니스 오너와 기술 오너를 명확히 지정해 책임 소재를 분명히 하며, 표준화된 문서 템플릿을 통해 데이터 이해도를 높이고 협업을 용이하게 한다.
데이터 카탈로그의 주요 기능
데이터 카탈로그는 단순한 메타데이터 저장소를 넘어, 조직 전반의 데이터 활용과 거버넌스를 지원하는 핵심 플랫폼이다. 이를 위해 다양한 기능을 제공한다.
우선 주요 데이터 플랫폼과의 폭넓은 연동 지원이 필수적이다. 데이터 웨어하우스, BI 도구, ETL/ELT 도구, 오케스트레이션 도구 등 여러 시스템에서 메타데이터를 수집해 하나의 통합된 뷰로 제공한다.
또한 비즈니스 용어집(Business Glossary)을 통해 비즈니스 관점의 용어와 실제 데이터 자산을 연결하고, 주석·문서·태그와 같은 협업 기능을 제공함으로써 데이터에 대한 조직 내 공통 이해를 형성한다. 여기에 데이터 리니지, 모니터링, 감사 및 트레이싱 기능을 통해 데이터의 흐름과 사용 이력을 추적할 수 있다.
강력한 검색 기능 역시 중요한 요소이다. 단순 키워드 검색을 넘어 통합 검색이나 NLP 기반 검색을 지원하며, 사용자 역할(예: 마케팅 분석가)별로 데이터 추천을 제공해 데이터 탐색 효율을 높인다.
주요 데이터 플랫폼 지원
데이터 카탈로그는 다양한 데이터 생태계와 연동된다. Redshift, Snowflake, BigQuery와 같은 데이터 웨어하우스 및 데이터 레이크, Looker, Tableau, Power BI 등 BI 도구, dbt, Spark, Hive와 같은 ELT 처리 도구, Airflow와 같은 ETL 오케스트레이션 도구가 대표적이다.
이 외에도 Cassandra, Druid, Elasticsearch, Kafka Schema Registry와 같은 NoSQL 시스템이나 CSV 파일, 그리고 Azure AD, LDAP과 같은 사용자 관리 시스템까지 연계 대상에 포함된다.
데이터 리니지 기능
데이터 카탈로그의 핵심 기능 중 하나는 데이터 리니지(Lineage)이다. 리니지는 데이터가 어디서 생성되어 어떤 과정을 거쳐 소비되는지를 시각적으로 보여준다.
리니지는 데이터셋 간(dataset-to-dataset) 관계를 SQL 파싱을 통해 추적할 수 있으며, 입력 데이터셋 → 파이프라인 → 출력 데이터셋으로 이어지는 파이프라인 단위의 흐름도 관리한다. 또한 하나의 차트가 여러 대시보드에서 사용되는 경우를 고려한 dashboard-to-chart, chart-to-dataset 리니지도 중요하다.
특히 dbt와 같은 도구는 모델 간 의존성이 명확해, job-to-dataflow 관점에서 정교한 리니지 구성이 가능하다.
데이터 거버넌스 관점에서의 중요성
데이터 카탈로그는 조직이 보유한 모든 데이터 자산에 대한 통합 뷰를 제공한다. 이를 통해 데이터 탐색과 이해에 소요되는 시간이 줄어들고, 설문이나 데이터 티켓 감소로 이어져 전반적인 생산성이 향상된다.
또한 잘못된 데이터 사용이나 개인정보 확산과 같은 리스크를 줄이고, 불필요한 데이터 생성이나 사용되지 않는 데이터셋을 제거함으로써 인프라 비용 절감 효과도 기대할 수 있다. 컬럼 레벨 리니지와 CI/CD 프로세스를 연계하면, 데이터 변경으로 인한 장애와 이슈 역시 효과적으로 감소시킬 수 있다.
데이터 카탈로그 이후의 다음 단계
데이터 카탈로그 구축 이후에는 자동화된 데이터 거버넌스 워크플로우를 점진적으로 추가하는 것이 다음 단계가 된다. 그 시작점으로 데이터 품질 관련 경보 시스템을 구현할 수 있다.
예를 들어 중요한 메타데이터 변경이나 데이터 품질 이슈 발생 시 알림을 제공하거나, 관심 있는 데이터 자산의 오너나 정의가 변경될 경우 자동으로 통지하는 방식이다. 더 나아가 데이터 관련 지표를 정기적으로 리뷰하는 미팅을 운영함으로써, 데이터 거버넌스를 조직 문화로 정착시킬 수 있다.
-
-
-
[6기] 데브코스 DE WIL 07 | Airflow 기반 데이터 파이프라인
이번 주 학습 목표
Airflow의 DAG, Task, Operator 구조와 실행 흐름(start_date, execution_date, catchup)을 정확히 이해한다.
ETL과 ELT, Full Refresh와 Incremental Update의 차이를 구분하고 운영 관점에서의 장단점을 설명할 수 있다.
트랜잭션, 멱등성, Backfill을 고려하여 안정적인 데이터 파이프라인을 설계할 수 있다.
데이터 파이프라인 이해하기
데이터 파이프라인(Data Pipeline)이란 간단하게 말하자면, 데이터 소스(Data Source)로부터 목적지(Destination)로 이동시키는 일련의 작업을 일컫는 말이며, 이 작업은 대부분 코드(Python, Scala)나 SQL을 통해 구현되며, 대규모 데이터의 경우 Spark와 같은 분산 처리 엔진이 활용되기도 한다.
데이터의 소스는 매우 다양하며, 대표적인 데이터 소스로는 클릭 스트림 데이터, 광고 성과 데이터, 트랜잭션 로그, 센서 데이터, 메타 데이터 등 비즈니스 활동 전반에서 생서오디는 모든 데이터가 출발점이 될 수 있다.
목적지 또한 다양하지만, 대부분의 경우엔 데이터 웨어하우스(Data Warehouse)가 그 중심이 된다. 그 외에도 NoSQL 스토리지, S3와 같은 오브젝트 스토리지 혹은 프로덕션 데이터베이스가 목적지가 되기도 한다.
결국엔 데이터 파이프라인은 “데이터를 어디서 가져와서, 어떻게 가공하며, 어디에 쓸 것인가”를 정의하는 구조로 볼 수 있다.
ETL이란?
ETL은 Extract, Transform, Load의 약자로, 데이터 엔지니어링에서 가장 전통적인 개념이다. 먼저 데이터를 추출(Extract)하고, 외부 환경에서 필요한 형태로 변환(Transform)한 뒤, 최종 목적지에 적재(Load)한다.
ETL 방식에서 변환 작업은 데이터 웨어하우스 외부에서 이루어지는데, 즉 데이터를 가공하는 로직이 별도의 처리 레이어에 존재하며, 완성된 결과만을 데이터 웨어하우스에 적재한다.
가장 주로 사용되는 Airflow에서는 이러한 ETL의 각 단계를 개별 Task로 정의하고 이를 DAG 형태로 연결하여 실행 순서를 제어한다. 관련 예시로 “API로부터 데이터 추출 → Spark로 변환 → 데이터 웨어하우스 적재”라는 일련의 흐름을 하나의 DAG로 표현할 수 있다.
ELT란?
최근에는 ELT 방식이 널리 사용되고 있다. ELT는 Extract, Load, Transform의 순서를 따르며, 데이터를 원형 그대로 웨어하우스에 적재한 뒤, 웨어하우스 내부에서 SQL을 활용해 변환 작업을 수행하는 방식이다.
해당 방식의 핵심은 “변환을 웨어하우스 내부에서 수행한다”는 점이다. 클라우드 데이터 웨어하우스의 성능이 크게 향상되면서, 굳이 외부에서 복잡하게 가공하지 않고 내부 연산 능력을 활용하는 것이 더 효율적인 경우가 많아졌기 때문이다.
ELT는 데이터 분석가가 주도하는 경우가 많으며, 원시 데이터를 기반으로 요약 테이블이나 리포트용 테이블을 생성하고, 이를 BI 도구에서 활용하는 데에 사용된다. 이러한 영역을 전문적으로 다루는 대표적인 도구로 dbt가 있으며, dbt는 SQL을 기반으로 데이터 모델을 정의하고 관리하는 도구이다.
ETL이 데이터 엔지니어 중심의 외부 가공 모델이라면, ELT는 데이터 분석가 중심의 내부 가공 모델이라고 이해할 수 있다.
Data Lake와 Data Warehouse
데이터 아키텍처를 논할 때 빠지지 않는 개념이 Data Lake와 Data Warehouse이다. 두 개념은 목적과 활용 방식에서 뚜렷한 차이를 가진다.
Data Lake
Data Lake는 구조화 데이터와 비구조화 데이터를 모두 저장할 수 있는 대규모 스토리지로, 로그 파일, 이미지, JSON, 스트림 데이터 등 원본 형태 그대로의 데이터를 보존하는 것이 특징이다.
“모든 데이터를 일단 저장해두는 공간”이라고 정의 가능하며, 필요에 따라 정제하여 데이터 웨어하우스로 이동시키거나, 레이크 위에서 직접적으로 분석 작업을 수행하기도 한다.
Data Warehouse
Data Warehouse는 정제되고 구조화된 데이터를 저장하는 공간으로, 보존 정책이 존재하며, 분석과 리포팅에 최적화되어 있다. 스타 스키마나 Snowflake 스키마와 같은 모델링 기법이 적용되며, 성능과 일관성이 중요한 요소다.
데이터 파이프라인의 세 가지 유형
1.Raw ETL Job
외부 API나 내부 데이터 소스로부터 데이터를 수집하고, 필요한 포맷으로 변환한 뒤 데이터 웨어하우스에 적재하는 작업으로, 데이터의 규모가 커질수록 Spark와 같은 분산 처리 기술이 필요해진다.
이 유형은 전통적인 데이터 엔지니어의 역할에 해당한다.
2.Summary & Report Jobs
이미 웨어하우스나 레이크에 존재하는 데이터를 읽어 다시 웨어하우스에 요약 테이블 형태로 저장하는 작업으로, 일별 매출 요약 테이블, 사용자 리텐션 테이블, A/B 테스트 결과 테이블 등이 이에 해당한다.
데이터 엔지니어의 관점에서는 분석가가 이런 작업을 효율적으로 수행할 수 있는 환경을 제공하는 것이 중요하다. 이 지점에서 dbt와 같은 도구가 중요한 역할을 한다.
3.Production Data Jobs
웨어하우스에 저장된 데이터를 다시 외부 스토리지나 프로덕션 환경으로 내보내는 작업으로, 성능상의 이유로 요약 정보를 Redis에 적재하거나, 머신러닝 모델에서 사용할 피처를 미리 계산해 NoSQL 스토리지에 저장하는 경우가 이에 해당한다.
Cassandra, HBase, DynamoDB와 같은 NoSQL, MySQL과 같은 OLTP 데이터베이스, Redis나 Memcache, ElasticSearch 등이 주요 타겟이 된다.
데이터 파이프라인 설계 시 고려사항
데이터 파이프라인을 처음 만들 때 흔히 이런 기대를 한다.
“내가 만든 파이프라인은 문제없이 동작할 것이다.”
“운영과 관리는 크게 어렵지 않을 것이다.”
하지만 현실은 다르다. 파이프라인은 다양한 이유로 실패한다.
단순한 버그부터, 데이터 소스의 장애, API 포맷 변경, 예상하지 못한 스키마 변경까지 원인은 다양하다.
또한 여러 파이프라인 간 의존성을 충분히 이해하지 못한 상태에서 설계하면, 한 소스의 실패가 연쇄적인 장애로 이어질 수 있다.
파이프라인 수가 늘어날수록 유지보수 비용은 기하급수적으로 증가하는데, 마케팅 채널 데이터가 업데이트되지 않으면, 이를 참조하는 모든 리포트 테이블이 함께 멈출 수 있다.
또한, 관리해야 할 테이블 수가 늘어나면서 source of truth 혼란, 검색 비용 증가 등의 문제도 발생한다.
Best Practice 1: Full Refresh vs Incremental
가능하다면 데이터가 크지 않을 경우 Full Refresh 전략이 가장 단순하고 안전하다. 매 실행 시 전체 데이터를 다시 만들어 정합성을 보장하는 방식이다. Incremental 업데이트가 필요한 경우에는 데이터 소스가 몇 가지 조건을 만족해야 한다:
프로덕션 DB 테이블이라면 최소한 modified, deleted 필드가 존재해야 한다.
API 기반 소스라면 특정 시점 이후 생성되거나 수정된 레코드를 조회할 수 있어야 한다.
증분 기준이 불명확하면 데이터 누락이나 중복이 발생하기 쉽다.
Best Practice 2: 멱등성(Idempotency)
멱등성은 데이터 파이프라인 설계에서 가장 중요한 개념 중 하나다.
같은 입력 데이터를 가지고 여러 번 실행해도 결과 테이블이 달라지지 않아야 한다. 중복 레코드가 생기거나, 실행 횟수에 따라 결과가 변하면 안 된다.
이를 위해 중요한 처리 구간은 하나의 atomic action으로 실행되어야 한다. SQL 기반 처리라면 transaction을 활용해 원자성을 보장하는 것이 기본이다.
Best Practice 3: 재실행과 Backfill
실패는 반드시 발생한다. 중요한 것은 쉽게 재실행할 수 있어야 한다는 점이다.
또한 과거 데이터를 다시 채워야 하는 상황(Backfill)도 자주 발생한다. 이때 날짜 단위로 재처리가 가능하도록 설계되어 있어야 한다.
Airflow는 스케줄 기반 실행과 과거 실행(backfill)에 강점을 가진다. 하지만 설계가 이를 고려하지 않았다면 도구의 장점도 활용하기 어렵다.
Best Practice 4: 명확한 입력/출력과 문서화
모든 데이터 파이프라인은 다음을 명확히 해야 한다:
입력 데이터는 무엇인가
출력 테이블은 무엇인가
비즈니스 오너는 누구인가
“누가 이 데이터를 요청했는가”를 기록으로 남겨야 한다. 이는 이후 데이터 카탈로그에 반영되어 데이터 디스커버리와 데이터 리니지 관리에 활용될 수 있다.
데이터 리니지를 이해하지 못하면, 테이블 하나를 수정하는 순간 예상치 못한 장애를 유발할 수 있다.
Best Practice 5: 불필요한 데이터 정리
사용하지 않는 테이블과 파이프라인은 적극적으로 제거해야 한다:
Unused 테이블 삭제
더 이상 쓰이지 않는 DAG 제거
오래된 데이터는 Data Lake나 저비용 스토리지로 이동
데이터 웨어하우스에는 반드시 필요한 데이터만 남기는 것이 운영 비용과 복잡도를 줄이는 방법이다.
Best Practice 6: 사고 리포트(Post-mortem)
데이터 사고가 발생할 때마다 Post-mortem을 작성하는 것이 좋다. 목적은 비난이 아니라 재발 방지다:
Root cause 분석
재발 방지를 위한 액션 아이템 정의
기술 부채 수준 점검
Post-mortem은 조직의 데이터 성숙도를 보여주는 지표이기도 하다.
Best Practice 7: 입력과 출력 검증
중요 파이프라인은 최소한의 검증 로직을 포함해야 한다:
입력 레코드 수와 출력 레코드 수 비교
Primary Key가 존재한다면 uniqueness 체크
중복 레코드 여부 확인
Null 비율 급증 여부 점검
간단한 체크만으로도 많은 사고를 예방할 수 있다.
Airflow 소개
Apache Airflow 는 파이썬으로 작성된 데이터 파이프라인(ETL) 오케스트레이션 프레임워크이다.
Airbnb에서 시작되어 현재는 아파치 오픈소스 프로젝트로 발전했으며, 데이터 파이프라인 관리 도구 중 가장 널리 사용되는 표준 중 하나로 자리 잡았다.
Airflow의 핵심 목적은 데이터 파이프라인을 코드로 정의하고, 스케줄링하고, 모니터링하는 것이다. 정해진 시간에 ETL을 실행하거나, 하나의 작업이 끝난 뒤 다음 작업을 실행하도록 의존성을 설정할 수 있다. 또한 웹 UI를 통해 실행 상태를 시각적으로 확인할 수 있다.
Airflow에서는 데이터 파이프라인을 DAG(Directed Acyclic Graph) 라고 부른다. 하나의 DAG는 여러 개의 Task로 구성되며, Task 간 의존성을 정의할 수 있다.
Airflow 주요 기능
Airflow는 데이터 파이프라인을 쉽게 만들 수 있도록 다양한 모듈을 제공한다. 여러 데이터 소스와 데이터 웨어하우스를 연결할 수 있는 Operator와 Hook이 기본적으로 제공되며, 확장도 용이하다.
또한 운영 측면에서 중요한 기능들을 포함한다. 대표적으로 Backfill 기능이 있다. 특정 기간의 과거 데이터를 다시 실행해야 할 때, 날짜 단위로 손쉽게 재처리할 수 있다. 이는 배치 기반 데이터 환경에서 매우 중요한 기능이다.
Airflow 구성 요소
Airflow는 다음과 같이 다섯 가지 컴포넌트로 구성된다:
Web Server: Flask 기반으로 구현되어 있으며, DAG 상태와 실행 결과를 시각화한다.
Scheduler: DAG를 읽고 실행 시점을 판단해 작업을 배정한다.
Worker: 실제 Task를 실행한다.
4. Metadata Database: DAG 실행 이력과 상태 정보를 저장한다. (기본값: SQLite, 프로덕션 추천: MySQL, Postgres)
Queue: 다중 서버 환경에서 Worker들에게 작업을 분배할 때 사용된다.
단일 서버 구조
단일 서버 구성에서는 Scheduler, Worker, Web Server가 하나의 서버에서 동작한다. 소규모 환경이나 개발 환경에 적합한 구조다.
다중 서버 구조
트래픽과 DAG 수가 증가하면 스케일링이 필요하다. 방법은 두 가지로 정의할 수 있다:
스케일 업: 더 좋은 사양의 서버 사용
스케일 아웃: Worker 서버를 추가
다중 서버 환경에서는 Queue가 추가되고, Executor 종류에 따라 작업 분배 방식이 달라진다.
Executor 종류
Airflow는 다양한 Executor를 지원한다.
Sequential Executor
Local Executor
Celery Executor
Kubernetes Executor
CeleryKubernetes Executor
Dask Executor
Executor는 Task를 어떤 방식으로 실행할지를 결정하는 핵심 컴포넌트이다. 단일 머신에서 실행할지, 분산 큐 기반으로 실행할지, Kubernetes 위에서 실행할지에 따라 선택이 달라진다.
Airflow 개발의 장단점
장점
데이터 파이프라인을 코드로 세밀하게 제어 가능함
다양한 데이터 소스와 웨어하우스를 지원함
Backfill이 용이함
단점
러닝 커브가 존재함
개발 환경 구성이 쉽지 않음
직접 운영 시 인프라 관리 부담이 커, 클라우드용 버전 사용이 선호됨
DAG란 무엇인가
Apache Airflow에서 데이터 파이프라인은 DAG(Directed Acyclic Graph) 라는 개념으로 표현된다. DAG는 말 그대로 방향성이 있고(Directed), 순환이 없는(Acyclic) 그래프(Graph)를 의미한다.
Airflow에서 하나의 ETL은 곧 하나의 DAG이며, DAG는 하나 이상의 Task로 구성된다. 예를 들어 가장 단순한 형태라면 Extract → Transform → Load의 세 단계로 구성될 수 있다.
Task와 Operator
Airflow에서 Task는 Operator를 통해 생성된다. 여기서 Operator는 “어떤 작업을 수행할 것인가”를 정의하는 실행 단위이며, Airflow는 다양한 기본 Operator를 제공한다.
예를 들면 다음과 같다.
Postgres 쿼리 실행
Redshift 적재
S3 읽기/쓰기
Hive 쿼리
Spark Job 실행
Shell Script 실행
상황에 맞는 Operator를 선택해 사용하거나, 필요하다면 직접 커스텀 Operator를 개발할 수도 있다. 결국 DAG는 “Operator로 만들어진 Task들의 집합”이라고 말할 수 있다.
DAG 정의 시 필요한 기본 정보
모든 DAG에는 공통적으로 필요한 기본 설정이 있는데, 다음과 같은 예시로 정의할 수 있다:
default_args = {
'owner': 'jun', # DAG의 소유자
'start_date': datetime(2020, 8, 7, hour=0, minute=0), # 실행 범위(시작일)
'end_date': datetime(2020, 8, 31, hour=23, minute=0), # 실행 범위(종료일)
'email': ['yyt1186@gmail.com'],
'retries': 1, # 실패 시 재시도 횟수
'retry_delay': timedelta(minutes=3), # 재시도 간격
}
DAG의 객체는 다음과 같은 방식으로 생성할 수 있다:
from airflow import DAG
test_dag = DAG(
"dag_v1", # DAG 이름
schedule="0 9 * * *",
tags=['test'],
default_args=default_args
)
여기서 중요한 것은 schedule으로, Airflow는 크론탭(Crontab) 문법을 따른다.
“0 * * * *” → 매 정각마다 실행
“0 12 * * *” → 매일 12:00에 실행
즉, DAG는 단순한 코드 묶음이 아니라 “언제 실행될 것인가”까지 정의된 스케줄 단위이다.
Operator 생성 예시 1
from airflow.operators.bash_operator import BashOperator
t1 = BashOperator(
task_id='print_date',
bash_command='date',
dag=test_dag)
t2 = BashOperator(
task_id='sleep',
bash_command='sleep 5',
retries=3,
dag=test_dag)
t3 = BashOperator(
task_id='ls',
bash_command='ls /tmp',
dag=test_dag)
Task 간의 의존성은 다음과 같이 정의할 수 있다.
t1 >> t2
t1 >> t3
t1 >> [t2, t3]
t2.set_upstream(t1)
t3.set_upstream(t1)
>> 연산자는 “앞의 Task가 끝난 후 뒤의 Task가 실행된다”는 의미이다.
Operator 생성 예시 2: 시작과 종료 노드 추가
from airflow.operators.bash_operator import BashOperator
from airflow.operators.dummy_operator import DummyOperator
start = DummyOperator(dag=dag, task_id="start")
t1 = BashOperator(
task_id='t1',
bash_command='ls /tmp/downloaded',
retries=3,
dag=dag)
t2 = BashOperator(
task_id='t2',
bash_command='ls /tmp/downloaded',
dag=dag)
end = DummyOperator(dag=dag, task_id='end')
의존성은 다음과 같이 정의할 수 있다.
start >> t1 >> end
start >> t2 >> end
start >> [t1, t2] >> end
트랜잭션이란 무엇인가
데이터 파이프라인이나 데이터베이스 작업을 하다 보면, “중간에 실패하면 안 되는 작업”을 반드시 만나게 된다.
예를 들어, 은행 이체 과정을 생각해보자.
내 계좌에서 인출
다른 사람 계좌로 송금
만약 인출은 성공했지만 송금 단계에서 오류가 발생한다면 어떻게 될까?
내 돈은 빠져나갔지만 상대방은 받지 못하는 불완전한 상태가 된다.
이처럼 여러 단계가 하나의 논리적 작업으로 묶여 있고, 중간에 실패하면 전체를 되돌려야 하는 경우에 필요한 개념이 바로 트랜잭션(Transaction) 이다.
트랜잭션의 개념
트랜잭션은 여러 개의 SQL을 하나의 원자적(Atomic) 작업처럼 처리하는 방법이다. 일반적인 형태는 다음과 같다.
BEGIN;
SQL 1;
SQL 2;
SQL 3;
COMMIT;
BEGIN : 트랜잭션 시작
COMMIT : 모든 작업이 성공했을 때 최종 반영
ROLLBACK : 중간에 하나라도 실패하면 이전 상태로 복구
트랜잭션 내부에서 실행된 SQL의 결과는 임시 상태로 존재한다.
COMMIT이 되기 전까지는 다른 세션에서 보이지 않는다.
중간에 문제가 발생하면 다음과 같이 처리한다.
BEGIN;
SQL 1;
SQL 2;
ROLLBACK;
ROLLBACK이 실행되면 BEGIN 이전 상태로 돌아간다.
트랜잭션의 동작 방식
트랜잭션 안에 포함된 SQL은 모두 성공해야만 최종 상태로 확정된다.
모두 성공 → COMMIT
하나라도 실패 → ROLLBACK
이 특성 덕분에 데이터 정합성을 유지할 수 있다.
다만, 트랜잭션에 포함되는 SQL은 최소화하는 것이 좋다.
트랜잭션이 길어질수록 락(lock) 유지 시간이 길어지고, 성능과 동시성에 영향을 줄 수 있기 때문이다.
Autocommit과 트랜잭션
트랜잭션에는 두 가지 동작 방식이 있다.
autocommit=True
각 SQL 실행 시마다 자동으로 COMMIT
모든 변경이 즉시 물리 테이블에 반영됨
이 상태에서 트랜잭션으로 묶고 싶다면 명시적으로 BEGIN과 COMMIT을 사용해야 한다.
BEGIN;
...
COMMIT;
또는 실패 시 ROLLBACK을 호출한다.
autocommit=False
기본적으로 모든 SQL이 자동 커밋되지 않음
명시적으로 commit/rollback을 호출해야 반영됨
Python에서는 보통 다음과 같이 처리한다.
try:
cur.execute(sql1)
cur.execute(sql2)
conn.commit()
except Exception:
conn.rollback()
raise
성공하면 commit(), 실패하면 rollback()을 실행한다.
try/except 사용 시 주의할 점
다음과 같은 코드가 있다고 가정하자.
try:
cur.execute(create_sql)
cur.execute("COMMIT;")
except Exception as e:
cur.execute("ROLLBACK;")
이 경우 예외가 발생해도 에러가 외부로 전달되지 않을 수 있다.
ETL 관점에서는 에러가 “숨겨지는 것”이 가장 위험하다.
따라서 반드시 raise를 사용해 원래 예외를 다시 던져주는 것이 좋다.
try:
cur.execute(create_sql)
cur.execute("COMMIT;")
except Exception as e:
cur.execute("ROLLBACK;")
raise
이렇게 해야:
트랜잭션은 롤백되고
에러는 상위 레벨(Airflow 등)로 전달되며
DAG 실행이 실패로 기록된다
데이터 파이프라인에서는 조용히 실패하는 것보다 명확히 실패하는 것이 훨씬 안전하다.
Backfill과 Airflow
Incremental Update 기반의 데이터 파이프라인은 효율적이지만, 운영 난이도가 높다.
특히 실패한 날짜를 어떻게 재실행할 것인가는 데이터 엔지니어의 삶의 질에 직접적인 영향을 준다.
Full Refresh vs Incremental
가능하다면 Full Refresh가 가장 단순하다.
문제가 생기면 전체를 다시 실행하면 된다.
정합성 측면에서도 안전하다.
반면 Incremental Update는:
효율성은 좋지만
실수로 특정 날짜 데이터가 빠질 수 있고
과거 데이터를 다시 채우려면 별도 작업이 필요하다.
즉, Incremental 방식에서는 재실행(Backfill)이 얼마나 쉬운 구조인가가 핵심이다.
잘못 설계된 Daily ETL의 예
보통 다음과 같이 구현하는 경우가 많다.
from datetime import datetime, timedelta
y = datetime.now() - timedelta(1)
yesterday = datetime.strftime(y, '%Y-%m-%d')
sql = f"SELECT * FROM table WHERE DATE(ts) = '{yesterday}'"
문제는, 1년 치 데이터를 다시 채워야 한다면?
코드를 수정해야 한다.
날짜를 하드코딩한다.
실수하기 쉽다.
운영 중 코드 변경은 위험하다.
이 방식은 Backfill에 매우 취약하다.
Airflow의 접근 방식
Apache Airflow 는 이 문제를 시스템적으로 해결한다. 핵심 개념은 execution_date다.
모든 DAG 실행에는 execution_date가 존재한다.
이 값은 “읽어와야 하는 데이터의 날짜”다.
실행 결과는 Metadata DB에 기록된다.
즉, 데이터 엔지니어는 날짜를 계산하지 않는다. Airflow가 지정해준 execution_date를 그대로 사용하면 된다.
sql = f"""
SELECT *
FROM table
WHERE DATE(ts) = '{{{{ ds }}}}'
"""
이렇게 작성하면, Backfill이 자동으로 쉬워진다.
Daily Incremental Update의 시간 개념
예를 들어:
2020-11-07 데이터부터 매일 하루치씩 읽는다고 가정
이 ETL은 언제 처음 실행되어야 할까?
정답은 2020-11-08이다.
하지만 읽어야 할 데이터는? → 2020-11-07
여기서 중요한 점:
start_date는 DAG가 시작되는 날짜가 아니라 처음 읽어야 할 데이터의 날짜
execution_date는 읽어야 할 데이터의 날짜
구분
의미
start_date
처음 읽을 데이터의 날짜
execution_date
해당 실행이 처리할 데이터 날짜
catchup의 위험성: 만불짜리 쿼리
잘못 설정된 start_date + catchup=True + 대용량 쿼리 조합은 매우 위험하다.
예:
start_date = 2020-08-06
오늘 날짜 = 2020-08-14
catchup = True (기본값)
이 경우 DAG를 Enable하는 순간:
8번 실행된다.
만약 BigQuery/Snowflake에서 2천불짜리 쿼리라면?
8번 실행 → 1만6천불
Redshift처럼 월 정액 구조는 괜찮지만, 쿼리 단위 과금 시스템에서는 치명적이다.
start_date & execution_date 이해 문제
조건:
start_date = 2020-08-10 02:00:00
daily job
catchup = True
현재 시간 = 2020-08-13 20:00:00
지금 처음 활성화
이 경우 실행 횟수는?
2020-08-10 02:00:00
2020-08-11 02:00:00
2020-08-12 02:00:00
2020-08-13 02:00:00
총 4번 실행되며, 이것이 catchup의 기본 동작이다.
Backfill 관련 핵심 파라미터
변수
설명
start_date
DAG가 처음 읽어야 할 데이터의 날짜
execution_date
해당 실행이 처리할 데이터의 날짜
catchup
start_date 이후 실행되지 않은 구간을 자동으로 따라잡을지 여부 (기본 True)
end_date
특정 기간까지만 실행하고 싶을 때 사용
수동 Backfill 명령으로 특정 날짜 범위를 재실행할 수 있다.
airflow dags backfill -s 2023-01-01 -e 2023-01-31 dag_id
-
-
-
-
[6기] 데브코스 DE WIL 03 | Django API 서버
이번 주 학습 목표
Django 프로젝트·앱 생성부터 웹 앱 → API 서버로 확장되는 전체 개발 흐름을 직접 구현하며, Django 프레임워크의 구조와 역할을 입체적으로 이해한다.
URL → View → ORM → Serializer → Response(JSON) 로 이어지는 요청 처리 과정을 실습을 통해 체득하고, 함수 기반 뷰부터 Generic API View까지 여러 추상화 단계의 차이와 목적을 이해한다.
Django ORM, Admin, Shell, DRF를 활용해 CRUD API를 완성하고, User·인증·권한(Owner 기반 접근 제어)까지 포함한 실무 수준의 데이터 관리 및 접근 제어 구조를 구현한다.
Django Project 생성하기
Django 개발의 시작은 하나의 프로젝트(Project)를 생성하는 것이다. 여기서 프로젝트란 전체 설정과 앱들을 감싸는 최상위 단위 역할을 한다.
$ django-admin startproject mysite
프로젝트가 생성되면, 기본 설정이 포함된 디렉터리 구조가 함께 만들어진다. 이후 개발 서버를 실행해 정상적으로 동작하는지 확인한다.
$ python manage.py runserver
Django App 생성하기
Django 프로젝트 내부에서는 실제 기능 단위를 앱(App)이라는 개념으로 분리한다. 예제로 polls라는 앱을 생성해본다.
$ python manage.py startapp polls
앱 내부의 view.py에 가장 단순한 응답을 반환하는 함수를 작성한다.
from django.http import HttpResponse
def index(request):
return HttpResponse("Hello World.")
URL 연결하기
작성한 view 함수가 실제 URL 요청과 연결되기 위해서는 URL 설정이 필요하다. 프로젝트 단위의 urls.py에서 앱의 URL을 포함시킨다.
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("polls/", include("polls.urls")),
]
이후 앱 내부에 urls.py를 생성하고 view와 연결한다.
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
]
URL 경로(Path) 확장하기
하나의 앱 안에서도 여러 URL 경로를 가질 수 있다.
view 함수와 URL을 추가로 연결해본다.
from django.http import HttpResponse
def index(request):
return HttpResponse("Hello, world.")
def some_url(request):
return HttpResponse("Some url을 구현해 봤습니다.")
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("some_url", views.some_url),
]
모델(Model) 만들기
Django의 핵심 개념 중 하나는 ORM(Object Relational Mapping)이다.
모델을 통해 데이터베이스 구조를 코드로 정의한다.
먼저 앱을 프로젝트 설정에 등록한다.
# mysite/settings.py
INSTALLED_APPS = [
...
"polls.apps.PollsConfig",
]
이후 models.py에 모델을 정의한다.
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField("date published")
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
choice_text = models.CharField(max_length=200)
votes = models.IntegerField(default=0)
마이그레이션(Migration)
모델 변경 사항을 데이터베이스에 반영하기 위해 마이그레이션을 생성하고 실행한다.
$ python manage.py makemigrations polls
생성될 SQL을 확인할 수도 있다.
$ python manage.py sqlmigrate polls 0001
마이그레이션을 실제로 적용한다.
$ python manage.py migrate
다양한 모델 필드 활용
Django는 다양한 필드 타입을 기본으로 제공한다.
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField("date published")
# is_something = models.BooleanField(default=False)
# average_score = models.FloatField(default=0.0)
기본 데이터베이스로는 SQLite가 사용된다.
$ sqlite3 db.sqlite3
마이그레이션을 이전 상태로 되돌릴 수도 있다.
$ python manage.py migrate polls 0001
Django Admin – 관리자 계정 생성
Django는 기본적으로 관리자 페이지를 제공한다. 관리자 계정을 생성한다.
$ python manage.py createsuperuser
Admin에 모델 등록하기
관리자 페이지에서 모델을 관리하려면 등록이 필요하다.
# polls/admin.py
from django.contrib import admin
from .models import *
admin.site.register(Question)
admin.site.register(Choice)
문자열 표현을 추가하면 관리자 화면에서 더 읽기 쉬워진다.
def __str__(self):
return f"제목:{self.question_text}, 날짜:{self.pub_date}"
Django Shell 사용하기
Django Shell은 ORM을 직접 다뤄볼 수 있는 실습 도구다.
$ python manage.py shell
>>> from polls.models import *
>>> Question.objects.all()
>>> Choice.objects.all()
관계형 데이터 접근도 가능하다.
>>> choice.question
>>> question.choice_set.all()
현재 시간 다루기
>>> from datetime import datetime
>>> datetime.now()
>>> from django.utils import timezone
>>> timezone.now()
레코드 생성하기
>>> q1 = Question(question_text="커피 vs 녹차")
>>> q1.pub_date = timezone.now()
>>> q1.save()
>>> q3.choice_set.create(choice_text="b")
레코드 수정 및 삭제
>>> q = Question.objects.last()
>>> q.question_text += "???"
>>> q.save()
>>> choice.delete()
Django Shell – 모델 필터링(Model Filtering)
Django ORM은 모델 객체를 SQL처럼 조회할 수 있도록 get()과 filter()를 제공한다. get()은 하나의 결과만 기대할 때 사용하며, 조건에 맞는 데이터가 여러 개면 예외가 발생한다.
>>> from polls.models import *
>>> Question.objects.get(id=1)
>>> q = Question.objects.get(question_text__startswith='휴가를')
>>> Question.objects.get(pub_date__year=2023)
polls.models.Question.MultipleObjectsReturned: get() returned more than one Question
반대로 filter()는 조건에 맞는 결과를 QuerySet(목록) 형태로 반환한다.
여기서 .count()로 개수를 확인할 수도 있다.
>>> Question.objects.filter(pub_date__year=2023)
>>> Question.objects.filter(pub_date__year=2023).count()
또 하나 유용한 점은, ORM이 실제로 어떤 SQL을 만드는지 확인할 수 있다는 것이다.
>>> print(Question.objects.filter(pub_date__year=2023).query)
>>> print(Question.objects.filter(question_text__startswith='휴가를').query)
관계형 조회도 자연스럽게 이어진다. ForeignKey 관계로 연결된 데이터를 .choice_set으로 접근할 수 있다.
>>> q = Question.objects.get(pk=1)
>>> q.choice_set.all()
>>> print(q.choice_set.all().query)
모델 필터링(Model Filtering) 2
ORM 필터링은 다양한 “룩업(lookup)” 연산자를 통해 확장된다. startswith, contains, gt(>) 같은 연산자를 활용하면 조건을 세밀하게 만들 수 있다.
>>> from polls.models import *
>>> Question.objects.filter(question_text__startswith='휴가를')
>>> Question.objects.filter(question_text__contains='휴가')
>>> Choice.objects.filter(votes__gt=0)
>>> print(Choice.objects.filter(votes__gt=0).query)
데이터를 바꿔보고 저장하면서 ORM 흐름을 손에 익히는 것도 중요하다.
>>> choice = Choice.objects.first()
>>> choice.votes = 5
>>> choice.save()
또한 정규표현식 기반 필터링도 가능하다.
>>> print(Question.objects.filter(question_text__regex=r'^휴가.*어디').query)
Django 모델 관계 기반 필터링
관계형 모델을 연결해두면, ORM은 “JOIN을 직접 쓰지 않아도” 관계를 타고 들어가 필터링을 할 수 있다. 이를 위해 __(double underscore)로 필드를 이어붙인다.
먼저 모델의 출력 형태를 보기 좋게 정리한다.
# polls/models.py
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField('date published')
def __str__(self):
return f'제목:{self.question_text}, 날짜:{self.pub_date}'
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
choice_text = models.CharField(max_length=200)
votes = models.IntegerField(default=0)
def __str__(self):
return f'[{self.question.question_text}]{self.choice_text}'
이제 Choice를 조회하면서 Question의 필드 조건으로 필터링할 수 있다.
>>> from polls.models import *
>>> Choice.objects.filter(question__question_text__startswith='휴가')
반대로 특정 조건을 제외하고 가져오고 싶다면 exclude()를 사용한다.
>>> Question.objects.exclude(question_text__startswith='휴가')
Django Shell – 모델 메소드
모델은 단순히 DB 스키마만 정의하는 곳이 아니라, “해당 데이터가 가져야 할 행동”을 메소드로 담을 수 있다. 예를 들어, “최근 게시물인지” 같은 판단 로직을 모델에 넣어두면 재사용이 쉬워진다.
# polls/models.py
from django.utils import timezone
import datetime
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField('date published')
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
def __str__(self):
new_badge = 'NEW!!!' if self.was_published_recently() else ''
return f'{new_badge} 제목:{self.question_text}, 날짜:{self.pub_date}'
뷰(Views)와 템플릿(Templates)
이제부터는 Shell에서 조회하던 데이터를 실제 웹 화면으로 보여준다. View에서는 데이터를 조회하고, Template로 전달할 context를 구성해 렌더링한다.
# polls/views.py
from .models import *
from django.shortcuts import render
def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
context = {'first_question': latest_question_list[0]}
return render(request, 'polls/index.html', context)
정렬/슬라이싱이 어떤 SQL이 되는지도 확인할 수 있다.
>>> from polls.models import *
>>> print(Question.objects.order_by('-pub_date')[:5].query)
템플릿에서는 전달된 변수를 출력한다.
<!-- polls/templates/polls/index.html -->
{% raw %}
<ul>
<li>{{ first_question }}</li>
</ul>
{% endraw %}
템플릿에서 제어문 사용하기
템플릿은 단순 출력뿐 아니라, 조건문/반복문을 제공한다. 리스트를 전달하고 반복 렌더링하도록 수정한다.
# polls/views.py
from .models import *
from django.shortcuts import render
def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
context = {'questions': latest_question_list}
# context = {'questions': []}
return render(request, 'polls/index.html', context)
{% raw %}
{% if questions %}
<ul>
{% for question in questions %}
<li>{{ question }}</li>
{% endfor %}
</ul>
{% else %}
<p>no questions</p>
{% endif %}
{% endraw %}
상세(detail) 페이지 만들기
목록만 보여주는 것에서 끝나지 않고, 특정 질문의 상세 페이지로 이동할 수 있어야 한다. URL에서 question_id를 받아 해당 객체를 조회해 템플릿에 전달한다.
# polls/views.py
def detail(request, question_id):
question = Question.objects.get(pk=question_id)
return render(request, 'polls/detail.html', {'question': question})
URL 패턴도 정수 파라미터를 받도록 추가한다.
# polls/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('some_url', views.some_url),
path('<int:question_id>/', views.detail, name='detail'),
]
템플릿에서는 연결된 Choice들을 순회해 출력한다.
{% raw %}
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>
{% endraw %}
상세 페이지로 링크 추가하기
이제 목록 페이지에서 각 질문을 클릭하면 상세 페이지로 이동하도록 링크를 연결한다. 이를 위해 app_name을 지정하고 namespaced URL을 사용한다.
# polls/urls.py
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('some_url', views.some_url),
path('<int:question_id>/', views.detail, name='detail'),
]
{% raw %}
{% if questions %}
<ul>
{% for question in questions %}
<li>
<a href="{% url 'polls:detail' question.id %}">
{{ question.question_text }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>no questions</p>
{% endif %}
{% endraw %}
404 에러 처리하기
get()으로 객체를 가져올 때 데이터가 없으면 예외가 발생한다. Django에서는 이를 깔끔하게 처리하기 위해 get_object_or_404()를 제공한다.
from django.shortcuts import render, get_object_or_404
def detail(request, question_id):
question = get_object_or_404(Question, pk=question_id)
return render(request, 'polls/detail.html', {'question': question})
폼(Forms) – 투표 기능 만들기
이제 상세 페이지에서 사용자가 선택지를 고르고 제출하면, 서버가 이를 받아 votes를 증가시키는 흐름을 만든다. 폼은 POST 요청으로 서버에 데이터를 전달한다.
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.shortcuts import get_object_or_404, render
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {
'question': question,
'error_message': '선택이 없습니다.'
})
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:index'))
URL도 vote 엔드포인트를 추가한다.
# polls/urls.py
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]
템플릿에서 라디오 버튼과 csrf 토큰을 포함한 폼을 만든다.
{% raw %}
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
<h1>{{ question.question_text }}</h1>
{% if error_message %}
<p><strong>{{ error_message }}</strong></p>
{% endif %}
{% for choice in question.choice_set.all %}
<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label>
<br>
{% endfor %}
<input type="submit" value="Vote">
</form>
{% endraw %}
에러 방어하기 1
사용자가 선택 없이 제출했거나, 잘못된 ID로 요청했을 때를 대비해 에러 메시지를 더 구체화한다.
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {
'question': question,
'error_message': f"선택이 없습니다. id={request.POST['choice']}"
})
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))
에러 방어하기 2 – 동시성(F) 처리
단순히 votes += 1은 동시 요청이 들어올 경우 값이 꼬일 수 있다. 이를 방지하기 위해 DB 레벨에서 안전하게 증가시키는 F() 표현식을 사용한다.
from django.db.models import F
from django.urls import reverse
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {
'question': question,
'error_message': f"선택이 없습니다. id={request.POST['choice']}"
})
else:
selected_choice.votes = F('votes') + 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:index'))
결과(result) 조회 페이지
투표가 끝난 뒤 결과를 확인할 수 있도록 결과 페이지를 만든다. vote 이후 redirect를 result로 보내고, result view에서 해당 질문과 선택지 정보를 렌더링한다.
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.db.models import F
from django.http import HttpResponseRedirect
def vote(request, question_id):
...
else:
selected_choice.votes = F('votes') + 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:result', args=(question.id,)))
def result(request, question_id):
question = get_object_or_404(Question, pk=question_id)
return render(request, 'polls/result.html', {'question': question})
{% raw %}
<h1>{{ question.question_text }}</h1><br>
{% for choice in question.choice_set.all %}
<label>
{{ choice.choice_text }} -- {{ choice.votes }}
</label>
<br>
{% endfor %}
{% endraw %}
URL도 result 엔드포인트를 추가한다.
# polls/urls.py
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/vote/', views.vote, name='vote'),
path('<int:question_id>/result/', views.result, name='result'),
]
Django Admin 편집 페이지 커스터마이징
Django Admin은 기본 설정만으로도 강력하지만, 실제 운영 관점에서는 “입력 폼”을 더 읽기 쉽게 구성하는 일이 중요하다. 특히, Question과 Choice처럼 부모-자식 관계가 있는 모델에서는, 한 화면에서 함께 편집할 수 있도록 만들면 관리 효율이 크게 올라간다.
아래 설정은 Question 편집 페이지에서 Choice를 인라인(inline) 형태로 함께 수정할 수 있도록 만든다. 또한 입력 폼을 섹션 단위로 나누고, 특정 필드를 읽기 전용으로 설정한다.
# polls/admin.py
from django.contrib import admin
from .models import Choice, Question
admin.site.register(Choice)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
('질문 섹션', {'fields': ['question_text']}),
('생성일', {'fields': ['pub_date'], 'classes': ['collapse']}),
]
readonly_fields = ['pub_date']
inlines = [ChoiceInline]
admin.site.register(Question, QuestionAdmin)
TabularInline : 자식 모델을 테이블 형태로 나열해 입력할 수 있게 만든다.
extra : 기본으로 몇 개의 빈 입력 폼을 더 보여줄지 설정한다.
fieldsets : 편집 폼을 섹션별로 나눠 가독성을 높인다.
readonly_fields : 생성일처럼 수정되면 안 되는 값을 읽기 전용으로 만든다.
Django Admin 목록 페이지 커스터마이징
관리자 페이지에서 “편집”만큼 중요한 것이 “목록 화면”이다. 데이터가 쌓이면 목록에서 빠르게 탐색하고 필터링할 수 있어야 하며, 이때 list_filter, search_fields 같은 옵션이 효과적이다.
먼저, 모델에서 관리자 목록에 표시할 수 있는 “계산 컬럼”을 추가한다. 여기서는 하루 기준으로 최근 생성 여부를 boolean으로 보여준다.
# polls/models.py
import datetime
from django.db import models
from django.utils import timezone
from django.contrib import admin
class Question(models.Model):
question_text = models.CharField(max_length=200, verbose='질문')
pub_date = models.DateTimeField(auto_now_add=True, verbose='생성일')
@admin.display(boolean=True, description='최근생성(하루기준)')
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
def __str__(self):
return f'제목: {self.question_text}, 날짜: {self.pub_date}'
그리고 Admin에서 필터/검색 설정을 추가한다.
# polls/admin.py
from django.contrib import admin
from .models import Choice, Question
admin.site.register(Choice)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
('질문 섹션', {'fields': ['question_text']}),
('생성일', {'fields': ['pub_date'], 'classes': ['collapse']}),
]
readonly_fields = ['pub_date']
inlines = [ChoiceInline]
list_filter = ['pub_date']
search_fields = ['question_text', 'choice__choice_text']
admin.site.register(Question, QuestionAdmin)
list_filter : 생성일 기준 필터 UI가 자동으로 생긴다.
search_fields : 텍스트 검색이 가능해진다.
관계 기반 검색(choice__choice_text)처럼 “모델 관계를 타고” 검색 범위를 확장할 수 있다.
Serializer
이제부터는 “화면 렌더링” 중심이었던 흐름에서, REST API 형태로 데이터를 주고받는 구조로 넘어간다.
Django REST Framework(DRF)에서 Serializer는 모델 객체를 JSON으로 변환(Serialize)하고, JSON을 검증해 모델로 복원(Deserialize)하는 역할을 한다.
아래는 Serializer 클래스를 직접 정의한 방식이다.
# polls_api/serializers.py
from rest_framework import serializers
from polls.models import Question
class QuestionSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
question_text = serializers.CharField(max_length=200)
pub_date = serializers.DateTimeField(read_only=True)
def create(self, validated_data):
return Question.objects.create(**validated_data)
def update(self, instance, validated_data):
instance.question_text = validated_data.get('question_text', instance.question_text)
instance.save()
return instance
read_only=True : 클라이언트 입력값으로 받지 않고 응답에만 포함한다.
create() / update() : .save() 호출 시 실제 DB 반영 로직이 여기서 실행된다.
Django Shell에서 Serializer 사용하기
Serializer는 “HTTP 요청이 오기 전에도” Shell에서 충분히 연습할 수 있다. 이 실습에서는 Serialize → JSON 렌더링 → Deserialize → 검증 → 저장(Create/Update) 흐름을 한 번에 경험한다.
# polls_api/serializers.py
from rest_framework import serializers
from polls.models import Question
class QuestionSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
question_text = serializers.CharField(max_length=200)
pub_date = serializers.DateTimeField(read_only=True)
def create(self, validated_data):
return Question.objects.create(**validated_data)
def update(self, instance, validated_data):
instance.question_text = validated_data.get('question_text', instance.question_text) + '[시리얼라이저에서 업데이트]'
instance.save()
return instance
# Serialize
>>> from polls.models import Question
>>> from polls_api.serializers import QuestionSerializer
>>> q = Question.objects.first()
>>> serializer = QuestionSerializer(q)
>>> serializer.data
>>> from rest_framework.renderers import JSONRenderer
>>> json_str = JSONRenderer().render(serializer.data)
# Deserialize
>>> import json
>>> data = json.loads(json_str)
>>> serializer = QuestionSerializer(data=data)
>>> serializer.is_valid()
>>> serializer.validated_data
>>> new_question = serializer.save() # Create
# Update
>>> data = {'question_text': '제목수정'}
>>> serializer = QuestionSerializer(new_question, data=data)
>>> serializer.is_valid()
>>> serializer.save()
# Validation 실패 케이스
>>> long_text = "abcd"*300
>>> serializer = QuestionSerializer(data={'question_text': long_text})
>>> serializer.is_valid()
>>> serializer.errors
여기서 핵심은, .is_valid()가 통과해야 .save()가 가능하고, 검증 실패 시 errors로 어떤 제약에 걸렸는지 확인할 수 있다는 점이다.
ModelSerializer
직접 필드를 일일이 선언하는 대신, 모델 정보를 기반으로 Serializer를 자동 생성할 수도 있다. ModelSerializer는 실무에서 훨씬 자주 사용되는 방식이다.
# polls_api/serializers.py
from rest_framework import serializers
from polls.models import Question
class QuestionSerializer(serializers.ModelSerializer):
class Meta:
model = Question
fields = ['id', 'question_text', 'pub_date']
Shell에서 구조를 출력해보면 자동으로 필드가 구성된 것을 확인할 수 있다.
>>> from polls_api.serializers import QuestionSerializer
>>> print(QuestionSerializer())
>>> serializer = QuestionSerializer(data={'question_text':'모델시리얼라이저로 만들어 봅니다.'})
>>> serializer.is_valid()
>>> serializer.save()
GET – 질문 목록 조회 API 만들기
이제 Serializer를 실제 API 응답에 연결한다. 가장 먼저 구현하기 좋은 API는 “목록 조회(GET)”이다.
# polls_api/views.py
from polls.models import Question
from polls_api.serializers import QuestionSerializer
from rest_framework.response import Response
from rest_framework.decorators import api_view
@api_view()
def question_list(request):
questions = Question.objects.all()
serializer = QuestionSerializer(questions, many=True)
return Response(serializer.data)
URL을 연결한다.
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', question_list, name='question-list')
]
프로젝트 URL에도 include로 연결한다.
# mysite/urls.py
from django.urls import include, path
from django.contrib import admin
urlpatterns = [
path('admin/', admin.site.urls),
path('polls/', include('polls.urls')),
path('rest/', include('polls_api.urls')),
]
HTTP Methods와 CRUD
REST API를 구현할 때 CRUD는 보통 다음 HTTP 메서드에 매핑된다.
Create : POST
Read : GET
Update : PUT
Delete : DELETE
즉, 같은 URL이라도 메서드가 달라지면 서버의 동작이 달라진다.
POST – 질문 생성 API 추가하기
기존 question_list 뷰에 POST 처리까지 추가해 “조회 + 생성”을 한 엔드포인트로 만든다.
# polls_api/views.py
from rest_framework.decorators import api_view
from polls.models import Question
from polls_api.serializers import QuestionSerializer
from rest_framework.response import Response
from rest_framework import status
@api_view(['GET','POST'])
def question_list(request):
if request.method == 'GET':
questions = Question.objects.all()
serializer = QuestionSerializer(questions, many=True)
return Response(serializer.data)
if request.method == 'POST':
serializer = QuestionSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
PUT / DELETE – 상세 API 만들기
목록 엔드포인트와 별개로, 특정 리소스 1개를 다루는 상세 엔드포인트를 만든다. 여기서는 /question/<id>/ 형태로 하나의 Question을 조회/수정/삭제한다.
# polls_api/views.py
from django.shortcuts import get_object_or_404
@api_view(['GET', 'PUT', 'DELETE'])
def question_detail(request, id):
question = get_object_or_404(Question, pk=id)
if request.method == 'GET':
serializer = QuestionSerializer(question)
return Response(serializer.data)
if request.method == 'PUT':
serializer = QuestionSerializer(question, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if request.method == 'DELETE':
question.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', question_list, name='question-list'),
path('question/<int:id>/', question_detail, name='question-detail'),
]
Class 기반의 뷰(Views)로 바꾸기
함수 기반 뷰(FBV)로도 충분하지만, API가 늘어나면 구조화가 필요해진다. DRF는 APIView를 통해 클래스 기반 구성도 지원한다.
# polls_api/views.py
from rest_framework.views import APIView
class QuestionList(APIView):
def get(self, request):
questions = Question.objects.all()
serializer = QuestionSerializer(questions, many=True)
return Response(serializer.data)
def post(self, request):
serializer = QuestionSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class QuestionDetail(APIView):
def get(self, request, id):
question = get_object_or_404(Question, pk=id)
serializer = QuestionSerializer(question)
return Response(serializer.data)
def put(self, request, id):
question = get_object_or_404(Question, pk=id)
serializer = QuestionSerializer(question, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
question = get_object_or_404(Question, pk=id)
question.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', QuestionList.as_view(), name='question-list'),
path('question/<int:id>/', QuestionDetail.as_view(), name='question-detail'),
]
Mixin으로 CRUD 조립하기
API 패턴이 반복되면, DRF에서 제공하는 Mixin을 이용해 코드를 더 줄일 수 있다. 핵심은 “queryset과 serializer_class만 지정하면, CRUD 동작을 조합할 수 있다”는 점이다.
# polls_api/views.py
from polls.models import Question
from polls_api.serializers import QuestionSerializer
from rest_framework import mixins, generics
class QuestionList(mixins.ListModelMixin,
mixins.CreateModelMixin,
generics.GenericAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)
class QuestionDetail(mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
generics.GenericAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
def put(self, request, *args, **kwargs):
return self.update(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', QuestionList.as_view(), name='question-list'),
path('question/<int:pk>/', QuestionDetail.as_view(), name='question-detail'),
]
Generic API View로 더 단순하게
Mixin까지 익숙해지면, DRF의 제네릭 뷰는 더 간결한 형태를 제공한다. List+Create, Retrieve+Update+Destroy 조합은 실무에서도 가장 흔한 기본 세트다.
# polls_api/views.py
from polls.models import Question
from polls_api.serializers import QuestionSerializer
from rest_framework import generics
class QuestionList(generics.ListCreateAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
class QuestionDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
User 추가하기
지금까지의 Question/Choice 모델은 “누가 만들었는지”에 대한 정보가 없었다. API 서버 관점에서는 리소스의 소유자(Owner)가 있어야 인증/권한을 적용할 수 있다. 이를 위해 Question 모델에 auth.User와의 관계를 추가한다.
# polls/models.py
class Question(models.Model):
question_text = models.CharField(max_length=200, verbose_name='질문')
pub_date = models.DateTimeField(auto_now_add=True, verbose_name='생성일')
owner = models.ForeignKey(
'auth.User',
related_name='questions',
on_delete=models.CASCADE,
null=True
)
@admin.display(boolean=True, description='최근생성(하루기준)')
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
def __str__(self):
return f'제목:{self.question_text}, 날짜:{self.pub_date}'
여기서 핵심은 related_name=’questions’다. 이 설정 덕분에 User 객체에서 user.questions.all()처럼 “역참조”가 가능해진다.
Shell에서 실제로 연결이 잘 되었는지 확인한다.
>>> from django.contrib.auth.models import User
>>> User.objects.all()
>>> from polls.models import *
>>> user = User.objects.first()
>>> user.questions.all()
>>> print(user.questions.all().query)
SELECT "polls_question"."id", "polls_question"."question_text", "polls_question"."pub_date", "polls_question"."owner_id"
FROM "polls_question"
WHERE "polls_question"."owner_id" = 1
User 관리하기
User를 단순히 “DB에 존재한다”에서 끝내지 않고, REST API로 조회할 수 있도록 구성한다. 먼저, UserSerializer를 만들고, User가 만든 Question들을 함께 노출한다.
# polls_api/serializers.py
from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
questions = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Question.objects.all()
)
class Meta:
model = User
fields = ['id', 'username', 'questions']
그리고 Generic API View로 User 목록/상세 조회 엔드포인트를 만든다.
# polls_api/views.py
from django.contrib.auth.models import User
from polls_api.serializers import UserSerializer
class UserList(generics.ListAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
class UserDetail(generics.RetrieveAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
마지막으로 urls에 연결해 API 엔드포인트로 노출한다.
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', QuestionList.as_view(), name='question-list'),
path('question/<int:pk>/', QuestionDetail.as_view()),
path('users/', UserList.as_view(), name='user-list'),
path('users/<int:pk>/', UserDetail.as_view()),
]
Form을 사용하여 User 생성하기
이번에는 API가 아니라, Django가 제공하는 “웹 폼 기반 회원가입” 흐름을 만든다. Django에는 기본 회원가입 폼인 UserCreationForm이 제공되며, 이를 CreateView로 감싸면 회원가입 화면을 쉽게 구성할 수 있다.
# polls/views.py
from django.views import generic
from django.urls import reverse_lazy
from django.contrib.auth.forms import UserCreationForm
class SignupView(generic.CreateView):
form_class = UserCreationForm
success_url = reverse_lazy('user-list')
template_name = 'registration/signup.html'
템플릿에서는 폼을 렌더링하고 POST로 제출한다.
{% raw %}
<!-- polls/templates/registration/signup.html -->
<h2>회원가입</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">가입하기</button>
</form>
{% endraw %}
URL에 회원가입 페이지를 연결한다.
# polls/urls.py
from django.urls import path
from . import views
from .views import *
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/vote/', views.vote, name='vote'),
path('<int:question_id>/result/', views.result, name='result'),
path('signup/', SignupView.as_view()),
]
reverse_lazy(‘user-list’)가 실제로 어떤 URL을 가리키는지도 Shell에서 확인할 수 있다.
>>> from django.urls import reverse_lazy
>>> reverse_lazy('user-list')
'/rest/users/'
Serializer를 사용하여 User 생성하기
웹 폼 방식이 “브라우저 화면 중심”이라면, API 서버에서는 “JSON 기반 회원가입”이 필요하다. 이를 위해 RegisterSerializer를 만들어 password 검증 + password 해싱 저장까지 처리한다.
# polls_api/serializers.py
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(
write_only=True,
required=True,
validators=[validate_password]
)
password2 = serializers.CharField(write_only=True, required=True)
def validate(self, attrs):
if attrs['password'] != attrs['password2']:
raise serializers.ValidationError({"password": "두 패스워드가 일치하지 않습니다."})
return attrs
def create(self, validated_data):
user = User.objects.create(username=validated_data['username'])
user.set_password(validated_data['password'])
user.save()
return user
class Meta:
model = User
fields = ['username', 'password', 'password2']
View는 CreateAPIView로 간단히 구성한다.
# polls_api/views.py
from polls_api.serializers import RegisterSerializer
class RegisterUser(generics.CreateAPIView):
serializer_class = RegisterSerializer
URL에 회원가입 API 엔드포인트를 연결한다.
# polls_api/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('question/', QuestionList.as_view(), name='question-list'),
path('question/<int:pk>/', QuestionDetail.as_view()),
path('users/', UserList.as_view(), name='user-list'),
path('users/<int:pk>/', UserDetail.as_view()),
path('register/', RegisterUser.as_view()),
]
User 권한 관리
User가 생겼다면, 이제 API에서 “누구나 수정/삭제 가능한 상태”를 끝내고 권한을 적용해야 한다. 먼저 DRF가 제공하는 로그인/로그아웃 UI를 사용하기 위해 api-auth/를 연결한다.
# polls_api/urls.py
from django.urls import path, include
from .views import *
urlpatterns = [
path('question/', QuestionList.as_view(), name='question-list'),
path('question/<int:pk>/', QuestionDetail.as_view()),
path('users/', UserList.as_view(), name='user-list'),
path('users/<int:pk>/', UserDetail.as_view()),
path('register/', RegisterUser.as_view()),
path('api-auth/', include('rest_framework.urls')),
]
로그인/로그아웃 이후 이동할 URL도 설정한다.
# mysite/settings.py
from django.urls import reverse_lazy
LOGIN_REDIRECT_URL = reverse_lazy('question-list')
LOGOUT_REDIRECT_URL = reverse_lazy('question-list')
QuestionSerializer에는 owner를 읽기 전용으로 노출한다.
이렇게 하면 응답에서는 owner가 보이되, 클라이언트가 임의로 owner를 바꾸는 것은 막을 수 있다.
# polls_api/serializers.py
class QuestionSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
class Meta:
model = Question
fields = ['id', 'question_text', 'pub_date', 'owner']
다음으로 “소유자만 수정/삭제 가능” 정책을 커스텀 Permission으로 만든다.
# polls_api/permissions.py
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
마지막으로 View에 권한을 적용한다.
# polls_api/views.py
from rest_framework import generics, permissions
from .permissions import IsOwnerOrReadOnly
class QuestionList(generics.ListCreateAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class QuestionDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
목록 조회(GET)는 누구나 가능하다.
생성(POST)은 로그인 사용자만 가능하다.
수정/삭제(PUT/DELETE)는 로그인 + “소유자”만 가능하다.
perform_create()에서 owner를 서버가 강제로 주입하여 “조작 불가”하게 만든다.
-
[6기] 데브코스 DE WIL 02 | 데이터 크롤링 및 분석
이번 주 학습 목표
웹 페이지의 요소들을 이해하여 웹 데이터 크롤링에 활용한다.
인터넷 사용자 간의 약속인 HTTP 프로토콜 통신을 이해한다.
웹 데이터 크롤링 라이브러리인 BeautifulSoup과 Selenium 기반의 데이터 분석을 수행한다.
CSS(Cascading Style Sheets)이란?
색상이나 글꼴을 바꾸는 등 문서의 외형을 예쁘게 꾸며주는 ‘언어’이다.
JS(JavaScript)이란?
문서에 다양한 기능을 만들어주는 ‘언어’이다.
HTML(Hyper Text Markup Language)이란?
HTML은 웹 브라우저가 이해하고 보여줄 수 있는 문서를 만들기 위한 하나의 ‘언어’로써 웹 문서를 만들 수 있다.
<!DOCTYPE html> <!-- 문서의 버전 -->
<html lang="en"> <!-- HTML 문서 시작 선언 및 문서 기본 언어 설정 -->
<head> <!-- 문서에 필요한 정보가 기입되는 곳: head 태그 -->
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatiable" content="IE-edge">
<meta nam="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title> <!-- 문서의 제목 -->
</head>
<body> <!-- 실제 사용자가 눈으로 확인 가능한 문서의 내용이 입력되는 곳: body 태그-->
</body>
</html>
HTML 기본 문법
HTML은 콘텐츠를 가지는 태그(ex. <div> 콘텐츠 </div>)와 콘텐츠를 가지지 않는 태그(ex. )로 나뉘어 작성된다.
콘텐츠를 가지는 태그는 열리는 태그(시작 태그)와 닫히는 태그(종료 태그)가 하나의 쌍을 이뤄 작성되어야 한다.
콘텐츠를 가지지 않는 태그는 단일 태그 하나만 가지며 셀프 클로징을 가져야 하는 특징이 있다.
속성과 값
다음과 같은 HTML의 코드에서의 속성과 값 그리고 콘텐츠는 다음과 같다:
<a href="https://naver.com">네이버 바로가기</a>
해당 코드에서 속성은 href, 값은 https://...이며 콘텐츠는 네이버 바로가기로 이루어진다.
HTML <HEAD> 태그
<head> 태그는 사람 눈에 보이지 않지만 기계는 읽을 수 있는 문서의 정보를 정의하는 영역이다. 여기서 태그가 담을 수 있는 정보의 종류는 다음과 같다:
타이틀
메타 데이터
2-1. 인코딩 정보
2-2. 문서 설명
2-3. 문서 작성자
CSS, JavaScript
메타 데이터 - 인코딩
charset(character set)은 문서에서 허용하는 문서의 집합을 의미한다. charset에 선언된 문서의 집합 규칙에 따라 문서에서 사용할 수 있는 문자가 제한된다. 이에 대부분 전 세계적인 charset 집합인 UTF-8을 사용하는게 일반적이다.
HTML <STYLE>, <LINK>, <SCRIPT> 태그
문서 내용인 콘텐츠의 외형에 영향을 주는 태그들이다. 해당 태그들의 활용은 다음과 같다:
<style> 태그는 문서의 헤드 안에서 style을 지정하는 태그로 사용된다.
<link> 태그는 컨텐츠를 가지지 않는 단일 태그이다.
<script> 태그는 style 태그와 link 태그의 두 기능을 제공하는 태그로, 콘텐츠 방식과 링크 방식으로 나뉜다.
<!DOCTYPE html>
<html lang="en">
<head>
<style> <!-- body의 p 태그 콘텐츠의 색상을 파랑으로 정의 -->
body {
color: blue;
}
</style>
<link rel="stylesheet" href="style.css">
<script>
const hello = 'world';
console.log(hello)
</script>
<script src="script.js"></script>
</head>
<body>
</body>
</html>
HTML <BODY> 태그
<BODY> 태그는 사람 눈에 실제로 보이는 콘텐츠 영역이다. 해당 태그 안에서 여러 개의 태그를 사용하여 웹 문서를 만들고, 해당 웹 문서를 여러 개를 만들어 사용자에게 웹사이트로 제공한다.
block(블록 레벨 요소)
레고 블록처럼 차곡차곡 쌓이고 화면 너비가 꽉 차는 요소로 블록의 크기와 내/외부에 여백을 지정할 수 있고 일반적으로 페이지의 구조적 요소를 나타낸다. 인라인 요소를 포함할 수 있으나, 인라인 요소에 포함될 수는 없다.
대표적인 블록 레벨 요소
<div>
가장 흔히 사용되는 레이아웃 태그로 단순히 구역을 나누기 위한 태그로써 사용된다.
<article>
블로그, 포스트, 뉴스 기사와 같은 독립적인 문서를 전달하는 태그로써 사용된다.
<section>
콘텐츠의 구역을 나누는 태그로, 신문지에서 여러 기사가 각자의 구역에서 각자의 정보를 전달하는 의미와 비슷한 역할을 하는 태그로써 사용된다.
inline(인라인 레벨 요소)
블록 요소 내 포함되는 요소로 주로 문장, 단어 같은 작은 부분에 사용되며 한 줄에 나열되는 특징이 있다. 좌/우 여백을 넣는 것만을 허용한다.
대표적인 인라인 레벨 요소
span
특별한 의미 없이 콘텐츠의 특정 부분을 그룹화하고 스타일을 적용하기 위해 사용된다.
a
클릭하면 페이지를 이동할 수 있는 링크 요소를 만들며, href 속성을 사용하여 이동하고자 하는 파일 혹은 URL을 지정할 수 있다. 또, target 속성을 사용하여 이동해야 할 링크를 새 창(_blank), 현재 창(_self) 등 원하는 타겟을 지정할 수 있다.
strong
강한 중요성, 심각성, 긴급성을 나타내기 위해 텍스트를 굵게(Bold)로 표시하여 중요한 부분임을 강조하는데 사용된다.
레이아웃(Layout)
레이아웃 태그 #1(<Header>, <Footer>, <Main>)
<header>
블로그의 글 제목, 작성일 등의 주요 정보를 담는 태그로 사용된다.
<footer>
페이지의 바닥줄에 사용되며 저작권 정보, 연락처 등의 부차적인 정보를 담는 태그로 사용된다.
<main>
페이지의 가장 큰 부분으로 사이트의 내용 즉, 주요 콘텐츠를 담는 태그로 사용된다.
레이아웃 태그 #2(<section>, <article>, <aside>)
<section>
콘텐츠의 구역을 나누는 태그로, 신문지에서 여러 기사가 각자의 구역에서 각자의 정보를 전달하는 의미와 비슷한 역할을 하는 태그로 사용된다.
<article>
블로그, 포스트 뉴스 기사와 같은 독립적인 문서를 전달하는 태그로 사용된다.
<aside>
문서의 내용에 간접적인 정보를 전달하는 태그로 쇼핑몰의 오른쪽에 따라다니는 “오늘의 상품” 같은 것으로 사용된다.
콘텐츠(Contents)
제목 태그(<h1> ~ <h1>)
문서 구획 제목을 나타내는 태그로 Heading이라고 부른다. h1부터 h6까지 표현할 수 있으며, h1 태그는 페이지 내에서 ‘한 번만’ 사용되어야 하고, 구획의 순서는 지켜져야 한다.
문단 태그(<p>)
문서에서 하나의 문단(Paragraph)을 나타내는 태그로 제목 태그와 함께 사용되기도 단독으로 사용되기도 한다.
서식 태그(<b>/<strong>, <i>/<em>, <u>, <s>/<del>)
<b>/<strong>
글씨의 두께를 조절할 수 있는 태그이다.
<b>: 의미를 가지지 않고 단순히 굵은 글씨로 변경한다.
<strong>: 굵은 글씨 변경 후 “강조”의 의미를 부여한다.
<i>/<em>
글씨의 기울기를 조절할 수 있는 태그이다.
<i>: 기울임과 동시에 텍스트가 문단의 내용과 구분되어야 하는 경우 사용할 수 있다.
<em>: 기울임과 내용에 “강조”를 나타낸다.
<u>
글씨에 밑줄을 넣고 주석을 가지는 단어임을 알 수 있으며, CSS로 스타일링하여 빨간 밑줄을 넣는 것으로 “오타”를 나타내는 것처럼 사용 가능하고, 단순하게 “밑줄”만 긋는 용도로는 사용 할 수 없다.
<s>/<del>
글씨에 취소선을 추가할 수 있는 태그이다.
<s>: 단순히 시각적인 취소선만 추가되고 접근성 기기에 취소에 대한 안내는 하지 않다.
<del>: 문서에서 제거된 텍스트를 나타낼 수 있다. <ins> 태그를 함께 사용하면 제거된 텍스트 옆에 추가된 텍스트를 표현할 수 있다.
멀티 미디어(Multi Media)
이미지 태그(<img>)
문서 내에 이미지를 넣을 수 있는 태그이다. “src” 속성을 사용해 이미지 경로를 넣으면 이미지가 출력된다. “alt”속성을 사용해 이미지 로딩 문제 시 대체 텍스트를 띄울 수 있다.
이미지 태그(<figure>, <figcaption>)
하나의 독립적인 콘텐츠로 분리하고 그에 대한 설명을 넣을 수 있는 태그로, <figcaption> 태그를 사용해 콘텐츠의 설명 혹은 범례를 추가할 수 있고 제일 처음이나 제일 아래에 추가해서 사용할 수 있다. 보통의 경우에 이미지를 넣는데 인용문, 비디오/오디오 등 문서의 흐름에 참조는 되지만 독립적으로 분리되어도 되는 내용을 담을 수 있다.
비디오 태그(<video>)
문서 내에 영상을 첨부할 수 있는 태그로, “src”속성을 사용해 비디오를 문서 내 첨부 가 가능하다. “poster” 속성을 사용해 비디오가 로드되기 전에 포스터를 보여줄 수 있다. 또한, <source> 태그를 사용해 여러 타입의 비디오를 제공할 수 있다.
오디오 태그(<audio>)
문서 내에 소리를 첨부할 수 있는 태그이다. “src” 속성을 사용하여 소리를 문서 내에 첨부할 수 있다. <source> 태그를 사용하면 여러 타입의 비디오를 제공할 수 있다. 또한, “controls” 속성을 사용하면 재생/정지 버튼 등이 있는 컨트롤러를 띄울 수 있다.
리스트(List)
정렬되지 않은 목록(<ul>, <li>)
기본 불릿 형식으로 목록을 그리며, <ul> 태그의 자식요소는 <li> 태그만 들어와야 한다.
정렬된 목록(<ol>, <li>)
정렬된 목록 태그로, 기본 숫자 형식으로 목록을 그린다. <li>태그를 사용하여 목록을 구성할 수 있고 다양한 태그를 포함할 수 있다. <ol> 태그의 자식요소는 <li> 태그만 들어와야 한다.
설명 목록(<dl>, <dt>, <dd>)
설명 목록 태그로, <dt> 태그에 사용된 단어 혹은 내용의 설명을 <dd> 태그에 작성할 수 있다. 주로 용어사전이나 “키-값”이 있는 쌍의 목록을 나타낼 때 사용한다. <dt> 태그를 여러 개 작성하고 하나의 <dd> 태그를 작성하는 것으로 여러 개의 용어를 설명할 수 있다.
표(Table)
표 생성(<table>)
표를 만드는 태그로, <tr> 태그로 행(row)을 구분한다. <td> 태그로 열(cell)을 생성한다.
열 제목 태그(<th>)
<th> 태그를 사용하면 셀의 제목을 만들 수 있다.
제목 그룹 태그(<thead>)
<thead> 태그 안에 “열(cell)” 제목의 행을 넣음으로써 그룹을 지을 수 있다.
표 본문 요소 태그(<tbody>)
<tbody> 태그 안에 여러 “열(cell)의 행”을 넣음으로써 본문 요소를 그룹 지을 수 있다.
표 바닥글 요소 태그(<tfoot>)
<tfoot> 태그 안에 여러 “열(cell)의 행”을 넣음으로써 표의 바닥글 요소를 넣을 수 있다.
표 설명 태그(<caption>)
<caption> 태그를 사용하여 “표가 가진 데이터에 대한 설명”을 넣을 수 있다.
외부 콘텐츠(<iframe>)
현재 문서 안에 다른 HTML 페이지를 삽입할 수 있는 태그로, ‘src’ 속성에 원하는 HTML 문서 또는 URL을 넣을 수 있다. 외부 페이지를 불러올 수 있기 때문에 외부 페이지의 영향을 받을 수 있다.
인터넷의 약속, HTTP
인터넷과 웹
인터넷(Internet)은 본래 컴퓨터와 컴퓨터를 서로 연결하기 위해 등장한 네트워크(Network)에서 출발했다. 초기에는 가까운 거리의 컴퓨터들을 연결하는 형태였고, 이러한 네트워크들을 묶어 근거리 지역 네트워크(Local Area Network; LAN)가 만들어졌다. 이후 이 LAN들이 점차 확장되며 전 세계적으로 연결되었고, 오늘날 우리가 사용하는 범지구적 네트워크, 즉 인터넷(Internet)이 탄생하게 되었다.
웹(Web)은 이러한 인터넷 위에서 동작하는 서비스 중 하나이다. 인터넷이라는 거대한 네트워크 환경 위에서 정보를 주고 받을 수 있는 공간이 바로 월드 와이드 웹(World Wide Web; WWW)이다.
HTTP의 구조
HTTP(Hypertext Transfer Protocol)는 웹 상에서 클라이언트와 서버가 어떤 규칙으로 정보를 주고 받는지 정해 놓은 약속과 같은 것이다. 주소 창에 URL을 입력하고 엔터키를 입력하는 순간, 브라우저는 서버를 향해 HTTP 요청(Request)을 보낸다. 그리고 서버는 해당 요청을 처리한 뒤, 그 결과를 HTTP 응답(Response) 형태로 다시 클라이언트에게 전달한다.
HTTP 요청에는 서버가 요청을 정확히 이해하고 처리하기 위해 필요한 정보들이 함께 포함된다. 먼저 Host는 요청을 받는 서버의 이름을 의미하며, Resource는 서버 내의 어떤 자원을 요청하는 지를 나타낸다. 여기에 Method는 요청의 목적과 방식을 정의하는 요소로써, 데이터를 조회(GET)할 것인지, 새로 생성(POST)할 것인지와 같은 동작을 구분한다.
HTTP 요청과 응답은 공통적으로 Header와 Body 구조를 가진다. Header에는 요청을 보낸 주체와 받는 대상, 데이터의 형식, 요청 시각 등과 같은 메타 정보가 담긴다. Body에는 실제로 전달하고자 하는 내용이 포함되어, 웹 페이지를 구성하는 HTML 문서나 JSON 데이터 등이 이 영역에 담긴다.
HTTP 통신 with Python
Python에서는 request 라이브러리를 사용하면 간단하게 HTTP 요청과 응답을 처리할 수 있다. request는 HTTP 요청을 보내고, 서버로부터 받은 응답을 객체 형태로 다룰 수 있도록 도와주는 라이브러리이다.
가장 기본적인 요청 방식은 GET 요청이다. GET은 서버에게 특정 자원을 요청할 때 사용되며, 일반적으로 웹 페이지를 조회하거나 데이터를 가져올 때 활용된다.
import requests
res = requests.get("https://naver.com")
# 응답 헤더(Header) 확인
res.headers
# 응답 바디(Body) 확인 (일부분만 출력)
res.text[:1000]
위 코드에서 requests.get()을 통해 서버로 HTTP GET 요청을 보내면, 서버는 HTTP 응답을 반환한다. 이 응답 객체에는 Header 정보와 Body 정보가 함께 담겨 있으며, res.headers를 통해 응답 헤더를, res.text를 통해 HTML 문서와 같은 응답 본문을 확인할 수 있다.
반면 POST 요청은 서버로 단순히 정보를 요청하는 것이 아닌, 데이터와 함께 전달하여 서버가 특정 작업을 수행하도록 요청할 때 사용한다. 예시로, 회원가입, 로그인, 데이터 저장과 같은 작업이 해당된다.
import requests
payload = {"name": "Hello", "age": 13}
res = requests.post(
"https://webhook.site/363c6360-9174-4a11-91b7-71757d5dfd1d"
data=payload
)
# 상태 코드 확인
res.status_code
POST 요청에서는 Body 영역에 데이터(payload)가 포함되며, 서버는 이 데이터를 기반으로 요청을 처리한다. 이때 응답 상태를 나타내는 HTTP 상태 코드(Status code)를 통해 요청이 정상적으로 처리되었는지 여부를 확인할 수 있다.
윤리적인 웹 스크래핑 크롤링 진행하기
웹에서 데이터를 수집하는 방식에는 크게 웹 스크래핑(Web Scraping)과 웹 크롤링(Web Crawling)이 있다.
웹 스크래핑은 특정한 목적을 가지고 특정 웹 페이지로부터 원하는 정보를 추출하는 행위를 의미한다. 예를 들어 날씨 정보, 주가 데이터, 뉴스 제목과 같은 데이터를 수집하는 작업이 이에 해당한다.
반면 웹 크롤링은 크롤러(Crawler)를 통해 여러 웹 페이지를 URL을 따라가며 반복적으로 방문하고, 그 정보를 수집하여 색인(Indexing)하는 과정이다. 대표적인 예시로 검색 엔진이 웹 페이지를 수집하고 정리하는 방식이 있다.
올바르게 HTTP 요청하기
웹 스크래핑이나 크롤링을 진행할 때 가장 중요한 것은 기술적으로 가능하다고 해서 항상 허용되는 것은 아니라는 점이다. 데이터를 수집하긴 전에 다음과 같은 질문을 스스로 던져볼 필요가 있다:
이 스크래핑/크롤링은 어떤 목적을 가지고 있는가?
과도한 요청으로 서버에 부하를 주지 않는가?
해당 사이트의 정책을 위반하고 있지는 않은가?
이를 위해 등장한 개념이 바로 로봇 배제 프로토콜(Robot Exlusion Protocol; REP)이다. 웹 브라우징은 사람이 직접 수행할 수도 있지만, 로봇(프로그램)에 의해 자동으로 수행될 수도 있다. 모든 로봇이 모든 웹 페이지에 접근하는 것이 정당하지 않기 때문에, 웹 사이트는 robots.txt 파일을 통해 로봇의 접근 범위를 정의할 수 있다.
# 모든 user-agent에 대해서 접근을 거부
User-agent: *
Disallow: /
# 모든 user-agent에 대해서 접근을 허가
User-agent: *
Allow: /
# 특정user-agent에 대해서 접근을 불허
User-agent: MussBot
Disallow: /
이러한 규칙을 통해 사이트 운영자는 어떤 로봇이, 어떤 경로에 접근할 수 있는지를 명시할 수 있다. 윤리적인 웹 스크래핑과 크롤링이란, 단순히 데이터를 가져오는 것을 넘어 서버 자원을 존중하고, 서비스 제공자의 의도를 존중하는 태도에서 출발한다.
웹 브라우저가 HTML을 다루는 방법
웹 브라우저는 단순히 HTML 문서를 받아 화면에 그대로 출력하지 않는다. 브라우저는 서버로부터 HTML 문서를 응답받은 뒤, 내부의 렌더링 엔진(Rendering Engine)을 통해 문서를 해석하고 구조화하는 과정을 거친다. 이 과정의 핵심 결과물이 바로 DOM(Document Object Model)이다.
브라우저 렌더링 엔진은 HTML 문서를 로드한 후, 문서를 위에서 차례대로 파싱(Parsing)한다. 이때 HTML 문서는 단순한 문자열이 아닌, 트리 구조(Tree Structure)로 변환되며 각 요소는 하나의 객체로 관리된다. 이렇게 생성된 문서 구조를 DOM이라고 부른다.
DOM은 HTML 문서를 객체(Object)의 집합으로 표현한 모델이다. 실제 DOM 구조는 매우 복잡하지만, 각 태그를 하나의 노드(Node)로 생각하면 문서를 훨씬 직관적으로 이해할 수 있다. 이 덕분에 브라우저는 문서를 단순히 “보여주는 대상”이 아니라, 조작 가능한 객체 구조로 다룰 수 있게 된다.
브라우저는 먼저 렌더링 과정을 통해 DOM을 생성한 뒤, DOM Manipulation, 즉 DOM 조작을 수행할 수 있다. 예를 들어 자바스크립트를 통해 새로운 요소를 추가하거나, 기존 요소를 수정·삭제하는 작업이 가능하다.
var imageElement = document.createElement("img");
document.body.appendChild(imgElement);
위 코드는 DOM Tree를 순회하여 새로운 img 요소를 생성하고 이를 문서의 body에 추가하는 예시이다. 이처럼 DOM Tree를 기반으로 특정 요소를 추가할 수도 있고, 탐색할 수도 있다.
document.getElementsByTagName("h2");
브라우저가 HTML을 그대로 다루지 않고 DOM으로 변환하는 이유는 명확한데, DOM을 사용하면 원하는 요소를 동적으로 변경할 수 있고, 특정 요소를 쉽게 탐색할 수 있기 때문이다.
스크래핑 관점에서 DOM이 주는 인사이트
웹 스크래핑 관점에서 보면, 중요한 사실 하나를 알 수 있다. 브라우저는 HTML을 기반으로 DOM을 생성하고, 이후 모든 조작과 탐색은 DOM을 기준으로 이루어진다는 점이다.
즉, 우리가 웹 페이지에서 보고 있는 데이터는 HTML 문자열 그 자체가 아니라, HTML이 파싱되어 만들어진 DOM 구조의 결과물이다.
이 관점에서 보면, 파이썬으로 웹 페이지를 분석하기 위해서는 HTML을 DOM과 유사한 구조로 변환해 줄 도구, 즉 HTML Parser가 필요하다는 결론에 도달한다.
이 역할을 수행하는 대표적인 라이브러리가 바로 BeautifulSoup이다.
HTML을 분석해주는 BeautifulSoup
기본적으로 requests 라이브러리를 사용하면 서버로부터 HTML 문서를 문자열 형태로 받아올 수 있다.
import requests
res = requests.get("https://example.com")
res.text
하지만, 이 상태의 HTML은 단순한 문자열이기 때문에, 특정 태그를 찾거나 구조적으로 분석하기는 불편하다. 이때 BeautifulSoup을 사용하면 HTML 문서를 파싱하여 DOM과 유사한 구조로 변환할 수 있다.
from bs4 import BeautifulSoup
soup = BeautifulSoup(res.text, "html.parser")
soup.prettify()
BeautifulSoup을 통해 생성된 soup 객체는 HTML 문서를 트리 구조로 다룰 수 있게 해준다. 이를 통해 문서의 특정 영역을 쉽게 가져올 수 있다.
# head 가져오기
soup.head
# body 가져오기
soup.body
또한 태그 기반 탐색도 매우 직관적으로 수행할 수 있다.
# <h1> 태그 하나 찾기
soup.find("h1")
# <p> 태그 모두 찾기
soup.find_all("p")
찾아낸 태그 객체는 이름, 속성, 내용 등을 각각 분리해서 다룰 수 있다.
h1 = soup.find("h1")
# 태그 이름
h1.name
# 태그 내부 텍스트
h1.text
이러한 방식은 브라우저가 DOM을 다루는 방식과 매우 유사하며, 스크래핑에 있어 핵심적인 접근법이다.
원하는 요소 가져오기 - 책 제목 스크래핑
실제 예제를 통해 원하는 데이터를 추출한다. 해당 예시는 책 목록 페이지에서 책 제목 정보를 스크래핑하는 간단한 예제이다.
import requests
from bs4 import BeautifulSoup
res = requests.get(
"https://books.toscrape.com/catalogue/category/books/travel_2/index.html"
)
soup = BeautifulSoup(res.text, "html.parset")
페이지 구조를 살펴보면, 각 책의 제목은 <h3> 태그 내부에 포함되어 있다. 이를 기반으로 모든 <h3> 태그를 가져온 뒤 반복문을 통해 책 제목을 추출할 수 있다.
h3_result = soup.find_all("h3")
for book in h3_result:
print(book.a["title"])
이처럼 스크래핑의 핵심은 HTML 구조를 먼저 관찰하고, 그 구조에 맞춰 DOM Tree를 탐색하듯 요소를 찾아내는 것이다.
HTML의 Locator로 원하는 요소 찾기
웹 페이지에서 원하는 데이터를 정확히 가져오기 위해서는, 단순히 태그 이름만으로는 한계가 있다. 실제 HTML 문서에는 같은 태그가 수십, 수백 개 존재하기 때문에, 어떤 요소를 대상으로 삼을 것인지 명확하게 지정하는 기준이 필요하다. 이때 사용하는 것이 바로 Locator다.
HTML에서 가장 대표적인 Locator는 id와 class 속성이다.
id는 문서 내에서 고유한 값을 가지며, class는 여러 요소가 공통으로 가질 수 있는 분류 값이다. 이 두 속성을 활용하면 특정 영역의 데이터를 훨씬 정밀하게 추출할 수 있다.
특정 요소(id와 class)를 지정하여 정보 가져오기
먼저 기본적인 흐름은 동일하다. requests로 HTML 문서를 가져오고, BeautifulSoup으로 파싱한 뒤 원하는 요소를 탐색한다.
import requests
from bs4 import BeautifulSoup
res = requests.get("https://example.python-scraping.com")
soup = BeautifulSoup(res.text, "html.parser")
가장 단순한 방식으로는 태그 이름만을 이용하여 요소를 찾을 수 있다.
# id 없이 div 태그 하나 찾기
soup.find("div")
하지만 특정 영역을 정확히 지정하고 싶다면 id나 class를 함께 사용해야 한다.
# id가 "results"인 div 태그 찾기
soup.find("div", id="results")
# class가 "page-header"인 div 태그 찾기
find_result = soup.find("div", "page-header")
이렇게 찾아낸 결과는 BeautifulSoup의 태그 객체이며, 내부의 텍스트를 그대로 출력하면 줄바꿈(\n)이나 공백이 함께 포함될 수 있다. 이 경우 strip()을 사용하여 깔끔하게 정리가 가능하다.
# <h1> 태그 내부 텍스트만 깔끔하게 추출
find_result.h1.text.strip()
해당 과정은 브라우저에서 개발자 도구를 특정 영역을 클릭하고, 해당 요소의 id나 class를 확인한 뒤 이를 코드로 옮기는 방식과 동일하다.
원하는 요소 가져오기 - Hashcode 질문 스크래핑
일부 웹 사이트는 User-Agent가 없는 요청을 비정상적인 접근으로 판단하여 응답을 제한한다. 따라서 브라우저에서 접속한 것처럼 보이도록 User-Agent를 함께 설정하는 것이 중요하다.
user_agent = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) \
AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/83.0.4103.97 Safari/537.36"
}
import requests
from bs4 import BeautifulSoup
res = requests.get("https://hashcode.co.kr/", headers=user_agent)
soup = BeautifulSoup(res.text, "html.parser")
페이지 구조를 살펴보면, 질문 목록은 특정 li 태그와 class 조합으로 구성되어 있다. 이를 기반으로 질문 제목을 추출할 수 있다.
questions = soup.find_all("li", "question-list-item")
for question in questions:
print(question.find("div", "question").find("div", "top").h4.text)
많은 웹 페이지는 데이터를 여러 페이지로 나누어 제공하는 페이지네이션(Pagination) 방식을 사용한다. 이 경우 URL의 패턴을 분석해 반복적으로 요청을 보내는 방식으로 데이터를 수집할 수 있다.
import time
for i in range(1, 6):
res = requests.get(
"https://hashcode.co.kr/?page={}".format(i),
headers=user_agent
)
soup = BeautifulSoup(res.text, "html.parser")
questions = soup.find_all("li", "question-list-item")
for question in questions:
print(question.find("div", "question").find("div", "top").h4.text)
# 서버 부하 방지를 위한 딜레이
time.sleep(0.5)
정적 웹 사이트와 동적 웹 사이트
지금까지의 웹 스크래핑은 비교적 단순한 HTML 구조를 전제로 했다. 하지만 실제 서비스 환경에서는 HTML이 항상 고정되어 있지 않다. 웹 페이지는 어떻게 생성되느냐에 따라 크게 두 가지 유형으로 나뉜다.
정적(static) 웹사이트는 서버가 응답할 때 이미 완성된 HTML 문서를 전달한다. 이 경우 브라우저는 HTML을 그대로 렌더링하기만 하면 되며, requests와 BeautifulSoup만으로도 원하는 정보를 충분히 추출할 수 있다.
반면 동적(dynamic) 웹사이트는 서버 응답 이후에도 HTML 내용이 변한다. 초기 응답에는 뼈대만 전달되고, 실제 데이터는 이후에 추가로 채워지는 구조다. 이 과정에서 렌더링이 완료될 때까지의 지연 시간이 발생하며, 단순한 HTTP 요청만으로는 완전한 데이터를 얻기 어려운 상황이 생긴다.
동적 웹 사이트의 동작 방식
웹 브라우저 내부에서는 JavaScript(JS)라는 프로그래밍 언어가 실행된다. 동적 웹사이트는 이 JavaScript를 이용해 서버와 추가 통신을 수행하고, 필요한 데이터를 화면에 채워 넣는다.
이때 중요한 개념이 동기 처리와 비동기 처리다.
동기 처리에서는 요청을 보낸 뒤 응답이 올 때까지 기다리므로, HTML 로딩에 문제가 없다.
비동기 처리에서는 요청과 응답이 분리되어 실행되기 때문에, HTML이 완전히 렌더링되기 전에 데이터를 추출하면 불완전한 결과를 얻게 될 수 있다.
즉, 동적 웹사이트에서는 “HTML을 받았다”는 사실이 곧 “데이터가 준비되었다”는 의미가 아니다.
스크래퍼의 한계점
지금까지 사용한 requests 기반 스크래퍼는 다음과 같은 한계를 가진다.
첫째, 비동기 처리 환경에서는 서버 응답 직후 데이터를 가져오면 아직 로딩되지 않은 상태의 HTML을 얻게 된다. 이를 해결하려면 임의의 시간을 지연한 뒤 데이터를 가져오는 방식이 필요하지만, 이는 안정적인 해결책이 아니다.
둘째, 키보드 입력이나 마우스 클릭과 같은 UI 상호작용은 requests로 처리할 수 없다. 실제 사용자처럼 버튼을 누르거나 입력창에 값을 넣기 위해서는 웹 브라우저 자체를 자동으로 조작해야 한다.
이러한 문제를 해결하기 위해 등장한 도구가 바로 Selenium이다.
브라우저 자동화 도구, Selenium
Selenium은 웹 브라우저를 실제 사용자처럼 조작할 수 있게 해주는 라이브러리다. 단순한 HTTP 요청이 아니라, 브라우저를 띄우고, 렌더링이 끝난 화면을 기준으로 요소를 다룬다는 점이 핵심이다.
from selenium import webdriver
driver = webdriver.Chrome()
driver.implicitly_wait(10)
driver.get("https://example.com")
# UI와 상호작용 가능
elem = driver.find_element_by_tag_name("hello-input")
elem.send_keys("Hello!")
이 방식은 동적 웹사이트에서도 렌더링이 완료된 이후의 DOM을 기준으로 데이터를 추출할 수 있게 해준다.
Selenium 시작하기
Selenium은 브라우저 드라이버가 필요하며, webdriver-manager를 사용하면 이를 자동으로 관리할 수 있다.
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
with webdriver.Chrome(service=Service(ChromeDriverManager().install())) as driver:
driver.get("https://example.com")
print(driver.page_source)
page_source를 통해 브라우저가 렌더링을 마친 이후의 HTML을 확인할 수 있다는 점이 중요하다.
Driver에서 특정 요소 추출하기
Selenium에서는 다양한 기준을 통해 요소를 탐색할 수 있다. 가장 기본적인 방식은 태그 단위 탐색이다.
from selenium.webdriver.common.by import By
driver.find_element(By.TAG_NAME, "p")
driver.find_elements(By.TAG_NAME, "p")
BeautifulSoup이 정적인 HTML 파서라면, Selenium은 실시간 DOM을 대상으로 한 탐색 도구라고 볼 수 있다.
Wait and Call
동적 웹사이트에서 가장 중요한 개념 중 하나는 기다림(Wait)이다.
요소가 생성되기 전에 접근하면 오류가 발생하기 때문에, Selenium은 두 가지 대기 방식을 제공한다.
Implicit Wait / Explicit Wait
Implicit Wait는 페이지 내 요소가 모두 로딩될 때까지 지정한 시간만큼 기다리는 방식이다.
from selenium.webdriver.support.ui import WebDriverWait
with webdriver.Chrome(service=Service(ChromeDriverManager().install())) as driver:
driver.get("https://indistreet.com/live?sortOption=startDate%3AASC")
driver.implicitly_wait(10)
print(
driver.find_element(
By.XPATH,
'//*[@id="__next"]/div/main/div[2]/div/div[4]/div[1]/div[1]/div/a/div[2]/p[1]'
).text
)
반면, Explicit Wait는 특정 요소가 등장할 때까지 기다리는 방식으로, 훨씬 정밀한 제어가 가능하다.
from selenium.webdriver.support import expected_conditions as EC
with webdriver.Chrome(service=Service(ChromeDriverManager().install())) as driver:
driver.get("https://indistreet.com/live?sortOption=startDate%3AASC")
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located(
(
By.XPATH,
'//*[@id="__next"]/div/main/div[2]/div/div[4]/div[1]/div[1]/div/a/div[2]/p[1]'
)
)
)
print(element.text)
마우스 이벤트 처리하기
동적 웹사이트에서는 버튼 클릭과 같은 마우스 이벤트가 필수적인 경우가 많다. Selenium의 ActionChains를 이용하면 이러한 이벤트를 처리할 수 있다.
from selenium import webdriver
from selenium.webdriver import ActionChains
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from webdriver_manager.chrome import ChromeDriverManager
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()))
driver.get("https://hashcode.co.kr/")
driver.implicitly_wait(0.5)
button = driver.find_element(By.CLASS_NAME, "nav-link.nav-signin")
ActionChains(driver).click(button).perform()
키보드 이벤트 처리하기
키보드 입력 역시 사용자 행동을 그대로 재현할 수 있다. 이를 통해 로그인과 같은 절차도 자동화가 가능하다.
from selenium import webdriver
from selenium.webdriver import ActionChains, Keys
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from webdriver_manager.chrome import ChromeDriverManager
import time
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()))
driver.get("https://hashcode.co.kr")
time.sleep(1)
button = driver.find_element(By.CLASS_NAME, "nav-link.nav-signin")
ActionChains(driver).click(button).perform()
time.sleep(1)
id_input = driver.find_element(By.ID, "user-email")
ActionChains(driver).send_keys_to_element(id_input, "여러분의 아이디").perform()
time.sleep(1)
pw_input = driver.find_element(By.ID, "user-password")
ActionChains(driver).send_keys_to_element(pw_input, "여러분의 비밀번호").perform()
time.sleep(1)
login_button = driver.find_element(By.ID, "btn-sig-in")
ActionChains(driver).click(login_button).perform()
time.sleep(1)
시각화 라이브러리, Seaborn
문자열이나 숫자의 나열만으로는 패턴이나 경향을 파악하기 어렵기 때문에,
시각화는 데이터 분석의 선택이 아니라 필수에 가깝다.
지금까지 우리는 다양한 기법으로 데이터를 수집할 수 있었다. 하지만 스크래핑 결과가 여기저기 흩어져 있다면, 그 가치를 제대로 전달하기 어렵다.
이때 시각화는 데이터를 “보는 사람에게 떠먹여 주는 도구” 역할을 한다.
seaborn은 matplotlib을 기반으로 만들어진 고수준(high-level) 시각화 라이브러리다. 복잡한 설정 없이도 다양한 그래프를 손쉽게 그릴 수 있다는 점이 큰 장점이다.
import seaborn as sns
tips = sns.load_dataset("tips")
sns.relplot(
data=tips,
x="total_bill", y="tip", col="time",
hue="smoker", style="smoker", size="size",
)
간단한 리스트 데이터만으로도 그래프를 그릴 수 있다.
import seaborn as sns
# Scatterplot을 직접 그려봅시다
# 값 x=[1, 3, 2, 4]
# 값 y=[0.7,0.2,0.1,0.05]
sns.lineplot(x=[1, 3, 2, 4], y=[4, 3, 2, 1])
Line Chart 1
# Barplot을 직접 그려봅시다
# 범주 x=[1,2,3,4]
# 값 y=[0.7,0.2,0.1,0.05]
sns.barplot(x=[1,2,3,4],y=[0.7,0.2,0.1,0.05])
Bar Chart 1
matplotlib과 함께 사용하면 제목, 축 이름, 범위 등을 더욱 세밀하게 제어할 수 있다.
# matplotlib.pyplot을 불러와봅시다.
import matplotlib.pyplot as plt
# 제목을 추가해봅시다.
sns.barplot(x=[1,2,3,4], y=[0.7, 0.2, 0.1, 0.05])
plt.title("Bar Plot")
plt.show()
# xlabel과 ylabel을 추가해봅시다.
sns.barplot(x=[1,2,3,4], y=[0.7, 0.2, 0.1, 0.05])
plt.xlabel("X label")
plt.ylabel("Y label")
plt.show()
Bar Chart 2
Bar Chart 3
# lineplot에서 ylim을 2~3으로 제한해봅시다.
sns.lineplot(x=[1,3,2,4], y=[4,3,2,1])
plt.ylim(0, 10)
plt.show()
# 크기를 (20, 10)으로 지정해봅시다.
sns.lineplot(x=[1,3,2,4], y=[4,3,2,1])
plt.figure(figsize=(20, 10))
plt.show()
Line Chart 2
Line Chart 3
스크래핑 결과 시각화 ① – 날씨 데이터
Selenium으로 데이터 수집
동적 웹사이트의 데이터를 수집하기 위해 Selenium을 사용해 기상청 날씨 데이터를 가져온다.
# 스크래핑에 필요한 라이브러리를 불러와봅시다.
from selenium import webdriver
from selenium.webdriver import ActionChains
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver import Keys, ActionChains
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
# driver를 이용해 기상청 날씨 데이터를 가져와봅시다.
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()))
driver.get("https://www.weather.go.kr/w/weather/forecast/short-term.do")
driver.implicitly_wait(1)
temps = driver.find_element(By.ID, "my-tchart").text
temps = [int(i) for i in temps.replace("℃", "").split("\n")]
수집한 데이터를 바탕으로 꺾은선 그래프를 그려본다.
# 받아온 데이터를 통해 꺾은선 그래프를 그려봅시다.
# x = Elapsed Time(0~len(temperatures)
# y = temperatures
import seaborn as sns
sns.lineplot(
x = [i for i in range(len(temps))],
y = temps
)
# 받아온 데이터를 통해 꺾은선 그래프를 그려봅시다.
import matplotlib.pyplot as plt
plt.ylim(min(temps) - 5 , max(temps) + 5)
plt.title("Expected Temperature from now on")
sns.lineplot(
x = [i for i in range(len(temps))],
y = temps
)
plt.show()
스크래핑 결과 시각화 ② – 해시코드 질문 태그
질문 태그 빈도 분석
해시코드 질문 페이지에서 태그 빈도를 수집하고 시각화한다.
# 다음 User-Agent를 추가해봅시다.
user_agent = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36"}
# 필요한 라이브러리를 불러온 후, 요청을 진행해봅시다.
# 응답을 바탕으로 BeautifulSoup 객체를 생성해봅시다.
# 질문의 빈도를 체크하는 dict를 만든 후, 빈도를 체크해봅시다.
import time
frequency = {}
import requests
from bs4 import BeautifulSoup
for i in range(1, 11):
res = requests.get("https://hashcode.co.kr/?page={}".format(i), user_agent)
soup = BeautifulSoup(res.text, "html.parser")
# 1. url 태그 모두 찾기
# 2. 1번 안에 있는 li 태그의 text 추출
ul_tags = soup.find_all("ul", "question-tags")
for i in ul_tags:
li_tags = ul.find_all("li")
for li in li_tags:
tag = li.text.strip()
if tag not in frequency:
frequency[tag] = 1
else:
frequency[tag] += 1
time.sleep(0.5)
print(frequency)
가장 많이 등장한 태그를 확인한다.
# Counter를 사용해 가장 빈도가 높은 value들을 추출합니다.
from collections import Counter
counter = Counter(frequency)
counter.most_common(10)
이를 Barplot으로 시각화한다.
# Seaborn을 이용해 이를 Barplot으로 그립니다.
import seaborn as sns
x = [elem[0] for elem in counter.most_common(10)]
y = [elem[1] for elem in counter.most_common(10)]
sns.barplot(x=x, y=y)
# figure, xlabel, ylabel, title을 적절하게 설정해서 시각화를 완성해봅시다.
import matplotlib.pyplot as plt
plt.figure(figsize=(20, 10))
plt.title("Frequency of question in Hashcode")
plt.xlabel("Tag")
plt.ylabel("Frequency")
sns.barplot(x=x, y=y)
plt.show()
뭉게뭉게 단어구름, WordCloud
wordcloud는 텍스트 데이터의 빈도를 기반으로 단어 구름을 만들어주는 라이브러리이다. 한국어 문장을 다루기 위해서는 형태소 분석기가 필요하며, konlpy의 Hannanum을 사용한다.
# 시각화에 쓰이는 라이브러리
import matplotlib.pyplot as plt
from wordcloud import WordCloud
# 횟수를 기반으로 딕셔너리 생성
from collections import Counter
# 문장에서 명사를 추출하는 형태소 분석 라이브러리
from konlpy.tag import Hannanum
# 워드클라우드를 만드는 데 사용할 애국가 가사입니다.
national_anthem = """
동해물과 백두산이 마르고 닳도록
하느님이 보우하사 우리나라 만세
무궁화 삼천리 화려 강산
대한 사람 대한으로 길이 보전하세
남산 위에 저 소나무 철갑을 두른 듯
바람 서리 불변함은 우리 기상일세
무궁화 삼천리 화려 강산
대한 사람 대한으로 길이 보전하세
가을 하늘 공활한데 높고 구름 없이
밝은 달은 우리 가슴 일편단심일세
무궁화 삼천리 화려 강산
대한 사람 대한으로 길이 보전하세
이 기상과 이 맘으로 충성을 다하여
괴로우나 즐거우나 나라 사랑하세
무궁화 삼천리 화려 강산
대한 사람 대한으로 길이 보전하세
"""
# Hannanum 객체를 생성한 후, .nouns()를 통해 명사를 추출합니다.
hannanum = Hannanum()
nouns = hannanum.nouns(national_anthem)
words = [noun for noun in nouns if len(noun) > 1]
words[:10]
# counter를 이용해 각 단어의 개수를 세줍니다.
counter = Counter(words)
# WordCloud를 이용해 텍스트 구름을 만들어봅시다.
wordcloud = WordCloud(
font_path="C:\\Users\\yyt11\\EliceDigitalBaeum_Bold.ttf",
background_color="white",
width=1000,
height=1000
)
img = wordcloud.generate_from_frequencies(counter)
plt.imshow(img)
WordCloud
WordCloud로 해시코드 질문 키워드 요약
해시코드 질문 텍스트를 기반으로 주요 키워드를 요약할 수도 있다.
# 텍스트 구름을 그리기 위해 필요한 라이브러리를 불러와봅시다.
# 시각화에 쓰이는 라이브러리
import matplotlib.pyplot as plt
from wordcloud import WordCloud
# 횟수를 기반으로 딕셔너리 생성
from collections import Counter
# 문장에서 명사를 추출하는 형태소 분석 라이브러리
from konlpy.tag import Hannanum
# Hannanum 객체를 생성한 후, .nouns()를 통해 명사를 추출합니다.
words = []
Hannanum = Hannanum()
for question in questions:
nouns = hannanum.nouns(question)
words += nouns
print(len(words))
# counter를 이용해 각 단어의 개수를 세줍니다.
counter = Counter(words)
counter
# WordCloud를 이용해 텍스트 구름을 만들어봅시다.
wordcloud = WordCloud(
font_path="C:\\Users\\yyt11\\EliceDigitalBaeum_Bold.ttf",
background_color="white",
width=1000,
height=1000
)
img = wordcloud.generate_from_frequencies(counter)
plt.imshow(img)
plt.axis("off")
plt.show()
-
[6기] 데브코스 DE WIL 01 | 자료구조/알고리즘
이번 주 학습 목표
자료구조와 알고리즘의 필요성과 함께 각각의 특성을 이해한다.
코딩 테스트 대비를 위한 기본 자료구조/알고리즘 개념을 정리한다.
단순 암기가 아닌 문제 상황에 맞는 자료구조 선택 기준을 정립한다.
왜 자료구조를 알아야 하는가?
먼저, Python 데이터 타입에는 문자열(str), 리스트(list), 사전(dict), 순서쌍(tuple), 집합(set) 등이 존재한다. 해당 데이터 타입들은 다음과 같이 사용 가능하다.
문자열(str): “This is a String”
리스트(list): [5, 9, 2, 7]
사전(dict): {‘a’: 6, ‘bc’: 4}
순서쌍(tuple): (5, 2, 7)
집합(set): set([1, 2, 3])
그렇다면 Python에서 기본적으로 제공하는 데이터 타입들을 가지고도 대부분의 문제들을 해결할 수 있을 것 같은데, 왜 자료구조를 알아야 할까? 이 질문에 답을 알기 위해 자료구조와 알고리즘이 무엇인지 우선 짚고 답해본다.
자료구조와 알고리즘이란?
자료구조의 사전적 정의는 다음과 같다. 어떤 데이터에 대하여 행할 수 있는 연산들이 존재하는 무엇인가의 구조다. 정의에서 언급된 연산의 종류에는 최댓값 찾기, 새로운 원소 삽입, 원소 삭제 등이 있다.
그리고 알고리즘이란 어떠한 문제들을 해결하기 위한 절차, 방법 그리고 명령어들의 집합을 일컫는 말이다.
프로그래밍을 진행하다 보면 다방면에서 문제들을 직면하곤 한다. 이에 해결하고자 하는 문제에 따라(응용 종류와 범위에 따라) 최적의 해법은 천차만별이다. 이에 정답에 근접한 선택을 어떻게 해야 할지 알기 위해서 자료구조를 이해해야 한다.
자료구조 1 | 선형 배열(Linear Array)
선형 배열은 원소들을 순서대로 늘어 놓은 것으로, 여기서 말하는 순서는 인덱스(index)로 표현하며 0부터 시작한다는 특징이 있다.
Python에서는 배열처럼 데이터를 줄세우는 데이터 타입인 리스트(list)가 존재한다.
리스트에 활용 가능한 연산들
리스트 길이와 관계없이 빠른 실행 결과를 보이는 연산들
(O(1): 리스트 길이와 관계없이 무관한 상수 시간)
append(): 원소를 가장 뒤쪽에 덧붙인다.
pop(): 원소를 꺼낸다.
리스트 길이에 비례한 실행 시간이 걸리는 연산들
(O(n): 리스트 길이에 비례한 실행 시간)
insert(): 특정 위치에 원소 삽입한다.
del(): 특정 위치에 원소 삭제한다.
index(): 특정 원소의 위치를 탐색하여 인덱스 번호를 반환한다.
배열 기반의 정렬과 탐색
정렬(sort)
복수의 원소로 주어진 데이터를 정해진 기준에 따라 새로 줄세우는 작업을 말한다.
파이썬 내장 함수(built-in function): sorted() -> 정렬된 새로운 리스트를 얻어낸다.
리스트에 쓸 수 있는 메서드(method): .sort() -> 해당 리스트를 정렬한다.
키를 이용한 정렬 (lambda 기반)
L = [{'name': 'John', 'score': 83},
{'name': 'Paul', 'score': 92}]
L.sort(key = lambda x: x['score'], reverse = True)
선형 탐색(Linear Search)
타겟 번호를 주어진 리스트 내의 인덱스 번호 순으로 인자와 비교하여 찾는 방법을 말한다.
-> 리스트 길이에 비례하는 시간(O(n))이 소요된다.
def linear_search(L, x):
i = 0
while i < len (L) and L(i) != k:
i += 1
if i < len(L):
return i
else:
return -1
이진 탐색(Binary Search)
탐색하려는 리스트가 이미 크기 순으로 정렬된 경우에만 사용 가능한 탐색 방법을 말한다.
-> O(log n) 시간이 소요된다.
def binary_search(L, x):
lower = 0
upper = len(L) - 1
idx = -1
while lower <= upper:
middle = (lower + upper) // 2
if L[middle] == target:
...
elif L[middle] < target:
lower = ...
else:
upper = ...
재귀 함수(Recursive Function)
하나의 함수에서 자신을 다시 호출하는 작업을 수행하는 함수로, 많은 종류의 문제가 재귀적(Recursively)으로 해결이 가능하다.
# 재귀적(Recursive) 구조의 자연수 합 구하기
def recursively_sum(n):
if n <= 1:
return n
else:
return n + recursively_sum(n - 1)
# 반복적(iterative) 구조의 자연수 합 구하기
def iterative_sum(n):
s = 0
while n >= 0:
s += n
n -= 1
return s
알고리즘의 복잡도
프로그래밍 코드가 얼마나 이해하기 어렵고 복잡한 정도가 아닌, 문제를 해결하는데 얼만큼의 자원을 요구하는가를 뜻한다. 여기서 말하는 컴퓨팅 자원은 크게 시간과 공간으로 나뉜다.
시간 복잡도(Time Complexity): 문제(일반적으로 주어지는 데이터의 집합)의 크기에 따라 걸리는 시간으로, 데이터의 크기가 커지면 해결하기까지 소요되는 시간과의 상관관계를 나타낸다.
공간 복잡도(Space Complexity): 문제 해결을 위해 필요한 물리적인 메모리 공간의 크기로, 데이터를 저장하는 자체 메모리 뿐만 아니라 문제 해결을 위한 부가적인 메모리 요구량가지 포함한다.
시간 복잡도
평균 시간 복잡도(Average Time Complexity): 임의의 입력 패턴 해결에 소요되는 시간의 평균을 말한다.
최악 시간 복잡도(Worst Time Complexity): 가장 긴 시간을 소요하게 만드는 입력 패턴 해결에 소요되는 시간을 말한다.
빅오 표기법(Big-O Notation)
점근 표기법(Asymptotic Notation) 중 하나로, 어떤 함수의 증가 양상을 다른 함수와의 비교로 표현한다.
입력의 크기: n
O(1): 입력된 크기와 상관없이 한 번의 실행 시간이 소요된다.
O(log n): 입력된 크기의 비례하는 시간이 소요된다.
O(n): 입력된 크기에 비례하는 시간이 소요된다.
그 외: O(n^2), O(2^n)
자료구조 2 | 연결 리스트(Linked List)
연결 리스트는 추상적 자료구조(Abstract Data Structures) 중 하나로, 자료 구조의 내부 구현은 숨겨두고 밖에서 보이는 것들인 데이터와 연산을 제공하는 구조이다.
여기서 말하는 데이터는 정수, 문자열, 레코드 등의 타입을 의미하며, 연산은 삽입, 삭제, 순회, 정렬, 탐색 등이 있다.
기본적인 연결 리스트의 구성은 노드(Node)라고 칭하며, Data와 Link(Next)로 이루어져 있다. Node 내의 데이터는 정수형뿐만 아니라 다른 구조로 이루어질 수 있는데 문자열, 레코드, 또 다른 연결 리스트 등이 담길 수 있다.
# 첫 번째 노드 정의
class Node:
def __init__(self, item):
self.data = item
self.next = None
# 연결 리스트 정의: 노드 내 시작 부분(Head)과 끝 부분(Tail)
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = None
self.tail = None
배열 vs 연결 리스트
배열
연결 리스트
저장 공간
연속한 위치
임의의 위치
특정 원소 지칭
매우 간편함
선형 탐색과 유사함
연결 리스트 연산 네 가지
연산 1. k번째 특정 원소 참조
첫 번째 연결 리스트를 이용한 연산으로는 k(pos)번째에 위치한 Node 내 Data 자체를 리턴하는 연산이다.
def getAt(self, pos):
if pos <= 0 and pos > self.nodeCount:
return None
i = 1
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
연산 2. 원소 삽입
pos가 가리키는 위치(1 <= pos <= nodeCount+1)에 newNode를 삽입하는 연산으로, 성공/실패 여부에 따라 True/False를 반환한다.
def insertAt(self, pos, newNode):
if pos < 1 or self.nodeCount + 1:
return False
if pos == 1:
newNode.next = self.head
self.head = newNode
else:
prev = self.getAt(pos-1)
newNode.next = prev.next
prev.next = newNode
if pos == self.nodeCount + 1:
self.tail = newNode
self.nodeCount += 1
return True
연결 리스트 원소 삽입의 시간 복잡도
맨 앞에 삽입하는 경우: O(1)
중간에 삽입하는 경우: O(n)
끝에 삽입하는 경우: O(1)
연산 3. 원소 삭제
pos가 가리키는 위치(1 <= ps <= nodeCount) node를 삭제하는 연산으로, node의 데이터를 반환한다.
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
if pos == 1:
curr = self.head
self.head = curr.next
if self.nodeCout == 1:
self.tail = None
else:
prev = self.getAt(pos-1)
curr = prev.next
prev.next = curr.next
if pos == self.nodeCount:
self.tail = prev
self.nodeCount -= 1
return curr.data
연결 리스트 원소 삭제의 시간 복잡도
맨 앞에 삽입하는 경우: O(1)
중간에 삽입하는 경우: O(n)
끝에 삽입하는 경우: O(n)
연산 4. 두 리스트의 연결
두 연결 리스트 L1과 L2를 이어 붙이기 위해서 연결 리스트 self 뒤에 또 다른 연결 리스트를 이어 붙이는 연산이다.
def concat(self, L):
self.tail.next = L.head
# L.tail이 None인 경우를 방지
if L.tail:
self.L.tail
self.nodeCount += L.nodeCount
자료구조 3 | 확장된 연결 리스트(Extended Linked List)
연결 리스트는 삽입과 삭제가 유연하다는 가장 큰 장점을 가지고 있다. 하지만, 어느 위치에 노드를 삽입하는 괒에서 삽입 위치까지 따라 가야하는 부담이 존재한다.
이에 기존의 연결 리스트의 맨 앞에 dummy node를 추가하여 기존의 부담을 해소한다.
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = None
self.tail = None
self.head.next = self.tail
연결 리스트 연산 네 가지
연산 1. 리스트 순회
def traverse(self):
result = []
curr = self.head
while curr.next:
curr = curr.next
result.append(curr.data)
return result
연산 2. 원소의 삽입
prev가 가리키는 node 다음에 newNode를 삽입하고 성공/실패 여부에 따라 True/False를 리턴하는 연산이다.
def insertAfter(self, prev, newNode):
newNode.next = prev.next
if prev.next is None:
self.tail = newNode
prev.next = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
if pos != 1 and pos == self.nodeCount + 1:
prev = self.tail
else:
prev = self.getAt(pos-1)
return self.insertAfter(prev, newNode)
연산 3. k번째 원소 얻어내기
def getAt(self, pos):
if pos < 1 or pos > self.nodeCount:
return None
i = 0
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
연산 4. 원소의 삭제
prev의 다음 node를 삭제하고 그 node의 데이터를 리턴하는 연산이다.
def popAfter(self, prev):
curr = prev.next
if curr is None:
return None
prev.next = curr.next
if curr.next is None:
self.tail = prev
self.nodeCount -= 1
return curr.data
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
prev = self.getAt(pos-1)
return self.popAfter(prev)
두 리스트의 연결
def concat(self, L):
self.tail.next = L.head.next
if L.tail:
self.tail = L.tail
self.nodeCount += L.nodeCount
자료구조 4 | 양방향 연결 리스트(Doubly Linked List)
기존의 연결 리스트는 한 쪽으로만 링크를 연결하였지만, 앞(다음 node)과 뒤(이전 node)로 각각 양쪽으로 연결하여 진행 및 연산이 가능한 구조를 말한다.
class DoublyLinkedList:
def __init__(self, item):
self.nodeCount = 0
self.head = None
self.tail = None
self.head.prev = None
self.head.next = self.tail
self.tail.prev = self.head
self.tail.next = None
연산 1. 리스트 순회/역순회
def traverse(self):
result = []
curr = self.head.next
while curr is not None and curr is not self.tail:
result.append(curr.data)
curr = curr.next
return result
def reverse(self):
result = []
curr = self.tail.prev
while curr is not None and curr is not self.head:
result.append(curr.data)
curr = curr.prev
return result
연산 2. 원소의 앞/뒤 삽입
prev가 가리키는 node 다음에 newNode를 삽입하고 성공/실패 여부에 따라 True/False를 리턴하는 연산이다.
def insertAfter(self, prev, newNode):
if prev is None or prev is self.tail:
return False
nxt = prev.next
if nxt is None:
return False
newNode.prev = prev
newNode.next = nxt
prev.next = newNode
nxt.prev = newNode
self.nodeCount += 1
return True
def insertBefore(self, nxt, newNode):
if nxt is None or nxt is self.head:
return False
prev = nxt.prev
if prev is None:
# 더미 head 구조에서 nxt.prev가 None이면 구조가 깨진 것
return False
newNode.prev = prev
newNode.next = nxt
prev.next = newNode
nxt.prev = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
prev = self.getAt(pos - 1)
if prev is None:
return False
return self.insertAfter(prev, newNode)
연산 3. k번째 원소 얻어내기
def getAt(self, pos):
if pos < 0 or pos > self.nodeCount + 1:
return None
# tail까지 포함해 접근 가능하도록 범위를 nodeCount+1로 둔다.
# (예: insertAfter(tail.prev, ...) / insertBefore(tail, ...) 같은 조합을 위해)
if pos > (self.nodeCount + 1) // 2:
# tail에서 뒤로 이동: tail이 (nodeCount+1) 위치
i = 0
curr = self.tail
steps = (self.nodeCount + 1) - pos
while i < steps and curr is not None:
curr = curr.prev
i += 1
else:
# head에서 앞으로 이동: head가 0 위치
i = 0
curr = self.head
while i < pos and curr is not None:
curr = curr.next
i += 1
return curr
연산 4. 원소의 앞/뒤 삭제
prev의 다음 node를 삭제하고 그 node의 데이터를 리턴하는 연산이다.
def popAfter(self, prev: Node) -> Optional[Any]:
if prev is None or prev is self.tail:
return None
curr = prev.next
if curr is None or curr is self.tail:
return None
nxt = curr.next
if nxt is None:
# 더미 tail 구조에서 curr.next가 None이면 구조가 깨진 것
return None
prev.next = nxt
nxt.prev = prev
self.nodeCount -= 1
# 안전하게 분리(선택 사항)
curr.prev = None
curr.next = None
return curr.data
def popBefore(self, nxt: Node) -> Optional[Any]:
if nxt is None or nxt is self.head:
return None
curr = nxt.prev
if curr is None or curr is self.head:
return None
prev = curr.prev
if prev is None:
# 더미 head 구조에서 curr.prev가 None이면 구조가 깨진 것
return None
prev.next = nxt
nxt.prev = prev
self.nodeCount -= 1
# 안전하게 분리(선택 사항)
curr.prev = None
curr.next = None
return curr.data
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError("pos out of range")
prev = self.getAt(pos - 1)
if prev is None:
raise IndexError("pos out of range")
data = self.popAfter(prev)
if data is None:
# 범위 검증을 했으므로 보통 발생하지 않지만, 방어적으로 처리
raise IndexError("pos out of range")
return data
자료구조 5 | 스택(Stack)
스택은 자료(element)를 보관할 수 있는 선형 구조로써, 데이터 삽입(push 연산) 시에는 안 쪽 끝에서 밀어 넣어야 하는 제약사항이 있는 자료구조이다. 데이터 삭제(pop 연산) 시에는 같은 쪽을 뽑아 꺼내야만 한다. 이러한 구조를 후입 선출(LIFO; Last-In First-Out)이라고 칭한다.
스택에서 발생하는 오류
비어 있는 스택에서 데이터 원소를 꺼내는 경우
-> 스택 언더플로우(Stack Underflow)
꽉 찬 스택에 데이터 원소를 넣으려는 경우
-> 스택 오버플로우(Stack Overflow)
스택의 연산 정의
size(): 현재 스택에 담긴 데이터 원소 갯수를 반환한다.
isEmpty(): 현재 스택이 비어 있는가에 대한 참/거짓을 반환한다.
push(x): 데이터 원소 x를 스택의 맨 위에 추가한다.
pop(): 스택의 맨 위에 저장된 데이터 원소를 제거한다. (혹은 반환한다.)
peek():스택의 맨 위에 저장된 데이터 원소를 반환한다. (제거하지 않는다.)
스택의 추상적 자료구조 구현 01 - 배열
Python 리스트와 메서드들을 이용하여 스택을 구현할 수 있다.
class ArrayStack:
# 빈 스택 초기화하는 함수
def __init__(self):
self.data = []
# 스택의 크기를 반환하는 함수
def size(self):
return len(self.data)
# 스택이 비어 있는지 판단
def isEmpty(self):
return self.size() == 0
# 데이터 원소 추가
def push(self.item):
self.data.append(item)
# 데이터 원소 삭제 후 반환
def pop(self):
return self.data.pop()
# 스택의 꼭대기 원소 반환
def peek(self):
return self.data[-1]
스택의 추상적 자료구조 구현 02 - 연결 리스트
양방향 연결 리스트를 기반으로 하여 스택을 구현할 수 있다.
class LinkedListStack:
def __init__:
self.data = DoublyLinkedList()
def size(self):
return self.data.getLength()
def isEmpty(self):
return self.size() == 0
def push(self.item):
node = Node(item)
self.data.insertAt(self.size() + 1, node)
def pop(self):
return self.data.popAt(self.size())
def peek(self):
return self.data.getAt(self.size()).data
자료구조 6 | 큐(Queue)
스택은 자료(element)를 보관할 수 있는 선형 구조로써, 데이터 삽입(push 연산) 시에는 안 쪽 끝에서 밀어 넣어야 하는 제약사항이 있는 자료구조이다. 데이터 삭제(pop 연산) 시에는 반대 쪽을 뽑아 꺼내야만 한다. 이러한 구조를 선입 선출(FIFO; First-In First-Out)이라고 칭한다.
큐의 연산 정의
size(): 현재 스택에 담긴 데이터 원소 갯수를 반환한다.
isEmpty(): 현재 스택이 비어 있는가에 대한 참/거짓을 반환한다.
enqueue(x): 데이터 원소 x를 스택의 맨 위에 추가한다.
dequeue(): 스택의 맨 위에 저장된 데이터 원소를 제거한다. (혹은 반환한다.)
peek():스택의 맨 위에 저장된 데이터 원소를 반환한다. (제거하지 않는다.)
큐의 추상적 자료구조 구현 01 - 배열
Python 리스트와 메서드들을 이용하여 스택을 구현할 수 있다.
class ArrayQueue:
# 빈 큐를 초기화
def __init__(self):
self.data = []
# 큐의 크기를 리턴
def size(self):
return len(self.data)
# 큐가 비었는 지 판단
def isEmpty(self):
return self.size() == 0
# 데이터 원소를 추가
def enqueue(self):
self.data.append(item)
# 데이터 원소를 삭제(리턴)
def dequeue(self):
return self.pop(0)
# 큐 맨 앞의 원소를 반환
def peek(self):
return self.data[0]
배열로 구현한 큐의 연산 복잡도
연산
복잡도
size()
O(1)
isEmpty()
O(1)
enqueue()
O(1)
dequeue()
O(n)
peek()
O(1)
큐의 추상적 자료구조 구현 02 - 연결 리스트
양방향 연결 리스트를 기반으로 하여 스택을 구현할 수 있다.
class LinkedListQueue:
# 빈 큐를 초기화
def __init__(self):
self.data = DoublyLinkedList()
# 큐의 크기를 리턴
def size(self):
return self.data.getLength()
# 큐가 비었는 지 판단
def isEmpty(self):
return self.size() == 0
# 데이터 원소를 추가
def enqueue(self):
node = Node(item)
self.data.insertAt(self.size() + 1, node)
# 데이터 원소를 삭제(리턴)
def dequeue(self):
return self.data.popAt(1)
# 큐 맨 앞의 원소를 반환
def peek(self):
if self.isEmpty():
raise IndexError("peek from empty queue")
node = self.data.getAt(1)
if node is None:
raise RuntimeError("LinkedList is corrupted: getAt returned None")
return node.data
연결 리스트로 구현한 큐의 연산 복잡도
연산
복잡도
size()
O(1)
isEmpty()
O(1)
enqueue()
O(1)
dequeue()
O(1)
peek()
O(1)
큐의 활용
자료를 생성하는 작업과 그 자료를 이용하는 작업이 비동기적(asynchronously)으로 일어나는 경우
자료를 생성하는 작업이 한 곳이 아닌 여러 곳에서 일어나는 경우 (producer)
자료를 이용하는 작업이 여러 곳에서 일어나는 경우(consumer)
자료를 생성하는 작업과 그 자료를 이용하는 작업이 양쪽 다 여러 곳에서 일어나는 경우
자료를 처리하여 새로운 자료를 생성하고, 나중에 그 자료를 또 처리해야 하는 작업의 경우
자료구조 7 | 환형 큐(Circular Queue)
정해진 개수의 저장 공간을 빙 돌려가며 이용하는 자료구조이다. 환형 큐는 저장 공간이 가득차면 더 이상 원소를 삽입할 수 없기 때문에, 큐 길이를 기억해야 한다.
환형 큐의 연산 정의
size(): 현재 스택에 담긴 데이터 원소 갯수를 반환한다.
isEmpty(): 현재 스택이 비어 있는가에 대한 참/거짓을 반환한다.
isFull(): 큐에 데이터 원소가 꽉 차 있는지를 판단한다.
enqueue(x): 데이터 원소 x를 스택의 맨 위에 추가한다.
dequeue(): 스택의 맨 위에 저장된 데이터 원소를 제거한다. (혹은 반환한다.)
peek():스택의 맨 위에 저장된 데이터 원소를 반환한다. (제거하지 않는다.)
환형 큐의 추상적 자료구조 구현 - 배열
Python 리스트와 메서드들을 이용하여 스택을 구현할 수 있다.
class CircularQueue:
# 빈 큐를 초기화
def __init__(self, n):
self.maxCount = n
self.data = [None] * n
self.count = 0
self.front = -1
self.rear = -1
# 현재 큐 길이 반환
def size(self):
return self.count
# 큐가 비어 있는지 확인
def isEmpty(self):
return self.count == 0
# 큐가 가득 차 있는지 확인
def isFull(self):
return self.count == self.maxCount
def enqueue(self, x):
if self.isFull():
raise IndexError
self.rear =
self.data[self.rear] = x
self.count += 1
def dequeue(self):
if self.isEmpty():
raise IndexError
self.front =
x =
self.count -= 1
return x
def peek(self):
if self.IsEmpty():
raise IndexError
return
자료구조 8 | 우선순위 큐(Priority Queue)
우선순위 큐는 큐가 선입선출(FIFO; First-In First-Out) 방식을 따르지 않고 원소들의 우선 순위에 따라 큐에서 빠져 나오는 방식이다.
우선순위 큐 구현
우선 순위 큐 구현에 있어서 서로 다른 두 가지 방식이 가능하다.
(1) Enqueue 연산 시, 우선순위 순서를 유지하도록 구현한다.
(2) Dequeue 연산 시, 우선순위 높은 것을 선택한다.
우선순위 큐의 연산 정의
size(): 현재 스택에 담긴 데이터 원소 갯수를 반환한다.
isEmpty(): 현재 스택이 비어 있는가에 대한 참/거짓을 반환한다.
isFull(): 큐에 데이터 원소가 꽉 차 있는지를 판단한다.
enqueue(x): 데이터 원소 x를 스택의 맨 위에 추가한다.
dequeue(): 스택의 맨 위에 저장된 데이터 원소를 제거한다. (혹은 반환한다.)
peek():스택의 맨 위에 저장된 데이터 원소를 반환한다. (제거하지 않는다.)
class PriorityQueue:
def __init__(self):
self.queue = DoublyLinkedList()
def size(self):
return self.queue.getLength()
def isEmpty(self):
return self.size() == 0
def enqueue(self, x):
newNode = Node(x)
curr = self.queue.head
while True:
assert curr.next is not None
if curr.next is self.queue.tail:
break
if not (x < curr.next.data):
break
curr = curr.next
self.queue.insertAfter(curr, newNode)
def dequeue(self):
if self.isEmpty():
raise IndexError("dequeue from empty priority queue")
return self.queue.popAt(self.queue.getLength())
def peek(self):
if self.isEmpty():
raise IndexError("peek from empty priority queue")
node = self.queue.getAt(self.queue.getLength())
if node is None:
# 논리적으로는 여기 오면 내부 구조가 깨진 것
raise RuntimeError("Linked list is corrupted: getAt returned None")
return node.data
-
-
-
-
-
-
-
-
Touch background to close