배치와 스트림 처리
쌓인 데이터를 한꺼번에 처리하는 배치와, 들어오는 대로 처리하는 스트림, 그리고 그 사이를 잇는 로그와 변경 데이터 캡처다.
유닉스 철학과 배치
시스템은 크게 세 가지로 일한다. 요청이 오면 바로 답하는 온라인 서비스, 쌓인 입력을 한꺼번에 처리해 결과를 내는 배치 처리, 그리고 들어오는 대로 조금씩 처리하는 스트림 처리다. 배치는 응답 시간 대신 처리량으로 평가하고, 입력을 바꾸지 않고 새 출력을 만들므로 잘못되면 출력을 지우고 다시 돌리면 된다.
배치 처리의 생각은 유닉스 셸에서 이미 보인다. 1978년 Bell System Technical Journal의 유닉스 특집 머리말에서 McIlroy 등은 프로그램 하나가 한 가지 일을 잘하고, 한 프로그램의 출력이 아직 모르는 다른 프로그램의 입력이 될 것을 예상하라는 원칙을 적었다.
- 같은 인터페이스: 모든 프로그램이 텍스트 줄을 읽고 쓰므로 미리 맞춰 보지 않은 프로그램끼리도 이어 붙는다.
- 입력은 그대로: 각 단계는 입력 파일을 고치지 않는다. 중간 결과를 파일로 남겨 두면 어디서 틀렸는지 한 단계씩 확인할 수 있다.
- 한계: 한 대의 메모리와 디스크 안에서만 돈다. 이 생각을 여러 대로 넓힌 것이 다음 절의 맵리듀스다.
맵리듀스
Jeffrey Dean과 Sanjay Ghemawat의 2004년 논문 「MapReduce: Simplified Data Processing on Large Clusters」는 수천 대에 걸친 계산을 함수 두 개로 줄였다. 사용자는 레코드 하나에서 (키, 값) 쌍을 뽑는 map과, 같은 키의 값들을 받아 결과를 내는 reduce만 쓴다. 나머지(입력 나누기, 기계 고르기, 같은 키 모으기, 실패한 작업 다시 돌리기)는 프레임워크가 한다.
- 데이터 가까이에서 계산: 입력은 분산 파일 시스템(GFS, 그 뒤의 HDFS)에 블록으로 나뉘어 있고, 스케줄러는 그 블록이 있는 기계에서 map을 돌려 네트워크 전송을 줄인다.
- 셔플: map 출력은 키의 해시로 리듀서별로 나뉘고 정렬된다. 리듀서는 각 map 작업에서 자기 몫을 가져와 합친다. 같은 키가 한 리듀서로 모이므로 조인도 같은 방식으로 한다(두 입력에서 같은 키를 내보내면 리듀서에서 만난다).
- 실패 처리: map과 reduce는 부작용이 없으므로 실패한 작업은 다른 기계에서 그냥 다시 돌린다. 느린 작업은 복사본을 하나 더 띄워 먼저 끝나는 쪽을 쓴다(백업 작업).
데이터플로 엔진
실제 분석은 맵리듀스 작업 하나로 끝나지 않고 수십 개가 이어진다. 맵리듀스는 작업마다 출력을 복제된 파일로 디스크에 쓰고, 다음 작업은 앞 작업이 완전히 끝나야 시작한다. 중간 결과는 곧 버릴 데이터인데도 비싸게 보관하는 셈이다.
Spark, Flink, Tez 같은 데이터플로 엔진은 전체 계산을 연산자의 방향 그래프로 받는다. 연산자 사이의 데이터는 메모리나 로컬 디스크로 바로 넘기고, 정렬이 필요 없는 곳에서는 정렬하지 않으며, 그래프 전체를 보고 데이터 이동을 줄이도록 계획을 짠다. 그러면 장애 때 무엇을 다시 계산할지가 문제가 되는데, Spark의 바탕이 된 Zaharia 등의 2012년 논문 「Resilient Distributed Datasets」는 각 데이터 조각이 어떤 연산으로 만들어졌는지(계보, lineage)를 기억해 잃은 조각만 다시 계산하는 방법을 제시했다. 다시 계산하려면 연산이 결정적이어야 하므로, 난수나 시각을 쓰는 연산은 조심해야 한다.
메시지 브로커: 큐와 로그
스트림 처리는 끝이 없는 입력을 다룬다. 이벤트를 만든 쪽과 처리하는 쪽 사이에는 보통 메시지 브로커를 둔다. 브로커는 크게 두 계열이다.
- 전통적 큐(AMQP·JMS 계열, RabbitMQ 등): 메시지마다 한 소비자에게 보내고 확인(ack)을 받으면 지운다. 소비자를 늘리면 일이 고르게 나뉘고, 오래 걸리는 메시지 하나가 다른 메시지를 막지 않는다. 대신 처리 순서가 섞일 수 있고, 지난 메시지를 다시 읽을 수 없다.
- 로그 기반(Kafka 등): 토픽을 파티션으로 나누고, 파티션마다 메시지를 끝에 추가만 한다. Kafka 소개 문서가 말하듯 읽어도 지우지 않고 보존 기간 동안 남긴다. 소비자 그룹은 파티션마다 다음에 읽을 오프셋만 기억하므로, 오프셋을 되돌리면 지난 데이터를 다시 처리할 수 있다. 파티션 하나는 그룹 안의 한 소비자만 읽으므로 병렬성은 파티션 수만큼이고, 순서는 파티션 안에서만 보장된다.
메시지 하나하나가 비싸고 순서가 상관없는 작업 분배라면 큐가, 순서가 중요하고 같은 데이터를 여러 시스템이 각자 읽거나 다시 읽어야 한다면 로그가 맞다. 대규모 이벤트 수집 사례는 데이터 파이프라인 사례 노트에서 다룬다.
만드는 쪽이 처리하는 쪽보다 빠르면 어딘가에 쌓인다. 쌓일 곳을 정하지 않으면 메모리가 넘쳐 죽거나, 몰래 메시지를 버린다. 받는 쪽이 감당할 만큼만 보내라고 거꾸로 알리는 것이 백프레셔(back pressure)다. JVM·JavaScript의 Reactive Streams 명세는 스레드 사이처럼 비동기 경계를 넘을 때 받는 쪽이 임의의 양을 버퍼에 쌓지 않아도 되게 하는, 막지 않는(non-blocking) 백프레셔를 목표로 삼는다. Kafka는 소비자가 스스로 읽어 가는 pull 구조를 택했는데, Confluent의 설계 문서는 그 장점으로 뒤처진 소비자가 나중에 따라잡을 수 있다는 점을 든다. 느린 소비자가 생산자를 막지 않는 대신, 그 느림은 오프셋이 뒤처진 정도인 소비자 지연(lag)으로 드러난다. 그래서 소비자 지연을 지표로 지켜보고, 계속 늘어나면 파티션 수 안에서 소비자를 늘린다. 처리량 자체를 넘는 요청은 앞단의 처리율 제한과 부하 차단으로 거절한다(설계의 부품 노트).
변경 데이터 캡처
같은 데이터를 데이터베이스, 검색 인덱스, 캐시에 함께 두는 일은 흔하다. 앱이 세 곳에 차례로 쓰면(이중 쓰기) 하나만 실패하거나, 두 요청이 서로 다른 순서로 도착해 저장소마다 최종 값이 달라진다. 변경 데이터 캡처(CDC)는 데이터베이스를 유일한 정본으로 두고, 그 데이터베이스가 복제에 쓰는 로그를 읽어 다른 저장소에 흘려보낸다.
앱이 UPDATE users SET name = 'Lee' WHERE id = 1을 실행한다. 앱은 데이터베이스에만 쓰고, 검색 인덱스나 캐시에는 직접 쓰지 않는다.
- 순서가 보장된다: 복제 로그는 커밋 순서 그대로라, 따라오는 저장소들은 늦을 수는 있어도 결국 같은 상태가 된다. 키를 기준으로 파티션을 나누면 한 행의 변경은 같은 파티션에서 순서대로 처리된다.
- 도구: Debezium은 MySQL binlog, PostgreSQL 논리 복제 같은 로그를 읽어 Kafka Connect를 통해 토픽으로 보낸다. PostgreSQL 커넥터 문서에 따르면 처음 연결할 때는 일관된 스냅숏을 먼저 보내고, 그 뒤로 행 단위 변경을 이어서 잡는다.
- 로그 압축: 토픽을 키별 최신 값만 남기도록 압축(compaction)하면, 새 소비자가 처음부터 읽어도 전체 데이터베이스의 최신 사본을 얻는다. 삭제는 값이 비어 있는 툼스톤 레코드로 표현한다.
이벤트 소싱
CDC가 데이터베이스의 낮은 수준 변경(이 행의 이 열이 바뀜)을 내보낸다면, 이벤트 소싱은 처음부터 응용 프로그램이 "무슨 일이 일어났는가"를 이벤트로 적는다. 현재 상태는 저장하지 않고 이벤트를 재생해 계산한다. Martin Fowler의 「Event Sourcing」이 이 패턴을 정리한 대표 글이다.
계좌를 연다. 상태를 바꾸는 대신 "계좌가 개설됐다"는 사실 하나를 로그에 추가한다.
- 명령과 이벤트: 사용자의 요청(출금해 달라)은 명령이고, 검증을 통과해야 이벤트(출금됐다)가 된다. 이벤트는 이미 일어난 사실이라 거절하거나 고치지 않는다. 실수는 고치는 이벤트를 새로 더해 바로잡는다.
- 얻는 것: 모든 변경의 이력이 남아 감사와 디버깅이 쉽고, 같은 로그로 새 읽기 모델(통계, 검색, 다른 모양의 표)을 언제든 다시 만든다. 읽기와 쓰기 모델을 나누는 CQRS와 함께 쓰는 경우가 많다.
- 치르는 것: 이벤트가 쌓일수록 재생이 느려져 주기적으로 스냅숏을 저장한다. 이벤트 형식은 몇 년 뒤에도 읽혀야 하므로 스키마 진화 규칙을 지켜야 하고, 개인정보 삭제 요청처럼 "지울 수 없는 로그"와 부딪히는 요구도 있다. 결제처럼 이력이 곧 정답인 영역의 예는 정확성이 중요한 시스템 노트에 있다.
스트림 조인과 윈도
스트림은 끝나지 않으므로 "합계"나 "평균"을 내려면 범위를 잘라야 한다. 이 범위가 윈도다. 먼저 정할 것은 어느 시각을 기준으로 할지다. 이벤트가 실제로 일어난 이벤트 시각과 처리기에 도착한 처리 시각은 다르고, 휴대폰이 비행기 모드였다가 한꺼번에 보낸 이벤트는 몇 시간 늦게 도착한다.
- 텀블링: 겹치지 않는 고정 길이 창. 모든 사건이 정확히 한 창에 든다. 분당 요청 수 같은 집계에 쓴다.
- 호핑: 고정 길이 창을 그보다 짧은 간격으로 연다. 창이 겹치므로 한 사건이 여러 창에 든다. 5분 이동 평균을 1분마다 갱신하는 식이다. Flink 문서는 이것을 sliding window라 부른다.
- 세션: 길이가 정해지지 않고, 사건 사이가 일정 시간 넘게 비면 창을 닫는다. 사용자 한 명의 방문 한 번을 묶는 데 쓴다.
늦게 오는 이벤트 때문에 창을 언제 닫을지가 어렵다. Akidau 등의 2015년 논문 「The Dataflow Model」은 "이 시각 이전 이벤트는 이제 거의 다 왔다"는 추정인 워터마크로 결과를 내고, 그 뒤 도착한 이벤트로 결과를 고쳐 다시 내보내는 방식을 정리했다. 조인도 윈도 위에서 한다. 두 이벤트 스트림을 같은 키와 시간 범위로 맞추는 스트림-스트림 조인(검색과 그 검색 결과 클릭), 이벤트에 테이블의 현재 값을 붙이는 스트림-테이블 조인(주문 이벤트에 고객 정보 붙이기, 테이블은 CDC로 따라온 로컬 사본), 두 테이블의 변경을 받아 조인 결과를 계속 유지하는 테이블-테이블 조인이 있다. 스트림-테이블 조인은 테이블이 바뀌는 시점에 따라 결과가 달라지므로, 어느 시점의 값을 붙였는지가 결과의 일부다.
정확히 한 번과 사실상 한 번
네트워크에서는 "보냈는데 응답이 안 왔다"가 늘 일어난다. 다시 보내면 중복이 생길 수 있고(최소 한 번), 안 보내면 잃을 수 있다(최대 한 번). 정확히 한 번(exactly-once)은 메시지를 정말 한 번만 보낸다는 뜻이 아니라, 재시도로 여러 번 처리돼도 결과가 한 번 처리한 것과 같다는 뜻이다. 그래서 사실상 한 번(effectively-once)이라고도 부른다.
- 멱등성: 같은 요청을 여러 번 해도 결과가 같게 만든다. Kafka 생산자는 0.11부터 멱등 옵션을 둔다. 브로커가 생산자 id와 파티션별 순번을 기억해 같은 순번의 재전송을 버리므로 재시도해도 로그에 한 번만 남는다.
- 트랜잭션: 스트림 처리는 "입력을 읽고, 출력을 쓰고, 입력 오프셋을 기록"하는 세 일이 함께 일어나야 한다. Kafka의 제안 문서 KIP-98은 여러 파티션에 걸친 쓰기와 오프셋 기록을 한 트랜잭션으로 묶고, 소비자가 커밋된 것만 읽게(read_committed) 하는 설계를 담았다. 설계 배경은 Confluent의 2017년 글 「Exactly-once Semantics Are Possible」에 있다.
- 경계 밖은 따로: 이 보장은 Kafka 안에서 읽고 쓰는 범위까지다. 처리 중에 이메일을 보내거나 외부 API를 부르면 재시도 때 다시 일어난다. 외부 시스템에는 요청마다 고유 키(멱등 키)를 붙여 받는 쪽이 중복을 걸러 내게 한다.
파생 데이터와 람다·카파
지금까지의 도구를 한 걸음 물러서서 보면, 시스템의 데이터는 정본(system of record, 처음 적히는 곳)과 거기서 계산한 파생 데이터(캐시, 검색 인덱스, 집계 표, 추천 모델)로 나뉜다. 파생 데이터는 잃어도 정본에서 다시 만들 수 있다. 배치와 스트림은 그 파생을 만드는 두 방식이고, 둘을 어떻게 섞느냐에 따라 구조가 갈린다.
- 람다: 정확하지만 느린 배치 계층이 전체를 주기적으로 다시 계산하고, 빠르지만 근사인 속도 계층이 그 사이 최근 데이터를 메운다. 같은 로직을 두 시스템에 따로 짜고 결과를 맞춰야 하는 것이 큰 부담이다.
- 카파: Jay Kreps는 2014년 글 「Questioning the Lambda Architecture」에서, 입력을 로그에 충분히 오래 보관하면 스트림 처리 하나로 충분하다고 주장했다. 로직을 바꿀 때는 새 버전을 띄워 로그를 처음부터 다시 읽게 하고, 따라잡으면 출력을 바꿔 끼운 뒤 옛 버전을 끈다.
- 지금: Flink나 Spark처럼 같은 코드로 배치와 스트림을 모두 돌리는 엔진이 늘면서 둘의 경계가 흐려졌다. 정본을 로그로 두고, 파생 데이터는 언제든 다시 만들 수 있게 한다는 생각이 공통으로 남는다.
- MapReduce: Simplified Data Processing on Large Clusters — Dean·Ghemawat
- UNIX Time-Sharing System: Foreword — McIlroy·Pinson·Tague (Bell System Technical Journal)
- Resilient Distributed Datasets — Zaharia 외 (NSDI 2012)
- Introduction — Apache Kafka
- Debezium Architecture — Debezium
- Debezium connector for PostgreSQL — Debezium
- Event Sourcing — Martin Fowler
- Windows — Apache Flink Documentation
- The Dataflow Model — Akidau 외 (VLDB 2015)
- Kafka Message Delivery Guarantees — Confluent Docs
- KIP-98: Exactly Once Delivery and Transactional Messaging — Apache Kafka
- Exactly-once Semantics Are Possible: Here's How Kafka Does It — Confluent
- Questioning the Lambda Architecture — Jay Kreps
- Reactive Streams — reactive-streams.org
- Kafka Consumer Design — Confluent Docs