브로커와 데이터베이스는 전통적으로 전혀 다른 범주의 도구로 생각되지만, 로그 기반 브로커는 데이터베이스에서 아이디어를 얻어 메시징에 적용함
그렇다면 그 반대도 가능할 것. 메시징과 스트림에서 아이디어를 가져와 데이터베이스에 적용한 사례는?
이벤트는 특정 시점에 발생한 사건을 기록한 레코드이며, 이를 데이터베이스에 기록하는 것. 데이터베이스에 뭔가를 기록한다는 것은 캡처해서 저장하고 처리할 수 있는 이벤트라는 의미이며 이것이 가장 근본적인 개념임
사실 복제 로그는 데이터베이스 기록 이벤트의 스트림이라고 볼 수 있음
단일 시스템에서 데이터 저장과 질의 처리 요구사항을 모두 만족시킬 수는 없음. 실제로 대부분의 중요 애플리케이션이 요구사항을 만족시키기 위해 다른 기술들의 조합을 사용함
이를 테면 사용자 요청에 대응하기 위한 OLTP 데이터베이스, 공통 요청의 응답 속도를 높이기 위한 캐시, 검색 질의를 다루기 위한 전문 색인, 분석용 데이터웨어하우스로 이루어질 수 있음.
이 시스템 각각은 데이터의 복제본을 가지고 있으며, 그 데이터는 목적에 맞게 최적화된 형태로 각각 저장됨
이때 관련이 있거나 동일한 데이터가 여러 다른 장소에서 나타나기 때문에 서로 동기화가 이루어져야함. 흔히 데이터웨어하우스에서는 이 동기화 과정을 ETL 과정에서 수행함
하지만 이 과정이 너무 느리면 대안으로 이중 기록(dual write)를 사용할 수 있음
즉, 데이터가 변할 때마다 애플리케이션 코드에서 명시적으로 각 시스템에 기록하는 방식. 먼저 데이터베이스에 기록한 다음 검색 색인을 갱신하고, 캐시 엔트리를 무효화하는 식
하지만 이중 기록에는 몇 가지 문제가 있음
경쟁 조건이 발생할 수 있음. 위 그림에서 처럼 클라이언트1과 클라이언트2에서 각기 다른 업데이트를 수행하지만, 순서가 뒤틀려 최종 요청에 대한 결과가 반영되지 않을 수 있음
이를 위해선 동시 쓰기 감지에서 설명한 버전 벡터와 같은 동시성 감지 메커지즘을 사용해야 함
또한 한쪽 쓰기가 성공할 때 다른 쪽 쓰기가 실패할 수도 있음. 이것은 동시성 문제라기보다는 내결함성 문제로 두 시스템 간 불일치가 발생할 수 있다는 의미.
이를 위해선 동시 성공 또는 동시 실패를 보장하는 원자적 커밋을 보장해야하지만, 이 문제를 특히나 각기 다른 시스템에서 보장하기위해선 비용이 많이 듦
단일 리더 복제 데이터베이스 하나만을 사용한다면, 리더가 쓰기 순서를 결정할 수 있음. 하지만, 데이터베이스에도 리더가 있고 검색 색인에도 리더가 있을지는 모르지만, 한 쪽이 다른 쪽을 팔로우하지 않기 때문에 충돌이 발생함
만일, 색인용 인덱스를 데이터베이스의 팔로워로 만들어 실제로 리더 하나만 존재하게 할 수 있을까?
수십 년 동안 많은 데이터베이스에서 데이터 변경 로그를 얻는 방법에 대한 문서를 제공하지 않아, 데이터베이스에서 발생한 데이터 변화를 감지해서 변경된 내용을 검색 색인, 캐시, 데이터 웨어하우스에 복제하는 것이 어려웠음
최근 들어 데이터 변경 캡처(change data capture, CDC)에 대한 관심이 높아지고 있음. 데이터베이스에 기록하는 모든 데이터의 변화를 관찰해 다른 시스템으로 데이터를 복제할 수 있는 형태로 추출하는 과정이며, CDC는 데이터가 기록되자마자 변경 내용을 스트림으로 제공할 수 있다면 특히 유용함
검색 색인과 데이터 웨어하우스에 저장된 데이터는 레코드 시스템에 저장된 데이터의 또 다른 뷰일 뿐이므로 로그 소비자를 파생 데이터 시스템이라고 할 수 있음
CDC는 파생 데이터 시스템이 레코드 시스템의 정확한 데이터 복제본을 가지게 하기 위해 레코드 시스템에 발생하는 모든 변경 사항을 파생 데이터 시스템에 반영하는 것을 보장하는 메커니즘이라고 할 수 있음
CDC는 본질적으로 변경 사항을 캡처할 데이터베이스 하나를 리더로 하고 나머지를 팔로워로 함
CDC를 구현하는 데 데이터베이스를 트리거로 사용하기도 함. 즉, 데이터 테이블의 모든 변화를 관찰하는 트리거를 등록하고 변경 로그 테이블에 해당 항목을 추가하는 방식. 하지만 이런 방식은 고장나기 쉽고 성능 오버헤드가 상당함
복제 로그를 파싱하는 방식은 스키마 변경 대응 등 해결해야 할 여러 문제가 있지만 트리거 방식보다 견고한 방법임
보틀드 워터는 쓰기 전 로그를 복호화하는 API를 사용해 포스트크레스큐엘용 CDC를 구현하고, 멕스웰은 binlog를 파싱해 유사한 방식으로 MySQL용 CDC를 구현하며, 몽고리버는 몽고DB의 oplog를 읽고 골든게이트는 비슷한 기능을 오라클용으로 제공하고 있음
CDC는 메시지 브로커와 동일하게 비동기 방식으로 동작함. 하지만 비동기 방식으로 이루어지므로 복제 지연의 모든 문제가 발생하는 단점이 존재
데이터베이스에서 발생한 모든 변경 로그가 있다면 로그를 재현해서 데이터베이스의 전체 상태를 재구축할 수 있음
그러나 모든 변경 사항을 영구적으로 보관하는 것은 디스크 공간이 너무 많이 필요하며 모든 로그를 재생하는 작업도 너무 오래 걸림
따라서 적당한 크기로 잘라야 함
전문 색인을 구축하는 것을 예로 들면 전체 데이터베이스 복사본이 필요하게 됨(전체 로그가 없기 때문에). 따라서 일관성 있는 스냅숏을 사용할 수 있음
이때 데이터베이스의 스냅숏은 변경 로그의 위치나 오프셋에 대응돼야 함. 그래야 스냅숏 이후의 변경 사항을 적용할 시점을 알 수 있기 때문
로그 히스토리 양을 제한한다면 새로운 파생 데이터 시스템을 추가할 때마다 스냅숏을 만들어야 함
하지만 로그 컴팩션(log compaction)이라는 대안이 존재
원리는 간단함. 저장 엔진은 주기적으로 같은 키의 로그 레코드를 찾아 중복을 제거하고 각 키에 대해 가장 최근에 갱신된 내용만 유지하는 것. 컴팩션과 병합은 백그라운드로 실행
CDC에서 모든 변경에 기본키가 포함되게 하고 키의 모든 갱신이 해당 키의 이전 값을 교체하면 특정 키에 대한 최신값을 유지할 수 있음
검색 색인과 같은 파생 데이터 시스템의 새로운 소비자는 컴팩션된 로그 토픽의 오프셋 0부터 시작해서 순차적으로 데이터베이스의 모든 키를 스캔하면 됨. 즉, 원본 데이터베이스의 스냅숏을 만들지 않더라도 데이터베이스의 콘텐츠 전체의 복사본을 얻을 수 있음
아파치 카프카는 로그 컴팩션 기능을 제공함. 추후에 알아보겠지만, 메시지 브로커는 일시적 메시징뿐만 아니라 지속성 있는 저장소로도 사용 가능함
최근 데이터베이스들은 기능 개선이나 리버스 엔지니어링(이미 만들어진 시스템이나 장치에 대한 해체나 분석을 거쳐 그 대상 물체의 구조와 기능, 디자인 등을 알아내는 일련의 과정)을 통해 CDC를 지원하기보다 점진적으로 변경 스트림을 기본 인터페이스로서 지원하기 시작
카프카 커넥트(Kafka Connect)는 카프카를 광범위한 데이터 시스템용 CDC 도구로 활용하기 위한 노력의 일환. 변경 이벤트를 스트림하는 데 카프카를 사용하면 검색 색인과 같은 파생 데이터 시스템을 갱신하는 데 사용 가능하고 스트림 처리 시스템에도 이벤트 공급이 가능함
이벤트 소싱(event sourcing)은 도메인 주도 설계(domain-driven design, DDD) 커뮤니티에서 개발한 기법
이벤트 소싱은 CDC와 유사하게 애플리케이션 상태 변화를 모두 변경 이벤트 로그로 저장함. CDC와 가장 큰 차이점은 추상화 레벨이 다르다는 점
CDC에서 애플리케이션은 데이터베이스를 변경 가능한 방식으로 사용해 레코드를 자유롭게 갱신하고 삭제하며 변경 로그는 데이터베이스에서 저수준으로 추출(예를 들어 복제 로그를 파싱)
이벤트 소싱에서 애플리케이션 로직은 이벤트 로그에 기록된 불변 이벤트를 기반으로 명시적으로 구축. 이때 이벤트 저장은 단지 추가만 가능하고 갱신이나 삭제는 금지함. 이벤트는 저수준에서 상태 변경을 반영하는 것이 아니라, 애플리케이션 수준에서 발생한 일을 반영하게끔 설계됨
이벤트 소싱은 데이터 모델링에 쓸 수 있는 강력한 기법임. 애플리케이션 관점에서 사용자의 행동을 불변 이벤트로 기록하는 방식은 변경 가능한 데이터베이스 상에 기록하는 것보다 훨씬 유의미함
애플리케이션을 지속해서 개선하기가 유리하며, 디버깅이나 버그를 방지하는 데도 도움이 됨
예를 들어 "어떤 학생이 간의 신청을 취소했다"는 이벤트를 저장한다고 가정해보자. 또한 이 동작에 부수효과 "수강 테이블에서 항목 하나를 삭제하고 학생 피드백 테이블에 취소 사유를 추가한다"가 있다면, 이는 데이터가 사용되는 방식에 또 다른 가정이 포함될 수 있음을 의미함
만약 애플리케이션에 "대기 목록의 다음 사람에게 자리를 제공"하는 새로운 기능을 추가하더라도 이벤트 소싱 접근법을 사용하면 새로 발생한 부수 효과를 기존 이벤트에서 쉽게 분리할 수 있음
일반적으로 이벤스 소싱 접근법은 특정 도구와 독립적으로 운영됨
이벤트 로그 그 자체로는 그다지 유용하지 않다고 생각할 수 있음
사용자는 일반적으로 시스템의 현재 상태를 보기를 원하지 수정 히스토리를 원하지는 않기 때문
쇼핑 웹사이트를 예로 들면 사용자는 장바구니에 발생한 모든 변경사항을 기록한 추가 전용 목록이 아니라 현재 장바구니의 내요을 보고 싶어할 것임
따라서 이벤트 소싱은 시스템에 기록한 데이터를 표현한 이벤트 로그를 가져와 사용자에게 보여주기에 적당한 애플리케이션 상태로 변환해야 함. 이때 변환 로직은 자유롭게 정하면 되지만, 결정적 과정(다시 수행하더라도 같은 결과값이 나와야)이어야 함
CDC와 마찬가지로 이벤트 소싱도 이벤트 로그를 재현하면 현재 시스템 상태를 재구성할 수 있음
하지만, 방식이 달라져야 함. 이벤트는 대게 사용자 행동의 결과로 발생한 상태 갱신 메커니즘이 아닌 사용자 행동 의도를 표현함. 이 경우 뒤에 발생한 이벤트가 앞선 이벤트를 덮어쓰지 않을 수 있음. 따라서 마지막 상태를 재구축하기 위해서는 이벤트의 전체 히스토리가 필요하며, 이 경우 로그 컴팩션이 불가능함
따라서, 이벤트 소싱에서는 이벤트 로그에서 파생된 현재 상태의 스냅숏을 저장하는 메커니즘을 지원함. 하지만 이 메커니즘은 장애 발생 시 읽고 복구하는 성능을 높이는 최적화에 불과하며, 이벤트 소싱 시스템에는 모든 원시 데이터를 영원히 저장하고 필요할 때마다 모든 이벤트를 재처리할 수 있어야 함
이벤트 소싱은 이벤트와 명령(command)을 구분하는 데 주의해야 함
사용자 요청이 처음 도착했을 때 이 요청은 명령임. 이 시점에서는 명령이 실패할 수 있으며, 명령이 승인되면 명령은 지속성 있는 불변 이벤트가 됨
예를 들어 사용자가 비행기 좌석을 예매하려고 한다면, 애플리케이션은 해당 사용자가 이미 사용 중이거나 좌석이 이미 예약이 끝났는지 확인해아며, 이 과정이 성공하면 애플리케이션은 특정 사용자명으로 좌석을 예약한다는 이벤트를 생성할 것임
이벤트는 생성 시점에 사실(fact)가 됨. 사용자가 나중에 예약을 변경하거나 취소하더라도 이전에 특정 좌석을 예약했다는 사실은 여전히 진실이며, 변경이나 취소는 나중에 추가된 독립적인 이벤트임
이벤트 스트림의 소비자는 이벤트를 거절하지 못하며, 소비자가 이벤트를 받은 시점에는 이벤트는 이미 불변 로그의 일부분임. 따라서 명령의 유효성은 이벤트가 되기 전에 동기식으로 검증되어야 함. 이를테면 직렬성 트랜잭션을 사용해 원자적으로 명령을 검증하고 이벤트를 발생시킬 수 있음
혹은 좌석을 예약하는 사용자 요청을 이벤트 두 개로 분할할 수도 있음. 하나는 가예약 이벤트고 다른 하나는 유효한 예약에 대한 확정 이벤트로 분할해 비동기 처리로 유효성 검사를 수행할 수도 있음
일괄 처리에서 입력 파일의 불변성이 여러 이점을 가져다 준 것처럼 불변성 원리는 이벤스 소싱과 CDC를 매우 강력하게 만들어 줌
상태가 변할 때마다 해당 상태는 시간이 흐름에 따라 변한 이벤트의 마지막 결과이며, 모든 변경 로그(changelog)는 시간이 지남에 따라 바뀌는 상태를 나타냄
변경 로그를 지속성 있게 저장한다면 상태를 간단히 재생성할 수 있는 효과가 있음
따라서 이벤트 로그를 레코드 시스템으로 생각하고 모든 변경 가능 상태를 이벤트 로그로부터 파생된 것으로 생각하면 시스템을 거치는 데이터 흐름에 관해 추론하기가 쉬워짐
트랜잭션 로그는 데이터베이스에 적용된 모든 변경 사항을 기록한다. 로그는 고속으로 덧붙여지고, 덧붙이기가 로그를 변경하는 유일한 방법이다. 이런 측면에서 데이터베이스의 내용은 로그의 최근 레코드 값을 캐시하고 있는 셈이다. 즉 로그가 진실이다. 데이터베이스는 로그의 부분 집합의 캐시다. 캐시한 부분 집합은 로그로부터 가져온 각 레코드와 색인의 최신 값이다
팻 헬랜드(Pat Helland)
우연히 버그가 있는 코드를 배포해서 데이터베이스에 잘못된 데이터를 기록했을 때 코드가 데이터를 덮어썼다면 복구하기가 매우 어려울 수 있음
하지만 추가만 하는 불변 이벤트 로그를 썼다면 문제 상황의 진단과 복구가 훨씬 쉬움
또한 불변 이벤트는 현재 상태보다 훨씬 많은 정보를 포함함
불변 이벤트 로그에서 가변 상태를 분리하면 동일한 이벤트로 여러 읽기 전용 뷰를 만들 수 있음
예를 들어 분석 데이터베이스 드루이드(Druid)는 카프카로부터 직접 데이터를 읽어 처리하고, 피스타치오(Pistachio)는 분산 키-값 저장소로 카프카를 커밋 로그처럼 사용하며, 카프카 커넥트 싱크는 카프카에서 여러 데이터베이스와 색인 데이터를 내보낼 수 있음
기존 데이터를 새로운 방식으로 표현하는 새 기능을 추가하려면 이벤트 로그를 사용해 신규 기능용으로 분리한 읽기 최적화된 뷰를 구축할 수 있음. 또한 기존 시스템을 수정할 필요가 없고 기존 시스템과 함께 운용이 가능함
이러한 식으로 데이터를 쓰는 형식과 읽는 형식을 분리해 다양한 읽기 뷰를 허용한다면 상당한 유연성을 획득할 수 있음. 이러한 개념을 명령과 질의 책임의 분리(command query respoinsibility segregation, CQRS)라고 부름
데이터를 쓰기 최적화된 이벤트 로그에서 읽기 최적화된 애플리케이션 상태로 전환이 가능하다면, 데이터를 비정규화하는 것이 전적으로 합리적임
왜냐하면 변환 프로세스가 뷰와 이벤트 로그 사이의 일관성을 유지하는 메커니즘을 제공하기 때문임
이벤트 소싱과 CDC의 가장 큰 단점은 이벤트 로그의 소비가 대개 비동기로 이루어진다는 점임
따라서 사용자가 로그에 이벤트를 기록하고 이어서 로그에서 파생된 뷰를 읽어도 기록한 이벤트가 아직 읽기 뷰에 반영되지 않았을 수 있음
해결책 하나는 읽기 뷰의 갱신과 이벤트를 추가하는 작업을 동기식으로 수행하는 것. 이 방법을 쓰려면 트랜잭션에서 여러 쓰기 원자적으로 결합해야 하므로 이벤트 로그와 읽기 뷰를 같은 저장 시스템에 담아야 하며, 다른 시스템에 있다면 분산 트랜잭션이 필요함
이벤트 로그와 애플리케이션 상태를 같은 방식으로 파티션 할 수 있음. 예를 들어 3번 파티션에 있는 사용자의 이벤트 로그를 처리할 때 애플리케이션 상태의 3번 파티션만 갱신하면 된다면, 간단하게 단일 스레드로 처리할 수 있음. 단지 단일 이벤트를 한 번에 하나씩 처리하면 되기 때문. 하지만 이 경우 하나의 이벤트가 여러 개의 파티션에 영향을 준다면 더 많은 작업이 필요함
이벤트 소스 모델을 사용하지 않는 많은 시스템에서도 불변성에 의존함
다양한 데이터베이스는 내부적으로 시점 스냅숏을 지원하기 위해 불변 자료 구조나 다중 버전 데이터를 사용함
하지만 영구적으로 모든 변화의 불변 히스토리를 유지하는 것이 어느 정도까지 가능할까?
당연히 데이터셋이 뒤틀리는 양에 따라 달라짐. 대부분 데이터를 추가하는 작업이며 갱신이나 삭제는 드물게 발생하는 작업부하는 불변으로 만들기 쉬움
상대적으로 작은 데이터셋에서 매우 빈번하게 갱신과 삭제를 하는 작업부하는 불변 히스토리가 감당하기 힘들 정도로 커지거나 파편화되는 문제가 발생할 수 있음
또한 데이터가 모두 불변성임에도 관리상의 이유로 데이터를 삭제할 필요가 있는 상황이 있을 수 있음
이를 테면 사생활 문제로 인해 사용자가 계정을 폐쇄하면 사용자의 개인 정보를 지울 필요가 있다면, 이전 데이터를 삭제해야 한다는 이벤트를 로그에 추가한다고 해결되지 않음
데이토믹(Datomic)은 이 기능을 적출(exicision)이라는 형태로 제공하며 포씰 버전 관리 시스템에서는 셔닝(shunning)이라는 형태로 제공함
하지만 실제로 데이터를 삭제하는 작업은 굉장히 어려운 일임. 많은 곳에 복제본이 남아 있기 때문. 예를 들어 저장소 엔진, 파일 시스템, SSD는 같은 장소에 데이터를 덮어 쓰기보다 주로 새로운 저장소에 기록함. 그리고 사고로 인한 삭제나 손상을 방지하기 위해 백업은 의도적으로 불변으로 만듦. 즉, 삭제는 해당 데이터를 "찾기 불가능하게끔"하는 문제라기보다 "찾기 어렵게"하는 문제인 경우가 많음