테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

배치와 스트림 처리

쌓인 데이터를 한꺼번에 처리하는 배치와, 들어오는 대로 처리하는 스트림, 그리고 그 사이를 잇는 로그와 변경 데이터 캡처다.

목차 (9)
  1. 유닉스 철학과 배치
  2. 맵리듀스
  3. 데이터플로 엔진
  4. 메시지 브로커: 큐와 로그
  5. 변경 데이터 캡처
  6. 이벤트 소싱
  7. 스트림 조인과 윈도
  8. 정확히 한 번과 사실상 한 번
  9. 파생 데이터와 람다·카파

유닉스 철학과 배치

시스템은 크게 세 가지로 일한다. 요청이 오면 바로 답하는 온라인 서비스, 쌓인 입력을 한꺼번에 처리해 결과를 내는 배치 처리, 그리고 들어오는 대로 조금씩 처리하는 스트림 처리다. 배치는 응답 시간 대신 처리량으로 평가하고, 입력을 바꾸지 않고 새 출력을 만들므로 잘못되면 출력을 지우고 다시 돌리면 된다.

배치 처리의 생각은 유닉스 셸에서 이미 보인다. 1978년 Bell System Technical Journal의 유닉스 특집 머리말에서 McIlroy 등은 프로그램 하나가 한 가지 일을 잘하고, 한 프로그램의 출력이 아직 모르는 다른 프로그램의 입력이 될 것을 예상하라는 원칙을 적었다.

접속 기록 → 상태 코드 500인 줄만 고르기(grep) → 호스트 열만 자르기(cut) → 정렬하며 중복 없애기(sort -u) → 줄 수 세기(wc -l)로 이어지는 파이프라인. 각 단계 아래에 그 단계가 내보내는 데이터를 적었고, 답은 2이다access.logA·200 B·500 C·500 …grep ' 500 'B·500 C·500 B·500 C·500cut -d' ' -f1B C B Csort -uB Cwc -l2500을 받은 호스트 수프로그램마다 한 가지 일만 하고, 모두 텍스트 줄을 읽고 써서 아무 순서로나 이어 붙인다
500 오류를 받은 서로 다른 호스트 수를 세는 파이프라인. 각 상자 아래는 그 단계가 다음 단계로 넘기는 데이터를 실제로 계산한 것이다.
  • 같은 인터페이스: 모든 프로그램이 텍스트 줄을 읽고 쓰므로 미리 맞춰 보지 않은 프로그램끼리도 이어 붙는다.
  • 입력은 그대로: 각 단계는 입력 파일을 고치지 않는다. 중간 결과를 파일로 남겨 두면 어디서 틀렸는지 한 단계씩 확인할 수 있다.
  • 한계: 한 대의 메모리와 디스크 안에서만 돈다. 이 생각을 여러 대로 넓힌 것이 다음 절의 맵리듀스다.

맵리듀스

Jeffrey Dean과 Sanjay Ghemawat의 2004년 논문 「MapReduce: Simplified Data Processing on Large Clusters」는 수천 대에 걸친 계산을 함수 두 개로 줄였다. 사용자는 레코드 하나에서 (키, 값) 쌍을 뽑는 map과, 같은 키의 값들을 받아 결과를 내는 reduce만 쓴다. 나머지(입력 나누기, 기계 고르기, 같은 키 모으기, 실패한 작업 다시 돌리기)는 프레임워크가 한다.

입력 조각 세 개를 map이 단어와 1의 쌍으로 바꾸고, 셔플이 같은 단어끼리 리듀서 두 대로 모은 뒤, reduce가 더해 a 3, b 2, c 3을 낸다입력 조각map셔플reduce결과a b aa:1 b:1 a:1b cb:1 c:1a c ca:1 c:1 c:1a: 1,1,1c: 1,1,1a 3c 3b: 1,1b 2map 출력은 로컬 디스크에, reduce 출력은 분산 파일 시스템에 쓴다.다음 작업은 그 파일을 다시 읽어 시작한다.
단어 세기. map은 입력 조각마다 따로 돌고, 셔플이 같은 단어를 같은 리듀서로 모으며, reduce가 더한다. 단어 세기는 원 논문이 첫 예로 든 문제다.
  • 데이터 가까이에서 계산: 입력은 분산 파일 시스템(GFS, 그 뒤의 HDFS)에 블록으로 나뉘어 있고, 스케줄러는 그 블록이 있는 기계에서 map을 돌려 네트워크 전송을 줄인다.
  • 셔플: map 출력은 키의 해시로 리듀서별로 나뉘고 정렬된다. 리듀서는 각 map 작업에서 자기 몫을 가져와 합친다. 같은 키가 한 리듀서로 모이므로 조인도 같은 방식으로 한다(두 입력에서 같은 키를 내보내면 리듀서에서 만난다).
  • 실패 처리: map과 reduce는 부작용이 없으므로 실패한 작업은 다른 기계에서 그냥 다시 돌린다. 느린 작업은 복사본을 하나 더 띄워 먼저 끝나는 쪽을 쓴다(백업 작업).

데이터플로 엔진

실제 분석은 맵리듀스 작업 하나로 끝나지 않고 수십 개가 이어진다. 맵리듀스는 작업마다 출력을 복제된 파일로 디스크에 쓰고, 다음 작업은 앞 작업이 완전히 끝나야 시작한다. 중간 결과는 곧 버릴 데이터인데도 비싸게 보관하는 셈이다.

위 줄은 맵리듀스 작업 셋이 사이사이 HDFS에 결과를 쓰고 다시 읽는 모습이다. 아래 줄은 데이터플로 엔진이 읽기, map, join, reduce, 쓰기를 한 그래프로 이어 중간 결과를 메모리에 두는 모습이다맵리듀스를 여러 개 이을 때작업 1HDFS작업 2HDFS작업 3단계마다 복제본 여러 개로 디스크에 쓰고, 앞 작업이 다 끝나야 다음이 시작한다데이터플로 엔진(Spark 등)읽기mapjoinreduce쓰기연산자 사이 결과는 메모리(넘치면 로컬 디스크)로 바로 넘긴다조각을 잃으면 어떤 연산으로 만들었는지(계보, lineage)를 따라 그 조각만 다시 계산한다
맵리듀스를 이어 붙일 때와 데이터플로 엔진의 차이. 데이터플로 엔진은 전체 작업을 연산자 그래프 하나로 보고 중간 결과를 메모리로 넘긴다.

Spark, Flink, Tez 같은 데이터플로 엔진은 전체 계산을 연산자의 방향 그래프로 받는다. 연산자 사이의 데이터는 메모리나 로컬 디스크로 바로 넘기고, 정렬이 필요 없는 곳에서는 정렬하지 않으며, 그래프 전체를 보고 데이터 이동을 줄이도록 계획을 짠다. 그러면 장애 때 무엇을 다시 계산할지가 문제가 되는데, Spark의 바탕이 된 Zaharia 등의 2012년 논문 「Resilient Distributed Datasets」는 각 데이터 조각이 어떤 연산으로 만들어졌는지(계보, lineage)를 기억해 잃은 조각만 다시 계산하는 방법을 제시했다. 다시 계산하려면 연산이 결정적이어야 하므로, 난수나 시각을 쓰는 연산은 조심해야 한다.

메시지 브로커: 큐와 로그

스트림 처리는 끝이 없는 입력을 다룬다. 이벤트를 만든 쪽과 처리하는 쪽 사이에는 보통 메시지 브로커를 둔다. 브로커는 크게 두 계열이다.

왼쪽 전통적 큐는 생산자가 넣은 메시지를 소비자 둘이 나눠 받고 확인하면 지운다. 오른쪽 로그 기반 브로커는 파티션 끝에 기록을 추가만 하고, 소비자 그룹마다 어디까지 읽었는지(오프셋)를 따로 기억하며, 지우지 않아 다시 읽을 수 있다전통적 큐(AMQP·JMS)생산자m1m2m3소비자 1소비자 2메시지 하나를 한 소비자에게 나눠 주고확인(ack)하면 지운다로그 기반(Kafka 등)파티션 00123456새 기록은 끝에 추가 →그룹 A그룹 B소비자는 어디까지 읽었는지(오프셋)만 기억한다읽어도 지우지 않아 처음부터 다시 읽을 수 있다순서는 파티션 안에서만 보장된다
전통적 큐와 로그 기반 브로커. 큐는 메시지를 나눠 주고 지우며, 로그는 쌓아 두고 소비자마다 자기 위치를 기억한다.
  • 전통적 큐(AMQP·JMS 계열, RabbitMQ 등): 메시지마다 한 소비자에게 보내고 확인(ack)을 받으면 지운다. 소비자를 늘리면 일이 고르게 나뉘고, 오래 걸리는 메시지 하나가 다른 메시지를 막지 않는다. 대신 처리 순서가 섞일 수 있고, 지난 메시지를 다시 읽을 수 없다.
  • 로그 기반(Kafka 등): 토픽을 파티션으로 나누고, 파티션마다 메시지를 끝에 추가만 한다. Kafka 소개 문서가 말하듯 읽어도 지우지 않고 보존 기간 동안 남긴다. 소비자 그룹은 파티션마다 다음에 읽을 오프셋만 기억하므로, 오프셋을 되돌리면 지난 데이터를 다시 처리할 수 있다. 파티션 하나는 그룹 안의 한 소비자만 읽으므로 병렬성은 파티션 수만큼이고, 순서는 파티션 안에서만 보장된다.

메시지 하나하나가 비싸고 순서가 상관없는 작업 분배라면 큐가, 순서가 중요하고 같은 데이터를 여러 시스템이 각자 읽거나 다시 읽어야 한다면 로그가 맞다. 대규모 이벤트 수집 사례는 데이터 파이프라인 사례 노트에서 다룬다.

만드는 쪽이 처리하는 쪽보다 빠르면 어딘가에 쌓인다. 쌓일 곳을 정하지 않으면 메모리가 넘쳐 죽거나, 몰래 메시지를 버린다. 받는 쪽이 감당할 만큼만 보내라고 거꾸로 알리는 것이 백프레셔(back pressure)다. JVM·JavaScript의 Reactive Streams 명세는 스레드 사이처럼 비동기 경계를 넘을 때 받는 쪽이 임의의 양을 버퍼에 쌓지 않아도 되게 하는, 막지 않는(non-blocking) 백프레셔를 목표로 삼는다. Kafka는 소비자가 스스로 읽어 가는 pull 구조를 택했는데, Confluent의 설계 문서는 그 장점으로 뒤처진 소비자가 나중에 따라잡을 수 있다는 점을 든다. 느린 소비자가 생산자를 막지 않는 대신, 그 느림은 오프셋이 뒤처진 정도인 소비자 지연(lag)으로 드러난다. 그래서 소비자 지연을 지표로 지켜보고, 계속 늘어나면 파티션 수 안에서 소비자를 늘린다. 처리량 자체를 넘는 요청은 앞단의 처리율 제한과 부하 차단으로 거절한다(설계의 부품 노트).

변경 데이터 캡처

같은 데이터를 데이터베이스, 검색 인덱스, 캐시에 함께 두는 일은 흔하다. 앱이 세 곳에 차례로 쓰면(이중 쓰기) 하나만 실패하거나, 두 요청이 서로 다른 순서로 도착해 저장소마다 최종 값이 달라진다. 변경 데이터 캡처(CDC)는 데이터베이스를 유일한 정본으로 두고, 그 데이터베이스가 복제에 쓰는 로그를 읽어 다른 저장소에 흘려보낸다.

앱이 데이터베이스에 쓰면 변경이 WAL에 적히고, CDC 커넥터가 그 로그를 읽어 토픽에 변경 이벤트를 넣는다. 검색 인덱스, 캐시, 웨어하우스가 토픽을 읽어 따라온다앱데이터베이스users 표WAL·binlogCDC 커넥터토픽 users{op: u, id: 1, before: Kim, after: Lee}검색 인덱스name: Kimname: Lee캐시user:1 = Kimuser:1 = Lee웨어하우스(나중에)스냅숏 + 로그 이어 읽기각자 자기 오프셋에서 읽는다
1 / 6

앱이 UPDATE users SET name = 'Lee' WHERE id = 1을 실행한다. 앱은 데이터베이스에만 쓰고, 검색 인덱스나 캐시에는 직접 쓰지 않는다.

변경 데이터 캡처. 데이터베이스를 정본으로 두고, 그 복제 로그를 이벤트 스트림으로 내보내 다른 저장소들이 따라오게 한다. 앱이 여러 저장소에 직접 두 번 쓰는 이중 쓰기(dual write)의 순서 꼬임을 피한다.
  • 순서가 보장된다: 복제 로그는 커밋 순서 그대로라, 따라오는 저장소들은 늦을 수는 있어도 결국 같은 상태가 된다. 키를 기준으로 파티션을 나누면 한 행의 변경은 같은 파티션에서 순서대로 처리된다.
  • 도구: Debezium은 MySQL binlog, PostgreSQL 논리 복제 같은 로그를 읽어 Kafka Connect를 통해 토픽으로 보낸다. PostgreSQL 커넥터 문서에 따르면 처음 연결할 때는 일관된 스냅숏을 먼저 보내고, 그 뒤로 행 단위 변경을 이어서 잡는다.
  • 로그 압축: 토픽을 키별 최신 값만 남기도록 압축(compaction)하면, 새 소비자가 처음부터 읽어도 전체 데이터베이스의 최신 사본을 얻는다. 삭제는 값이 비어 있는 툼스톤 레코드로 표현한다.

이벤트 소싱

CDC가 데이터베이스의 낮은 수준 변경(이 행의 이 열이 바뀜)을 내보낸다면, 이벤트 소싱은 처음부터 응용 프로그램이 "무슨 일이 일어났는가"를 이벤트로 적는다. 현재 상태는 저장하지 않고 이벤트를 재생해 계산한다. Martin Fowler의 「Event Sourcing」이 이 패턴을 정리한 대표 글이다.

왼쪽 이벤트 로그에 계좌 개설, 입금 100, 출금 30, 입금 50, 출금 20이 차례로 쌓이고, 오른쪽 상태 상자는 그 이벤트들을 재생한 잔액을 보여 준다이벤트 로그(추가만)#1계좌 개설#2입금 100+100#3출금 30-30#4입금 50+50#5출금 20-20재생상태(재생 결과)잔액0이벤트 1개 재생잔액100이벤트 2개 재생잔액70이벤트 3개 재생잔액120이벤트 4개 재생잔액100이벤트 5개 재생잔액100이벤트 5개 재생잔액70이벤트 3개 재생로그가 정본이고, 잔액 표·통계·검색 같은 읽기 모델은 로그에서 다시 만들 수 있다
1 / 7

계좌를 연다. 상태를 바꾸는 대신 "계좌가 개설됐다"는 사실 하나를 로그에 추가한다.

이벤트 소싱. 변하는 상태 대신 일어난 일(이벤트)을 순서대로 쌓고, 상태는 이벤트를 재생해 계산한다. 같은 로그로 다른 모양의 읽기 모델도 얼마든지 다시 만들 수 있다.
  • 명령과 이벤트: 사용자의 요청(출금해 달라)은 명령이고, 검증을 통과해야 이벤트(출금됐다)가 된다. 이벤트는 이미 일어난 사실이라 거절하거나 고치지 않는다. 실수는 고치는 이벤트를 새로 더해 바로잡는다.
  • 얻는 것: 모든 변경의 이력이 남아 감사와 디버깅이 쉽고, 같은 로그로 새 읽기 모델(통계, 검색, 다른 모양의 표)을 언제든 다시 만든다. 읽기와 쓰기 모델을 나누는 CQRS와 함께 쓰는 경우가 많다.
  • 치르는 것: 이벤트가 쌓일수록 재생이 느려져 주기적으로 스냅숏을 저장한다. 이벤트 형식은 몇 년 뒤에도 읽혀야 하므로 스키마 진화 규칙을 지켜야 하고, 개인정보 삭제 요청처럼 "지울 수 없는 로그"와 부딪히는 요구도 있다. 결제처럼 이력이 곧 정답인 영역의 예는 정확성이 중요한 시스템 노트에 있다.

스트림 조인과 윈도

스트림은 끝나지 않으므로 "합계"나 "평균"을 내려면 범위를 잘라야 한다. 이 범위가 윈도다. 먼저 정할 것은 어느 시각을 기준으로 할지다. 이벤트가 실제로 일어난 이벤트 시각과 처리기에 도착한 처리 시각은 다르고, 휴대폰이 비행기 모드였다가 한꺼번에 보낸 이벤트는 몇 시간 늦게 도착한다.

0초에서 20초 사이 사건 8개를 세 가지 창으로 묶는다. 텀블링 5초 창은 3, 1, 1, 3건, 10초 길이를 5초마다 여는 호핑 창은 4, 2, 4건, 3초 넘게 쉬면 끊는 세션 창은 3, 2, 3건이다사건텀블링 5초3건1건1건3건호핑 10초(5초마다 시작)0~10초: 4건5~15초: 2건10~20초: 4건세션(3초 넘게 쉬면 끊음)3건2건3건0초5초10초15초20초
같은 사건 8개를 세 가지 윈도로 묶은 결과. 창의 경계와 개수는 그림 파일에서 직접 계산했다.
  • 텀블링: 겹치지 않는 고정 길이 창. 모든 사건이 정확히 한 창에 든다. 분당 요청 수 같은 집계에 쓴다.
  • 호핑: 고정 길이 창을 그보다 짧은 간격으로 연다. 창이 겹치므로 한 사건이 여러 창에 든다. 5분 이동 평균을 1분마다 갱신하는 식이다. Flink 문서는 이것을 sliding window라 부른다.
  • 세션: 길이가 정해지지 않고, 사건 사이가 일정 시간 넘게 비면 창을 닫는다. 사용자 한 명의 방문 한 번을 묶는 데 쓴다.

늦게 오는 이벤트 때문에 창을 언제 닫을지가 어렵다. Akidau 등의 2015년 논문 「The Dataflow Model」은 "이 시각 이전 이벤트는 이제 거의 다 왔다"는 추정인 워터마크로 결과를 내고, 그 뒤 도착한 이벤트로 결과를 고쳐 다시 내보내는 방식을 정리했다. 조인도 윈도 위에서 한다. 두 이벤트 스트림을 같은 키와 시간 범위로 맞추는 스트림-스트림 조인(검색과 그 검색 결과 클릭), 이벤트에 테이블의 현재 값을 붙이는 스트림-테이블 조인(주문 이벤트에 고객 정보 붙이기, 테이블은 CDC로 따라온 로컬 사본), 두 테이블의 변경을 받아 조인 결과를 계속 유지하는 테이블-테이블 조인이 있다. 스트림-테이블 조인은 테이블이 바뀌는 시점에 따라 결과가 달라지므로, 어느 시점의 값을 붙였는지가 결과의 일부다.

정확히 한 번과 사실상 한 번

네트워크에서는 "보냈는데 응답이 안 왔다"가 늘 일어난다. 다시 보내면 중복이 생길 수 있고(최소 한 번), 안 보내면 잃을 수 있다(최대 한 번). 정확히 한 번(exactly-once)은 메시지를 정말 한 번만 보낸다는 뜻이 아니라, 재시도로 여러 번 처리돼도 결과가 한 번 처리한 것과 같다는 뜻이다. 그래서 사실상 한 번(effectively-once)이라고도 부른다.

첫 칸은 브로커가 기록했지만 확인 응답이 사라져 생산자가 다시 보내 같은 메시지가 두 번 저장되는 모습이다. 둘째 칸은 생산자 id와 순번이 같은 재전송을 브로커가 버리는 멱등 생산자다. 셋째 칸은 입력을 읽어 처리한 출력과 입력 오프셋을 한 트랜잭션으로 함께 커밋하는 모습이다재시도하면 중복멱등 생산자트랜잭션생산자브로커m✕확인 분실m 재전송생산자브로커m(7, 5)✕확인 분실m(7, 5) 재전송파티션 기록mm같은 메시지가 두 번(생산자 7, 순번 5)는 이미 있음m재전송은 버리고 확인만입력 토픽 읽기처리출력 토픽에 쓰기입력 오프셋 기록둘 다 커밋되거나 둘 다 취소
재시도가 중복을 만드는 이유와 두 가지 해법. 멱등 생산자는 같은 순번을 버리고, 트랜잭션은 출력과 입력 위치를 함께 커밋한다.
  • 멱등성: 같은 요청을 여러 번 해도 결과가 같게 만든다. Kafka 생산자는 0.11부터 멱등 옵션을 둔다. 브로커가 생산자 id와 파티션별 순번을 기억해 같은 순번의 재전송을 버리므로 재시도해도 로그에 한 번만 남는다.
  • 트랜잭션: 스트림 처리는 "입력을 읽고, 출력을 쓰고, 입력 오프셋을 기록"하는 세 일이 함께 일어나야 한다. Kafka의 제안 문서 KIP-98은 여러 파티션에 걸친 쓰기와 오프셋 기록을 한 트랜잭션으로 묶고, 소비자가 커밋된 것만 읽게(read_committed) 하는 설계를 담았다. 설계 배경은 Confluent의 2017년 글 「Exactly-once Semantics Are Possible」에 있다.
  • 경계 밖은 따로: 이 보장은 Kafka 안에서 읽고 쓰는 범위까지다. 처리 중에 이메일을 보내거나 외부 API를 부르면 재시도 때 다시 일어난다. 외부 시스템에는 요청마다 고유 키(멱등 키)를 붙여 받는 쪽이 중복을 걸러 내게 한다.

파생 데이터와 람다·카파

지금까지의 도구를 한 걸음 물러서서 보면, 시스템의 데이터는 정본(system of record, 처음 적히는 곳)과 거기서 계산한 파생 데이터(캐시, 검색 인덱스, 집계 표, 추천 모델)로 나뉜다. 파생 데이터는 잃어도 정본에서 다시 만들 수 있다. 배치와 스트림은 그 파생을 만드는 두 방식이고, 둘을 어떻게 섞느냐에 따라 구조가 갈린다.

람다 구조는 같은 이벤트를 배치 계층이 전부 다시 계산하고 속도 계층이 최근 것만 빨리 계산해 질의 때 합친다. 카파 구조는 이벤트 로그와 스트림 처리 하나만 두고, 로직을 바꿀 때는 새 버전이 로그를 처음부터 다시 읽어 따라잡으면 결과를 바꿔 끼운다람다(lambda)이벤트배치: 전부 다시 계산(느림)스트림: 최근 것만 빨리(근사)질의 때 두 결과를 합침같은 로직을 두 시스템에 따로 짜고 맞춰야 한다카파(kappa)이벤트 로그(오래 보존)스트림 처리 v1v2: 처음부터 다시 읽기결과 v1(지금 서비스)결과 v2(따라잡으면 교체)
람다와 카파. 람다는 배치와 스트림을 따로 돌려 합치고, 카파는 로그 하나를 다시 읽는 것으로 재처리를 대신한다.
  • 람다: 정확하지만 느린 배치 계층이 전체를 주기적으로 다시 계산하고, 빠르지만 근사인 속도 계층이 그 사이 최근 데이터를 메운다. 같은 로직을 두 시스템에 따로 짜고 결과를 맞춰야 하는 것이 큰 부담이다.
  • 카파: Jay Kreps는 2014년 글 「Questioning the Lambda Architecture」에서, 입력을 로그에 충분히 오래 보관하면 스트림 처리 하나로 충분하다고 주장했다. 로직을 바꿀 때는 새 버전을 띄워 로그를 처음부터 다시 읽게 하고, 따라잡으면 출력을 바꿔 끼운 뒤 옛 버전을 끈다.
  • 지금: Flink나 Spark처럼 같은 코드로 배치와 스트림을 모두 돌리는 엔진이 늘면서 둘의 경계가 흐려졌다. 정본을 로그로 두고, 파생 데이터는 언제든 다시 만들 수 있게 한다는 생각이 공통으로 남는다.