소프트웨어 엔지니어가 알아야 할 로그에 대한 모든 것

Apache Kafka의 탄생배경을 알아보자

scalalang2
취미로 논문 읽는 그룹
26 min readAug 14, 2023

--

많은 시스템이 Apache Kafka를 메시지 큐와 실시간 데이터 처리에 이용하고 있습니다. Linkedin 에서 Apache Kafka를 개발한 Jay Kreps이 글에서 Kafka를 개발하게 된 이유와 철학을 로그라는 하나의 주제에서 출발하여 멋지게 설명하고 있습니다. 오늘은 제가 거인의 어깨 위에 올라타 그가 설명한 로그에 대한 이야기를 해볼까 합니다.

이 글은 Jay Kreps가 링크드인에 기재한 내용을 토대로 재구성하고 원문 내용을 요약했습니다. 10년 전 글이기 때문에, 시대 배경을 상상하면서 읽으시면 더 맛있게 드실 수 있을것으로 기대하고요. 필요한 경우 제가 추가적인 내용을 기입하여 이해를 돕고자 했습니다. 더 상세한 내용은 원본글을 참고하시기 바랍니다.

원문에 나온 내용은 평범한 텍스트로 작성하였고, 이렇게 bar(|)와 같이 쓰인 문장은 제가 추가적인 내용을 덧붙인 것입니다. 그리고 저자가 표현한 문구임을 명확하게 하기 위해서 저자의 이름인 Jay Kreps를 줄여서 Jay라고 반복적으로 불렀습니다.

INDEX

  • Part 1. 로그
  • Part 1. 데이터베이스의 로그
  • Part 1. 분산 시스템의 로그
  • Part 1. 데이터베이스와 상태복제머신
  • Part 1. 테이블과 이벤트
  • Part 2. 로그 기반 데이터 플로우
  • Part 2. Extract-Transform-Load
  • Part 2. 로그와 이벤트
  • Part 2. 확장성있는 로그 만들기
  • Part 3. 로그와 실시간 스트림 프로세싱
  • Part 3. Dataflow Graph
  • Part 3. Stateful 실시간 프로세싱
  • Part 3. 로그 컴팩션
  • Part 3. 로그 중심의 인프라 스택

Part 1. 로그

로그(Log)라는 단어를 들으실 때 소프트웨어 엔지니어 분들이라면 애플리케이션에서 발생하여 에러가 포함되어 있고 시스템 상태를 표현하는 트레이스(Trace) 정보가 담겨져 있는 걸 상상하실지도 모를텐데요. Jay는 이런 정보는 애플리케이션 로그(Application Logs)라고 부르며 로그와는 철저하게 분리합니다.

이 글 전체에서 반복적으로 등장하는 로그란 “가장 단순하게 추상화된 저장소이며, Append-only만 가능하고, 전체 이벤트가 시간 순서대로 정렬된 자료구조” 를 의미합니다. 로그는 이벤트 혹은 정보의 집합이며 로그에 담긴 하나의 정보는 레코드(Record)라고 부릅니다. 아래 그림은 여기서 설명하는 로그의 모습을 보여줍니다.

Logs : Simple Abstraction of Storage, Append-only, Totally-ordered

로그의 레코드에는 각각 고유의 번호가 부여집니다. 로그에서 데이터는 시간 순서대로 정렬되며 로그의 왼쪽에 존재하는 이벤트는 오른쪽에 존재하는 이벤트보다 과거에 발생되었음을 의미합니다. 로그 구조는 데이터베이스의 테이블과 크게 다르지 않습니다. 로그에서의 각 정보는 레코드라고 부르고 테이블에서는 행(row)이라고 부를 뿐입니다.

로그의 구조는 단순하지만 로그는 이벤트가 “언제” 발생했는지, “무엇이” 발생했는지에 대한 중요한 단서를 가지고 있기 때문에 데이터베이스와 분산시스템에서 매우 중요한 역할을 합니다.

Part 1. 데이터베이스의 로그

로그는 데이터베이스가 탄생한 시점부터 매우 중요하게 쓰인 구조입니다. Jay는 여기서 로그를 누가 개발했는지는 몰라도 이진 탐색(Binary Search)처럼 너무 쉽고, 너무 당연해서 초기 발명자는 이를 기록할만한 가치를 못찾은 것으로 생각했습니다.

데이터베이스는 인덱스와 테이블 내의 데이터가 장애 상황에서 안전하게 복구할 수 있도록 데이터를 변경하기 전에 “무엇을 변경해야 하는지”를 로그에 기록하는데요. 대부분의 데이터베이스에서 이를 WAL(write-ahead-logs) 라고 부릅니다.

이후 시간이 지나서 데이터베이스 분야에서는 ACID원칙이라는 개념이 태동하였고, 여러 인스턴스로 데이터를 복제하도록 발전했습니다. 로그라는 존재가 “무엇을”, “언제”라는 정보를 순서대로 저장했기 때문에 원격 복제를 구현할 때 매우 핵심이 됩니다. Mysql, PostgreSQL은 모두 자체적인 로그 전송 프로토콜(log shipping protocol)을 지니고 있고, 복제 노드(Replica)에 이 로그를 전송해서 데이터 복제를 수행합니다.

복제 노드(Replica)가 메인 노드(Primary)노드에게 데이터를 구독한다는 개념은 Primary/Secondary모델 혹은 Leader/Follower모델 이라고 부르는데요. 거의 모든 데이터베이스가 이런 복제 모델을 택하고 있습니다. 이런 추상화는 데이터베이스에만 한정되지 않고 데이터 플로우와 실시간 처리 및 거의 모든 메시징 시스템의 매우 이상적인 모습입니다.

Primary/Secondary 복제 모델

Part 1. 분산 시스템의 로그

데이터를 순서대로 저장한다는 것, 데이터를 분산시켜서 복제한다는 로그의 개념은 분산시스템에서 더욱 중요합니다. 분산 시스템의 핵심은 어떤 변경사항을 어떤 순서로 진행해야 하는지 합의하는 과정입니다. 분산 시스템이 로그를 중심으로 설계된 데에는 상태 복제 머신 원칙(State Replication Machine Principles)이 작용합니다.

상태 복제 머신 원칙
두 개의 프로세스가 동일한 상태에서 출발해서, 동일한 입력을 같은 순서로 입력 받는다면 최종적으로 같은 상태를 가지게 된다.

결정성(Determinitic)
결정성은 컴퓨터 과학분야에서 시간에 의존하지 않는 프로세싱을 의미합니다. 예를 들어 특정 순간에 A 주식의 가격을 가져오는 함수는 언제 실행하냐에 따라 결과값이 달라지기 때문에 비결정성입니다. 상태 복제 머신은 서로 다른 노드가 하나의 값에 합의하기 위해서 반드시 프로세싱은 결정적으로 동작해야 합니다.

현대 많은 분산 시스템들은 내부에 합의 알고리즘을 이용한 상태 복제 머신을 가지고 있습니다. 쿠버네티스의 etcd, Apache Kafka는 주키퍼를 쓰고 있고, Kafka최근 빌드는 Raft 알고리즘을 채용했습니다. 비트코인으로 유명세를 탄 블록체인은 블록 단위의 로그를 노드들끼리 복제하고 비잔틴 환경에서 합의하는 시스템입니다. 멀티-플레이어 게임 서버또한, 유저가 바라보는 세계를 동일하게 합의하는 시스템으로 상태 복제 머신으로 볼 수 있습니다.

Raft는 2014년에 스탠포드 대학의 연구원들이 발표한 합의알고리즘으로 많은분산시스템이 채용하는 상태복제머신 입니다. 아래 그림은 Raft논문에서 표현된 상태 복제 머신의 구조도를 보여주는데요. 근본적으로 분산 시스템에서 여러 노드가 동일한 값을 ‘합의(Consensus)'하는 것은 전체 순서가 동일한 로그를 복제하는 것과 같습니다.

이런 로그 복제 시스템의 아름다운 점은 로그의 인덱스 혹은 타임스탬프가 복제 노드들의 상태를 표현하는 역할을 한다는 겁니다. 분산 시스템에 연결된 수많은 노드들의 현재 상태를 표현하는 데에는 로그의 몇 번 째 레코드를 프로세싱 했는가? 라는 단순한 질문으로 처리할 수 있습니다.

Part 1. 데이터베이스와 상태복제머신

리더-팔로워 복제와 상태복제머신의 차이

데이터베이스 연구자들은 물리적 로그논리적 로그를 분리해서 생각합니다. 물리적 로그는 변경된 내용 그 자체를 의미하고, 논리적 로그는 변경을 이끌어내는 명령문 같은 것을 말합니다. 예를 들면 SQL구문이 있습니다. 분산 시스템은 위에서 바라본 두 개의 복제 형태를 분리하는데요. 리더-팔로워 모델에서의 팔로워는 물리적 로그 형태를 그대로 적용하는 수동적인 복제 모습을 띠고, 상태 복제 머신에서는 각 참여자가 능동적으로 논리적 로그를 복제 받아서 같은 명령을 수행합니다.

명령을 복제하는 머신과 최종 결과만을 복제하는 머신

간단하게 하나의 값만을 가지고 있는 시스템이 있다고 상상해보면, 상태 복제 머신은 상태를 변경할 수 있는 연산을 명령어 형태로 입력받아서 능동적으로 처리하고, 리더-팔로워 모델에서 팔로워들은 수동적인 입장에서 완성된 데이터를 받습니다. 위 그림은 두 모델의 차이를 보여줍니다. 이 예제는 또한 왜 로그의 순서가 중요한지 보여줍니다. 순서가 다르게 입력이 되면 완전히 다른 결론이 도출되고 일관성이 깨집니다.

리더-팔로워 모델은 RDBMS에서 많이 사용하기 때문에 친숙하실지도 모릅니다. 상태복제은 분산 시스템에서 많이 이용됩니다. 대표적으로 쿠버네티스 내부에서 메타데이터 및 상태 정보를 저장하는데 쓰이는 etcd는 Raft로 구현된 상태복제머신입니다. RDBMS의 복제 모델과 상태 복제 머신은 무엇이 다른걸까요? 왜 쿠버네티스는 다른 DB를 데이터 저장소로 이용하지 않을까요?

Primary-Secondary Model vs State Machine Replication

리더-팔로워 모델은 요청을 수행하는 리더에 장애가 발생하면, 팔로워 노드 중 하나가 다시 리더로 선출됩니다. 이 과정에서 리더가 요청을 복제하지 못하고 장애가 발생하면 클라이언트는 본인의 요청이 유실되었다고 느끼게 됩니다. 왜냐면 선대 리더가 ACK 메세지를 주었거든요. 즉 내결함성을 갖추지 못한겁니다. 일부 데이터베이스에서는 동기식 복제라고 해서 팔로워가 트랜잭션을 커밋한 시점에 리더가 응답을 주도록 할 수 있긴 합니다, 하지만 이 경우 일관성은 보장되나 팔로워 노드가 장애 상황이면 리더는 팔로워가 복구 될 때 까지 대기해야 합니다.

반면, 상태 복제 머신에 쓰이는 합의 알고리즘은 Non-Byzantine 환경에서 일부 장애를 허용하는 내결함성을 갖추었습니다. 클러스터 내 전체 노드에서 정족수(quorum)만큼 복제가 완료된 경우 해당 시점의 메시지를 커밋하고, 완벽하게 커밋된 상태의 정보만 클라이언트에게 응답을 줍니다. 따라서, RDBMS보다 높은 가용성(High availability)강한 일관성(Strong consistency) 제공해야 하고 읽기-중심의 애플리케이션인 경우 상태복제머신을 이용할 수 있습니다.

Part 1. 테이블과 이벤트

은행 시스템에서 로그는 입출금 기록이며, 테이블은 현재 내 계좌의 잔액과 동일합니다. 재밌는 점은 로그에 쓰인 이벤트(Event)와 테이블은 동일성을 띈다는 겁니다. 입출금 기록으로 현재 계좌의 상태를 알 수 있고 반대로 계좌의 변경내역을 다시 로그에 이벤트를 기록할 수 있습니다. 서로 기록하는 방식은 다르지만 동일성을 가지고 있습니다.

물론 로그가 더 중요한 역할을 합니다. 로그는 현재 상태의 최종본을 구할 수 있을 뿐 아니라, 특정 시점의 중간 상태를 만들 수도 있으며 테이블의 모든 이전 상태를 백업할 수 있습니다. 우리에게 친숙한 버전 관리 시스템인 Git도 이런 로그를 복제하는 시스템으로, 소스코드에서 첫번째 커밋이 작성된 시점부터 현재까지 모든 순간의 중간 상태를 복원할 수 있습니다.

로그와 테이블의 동일성이라는 특징을 이용하기 위해 테이블의 변경 내역을 자동으로 이벤트로 만들어주는 변경 데이터 캡쳐(CDC)라 불리는 분야가 있습니다. 대표적인 도구로 Kafka Connect가 있는데요. MySQL, ElasticSearch 등 다양한 데이터 소스에서 발생된 변경 내역을 Kafka에 이벤트로 전송해주고, 이벤트를 다시 테이블로 만들어주는 기능을 가지고 있습니다.

Ref. Red Hat developer | Capture database changes with Debezium Apache Kafka connectors

Part 2. 로그 기반 데이터 플로우

데이터 통합(Data Ingeration)은 여러 데이터 소스들의 하나의 통합된 데이터 저장소로 모으는 것을 말합니다. 데이터 소스를 한 데 모아서 ETL 프로세싱을 거쳐 데이터 웨어하우스로 모으는 과정을 지칭하는 말인데요.

Jay는 원문 글에서 신뢰할 수 있는 데이터 전달 방식이 없다면 하둡 클러스터나 빅데이터 시스템들은 그냥 비싸고 사용하기 어려운 저장소일 뿐이라고 표현했습니다. 당시 많은 기업들은 빅 데이터(Big Data)라는 유행어에만 매료되어 있을 뿐 신뢰할 수 있는 데이터 플로우를 구축하는 일에는 관심이 적었습니다.

기존의 데이터 통합 과정

당시에는 데이터 통합을 위해 데이터 소스와 도착지가 End-to-End로 연결되는 일이 많았습니다. 서버에서 로그를 파일로 내리면 파일째로 가져가는 방식이 많이 사용되었죠. 이 과정에서 일부는 유실되기도 하고 강하게 커플링 되어 있기 때문에 한 곳의 장애가 다른 곳으로 쉽게 전파되기도 합니다. Jay는 많은 기업이 신뢰할 수 있는 완성형 데이터 플로우에 대한 고민은 적게 하면서 바로 빅 데이터와 같은 고급 데이터 모델링 테크닉을 적용하고 싶어하는 것 같다고 말했습니다.

데이터 통합 과정은 이벤트 데이터특수 목적 데이터베이스의 발전으로 더욱 어려워 졌습니다. 우선, 이벤트 데이터란 웹/모바일 사용자의 행위 로그, 데이터 센터 장비의 모니터링 로그 등을 말하며 전통적인 데이터베이스에서는 감당하기 어려운 대규모의 데이터가 생산됩니다. 그리고, 요즘에는 서비스가 RDBMS만 사용하는 경우가 많이 없죠. OLAP, 검색 엔진, 배치 프로세싱, 오브젝트 스토리지, 캐시, 그래프 데이터, 벡터 데이터 등 특수한 목적으로 활용하는 데이터베이스가 많아졌는데요. 이들 사이의 인터페이스가 모두 달라서 데이터를 통합하는데 어려움을 줍니다.

그런데 잠깐, 위에서 우리는 로그와 테이블은 동일성을 가지며, 모든 데이터는 결정적이며 순서가 보장된 형태의 선형 자료구조로 바꿀 수 있다고 했습니다. 그리고 분산 시스템과 데이터베이스에서는 요청을 로그에 저장/복제하고 이 이벤트를 읽어서 내부 데이터를 변경하는 시스템입니다. 그렇다면, 데이터 시스템들의 메모리 혹은 디스크로만 상주해있던 로그를 네트워크의 중간 미들웨어로 추상화 시킨다면 어떨까요?

데이터베이스가 가지고 있는 로그를 미들웨어로 추상화한 모습

논리적인 데이터 소스들은 로그로 모델링 할 수 있습니다. 그리고 데이터 소스는 이벤트 로그를 만드는 애플리케이션이며 데이터베이스의 테이블은 해당 이벤트로 인한 변경 사항을 반영하는 서버입니다. 따라서, 어떠한 시스템이라도 위의 그림처럼 로그를 구독해서 각자의 테이블을 만들 수 있습니다. 이때 구독자(Subscriber)는 캐시가 될 수도 있고 하둡이 될 수도 있습니다. 데이터 시스템의 종류는 상관 없습니다. 로그가 곧 데이터이니까요.

이런 구조는 데이터를 발행하는 프로듀서와 데이터를 읽는 컨슈머 사이의 결합성을 느슨하게 합니다. 프로듀서와 컨슈머 사이의 데이터를 쓰고/읽는 속도가 달라서 발생하는 여러 문제들을 해결해주는 중간 버퍼의 역할도 해줍니다. 이벤트를 푸시하는 기존의 방식은 데이터베이스가 메인터넌스로 잠시 중단되는 상황에서 문제가 되는데요. 이벤트를 구독하는 모델에서는 각 컨슈머가 스스로 본인의 페이스를 조절합니다.

여기서 로그 미들웨어는 로그와 동일한 속성을 가집니다. Append-only이며 리플레이가 가능하고, 순서가 보장되어있습니다. Jay는 발행/구독(Pub/Sub)과 로그는 다른 걸로 간주합니다. 발행/구독 모델은 간접적인 메시징의 전달방식일 뿐 무엇을 보장해주는 지는 표현하지 않죠. 반면, 로그라는 용어는 영속적이며 강한 순서 보장의 시맨틱을 내포합니다.

Part 2. Extract-Transform-Load

여기서는 전통적인 ETL 파이프라인을 비판합니다. ETL 프로세스에서는 데이터를 가져오고(Extract) 목적 시스템이 읽기(Load) 편한 형태로 변환(Transform) 합니다. 문제는 이 파이프라인이 다른 시스템에 데이터를 공급하기에는 유용하지 않을 가능성이 크다는 겁니다. 모든 데이터 소스를 모아서 하나의 데이터 웨어하우스를 ETL로 구축하는 건 실현하기 매우 어렵습니다.

만약 통합된 로그 시스템과 데이터를 잘 정의해서 수집한다면 새로운 데이터 시스템이 할 일은 그저 로그 파이프라인에 연결하는 단일 프로세스만 구축하는 일이 될 겁니다.

Part 2. 로그와 이벤트

로그 파이프라인의 부수효과로는 디커플링이벤트 기반 아키텍처입니다. 요즘에는 이 개념이 매우 중요하죠. 예전에는 서버에서 이벤트를 파일로 남기고 이 파일을 각 데이터베이스가 스크랩해가는 방식을 채용했습니다. 이는 데이터의 흐름이 데이터베이스의 기능이나 배치 프로세싱 스케쥴에 강하게 결합되어 있습니다. Kafka는 이 방식을 이벤트 기반(Event-driven)으로 바꾸는데 획기적입니다. 수백 혹은 수천개의 이벤트 타입을 정의하고 각 데이터 프로듀서는 이 형식에 맞춰 이벤트를 전송할 뿐이죠.

이해를 돕기위해 링크드인의 채용 공고(Job Posting)기능을 예시로 듭니다. 채용 공고는 사용자에게는 정보만 보여주면 되지만, 뒷단에서는 아래의 요구사항을 충족하기 위해 이벤트를 기록해야 합니다.

  • 오프라인 프로세싱을 위해 하둡과 데이터 웨어하우스로 전송한다.
  • 특정 사용자가 컨텐츠 스크래핑을 하지 않는지 검사하기 위해 페이지 뷰를 카운팅 해야 한다. (e.g. 채용 공고를 복사해서 다른 채용 사이트로 가져가는지)
  • 채용 공고를 낸 직원이 보는 분석 페이지에서 통계를 보여주기 위해 데이터를 집계한다.
  • 특정 사용자에게 과도하게 중복 추천을 하지 않기 위해 어떤 채용 공고를 봤는지 이벤트를 기록해야 한다.
  • 어떤 채용 공고가 있는지 추적해야 한다.
End-to-End 이벤트 전송 vs 로그 파이프라인을 이용하는 방식

이 요구사항을 구현하기 위해 데이터를 직접 푸시하면 시스템의 복잡성이 증가하고 각 데이터베이스 시스템들과 강하게 결합하게 됩니다. 시스템 중 하나라도 장애가 발생하면 선택할 수 있는 건 기록을 포기하거나 장애가 복구될때 까지 기다리는 것 뿐입니다. 이벤트-기반 접근법은 이런 문제상황을 단순화 시켜버립니다. 채용 공고를 개발하는 담당자는 ‘사실(fact)’ 정보의 이벤트만 로그로 기록하고 컨슈머들은 잘 정의된 로그를 활용하기만 하면 됩니다. 컨슈머들은 이벤트를 보고 실시간 추천을 할 수도 있고, 보안 관제, 분석 시스템, 데이터 웨어하우스 등 다양한 용도로 사용합니다.

Part 2. 확장성있는 로그 만들기

지금까지는 로그라는 자료구조를 추상화해서 미들웨어로 만들면 어떤 장점이 있는지 다루었습니다. 그렇다면 이러한 로그 시스템은 어떻게 만들 수 있을까요? 확장성 있고(Scalable), 빠르고, 저렴한 로그 시스템을 만들 수 없다면 이러한 주장은 그저 상상에서 그치겠죠.

Apache Kafka는 확장성 있는 로그를 만들기 위해 3가지 트릭을 이용합니다.

  • 로그를 파티셔닝한다
  • 배치 읽기/쓰기로 최적화 한다
  • 불필요한 데이터 카피를 방지한다

파티셔닝
수평 확장을 위해 로그를 파티션 단위로 분리합니다. 로그는 원래 전체 순서가 보장된 형태를 일컫는 말이지만 Apache Kafka에서는 파티션 단위의 순서만 보장합니다. 로그를 기록할 때 데이터의 파티션 키를 기록해야 하는데요. 유저 키(UserID)를 파티션 키로 잡으면 해당 유저가 발생한 이벤트에 대해서는 순서가 부분적으로 보장됩니다. 이 파티션은 브로커들에게 데이터가 복제되어서 내결함성을 갖추고, 특정 순간에는 하나의 브로커가 리더 역할을 하며 리더에 장애가 발생하면 남아있는 노드 중 하나가 리더로 선출됩니다.

Ref. Confluent : Kafka Replication and Committed Messages

배치 읽기/쓰기
파일 시스템을 다룰 때 데이터를 선형적으로 읽고/쓰는 패턴이 많아서 배치를 사용한 최적화가 쉽습니다. 로그 또한 연속된 데이터를 한꺼번에 읽고 쓰는 패턴이기 때문에 Kafka는 이 개념을 적극적으로 사용합니다. 클라이언트가 서버로 로그를 기록할 때, 반대로 클라이언트가 서버로부터 로그를 읽을 때, 서버간 데이터를 복제할 때 등 다양한 데이터 교환이 배치로 동작합니다.

불필요한 데이터 복사 방지
Kafka는 메모리, 디스크, 데이터 전송에 이용되는 데이터 표현을 하나의 단일 바이너리 포맷으로 통합해서 이용합니다. 이는 zero copy data transfer 등 다양한 최적화를 이용하기 쉽게 합니다.

Part 3. 로그와 실시간 스트림 프로세싱

스트림 프로세싱은 이벤트가 연속적이며 경계가 무한히 확장된 상황에서 데이터를 처리하는 방법을 다루는 데이터 처리 패러다임입니다. RDBMS에서 SQL로 데이터를 핸들링 하거나 배치 처리 도구를 이용하는 건 특정 시간에 고정된 데이터 셋에 대해 연산을 수행하는 반면, 스트림 프로세싱은 실시간으로 발생하는 데이터를 처리합니다.

많은 연구자들은 스트림 프로세싱이 배치 프로세싱을 대체할 수 있을 것으로 기대하고 있습니다. Jay는 그동안 스트림 프로세싱보다 배치 프로세싱이 더 많이 연구된 데에는 데이터를 수집하는 방식의 차이 때문이라고 봅니다. 국가에서 인구조사를 하는 방식을 생각해보죠.

  • 과거 인구조사는 각 지방관료가 지역마다 집을 하나씩 방문해가면서 데이터를 모아 중앙 행정기관으로 전송하는 방식을 따릅니다. 시스템으로 말하자면 애플리케이션이 로그를 주기적으로 덤프하고 Logstash같은 도구들이 한 번에 가져가는 것과 같죠. 이런 데이터 수집 방식을 사용하면 자연스럽게 기업의 데이터 처리 방식은 배치 모델이 될 수 밖에 없습니다.
  • 만약, 인구조사 방식이 처음부터 실시간으로 데이터를 전송하는 방식을 사용했다면 자연스럽게 계산 모델도 연속적인 데이터를 어떻게 처리할 것인지 고민했을 겁니다. 현재 링크드인은 거의 모든 시스템이 배치 모델로 데이터를 수집하지 않습니다. 데이터 수집 방식이 변경됨에 따라 스트림 프로세싱에 대한 관심이 증가했습니다.

배치 프로세싱 분야는 계산 모델을 Map/Reduce로 통합시켰고, RDBMS 분야에서는 벤더와 상관없이 SQL을 질의문으로 사용합니다. Jay는 이런 통합이 스트림 프로세싱 세계에서도 가능하다고 봅니다.

최근에는 SQL문을 스트림 처리 도메인에 어울리는 표준을 만들려고 Streaming SQL이라는 분야가 연구되고 있고, 대표적으로는 Apache Calcite가 있습니다. 구글이 개발한 Apache Beam에서는 PCollection이라는 객체로 Flink, Spark등 다양한 스트림 처리기를 추상화 시킵니다.

스트리밍 시스템(Streaming Systems)를 집필한 저자들은 배치는 스트림 프로세싱의 하위 분야이며 잘 만들어진 스트림 프로세싱은 배치 처리를 대체할 수 있다고 주장합니다. Jay 또한 비슷한 생각을 가지고 있는데요. 하루 단위로 데이터를 배치 처리하는 모델은 스트림 시스템에서는 현재 시간에서 과거 24시간 까지의 윈도우를 처리하는 것과 같습니다. 아직까지 완전히 배치 도구를 대체하지 못한 데에는 이러한 대규모 데이터를 실시간으로 수집할 수 있는 환경이 지금까지는 갖추어지지 않아서 학계의 연구 대상이 되지 못했기 때문이라고 진단합니다. 현재 아직까지 대세로 자리잡은 도구는 없지만 Apache Beam 등이 통합된 인터페이스를 제공하려는 시도를 하고 있습니다.

Part 3. Dataflow Graph

[Part 2]에서는 프로듀서가 발행한 이벤트르 저장할 통합된 자료구조로써의 Kafka의 역할을 설명했는데요. 스트림 프로세싱 세계에서는 로그를 처리하는 프로세서의 출력값이 다른 프로세서의 입력으로 주어질 수 있습니다. 데이터가 전체 시스템에서 어떻게 처리되는지 모델링 하는 방법을 데이터 플로우 모델이라고 부릅니다.

Dataflow Model

데이터 플로우의 각 프로세서를 End-to-End로 연결하면 확장성 있고 신뢰성있는 데이터 플로우를 만들 수 없습니다. 프로세서 A와 프로세서 B가 처리하는 속도가 서로 다르다고 해봅시다. A는 1초에 30개의이벤트를 생산하는데 B는 1초에 10개의 이벤트만 소화할 수 있습니다. 이 때, 프로세서가 할 수 있는 전략은 1. 데이터를 드랍시키거나 2. 블럭킹을 하거나 3. 버퍼링을 하거나 이 세가지 뿐입니다. 데이터를 드랍시키는 건 말도 안되고 그렇다고 블럭킹시키면 그래프에서 한 정점에서 발생한 대기가 그래프 전체로 전파될 위험이 있습니다. 가장 좋은 전략은 버퍼링을 시키는 건데요. 스트림 프로세서들의 입력과 출력 데이터를 저장하는 중간 버퍼로 Kafka를 이용하면 좋습니다. Kafka는 또한 데이터의 부분 정렬 기능을 매우 중요하게 생각하고 있죠.

Part 3. Stateful 실시간 프로세싱

실시간 프로세싱이 Stateless 하다면 얼마나 좋았겠냐만은, 실제 세계에서는 Stateful한 연산이 필요한 경우가 많습니다. 쇼핑몰에서 오픈 이래 현재까지 유저 한명당 평균 판매금액을 실시간으로 계산하고 싶다고 한다면 전체 데이터에 대해 매 순간 집계할 수 없기 때문에 과거에서 계산한 이력, 즉 컨텍스트를 가지고 다녀야 합니다.

클릭 이벤트를 수신받고 유저 정보를 데이터베이스에서 가져와서 조인하는 경우 컨텍스트를 저장해야 합니다. 왜 컨텍스트 정보가 필요할까요? 스트림 프로세서에 장애가 발생해서 디스크가 날아간 경우 로그를 순차적으로 읽으면서 재처리를 해야 하는데요. 만약 유저가 미래에 탈퇴한 경우 과거에 수행한 결과와 현재 처리한 결과가 달라집니다. 즉, 결정성을 지킬 수 없게 되죠.

두 가지 대안이 있습니다. (1) 메모리에 저장한다, 이 경우 스트림 프로세서가 중간에 부러진 경우 정보가 휘발됩니다. (2) S3같은 원격 저장소에 저장한다, 프로세서의 데이터 Locality가 낮아서 많은 네트워크 round-trip을 요구합니다. 테이블과 로그가 사실은 동일한 것이기 때문에 스트림 프로세서가 바라보는 테이블을 로그로부터 구성하는 아이디어가 있습니다. (e.g. Apache Samza) 유저 데이터베이스가 CDC를 이용해 변경 내역이 Kafka에 로그로 기록되어 있다면 스트림 프로세서는 데이터베이스를 직접 호출하는 것이 아니라 CDC의 로그 기록을 읽어서 테이블을 내부적으로 만들고 이를 조인하자는 내용입니다.

Apache Flink와 구글 클라우드의 Dataflow에서는 각 스트림 프로세서가 처리한 출력 결과를 체크포인트라는 이름으로 저장하고 있다가 장애가 발생하면, 마지막으로 처리한 체크포인트를 읽어와서 재처리를 수행합니다. 스트리밍 시스템에서는 Exactly Once Semantic이 매우 중요합니다. 스트림 프로세싱에 대한 자세한 내용은 제 지난 포스팅이 도움을 줄 수 있을 것 같아요 :)

Ref. Apache Flink : From Aligned to Unaligned Checkpoints — Part 1: Checkpoints, Alignment, and Backpressure

Part 3. 로그 컴팩션

당연하게도 디스크 공간이 제한되어 있으므로 로그를 영구히 저장할 수는 없습니다. 그래서 로그를 적절히 제거해야 하는데요. 이를 위한 2가지 전략이 있습니다. 보관 주기를 설정하는 것과 로그 컴팩션입니다.

  • (보관 주기 설정) 유저의 클릭 이벤트 같은 경우 과거의 데이터가 꼭 필요하지 않다면 리텐션 기간을 정해서 로그의 부관주기를 결정하고 특정 임계 시간을 넘긴 오래된 레코드는 삭제합니다.
  1. (중요!) 로그와 테이블의 동일성을 설명하면서 로그가 현재 상태를 구성하거나 과거 시점의 데이터를 복원되는데 활용할 수 있다는 점을 강조했었죠. 그런데, 시간을 기준으로 데이터를 지워버리면 데이터를 완전히 복구하거나 과거시점으로 돌아갈 수 없습니다.
UserID를 파티션키로 잡은 Kafka 토픽의 모습

로그의 보관 주기를 이용해서 로그를 지우는 경우 특정 유저의 이벤트 전체가 제거될 수 있습니다. 그러면 (2)번에서 설명한 테이블과 로그의 동일성 특징을 이용할 수 없겠죠. 그래서 카프카는 같은 파티션 키를 가진 이벤트 중에서 오래된 것을 제거하는 옵션을 지원합니다. 다시 말하자면 같은 파티션 키마다 최근의 이벤트는 유지되도록 해주는데요. 아래 상황에서 유용합니다.

  • 데이터 변경 구독 : 캐시, 검색엔진, 하둡 등 다양한 데이터 시스템에서 최근의 데이터만을 다루는 경우에는 과거 데이터를 삭제해도 되지만 장애 상황에서 온전한 데이터셋을 복구해야 하는 경우
  • 이벤트 소싱 : 컴팩션을 이용하면 각 키에 대해 최신 상태는 보관된다는 것을 보장합니다.
  • 고가용성을 위한 저널링 : 로컬 연산을 수행하는 프로세스의 변경된 상태를 로그로 저장해서 내결함성을 챙길 수 있습니다. 프로세스에서 장애가 발생하면 다른 프로세스가 해당 상태를 이어받아서 이어서 처리하는 방식입니다. 대표적으로 카운팅, 집계 등 “group-by” 스타일의 프로세싱 스타일이 있는데요. Kafka Stream이 이를 위해 로그 컴팩션을 이용합니다.
Ref. Kafka : Log Compaction

Part 3. 로그 중심의 인프라 스택

우리가 분산 시스템을 스스로 개발한다고 생각해봅시다. 분산 시스템은 사실 로그를 복제하는 시스템이기 때문에 내부에 로그를 구현하려면 아래의 특징을 만족하는 로그를 직접 구현해야 합니다.

  • 데이터의 일관성 보장
  • 노드 간 데이터 복제
  • 데이터의 커밋시맨틱 제공 (복제가 온전히 수행되고 나서 writer에게 ACK를 주는 경우)
  • 외부 데이터 구독 피드를 제공
  • 새로운 노드를 구성하거나 장애가 발생한 노드의 복구
  • 노드간 데이터 리밸런싱 핸들링

이 항목은 실제로 분산 데이터 시스템이 해야 하는 많은 일들을 표현한 겁니다. 이 문제는 Kafka가 해결해주었으니, 이제 분산 시스템을 구현하기 위해 남은 일은 인덱싱 전략과 Query API를 만드는 일입니다.

이건 로그와 Serving Layer 두 가지로 나뉩니다. 로그는 변경 내역을 순차적으로 저장하고 서빙 노드(serving node)는 인덱스를 구성하고 사용자에게 Query API를 제공하는 일을 합니다. 예를 들어 검색 엔진은 Inverted Index가 필요할 것이고 키-밸류 저장소는 이진 트리나 SSTABLE이 필요할 겁니다. 이를 추상화 시키면 아래와 같은 모습이 될 겁니다.

로그와 서빙 레이어

서빙 노드는 단일 프로세스로 개발해도 괜찮습니다. 로그가 단일 진실 공급원(Single Source of Truth) 이기 때문이죠. 분산 시스템을 직접 개발할 때 처럼 로그 복제, 장애 복구, 파티션 리밸런싱 같은 일은 우리가 신경 쓸 필요가없을 겁니다. 이 모든 복잡성은 로그 시스템에게 위임하고 다양한 시스템들은 동일한 로그를 공유하면서 서로 다른 인덱스 타입을 지원하게 될겁니다.

Log-centric Infrastructure Stack

엔딩 — 마무리
겸사 겸사 글을 마무리 해볼까 해요, 지금까지 복잡하게 얽힌 데이터 시스템 사이의 관계를 ‘로그’ 라는 하나의 자료구조로 추상화 시켜서 단순화시켰습니다. 그리고 Apache Kafka를 만들면서 로그라는 시스템을 현대 시스템 설계의 핵심으로 이끄는 내용이 인상깊었습니다. Jay의 글은 제게 깊은 인상을 주었는데요, 글을 읽고 나서 2가지 생각이 들었습니다.

(1) 기존의 문제에서 ‘조금’ 개선하는 건 재능만 있으면 누구나 할 수 있지만 기존 관행에서 문제점을 발견하는 것과 이를 본질적으로 관통하는 새로운 패러다임을 제시하는 건 ‘창의력’이 필요하다고 느꼈습니다. 저는 대학원에서 블록체인을 연구했었는데요. 이 분야에서는 Majority is not enough라는 논문과 Flash Boys 2.0이 그랬고, AI쪽으로는 데이터 생성 기법의 근본이라고 할 수 있는 GAN 논문이 비슷한 인상을 주었습니다.

(2) 복잡한 관계를 단순한 디자인으로 추상화 시키는 능력이 매우 중요하다고 생각합니다. 이 글은 추상화의 아름다움을 다시 한 번 느끼게 해주고 중요성을 깨닫게 해주었습니다.

제가 너무 이 글을 과장한걸지도 몰라요, 평소에 분산 시스템을 잘 아시는 분이라면 뭐야 뻔한 내용밖에 없는데? 라고 생각하실지도 모르겠습니다. 오늘은 글이 조금 길었는데 누군가에게 도움이 되었길 바랍니다 : )

--

--

scalalang2
취미로 논문 읽는 그룹

평범한 프로그래머입니다. 취미 논문 찾아보기, 코딩 컨테스트, 언리얼 엔진 등 / Twitter @scalalang2 / AtCoder @scalalang