Post

[허수아비] 실시간 데이터 파이프라인 아키텍처 설계: 선택과 집중

허수아비 프로젝트의 실시간 데이터 파이프라인을 구축하며 내린 기술적 의사결정 과정을 회고합니다. HDFS 멀티 노드와 메타 DB를 과감히 포기한 이유부터, Spark Streaming의 OOM 방어 전략, 그리고 프론트엔드 실시간 전송을 위한 Kafka-SSE 연동 구조까지 실무적인 고민을 담았습니다.

[허수아비] 실시간 데이터 파이프라인 아키텍처 설계: 선택과 집중

‘허수아비’ 프로젝트는 CCTV 영상과 레이더(Radar) 센서 데이터를 실시간으로 융합하여 객체를 탐지하고 분석하는 시스템입니다. 이 과정에서 쏟아지는 대용량 스트리밍 데이터를 어떻게 처리하고 저장할 것인지, 아키텍처 설계 단계에서 팀원들과 치열하게 고민했던 과정과 최종 결정을 공유합니다.

“한정된 자원(8GB RAM)과 시간(2주) 내에 달성 가능한 최적의 효율을 위해 오버엔지니어링을 배제한다”


1. 데이터 저장의 딜레마: 버릴 것은 버려라

고민 1: 초당 쏟아지는 레이더 데이터를 DB에 저장해야 할까?

레이더 엣지(Edge) 디바이스에서 초당 엄청난 수의 좌표 데이터가 들어옵니다. 처음에는 이 데이터를 Spark Streaming으로 연산한 뒤 전부 RDBMS에 넣으려고 했습니다.

  • 결정: ERD에서 레이더 원시 데이터 테이블을 과감히 삭제했습니다.
  • 이유: 실시간 관제 프론트엔드에 필요한 데이터이지, 영구 저장하여 RDBMS에서 복잡한 조인(Join)을 걸 대상이 아니었기 때문입니다. 대신 Spark에서 연산된 결과를 Kafka 토픽으로 발행하고, Spring Boot가 이를 구독하여 프론트엔드에 SSE(Server-Sent Events)로 직결하여 쏴주는 구조로 변경했습니다.

고민 2: 하둡(Hadoop) 전용 메타 DB(Hive Metastore)가 필요할까?

  • 결정: 구축하지 않기로 했습니다.
  • 이유: 우리는 HDFS에 원시 데이터를 저장할 때 Parquet(파케이) 파일 포맷을 사용합니다. Parquet는 파일 자체에 컬럼명과 타입(Schema)을 모두 품고 있는 아주 똑똑한 파일입니다. 따라서 복잡한 메타 DB를 거칠 필요 없이, Spark Batch 연산 시 spark.read.parquet(...) 한 줄만 실행하면 알아서 스키마 구조표를 쫙 그려냅니다.

고민 3: HDFS DataNode를 여러 EC2에 분산 배치(멀티 노드)해야 할까?

  • 결정: 단일 노드(Single Node) 체제를 유지합니다.
  • 이유: DataNode도 결국 JVM 기반이라 최소 1GB 이상의 메모리를 차지합니다. EC2 서버를 분리하면 IP 통신 및 방화벽 문제, 네트워크 지연 등 러닝 커브와 작업량이 폭증하여 현재 리소스(2주)로는 감당하기 어렵다고 판단했습니다.

💡 HDFS 데이터 저장의 3단계 물리적 흐름 Spark가 “데이터를 HDFS에 저장해!”라고 명령하면 다음 순서로 움직입니다.

  1. Spark가 NameNode에 “어느 책장(DataNode)에 꽂을까?” 묻습니다.
  2. NameNode는 메모리에 목차(Metadata)를 적어두고 주소를 알려줍니다.
  3. 주소를 받은 Spark는 NameNode를 거치지 않고 직접 DataNode의 하드디스크에 데이터를 쏟아붓습니다.

2. 장애 대응 및 상태 관리: 시스템 셧다운을 방어하라

데이터 파이프라인에서 가장 무서운 것은 ‘소리 없이 죽는 것’입니다. Spark 컨테이너가 뻗거나 지연될 때를 대비한 방어벽을 세웠습니다.

방어벽 1: Kafka Consumer Lag 모니터링

만약 Spark 연산 프로세스가 죽는다면, Kafka의 raw-radar-events 토픽에는 1초마다 데이터가 쌓이는데 빠져나가지 못하므로 대기열(Consumer Lag)이 급격하게 증가합니다.

  • 조치: 외부 모니터링 툴(Kafka-UI 등)을 이용해 “Consumer Lag이 1,000 이상 쌓이면 알림 발송” 같은 임계치 룰을 설정했습니다. Spark가 은밀하게 뻗어도 100% 잡아낼 수 있습니다.

방어벽 2: 인프라 레벨의 컨테이너 자동 복구 (Entrypoint Wrap)

docker-compose.ymlrestart: always 정책을 걸어두는 것만으로는 부족했습니다. 프로세스가 ‘재시작되는 순간’을 팀원들이 알아야 합니다.

  • 조치: 컨테이너 구동 시 실행되는 쉘 스크립트(entrypoint.sh) 안에서 spark-submit 명령을 실행하고 결과(Exit Code)를 검사합니다. 비정상 종료(Exit Code != 0) 시 즉시 Slack 알림을 쏘고 exit 1을 뱉어 도커가 다시 재시작 루프를 돌게 만듭니다.

방어벽 3: OOM 방지와 복구 최적화 (Watermark & Offset)

Spark Streaming은 네트워크 지연으로 뒤늦게 도착하는 과거 데이터(Late Data) 처리를 위해 메모리에 상태(State)를 일정 기간 들고 있습니다.

  • 조치 (OOM 방어): Spark 코드 내부에 반드시 워터마크(Watermark)를 설정하여, “N초가 지난 과거 데이터는 메모리에서 가차 없이 버린다”는 규칙을 명시해 워커 노드의 OOM을 방어했습니다.
  • 조치 (복구 지연 방어): 재시작 시 과거부터 쌓인 수만 건의 데이터를 재처리하느라 실시간성이 박살나는 것을 막기 위해, 체크포인트를 끄고 Kafka 설정에 startingOffsets="latest" 옵션을 주어 “켜지는 그 순간 새롭게 들어오는 가장 최신 데이터부터” 연산하도록 만들었습니다.

3. 역할과 책임(R&R)의 명확한 분리

마지막으로 인프라와 애플리케이션의 경계를 명확히 하여 팀원 간의 작업 병목을 없앴습니다.

  • 인프라 담당 (본인): 물리적 서버 환경(EC2), 도커 컨테이너 오케스트레이션, 방화벽 제어, 보안 환경 변수 주입, 글로벌 자원 제한.
  • 파이프라인 담당 (팀원): 파이썬 데이터 I/O 로직 연산 처리, Kafka 토픽/파티션 설계, 메시지 키 라우팅.

[협업 프로토콜] 파이프라인 담당자가 비즈니스 요구사항에 맞춰 다음 3가지 값을 산정하여 전달하면, 인프라 담당자가 이를 docker-compose.yml 리소스 설정에 반영하기로 합의했습니다.

  1. 데이터 보존 시간 (Retention Hours)
  2. 최대 메시지 크기 (Max Message Bytes)
  3. 기본 파티션 개수 (Num Partitions)

수정된 최종 아키텍처

architecure diagram architecure diagram


마치며

가장 좋은 아키텍처는 가장 복잡한 아키텍처가 아니라, 현재 팀의 리소스와 비즈니스 요구사항에 가장 잘 맞는 아키텍처입니다. 이번 ‘허수아비’ 프로젝트의 설계 과정을 통해, 때로는 최신 기술 스택을 도입하는 것보다 과감하게 덜어내고 방어 로직(Fail-Fast, Lag 모니터링)을 단단하게 구축하는 것이 훨씬 더 중요하다는 것을 깨달았습니다.

This post is licensed under CC BY 4.0 by the author.