-
[데이터 중심 애플리케이션] 스트림 처리Software Development/Database 2022. 1. 9. 16:07
일간 일괄 처리의 문제점은 입력 변화가 하루가 지나야 반영된다는 것인데 성격 급한 사용자가 느끼기에는 너무 느리다. 고정된 시간 조각이라는 개념을 완전히 버리고 단순히 이벤트가 발생할 때마다 처리해야 한다. 이 방법이 스트림 처리의 기본 개념이다.
"스트림"은 시간 흐름에 따라 점진적으로 생상된 데이터를 일컫는다. 이 개념은 유닉스의 stdin과 stdout, 자바의 FileInputsStream 같은 파일 시스템 API, TCP 연결, 인터넷 상의 오디오와 비디오 전송 등 많은 곳에서 등장한다.
이벤트 스트림 전송
일괄 처리 환경에서 작업은 입출력이 파일이다. 스트림 처리에서는 입력이 파일일 떄 대개 첫 번째 단계로 파일을 분석해 레코드의 연속으로 바꾸는 처리를 한다. 스트림 처리 문맥에서 레코드는 보통 이벤트라고 하지만 특정 시점에 일어난 사건에 대한 세부 사항을 포함하는, 작고 독립된 불변 객체라는 점에서 본질적으로 동이랗다. 이벤트는 일기준 시계를 따르는 이벤트 발생 타임스탬프를 포함한다.
예를 들면 웹 페이지를 보거나 상품을 구입하는 일 같은 사용자가 취한 행동이 이벤트가 될 수 있다. 온도 센서에서 주기적으로 측정한 값이나 CPU 사용률 지표와 같이 장비에서 발생한 것도 마찬가지로 이벤트가 될 수 있다. 웹 서버 로그의 각 줄을 이벤트 하나로 취급한다.
이벤트는 텍스트 문자열이나 JSON 또는 이진 형태 등으로 부호화된다. 이벤트는 부호화 과정을 통해 저장할 수 있다. 예를 들어 부호화한 이벤트를 파일에 덧붙이거나 관계형 테이블에 삽입하거나 문서 데이터베이스에 기록하거나 하는 식으로 저장 가능하다.
일괄 처리와 마찬가지로 한 번 기록하면 여러 작업에서 읽을 수 있다. 생산자가 이벤트를 한 번 만들면 복수의 소비자가 처리할 수 있다. 파일 시스템에서는 관련 레코드 집합을 파일 이름으로 식별하지만 스트림 시스템에서는 대개 토픽이나 스트림으로 관련 이벤트를 묶는다.
이론상으로 파일이나 데이터베이스가 있으면 생산자와 소비자를 연결하기에 충분하다. 생산자는 이벤트를 데이터스토어에 기록하고 각 소비자는 주기적으로 데이터스토어를 폴링해 마지막으로 처리한 이벤트 이후에 새로 발생한 이벤트가 있는지 확인한다. 일관 처리에서 매일 마지막에 그날 데이터 분량을 처리하는 것과 본질적으로 동일하다.
그러나 지연 시간이 낮으면서 지속해서 처리하는 방식을 지향할 때 데이터스토어를 이런 용도에 맞게 설계하기 않았다면 폴링 방식은 비용이 크다. 폴링이 잦을수록 새로운 이벤트를 반환하는 요청 비율이 낮아지기 때문에 폴링을 수행하는 오버헤드가 커진다. 새로운 이벤트가 발생할 때 소비자에게 알리는 편이 더 낫다.
데이터베이스는 전통적으로 알림 메커니즘을 강력하게 지원하지 않는다. 트리거가 있으나 기능이 제한적이다.
메세징 시스템
새로운 이벤트에 대해 소비자에게 알려주려고 쓰이는 일반적인 방법은 메세징 시스템을 사용하는 것이다.
유닉스 파이프나 TCP 연결과 같은 직접 통신 채널을 사용해 간단하게 구현할 수 있다. 유닉스 파이프와 TCP는 전송자 하나를 정확히 수신자 하나에 연결한다. 반면 메세징 시스템은 다수의 생산자 노드가 같은 토픽으로 메세지를 전송할 수 있고 다수의 소비자 노드가 토픽 하나에서 메세지를 받아 갈 수 있다.
발행/구독 모델에서는 여러 시스템들이 다양한 접근법을 사용하다. 모든 목적에 부합하는 단 하나의 정답은 없다. 아래 두 가지 질문이 시스템을 구별하는 데 상당히 도움이 된다.
- 생산자가 소비자가 메세지를 처리하는 속도보다 빠르게 메세지를 전송한다면 어떻게 될까?
- 시스템은 메세지를 버리거나 큐에 메세지를 버퍼링
- 생산자가 메세지를 더 보내지 못하게 적용한다. 유닉스 파이프와 TCP는 배압(backpressure)을 사용한다. 작은 고정 크기 버퍼를 두고 버퍼가 가득 차면 버퍼에서 데이터를 가져갈 때까지 전송자를 막는다.
- 메세지가 큐에 버퍼링될 때 큐 크기가 증가함에 따라 어떤 현상이 생기는지 이해해야 한다. 큐가 메모리보다 커지면 시스템이 중단되는지, 메세지를 디스크에 쓰는지 디스크에 쓴다면 메세징 시스템의 성능에 어떤 영향을 주는지
- 노드가 죽거나 일시적으로 오프라인이 된다면 어떻게 될까? 손실되는 메세지가 있을까?
- DB처럼 지속성을 갖추려면 디스크에 기록하거나 복제본을 생성을 하거나 둘 모두를 해야 한다.
메세지 유실을 허용할지 말지는 애플리케이션에 따라 상당히 다르다. 주기적으로 전송되는 센서 판독값과 지표는 가끔 데이터가 누락되더라도 큰 문제가 없다.
생산자에서 소비자로 메세지를 직접 전달하기
많은 메세지 시스템은 중간 노드를 통하지 않고 생산자와 소비자를 네트워크로 직접 통신한다.
- UDP 멀티캐스트는 낮은 지연이 필수인 주식 시장과 같은 금융 산업에서 널리 사용된다. UDP 자체는 신뢰성이 낮아도 애플리케이션 단의 프로토콜은 읽어버린 패킷을 복구할 수 있다.
- ZeroMQ 같은 브로커가 필요없는 메세질 라이브러리와 나노메세지가 이와 유사한 접근법을 사용한다. TCP 또는 IP 멀티캐스트 상에서 발생/구독 메세징을 구현한다.
- StatsD와 BruBeck은 네트워크 상의 모든 방비로부터 지표를 수집하고 모니터링하는 데 UDP 메세징을 사용한다.
- 소비자가 네트워크에 서비스를 노출하면 생산자는 직업 HTTP나 RPC를 보낼 수 있다.
직접 메세징 시스템은 설계 상황에서는 잘 동작하지만 일반적으로 메세지가 유실도리 수 있는 가능성을 고려해서 애플리케이션 코드를 작성해야 한다. 그러나 허용 가능한 결함은 상당히 제한적이다.
소비자가 오프라인이라면 메세지를 전달하지 못하는 상태에 있는 동안 전송된 메세지는 잃어버릴 수 있다. 생산자에게 재시도하게 하지만 생산자 장비가 죽어버리면 재시도하려고 했던 메세지 버퍼를 잃어버릴 수 있기 때문에 문제가 있다.
메세지 브로커
메세지 브로커는 근본적으로 메세지 스트림을 처리하는 데 최적화된 데이터베이스의 일종이다. 메세지 브로커는 서버로 구동되고 생산자와 소비자는 서버 클라이언트로 접속한다.
브로커에 데이터가 모이기 때문에 이 시스템은 클라이언트의 상태 변경에 쉽게 대처할 수 있다. 지속성 문제가 샌산자와 소비자에서 브로커오 옮겨왔기 때문이다.
메세지 브로커와 데이터베이스의 비교
어떤 메세지 브로커는 XA 또는 JTA를 이용해 2단계 커밋을 수행하기도 한다.
- 데이터베이스는 명시적으로 데이터가 삭제될 때 까지 보관한다. 메세지 브로커 대부분은 소비자에게 데이터 배달이 성공할 경우 자동으로 메세지를 삭제한다.
- 메세지 브로커는 메세지를 빨리 지우기 때문에 작업 집합이 상당히 작다고 가정한다. 즉 큐 크기다 작다.
- 데이터베이스는 보조 색인을 지원하고 데이터 검색을 위한 다양한 방법을 지원하지만 메세지 브로커는 특정 패턴과 부합하는 토픽의 부분 집합을 구독하는 방식을 지원한다.
- 데이터베이스에 질의할 때 그 결과는 일반적으로 질의 시점의 데이터 스냅샷을 기준으로 한다. 다른 클라이언트가 이어서 질의 결과를 바꾸는 어떤 데이터를 데이터베이스에 기록한다면 첫 번째 클라이언트는 다시 질의하거나, 데이터 변화를 폴링하지 않는다면 앞선 결과가 기간이 지나 유효하지 않다는 점을 알 길이 없다.
복수 소비자
로드 밸런싱: 각 메세지는 소비자 중 하나로 전달된다. 메세지 처리 비용이 비싸거 병렬화하기 위해 소비자를 추가하고 싶을 때 유용하다.
팬 아웃: 각 메세지는 모든 소비자로 전달된다. 같은 입력 파일을 읽어 다른 일괄 처리 작업에서 사용하는 것과 동일하다.
위 두가지는 함께 사용할 수 있다.
확인 응답과 재전송
소비자는 언제라도 장애가 발생할 수 있다. 메세지를 잃어버리지 않기 위해 브로커는 확인 응답을 사용한다.
클라이언트는 메세지 처리가 끝났을 때 브로커가 메세지를 큐에서 제거할 수 있게 브로커에게 명시적으로 알려야 한다.
브로커가 확인 응답을 받기 전에 클라이언트로의 연결이 닫히거나 타임아웃이되면 브로커는 메세지가 처리되지 않았다고 사정하고 다른 소비자에게 다시 전송한다. 메세지가 완전히 처리됐어도 네트워크 상에서 확인 응답을 유실할 수도 있다. 이런 경우를 처리하기 위해 원자적 커밋 프로토콜이 필요하다.
부하 균형 분산과 결합할 때 이런 재전송 행위는 메시지 순서에 영향을 미친다. 메세지 간 인과성이 있다면 이 상황을 고려해야 한다.
파티셔닝된 로그
네트워크 상에서 패킷을 전송하거나 네트워크 서비스에 요청하는 작업은 보통 영구적 추적을 남기지 않는 일시적 연산이다. 메세지 브로커도 일시적으로 보관하는 개념으로 만들어졌다.
데이터베이스나 파일시스템은 영구적으로 보관하기 만들어졌는데 이런 개념의 차이는 파생 데이터를 생성하는 방식에 큰 영향을 미친다. AMQP/JMS 형식의 메세징 처리는 브로커가 확인 응답을 받으면 메세지를 삭제하기 떄문에 이미 받은 메세지를 복구할 수 없다. 소비자를 다시 실행해도 동일한 결과를 받지 못한다.
데이터베이스의 지속성 있는 저장 장법과 메세징 시스템의 지연 시간이 짧은 알림 기능을 조합한 것이 로그 기반 메세지 브로커의 기본 아이디어다.
로그를 사용한 메세지 저장소
로그는 단순히 디스크에 저장된 추가 전용 레코드의 연속이다. 브로커를 구현할 떄도 같은 구조를 사용한다. 생산자가 보낸 메세지는 로그 끝에 추가하고 소비자는 로그를 순차적으로 읽어 메세지를 받는다. 소비자가 로그 끝에 도달하면 세 메세지가 추가됐다는 알림을 기다린다. 유닉스 도구 tail -f는 파일에 추가되는 데이터를 감시하는데 본질적으로 이 구조와 동일하다.
디스크 하나를 쓸 때보다 처리량을 높이기 위해 확장하는 방법으로 로그를 파티셔닝하는 방법이 있다. 다른 파티션은 다른 장비에서 서비스할 수 있다. 그러면 각 파티션은 다른 파티션과 독립적으로 일고 쓰기가 가능한 분리된 로그가 된다. 토픽은 같은 형식의 메세지를 전달하는 파티션들의 그룹으로 정의한다.
각 파티션 내에서 브로커는 모든 메세지에 오프셋이라고 부르는, 단조 증가하는 순번을 부여한다. 파티션이 추가 전용이고 따라서 파티션 내 전체 메세지는 전체 순서가 있기 때문에 순번을 부여하는 것은 타당하다. 단 다른 파티션 간 메세지의 순서는 보장하지 않는다.
아파치 카프카, 아마존 키네시스 스트림, 트위터의 분산 코드가 이런 방식으로 동작하는 로그 기반 메세지 브로커다. 이런 메세지 브로커는 모든 메세지를 디스크에 저장하지만 여러 장비에 메세지를 파티셔닝해 초당 수백만 개의 메세지를 처리할 수 있고 메세지를 복제함으로써 장애에 대비할 수 있다.
로그 방식과 전통적인 메세징 방식의 비교
로그 기반은 팬 아웃 메세징 방식을 제공한다. 소비자가 서로 영향 없이 독립적으로 로그를 읽을 수 있고 메세지를 읽어도 로그에서 삭제되지 않기 때문이다.
메세지 처리하는 비용이 비싸고 메세지 단위로 병렬화 처리하고 싶지만 메세지 순서는 그렇게 중요하지 않다면 AMQP/JMS 방식의 메세지 브로커가 적합하다.
처리량이 많고 메세지를 처리하는 속도가 빠르지만 메세지 순서가 중요하다면 로그 기반 접근법이 효과적이다.
소비자 오프셋
파티션 하나를 순서대로 처리하면 메세지를 어디까지 처리했는지 알기 쉽다. 소비자의 현재 오프셋보다 작은 오프셋을 가진 메세지는 이미 처리한 메세지고 소비자의 현재 오프셋보다 큰 오프셋을 가진 메세지는 아직 처리하지 않았다.
메세지 오프셋은 단일 디러 데이터베이스 복제에서 널리 쓰는 로그 순차 번호와 상당히 유사하다. 로그 순차 번호를 사용하면 기록을 누락하지 않고 복제를 재개할 수 있다. 정확하게 같은 원리가 여기서 사용된다. 메세지 브로커는 데이터베이스의 리더처럼 동작하고 소비자는 팔로워처럼 동작한다.
소비자 노드에 장애가 발생하면 소비자 그룹 내 다른 노드에 장애가 발생한 소비자의 파티션을 할당하고 마지막 기록된 오프셋부터 메세지를 처리하기 시작한다. 재시작시 오프셋 기록을 하지 못한 메세지는 두 번 처리될 수 있다. 이는 따로 엔지니어가 고려해야 하는 상황이다.
디스크 공간 사용
로그를 계속추가하면 디스크 공간을 전부사용하게 된다. 로그를 여러 조각으로 나누고 가끔 오래된 조각을 삭제하거나 보관 저장소로 이동한다.
소비자 처리가 너무 느려서 오프셋이 이미 삭제한 조각을 가리킬 수 있다. 메세지 일부를 잃어버리게 될 수 있다. 결과적으로 로그는 크기가 제한된 버퍼로 구현하고 버퍼가 가득 차면 오래된 메세지 순서대로 버린다. 이런 버퍼를 원형 버퍼, 링 버퍼라고 한다. 버퍼가 디스크상에 있다면 상당히 커질 수 있다.
일반적으로 큰 하드디스크의 용량이 6TB, 순사처리향은 150MB다. 가능한 최대 속도로 메세지를 기록한다면 디스크를 가득 채우는 데 약 11시간이 걸린다. 이 하드디스크는 11시간 동안 발생한 메세지를 버퍼링할 수 있다. 그 이후에는 오래도니 메세지를 덮어쓴다. 로그는 일반적으로 수 이렝서 수 주간 메세지를 보관할 수 있다.
메세지 보관 기간과 관계없이 몯느 메세지를 디스크에 기록하기 때문에 로그 처리량은 일정하다. 이런 동작은 기본적으로 메모리에 메세지를 유지하고 큐가 너무 커질 때만 디스크에 기록하는 메세징 시스템과는 반대다. 이 시스템은 큐가 작을 때는 따르지만 디스크에 기록하기 시작하면 매우 느려진다. 그래서 이 시스템의 처리량은 보유한 메세지 양에 따라 다르다.
소비자가 생산자를 따라갈 수 없을 때
소비지가 뒤처져 필요한 메세지가 디스크에 보유한 메세지보다 오래되면 필요한 메세지는 읽을 수 없다. 그래서 브로커는 버퍼 크기를 넘는 오래된 메세지를 자연스럽게 버린다. 소비자가 로그의 헤드로부터 얼마나 떨어졌는지 모니터링하면 눈에 띄게 뒤처지는 경우 경고할 수 있다. 버퍼가 커질수록, 사람이 소비자 처리가 느린 문제를 고쳐 메세지를 잃기 전에 따라 잡을 시간을 충분히 벌 수 있다.
어떤 소비자가 너무 뒤쳐져도 해당 소비자만 영향을 받는다. 이는 운영상 장점이 있다. 프로덕션 서비스에 영향을 줄 우려 없이 개발, 데스크 디버깅 목적으로 프로덕션 로그를 소비하는 실험이 가능하다. 전통적인 메세지 브로커는 소비자가 중단되면 그 소비자가 사용하면 큐를 삭제해줘야 한다. 그렇지 않으면 큐에 불필요한 메세지가 누적되고 여전히 활성화된 소비자로부터 메모리를 계속 뺏어가게 된다.
오래된 메세지 재생
오프셋 조작으로 예전 메세지를 처리할 수 있다. 일괄 처리와 유사한 측면이다. 많은 실험을 할 수 있고 오류와 버그를 복구하기 쉽기 때문에 조직 내에서 데이터플로를 통합하는 데 좋은 도구다.
데이터베이스와 스트림
로그 기반 브로커는 데이터베이스에서 아이디어를 얻어 메세징에 적용하는 데 성공했다. 뭔가를 기록한다는 사실은 캡처해서 저장하고 처리할 수 있는 이벤트다. 이런 관측을 통해 데이터베이스와 스트림 사이의 연결점이 단지 디스크에 로그를 저장하는 물리적 저장소 이상이라는 점을 알 수 있다. 이것이 가장 근본적인 개념이다.
이번에는 이종 데이터 시스템에서 발생하는 문제 한 가지를 먼저 살펴본 다음 이벤트 스트림의 아이디어를 데이터베이스에 적용해 이 문제를 해결하는 방법을 찾는다.
시스템 동기화 유지하기
대부분의 중요 애플리케이션의 요구사항을 만족하기 위해 몇 가지 다른 기술의 조합이 필요하다. 사용자 요청에 대응하기 위한 OLTP 데이터베이스, 공통 요청의 응답 속도를 높이기 위한 캐시, 검색 질의를 다루기 위한 전문 색인, 분석용 데이터 웨어하우스가 그 예다. 시스템 각각은 데이터의 복제본을 가지고 있고 그 데이터는 목적에 맞게 최적화된 형태로 각각 저장된다.
데이터 웨어하우스에는 동기화 과정을 대개 ETL 과정에서 수행한다. 흔히 데이터베이스 전체를 복사하고 변환한 후 데이터 웨어하우스로 벌크 로드한다. 즉 이 과정은 일괄 처리다.
주기적으로 데이터베이스 전체를 덤프하는 작업이 너무 느리면 대안으로 사용하는 방법으로 이중기록이 있다. 이중 기록을 사용하면 데이터가 변할 때마다 애플리케이션 코드에서 명시적으로 각 시스템에 기록한다. 예로 먼저 데이터베이스에 기록한 다음 검색 색인을 갱신한다. 그리고 캐시 엔트리를 무효화한다.
이중 기록은 경쟁 조건으로 인해 데이터베이스와 검색 색인간의 값이 달라질 수 있다. 예를 들어, A와 B라는 값이 거의 동시에 쓰일 때 A가 DB에 먼저 쓰고 B가 DB와 색인에 값을 쓰고 마지막으로 A가 검색 색인에 값을 쓴다면, DB와 검색 색인의 최종 값이 다를 수 있다.
버전 벡터와 같은 동시성 감지 메커니즘을 사용하지 않으면 동시 쓰기가 발생해도 알아차리지 못한 채 한 값이 다른 값을 덮어쓴다.
이중 기록의 다른 문제는 DB는 쓰기가 성공하고 검색 색인은 실패할 수 있다는 것이다. 내결함성 문제로 두 시스템간의 불일치가 발생하는 것이다. 이 문제를 해결하는데 비용이 많이 든다.
색인용 인덱스를 데이터베이스의 팔로워로 만들어 실제로 리더 하나만 존재하게 한다면 상황은 훨씬 낫다. 하지만 실제로 가능할까?
변경 데이터 캡쳐
대다수 데이터베이스의 복제 로그와 관련된 문제는 공개 API가 아니라 오랫동안 데이터베이스 내부 상세 구현으로 간주됐다. 클라이언트는 일반적으로 복제 로그를 파싱해서 데이터를 추출하는 방식을 사용하지 않고 데이터 모델과 질의 언어를 통해 데이터베이스에 질의한다.
수십 년 동안 많은 데이터베이스는 데이터 변경 로그를 얻는 방법에 대해 기술한 문서를 제공하지 않았다. 그렇기 때문에 데이터베이스엥서 발생하는 데이터 변화를 감지해서 변경된 내용을 검색 색인, 캐시, 데이터 웨어하우스 같은 다른 저장 기술에 복제하기 어려웠다.
최근 들어 변경 데이터 캡처(CDC)에 관심이 높아지고 있다. 데이터베이스에 기록하는 모든 데이터의 변화를 관찰해 다른 시스템으로 데이터를 복제할 수 있는 형태로 추출하는 과정이다. CDC는 데이터가 기록되자마자 변경 내용을 스트림으로 제공할 수 있으면 특히 유용하다.
예를 들어 데이터베이스의 변경 사항을 캡처해 같은 변경 사항을 검색 색인에 꾸준히 반영할 수 있다. 같은 순서로 로그 변경이 반영된다면 데이터베이스의 데이터와 색인이 일치할 것이다.
변경 데이터 캡처의 구현
검색 색인과 데이터 웨어하우스에 저장된 데이터 레코드 시스템에 저장된 데이터의 또 다른 뷰일 뿐이므로 로그 소비자를 파생 데이터 시스템이라 할 수 있다. 변경 데이터 캡처는 파생 데이터 시스템이 레코드 시스템의 정확한 데이터 복제본을 가지게 하기 위해 레코드 시스템에 발생하는 모든 변경 사항을 파생 데이터 시스템에 반영하는 것을 보장하는 메커니즘이다.
CDC는 본질적으로 변경 사항을 캡처할 데이터베이스 하나를 리더로 하고 나머지를 팔로워로 한다. 로그 기반 메세지 브로커는 원본 데이터베이스에서 변경 이벤트를 전송하기에 적합하다. 로그 기반 메세지 브로커는 메세지 순서를 유지하기 때문이다.
CDC를 구현하는 데 데이터베이스 트리거를 사용하기도 한다. 데이터 테이블의 모든 변화를 관찰하는 트리커를 등록하고 변경 로그 테이블에 해당 항목을 추가하는 방식이다. 하지만 이 방식은 고장나기 쉽고 오버헤드가 상당하다. 복제 로그를 파싱하는 방식은 스키마 변경 대응 등 해결해야 할 여러 문제가 있지만 트리거 방식보다 견고한 방법이다.
몽고리버는 몽고디비의 oplog를 읽고, 디비지움은 binlog를 파싱해 마이시퀄 CDC를 구현하고, 보틀드 워터는 쓰기 전 로그를 복호화하는 API를 사용해 포스트그레시퀄 CDC를 구현하고 골든게이트는 오라클용이다.
CDC는 메세지 브로커와 비슷하게 비동기 방식으로 동작한다. 레코드 데이터베이스 시스템은 변경 사항을 커밋하기 전에 변경 사항이 소비자에게 적용될 때까지 기다리지 않는다. 그러나 복제 지연은 발생할 수 있다.
초기 스냅샷
DB에 발생한 변경 로그가 있다면 로그를 재현해서 데이터베이스의 전체 상태를 재구축할 수 있다. 그러나 모든 변경 사항을 영구적으로 보관하는 일은 디스크 공간이 너무 많이 필요하고 모든 로그를 재생하는 작업도 너무 오래 걸린다.그래서 로그를 적당히 잘라야 한다.
데이터베이스 스냅샷은 변경 로그의 위치나 오프셋에 대응돼야 한다. 그래야 스냅샷 이후에 변경 사항을 적용할 시점을 알 수 있다.
로그 컴팩션
로그 히스토리의 양을 제한한다면 새로운 파생 데이터 시스템을 추가할 때마다 스냅샷을 만들어야 한다. 하지만 로그 컴팩션이 있다.
원리는 간단하다. 주기적으로 같은 키의 로그 레코드를 찾아 중복을 제고하고 각 키에 대해 가장 최근에 갱신된 내용만 유지한다. 컴팩션과 병합 과정은 백그라운드에서 실행된다.
로그 구조화 저장 엔진에서 특별한 널 값(tombstone)으로 갱신하는 것은 키의 삭제를 의미하고 로그 컴팩션을 수행할 때 실제로 값을 제거한다.
이벤트 소싱
DDD 커뮤니티에서 개발한 기법으로 애플리케이션 상태 변화를 모두 변경 이벤트 로그로 저장한다. 단지 추가만 가능하고 삭제 갱신은 권장하지 않거나 금지한다.
이벤트 소싱을 사용하면 애플리케이션을 지속해서 개선하기가 매우 유리하다. 어떤 상황이 발생한 후 상황 파악이 쉽기 때문에 디버깅에 도움이 되고 애플리케이션 버그를 방지한다.
이벤트 소싱은 연대기 데이터 모델과 유사하다. 또한 이벤트 로그와 별 모양 스키마에서 발견한 사실 테이블 사에어도 유사점이 있다.
이벤트 로그에서 현재 상태 파생하기
이벤트 소싱을 사용하는 애플리케이션은 시스템에 기록한 데이터를 표현한 이벤트 로그를 가져와 사용자에게 보여주에 적당한 애플리케이션 상태로 변환해야 한다. 다시 수행하더라도 이벤트 로그도부터 동일한 애플리케이션 상태를 만들 수 있어야 한다.
CDC와 마찬가지로 이벤트 로그를 재현하면 현재 시스템 상태를 재구성할 수 있다. 그러나 로그 컴팩션은 다르게 처리해야 한다.
- 레코드 갱신용 CDC 이벤트는 일반적으로 레코드의 가장 새로운 버전을 보유한다. 그래서 기본키의 현재 값은 전적으로 기본키의 가장 최신 이벤트로 결정되고 같은 키의 이전 이벤트는 로그 컴팩션을 통해 벌니다.
- 이벤트 소싱은 이벤트를 보다 상위 수준에서 모델링한다. 이벤트는 대개 사용자 해동의 결과로 발생한 상태 갱신 메커니즘이 아닌 사용자 행동 의도를 표현한다. 이 경우 뒤에 발생한 이벤트가 앞선 이벤트를 덮어쓰지 않는다. 그래서 마지막 상태를 재구축하기 위해서는 이벤트의 전체 히스토리가 필요하다. 이런 방식에서는 로그 컴팩션이 불가능하다.
이벤트 소싱을 사용하는 애플리케이션은 일반적으로 이벤트 로그에서 파생된 현재 상태의 스냅샷을 저장하는 메커니즘이 있기 때문에 전체 로그를 반복해서 재처리할 피룡는 없다. 하지만 이 메커니즘은 장애 발생 시 일고 복구하는 성능을 높여주는 최적화에 불과하다. 이벤트 소싱 시스템에는 모든 원시 이벤트를 영원히 저장하고 필요할 때마다 모든 이벤트를 재처리할 수 있어야 한다는 의도가 있다.
명령과 이벤트
이벤트 소싱 철학은 이벤트와 명령을 구훈하는 데 주의한다. 사용자 요청이 처음 도착했을 때 이 요청은 명령이다. 이 시점에서는 명령이 실패할 수도 있다. 예로 특정 무결성 조건을 위반하면 실패한다. 애플리케이션은 먼저 명령이 실행 가능한지 확인해야 한다. 무결성이 검증되고 명령이 승인되면 명령은 지속성 있는 불변 이벤트가 된다.
이벤트는 생성 시점에 사실이 된다
상태와 스트림 그리고 불변성
추가 전용 이벤트 로그를 적분하면 애플리케이션의 시간에 따른 상태를 구할 수 있다.
변경 로그를 지속성 있게 저장한다면 상태를 간단히 재생성할 수 있는 효과가 있다.
로그 컴팩션은 로그와 데이터베이스 상태 사이의 차이를 메우는 한 가지 방법이다. 로그 컴팩션은 각 레코드의 최신 버전만을 보유하고 덮어 쓰여진 버전은 삭제한다.
불변 이벤트의 장점
불변 이벤트 로그를 썼다면 문제 상황의 진단과 복구가 훨씬 쉽다.
불변 이벤트는 현재 상태보다 훨씬 많은 정보를 포함한다.
동일한 이벤트 로그로 여러 가지 뷰 만들기
불변 이벤트 로그에서 가변 상태를 분리하면 동일한 이벤트 로그로 다른 여러 읽기 전용 뷰를 만들 수 있다. 이것은 한 스트림이 여러 소비자를 가질 때와 동일한 방식으로 작동한다. 분석 데이터베이스 드루이드는 이 방식을 사용해 카프카로부터 직접 데이터를 읽어 처리하고, 피스타치오는 분산 키-값 저장소로 카프카를 커밋 로그처럼 사용한다. 카프카 커넥트 싱크는 카프카에서 여러 데이터베이스와 색인에 데이터를 내보낼 수 있다.
이벤트 로그에서 데이터베이스보 변환하는 명시적인 단계가 있으면 시간이 흐름에 따라 애플리케이션을 발전시키기 쉽다. 기존 데이터를 새로운 방식으로 표현하는 새 기능을 추가하려면 이벤트 로그를 사용해 신규 기능용으로 분리할 읽기 최적화된 뷰를 구축할 수 있다. 또한 기존 시스템을 수정할 필요가 없고 기존 시스템와 함께 운용이 가능하다. 신구 시스템을 나란히 구동하는 것은 기존 시스템에서 복잡한 스키마 이전을 수행하는 것보다 쉽다.
데이터를 쓰는 형식과 읽는 형식을 분리해 다양한 읽기 뷰를 허용한다면 상당한 유연성을 얻을 수 있다. 이 개념을 명령과 질의 책임의 분리(command query responsibility segregation CQRS)라 부른다.
데이터베이스와 스키마 설계의 전통적인 접근법은 데이터가 질의를 받게 될 형식과 같은 형식으로 데이터를 기록해야 한다는 잘못된 생각에 기초한다. 데이터를 쓰기 최적화된 이벤트 로그에서 읽기 최적화된 애플리케이션 상태로 전환 가능하면 정규화와 비정규화에 관한 논쟁은 의미가 거의 없다. 읽기 최적화된 뷰는 데이터를 비정규화하는 것이 전적으로 합리적이다. 변환 프로세스가 뷰와 이벤트 로그 사이의 알관성을 유지하는 메커니즘을 제공하기 때문이다.
동시성 제어
이벤트 소싱과 CDC의 단점은 비동기다. 이벤트 기록 후 읽을 때 아직 반영이 안될 수 있다.
해결책 하나는 읽기 뷰의 갱신과 로그에 이벤트를 추가하는 작업을 동기식으로 수행하는 방법이다.
이벤트 로그로 현재 상태를 만들면 동시성 제어 측면이 단순해진다. 다중 객체 트랜잭션은 단일 사용자 동작이 여러 다른 장소의 데이터를 변경해야 할 때 필요하다. 이벤트 소싱을 사용하면 사용자 동작에 대한 설명을 자체적으로 포함하는 이벤트를 설계할 수 있다. 그러면 사용자 동작은 한 장소에서 한 번만 쓰기가 필요하다. 즉 이벤트를 로그에 추가만 하면 되며 원자적으로 만들기 쉽다.
불변성의 한계
영구적으로 모든 변화의 불변 히스토리를 유지하는 것이 어느 정도까지 가능할까? 그 답은 데이터셋이 뒤틀리는 양에 따라 다르다. 대부분 데이터를 추가하는 작업이고 갱신이나 삭제는 드물게 발생하는 작업부하는 불변으로 만들기 쉽다. 작은 데이터셋에 매우 빈번하게 갱신과 삭제를 하는 작업 부하는 불변 히스토리가 감당하기 힘들 정도로 커지거나 파편화 문제가 발생할 수 있다.
사생활 침해 등의 이유로 데이터를 삭제할 필요가 있다. 데이터 삭제 이벤트 로그로는 해결되지 않으며 히스토리를 새로 써야 한다.
데이터를 실제로 삭제하는 것은 쉽지 않다.
스트림 처리
스트림을 처리하는 방법에는 크게 세가지 선택지가 있다.
- 이벤트에서 데이터를 꺼내 데이터베이스나 캐시, 검색 색인 또는 유사한 저장소 시스템에 기록하고 다른 클라이언트가 이 시스템에 해당 데이터를 질의한다.
- 이벤트를 사용자에 직접 보낸다.
- 하나 이상의 입력 스트림을 처리해 하나 이상의 출력 스트림을 생산한다.
3번에 대해서 설명할 것이며, 이처럼 스트림을 처리하는 코드 조각을 연산자나 작업이라 부른다. 스트림 처리자를 파티셔닝하고 병렬화하는 양식도 맵리듀스, 데이터플로 엔진 양식과 상당이 유사하다.
이괄 처리와의 차이점은 끝나지 않는다는 것이다.
스트림 처리의 사용
모니터링 목적으로 사용된다.
사기 감시 시스템, 이상 감지 등이 있으며 복잡한 패턴 매칭과 상관 관계 규명이 필요하다.
복잡한 이벤트 처리
complex event processing(CEP)은 특정 이벤트 패턴을 검색해야 하는 애플리케이션에 적합하다. CEP는 정규 표현식으로 문자열에서 특정 문자 패턴을 찾는 방식과 유사하게 스트림에서 특정 이벤트 패턴을 찾는 규칙을 규정할 수 있다.
CEP는 SQL이나 GUI 인터페이스를 이용하기도 한다. 매칭을 발견하면 복잡한 이벤트를 방출한다. 질의를 저장하고 스트림에 매칭을 시키는 방식이다.
스트림 분석
대량의 이벤트를 집계하고 통계적 지표를 뽑는 것을 더 우선한다.
- 특정 유형의 이벤트 빈도 측정
- 특정 기간에 걸친 값의 이동 평균 계산
- 이전 시간 간격과 현재 통계값의 비교
고정된 시간 간격으로 계산한다. 집계 시간 간격은 윈도우라 한다. 스트림 분석 시스템은 확률적 알고리즘을 사용하기도 한다. 확률적 알고리즘은 최적화 기법이다. 집합 구성원 확인 용도의 블룸 필터, 원소 개수 추정 용도의 하이퍼로그로그, 다양한 백분위 추정 알고리즘이 있다. 근사 결과를 제공한다.
아파치 스톰, 스파크 스트리밍, 플링크, 쌈자 카프카 스트림 등이 분석 용도로 설계됐다.
구체화 뷰 유지하기
데이터베이스 변경에 대한 스트림은 캐시, 검색 색인, DW 같은 파생 데이터 시스템이 원본 데이터베이스의 최신 내용을 따라잡게 하는 데 쓸 수 있다. 구체화 뷰를 유지하는 특별한 사례로 볼 수 잇다.
마찬가지로 이벤트 소싱에서 애플리케이션 상태는 이벤트 로그를 적용함으로써 유지된다. 애플리케이션 상태는 일종의 구체화 뷰다. 임의의 시간에 발생한 모든 이벤트가 피룡하다.
대부분 제한된 기간의 윈도우에서 동작하는 일부 분석 지향 프레임워크의 가정과 이벤트를 영원히 유지해야 하는 필요성은 서로 상반되지만 이론상으로는 어떤 스트림 처리라도 구체화 뷰를 유지하는 데 쓸 수 있다. 카프카 스트림은 카프카 로크 컴팩션 지원을 기반으로 구체화 뷰 유지 용도로 사용할 수 있다.
스트림 상에서 검색하기
CEP 외에도 전문 검색 질의와 같은 복잡한 기준을 기반으로 개별 이벤트를 검색해야 하는 경우도 있다. 미디어 모니터링 서비스는 미디어 아웃렛에서 새 기사와 방송 피드를 구독하고 관심 있는 회사나 상품 또는 주제를 언급하는 뉴스를 검색한다.
전통적인 검색 엔진은 먼저 문서를 색인하고 색인을 통해 질의를 실행한다. 반대로 스트림 검색은 처리 순서가 뒤집힌다. 질의를 먼저 저장한다.
메세지 전달과 RPC
메세지 전달 시스템은 RPC 대안으로 사용할 수 있다. 즉 액터 모델 등에서 쓰이는 서비스 간 통신 메커니즘으로 사용할 수 있다. 이런 시스템은 메세지와 이벤트에 기반을 두지만 일반적으로 이것들을 스트림 처리자로 생각하지는 않는다.
- 액터 프레임워크는 동시성을 관리하고 통신 모듈을 분산 실행하는 메커니즘이다. 스트림 처리는 기본저긍로 데이터 관리 기법이다.
- 액터 간 통신은 주로 단기적이고 1:1이다. 이벤트 로그는 지속성이 있고 다중 구독이 가능하다.
- 액터는 임의의 방식으로 통신할 수 있다. 스트림 처리자는 대개 비순환 파이프라인에 설정된다.
유사 RPC 시스템과 스트림 처리 사이에는 겹치는 영역이 있다. 이벤트 스트림을 처리하는 노드 집합에 질의를 맡길 수 있다.
액터 프레임웍을 사용해 스트림 처리도 가능하나, 장애 상황에서 메제시 전달을 보장하지 않기 때문에 추가적인 재시도 로직을 구현하지 않으면 처리에 내결함성 보장하지 못한다.
시간에 관한 추론
지난 5분이라는 개념인 윈도우 사이즈를 다루는 것은 상당히 까다롭다. 많은 스트림 처리 프레임워크는 윈도우 시간을 결정할 때 처리하는 장비의 시스템 시계를 이용한다. 이 접근법은 간단하다는 장점이 있다. 그러나 처리가 지연되면 문제가 생긴다.
이벤트 시간 대 처리 시간
메세지가 지연되면 메세지 순서를 예측하지 못할 수도 있다.
스트림 처리 알고리즘은 순서 문제를 명확히 작성할 필요가 있다. 이벤트 시간과 처리 시간을 혼동하면 좋지 않은 데이터가 만들어진다.
준비 여부 인식
이벤트 시간 기준으로 윈도우를 정의할 때 발생하는 까다로운 문제는 특정 윈도우에서 모든 이벤트가 도착했다거나 아직도 이벤트가 계속 들어오고 있는지 확신할수 없다는 점이다.
낙오자 이벤트를 처리할 방법이 필요하다.
낙오자 이벤트를 무시한다. 수정값을 발행한다.
어쨋든 어떤 시계를 사용할 것인가?
잘못된 시계를 조정하는 한 가지 방법은 세 가지 타임스탬프를 로그로 남기는 것이다.
- 이벤트가 발생한 시간, 장치 시계를 따른다.
- 이벤트를 서버로 보낸 시간, 장치 시계를 따른다.
- 서버에서 이벤트를 받은 시간, 서버 시계를 따른다.
윈도우 유형
텀블링 윈도우: 크기는 고정 길이.
홈핑 윈도우: 고정 길이 그러나 결과를 매끄럼게 만들기 위해 중첩 가능.
슬라이딩 윈도우: 시간 간격 사이에서 발생한 모든 이벤트를 포함한다.
세션 윈도우: 고정된 기간이 없다. 같은 사용자가 짧은 시간 동안 발생시킨 모든 이벤트를 그룹화해서 세션 윈도우를 정의한다. 웹사이트 분석시에 필요하다.
스트림 조인
스트림 스트림 조인, 스트림 테이블 조인, 테이블 테이블 조인 세가지로 구분한다.
스트림 스트림 조인
두 입력 스트림은 활동 이베느로 구성하고 조인 연산자는 시간 윈도우 내에 발생한 관련 이벤트를 검색한다. 예를 들어 같은 사용자가 취한 행동 중 시간 차가 30분 이내인 두 개의 행동을 매치하는 식이다. 한 스트림 내에서 관련 이벤트를 찾는다면 두 조인 입력은 사실 같은 스트림이다.
스트림 테이블 조인
한 입력 스트림은 활동 이벤트로 구성하고 다른 스트림은 데이터베이스의 변경 로그로 구성한다. 변경 로그는 데이터베이스의 최신 상태의 사본을 로컬에 유지한다. 조인 연산자는 각 활동 이벤트마다 데이터베이스에 질의하고 조인한 데이터를 추가한 활동 이벤트를 출력한다.
테이블 테이블 조인(구체화 뷰 유지)
양쪽 입력 스트림이 모두 데이터베이스의 변경 로그다. 이 경우 한 쪽의 모든 변경을 다른 쪽의 최산 상태와 조인한다. 조인의 결과는 두 테이블을 조인한 구체화 뷰의 변경 스트림이 된다.
조인의 시간 의존성
위의 세 가지 조인 유형은 공통점이 많다. 세 가지 유형 모두 스트림 처리자가 하나의 조인 입력을 기반으로 한 특정 상태를 유지해야 하고 다른 조인 입력에서 온 메세지에 그 상태를 질의한다.
상태를 유지하는 이벤트의 순서는 중요하다. 파티셔닝된 로그에서 단일 파티션 내 이벤트 순서는 보존되지만 다른 스트림이나 다른 파티션 사이에서 순서를 보장하는 일반적인 방법은 없다. 시간에 따라 변하는 조인의 상태를 어떻게 구별할 수 있을까?
위의 시간 의존성은 많은 곳에서 발생한다. 세율은 수시로 변경되기 때문에 송장에 어떤 세율을 넣을지 시간에 의존적이다.
이런 문제를 데이터 웨어하우스에서는 천천힣 변하는 차원(slowy changing dimension, SCD)이라 한다. 이 문제는 흔히 조인되는 레코드의 특정 버전을 가리키는 데 유일한 식별자를 사용해 해결한다. 이를테면 세율이 바뀔 때마다 새 식별자를 부여하고 송장에는 판매 시점의 세율을 표시하는 식별자를 포함해야 한다.
이렇게 변경한 조인은 결정적이지만 테이블에 있는 모든 레코드의 모든 버전을 보유해야 하기 때문에 로그 컴팩션이 불가능하다.
내결함성
일괄처리는 파일이 불변이기 때문에 재처리가 쉽다. 모든 레코드를 정확하게 한번만 처리한 것처럼 보인다. 태스크를 재시작할 때 사실 레코드를 여러 번 처리할 수도 있다는 뜻이지만 출력은 한 번만 처리된 것으로 보이는 효과가 나타난다. 이 원리를 정확히 한 번 시맨틱(exactly-once semantics)라 하지만 결과적으로 한 번(effectively-once)이라는 용어가 그 의미를 더 잘 설명한다.
스트림 처리에서는 동일한 내결함성 문제가 발생한다. 이를 어떻게 해결할까?
마이크로 일괄 처리와 체크포인트
스트림을 작은 블록으로 나누고 각 블록을 소형 일괄 처리와 같이 다루는 방법이다. 마이크로 일괄 처리라 한다. 일반적으로 1초 정도로 성능상 타협한 결과다. 일괄 처리 크기가 작을수록 스케줄링과 코디네티션 비용이 커진다.
또한 마이크로 일괄 처리는 일괄 처리 크기와 같은 텀블링 윈도우를 암묵적으로 지원한다. 마이크로 일괄 처리 크기보다 큰 윈도우가 필요한 작업은 마이크로 일괄 처리 작업을 수행한 후 상태를 명시적으로 다음 마이크로 일괄 처리 작업으로 넘길 필요가 있다.
아파치 플링크는 변형된 접근법을 사용한다. 주기적으로 상태의 롤링 체크포인트를 생성하고 지속성 있는 저장소에 저장한다. 스트림 연산자에 장애가 발생하면 스트림 연산자는 가장 최근 체크포인트에서 재시작하고 해당 체크포인트와 장애 발생 사이의 출력은 버린다.
체크포인트는 메세지 스트림 내 배리어가 트리거한다. 배리어는 마이크로 일괄 처리 사이의 경계와 비슷하지만 윈도우 크기를 특정하지 않는다.
스트림 처리 프레임워크 내에서 정확히 한 번 시맨틱을 지원한다. 그러나 출력이 스트림을 떠나자마자(디비에 기록 혹은 이메이 전송) 스트림 처리 프레임워크는 실패한 일괄 처리 출력을 더 이상 지울 수 없다. 마이크로 일괄 처리와 체크포인트 접근법만으로는 이 문제를 방지하기에 충분하지 않다.
원자적 커밋 재검토
장애가 발생했을 때 정확히 한 번 처리되는 것처럼 보일려면 처리가 성공했을 때만 모든 출력과 이벤트 처리의 부수 효과가 발생하게 해야 한다. 이 효과는 다운 스트림 연산자나 외부 메세징 시스템에 메세지 보내기, 디비에 쓰기, 연산자 상태 변환, 입력 메세지의 확인을 포함한다.
이런 효과는 원자적으로 모두 일어나거나 또는 모두 일어나지 않아야 하지만 서로 동기화가 깨지면 안 된다. 구글 클라우드 데이터플로, 볼트DB, 아파치 카프카(앞으로)도 원자적 커밋을 효율적으로 구현하는 것이 가능하다. XA와는 다르게 이종 기술 간 트랜잭션을 지원하지 않는 대신 스트림 처리 프레임워크 내에서 상태 변화와 메세지를 관리해 트랜잭션을 내부적으로 유지한다.
트랜잭션 프로토콜에서 발생하는 오버헤드는 단일 트랜잭션 내에서 여러 입력 메세지를 처리해 상쇄할 수 있다.
멱등성
결국 목표는 처리 효과가 두 번 나타나는 일 없이 안전하게 재처리하기 위해 실패한 태스크의 부분 출력을 버리는 것이다. 분산 트랜잭셩이 이 목표를 달성하는 한 하지 방법이지만 그 밖의 다른 방법으로 멱등성애 의존하는 것이 있다.
멱등 연산은 여러 번 수행하더라도 오직 한 번 수행한 것과 같은 효과를 내는 연산이다. 키-값 저장소에서 하나의 키에 고정된 특정 값을 설정하는 것은 멱등 연산이다. 반면 카운터를 증가시키는 것은 멱등 연산이 아니다.
연산 자체가 멱등적이지 않더라도 여분의 메타데이터로 연산을 멱등적으로 만들 수 있다. 예를 들어 카프카로부터 메세지를 소비할 때 모든 메세지에는 영속적이고 단조 증가하는 오프셋이 있다. 외부 데이터베이스에 값을 기록할 때 마지막으로 그 값을 기록하라고 트리거한 메세지의 오프셋을 함께 포함한다면 이미 갱신이 적용됐는지 확인할 수 있기 때문에 반복해서 같은 갱신이 수행되는 것을 막을 수 있다.
실패 후에 상태 재구축하기
윈더우 집계나 조인용 테이블과 색인처럼 상태가 필요한 스트림 처리는 실패 후에도 해당 상태가 복귀됨을 보장해야 한다. 한 가지 방법은 원격 데이터 저장소에 상태를 유지하고 복제하는 것이다. 개별 메세지를 원격 데이터베이스에 질의하는 것은 느리다. 다른 방법으로는 스트림 처리자의 로컬에 상태를 유지하고 주기적으로 복제하는 것이다. 그러면 스트림 처리자가 실패한 작업을 복구할 때 새 태스크는 복제된 상태를 읽어 데이터 손실 없이 처리를 재개할 수 있다.
예를 들어 플링크는 주기적으로 연산자 상태의 스냅샷을 캡처하고 캡처한 상태를 HDFS와 같은 지속성 있는 저장소에 기록한다. 쌈자와 카프카 스트림은 로그 컴팩션을 사용하는 상태 복제 전용 카프카 토픽에 상태 변화를 보내서 복제한다. 이 방식은 CDC와 비슷하다.
어떤 경우에는 상태 복제가 필요 없을 수 있다. 입력 스트림을 사용해 재구축할 수 있기 때문이다. 모든 트레이드오프는 기반 인프라스트럭처의 성능 특성에 달려있다. 어떤 시스템은 디스크 접근 지연 시간보다 네트워크 지연이 더 짧고 네트워크 대역폭이 디스크 대역폭과 비슷할 수도 있다. 모든 상황을 만족하는 이상적인 트레이드오프는 없다. 로컬 상태 대 원격 상태의 사치는 저장소와 네트워크 기술의 발전에 따라 얼마든지 바뀔 수 있다.
'Software Development > Database' 카테고리의 다른 글
데이터베이스 인터널스 (0) 2022.04.27 [MongoDB] collection.count(), collection.countDocuments({}) (0) 2022.01.14 [데이터 중심 애플리케이션] 일괄 처리 (0) 2021.12.26 [데이터 중심 애플리케이션 설계] 일관성과 합의 (0) 2021.12.05 [데이터 중심 애플리케이션] 분산 시스템의 골칫거리 (0) 2021.11.20 - 생산자가 소비자가 메세지를 처리하는 속도보다 빠르게 메세지를 전송한다면 어떻게 될까?