본문 바로가기

Engineering

Event Sourcing에서 CQRS까지 (2) - 이력 보존 문제의 해결

이 시리즈는 실제 경험을 바탕으로, 현재 상태를 저장하는 방식의 문제와 이를 해결할 대안들을 살펴봅니다. 각 대안의 장단점과 트레이드오프를 비교해 Event Sourcing을 선택한 이유를 설명합니다. 이어서 그 선택으로 감수한 비용과 제약을 짚고, 이를 보완하기 위해 최종적으로 CQRS를 선택한 과정까지 다룹니다.

이번 편은 시리즈의 두 번째 이야기로, 변경 이력이 사라지는 문제를 해결할 대안들을 비교하고 Event Sourcing을 선택한 이유를 설명합니다. 실제 도메인 대신 가상의 장비 대여 서비스를 사용하며, 데이터와 이벤트 이름은 설명을 위한 예시입니다.

 

1편에서는 현재 상태를 덮어쓰거나 삭제하면서 이전 값과 정정 근거, 원래 처리와 취소의 관계가 사라지는 문제를 살펴봤다. 이번에는 변경 이력을 별도로 관리하는 방법과 금액 원장, Event Sourcing이 이 문제를 어떻게 해결하고 어떤 비용을 추가하는지 비교해 보자.

 

변경 이력을 관리하는 방법

U-2의 현재 5시간과 50,000원, 최초 8시간과 80,000원 및 정정 이력

 

가장 간단한 대안은 기존 테이블을 유지하면서 변경 이력만 별도로 관리하는 것이다. 위 그림은 현재 값과 변경 이력을 함께 저장한 예시다. 현재 행에서는 50,000원을 바로 읽고, 이력에서는 이전 80,000원과 정정 사유를 확인할 수 있다. 따라서 변경 과정과 근거를 확인하는 것이 목적이라면 이력 추가만으로도 요구를 충족할 수 있다.

 

이 방식의 장점은 기존 저장 구조와 조회 방식을 유지할 수 있고, 변경이력을 쌓는 것이 매우 간단하다는 것이다. 현재 상태는 기존 테이블에서 읽고, 변경 과정을 확인할 때만 이력을 조회하면 된다. 다만 상태 변경과 그 변경을 설명하는 이력 기록을 함께 관리해야 한다. 그 이력을 빠짐없이 기록해야 한다는 비용도 추가된다. 상태 갱신과 이력 추가를 같은 DB 트랜잭션으로 묶으면 실행한 두 작업의 원자성을 보장할 수 있어 누락을 막을 수 있지만 이력 추가 자체를 호출하지 않은 경로까지 막을 순 없다.

 

더 나아가 변경 경로를 공통 처리로 모으거나 DB 트리거로 기록을 강제하는 방법도 고려해볼 수 있다. 이런 장치를 갖추면 Audit Log로도 신뢰할 수 있는 이력을 만들 수 있다. 필요한 초기 값과 변경 값, 적용 순서를 빠짐없이 보존하면 과거 상태 복원도 가능하다. 기존 조회의 단순함을 유지하는 대신, 현재 상태와 변경 이력의 일관성을 계속 관리하는 것이 이 방식의 트레이드오프다.

 

원장으로 다루는 법

U-2의 최초 이용료 80,000원과 정정액 -30,000원을 기록한 금액 원장

 

원장(Ledger)은 금액의 증가와 감소를 항목으로 기록하고 합산해 현재 금액을 설명하는 방식이다. U-2의 최초 이용료 +80,000원에 이용 시간 정정에 따른 -30,000원을 추가하면 현재 이용료는 50,000원이다. 기존 항목을 덮어쓰지 않고 조정 항목을 남기므로 금액이 바뀐 근거도 보존할 수 있다. 조정 항목에 적용 정책과 계산 근거, 대상 항목의 식별자를 함께 남기면 정책 변경으로 발생한 금액 차이와 이후의 보정도 추적할 수 있다.

 

하지만 금액 원장만으로는 도메인의 전체 라이프사이클을 설명하기 어렵다. 예를 들면 금액이 바뀌지 않는 처리도 있다. 반납했다가 반납을 취소해도 이용 시간은 그대로이고, 이용 내역이 없는 승인 대기 계약을 취소해도 이용료는 발생하지 않는다. 금액의 증가와 감소만 기록하면 이런 처리가 있었는지와 어떤 처리를 취소했는지는 알 수 없다. 따라서 단순히 금액만 원장으로 다루는 이 방식은 금액의 구성과 보정 근거를 보존하는 데 적합하지만, 변경 이력 전체를 파악하기에는 부족한 방식이었다.

 

Event Sourcing

최종적으로 Event Sourcing 방식을 선택했고, 그 이유는 다음과 같다.

 

  1. 이벤트를 상태 구성의 원본으로 사용한다. 상태 변경을 이벤트 저장으로 확정하고, 현재 상태는 그 이벤트를 적용한 결과로 구성한다. 상태를 만드는 입력과 변경 이력을 같은 기록으로 관리할 수 있기 때문에 애초에 이력 자체를 누락할 일도, 이력을 관리할 필요도 없다. (물론 과거 이벤트 해석을 위한 Backward compatibility 관리는 필요하다)
  2. 이전 기록을 보존하면서 변경 과정과 과거 상태를 복원할 수 있다. 기존 이벤트를 덮어쓰지 않고 새 이벤트를 추가하므로, 정정과 취소가 발생해도 원래 기록과 대상 관계가 남는다. 특정 시점까지의 이벤트를 순서대로 적용하면 당시 상태를 구성할 수 있어, 현재 값에 도달한 과정과 이전 상태를 함께 확인하려는 요구에 적합했다.

 

이 방식에서는 이벤트 순서와 상태 적용 규칙을 관리하고, 이벤트 구조와 처리 코드가 바뀐 뒤에도 과거 이벤트를 해석할 수 있도록 하위 호환성을 유지해야 한다. 조회 시에도 이벤트를 읽고 적용해 상태를 재구성하므로 읽기와 계산 비용이 늘어난다. 다만 내가 실제로 다룬 도메인에서는 최종 결과의 근거를 제시하고, 정정과 취소를 거쳐 결과가 달라진 이유를 소명하는 것이 중요했다. 그래서 이벤트 관리와 조회 비용을 감수하고, 변경 과정을 추적하고 과거 상태를 복원할 수 있는 이점을 선택했다.

 

기존 구조에서 달라진 점

1편의 반납 취소와 승인 대기 계약 취소를 다시 보자. 두 구조에서 같은 처리를 마친 뒤 어떤 데이터가 남는지 비교하면 차이를 확인할 수 있다.

 

반납 취소 후 현재 값만 남은 R-1과 삭제된 R-3를, 반납과 취소 및 신청과 취소 이벤트가 남는 기록과 비교

 

반납과 반납 취소

AS-IS에서 R-1은 RENTED, 반납 시각은 NULL이다. 이 행만 보면 한 번도 반납하지 않은 계약과 구별할 수 없다. TO-BE에서는 반납 시각을 담은 RENTAL_RETURNED와 이를 취소한 RETURN_CANCELLED가 모두 남는다. 취소 이벤트가 원래 반납 이벤트 E-7을 가리키므로 어떤 반납을 취소했는지도 확인할 수 있다.

 

승인 대기 계약 취소

승인 대기 계약 R-3도 마찬가지다. AS-IS에서는 행을 DELETE해 조회 결과가 없지만, TO-BE에서는 신청한 RENTAL_REQUESTED와 취소한 RENTAL_CANCELLED가 함께 남는다. 현재 상태는 취소됐더라도 신청 자체가 없었던 계약과 구별할 수 있게 된다. 현재 값을 덮어쓰거나 행을 지우던 처리가, 이전 기록을 유지하면서 새 이벤트를 추가하는 처리로 바뀐 것이다. 그림에는 각 처리와 관련된 기록만 표시했다.

 

변경 이력을 모두 확인할 수 있다

1편의 U-2를 다시 보자. 10월 2일의 이용 시간을 8시간으로 기록했다가 10월 7일에 5시간으로 정정한 예시다. 기존 DailyUsage에는 정정 후의 5시간과 50,000원만 남았다. 바뀐 구조에서는 8시간을 기록한 USAGE_RECORDED와 5시간으로 정정한 USAGE_CORRECTED가 모두 남는다.

 

현재 5시간과 50,000원만 남은 U-2와 최초 8시간 및 정정한 5시간을 함께 보존한 이벤트 기록을 나란히 비교

 

정정 이벤트는 target_event_id로 최초 기록을 가리키고, 정정한 시간과 처리자, 사유를 보존한다. 따라서 U-2가 처음부터 5시간이었던 것이 아니라 8시간으로 기록된 뒤 입력 오류로 정정됐다는 과정을 같은 저장소에서 확인할 수 있다. 현재 상태뿐 아니라 그 상태에 도달하기까지의 전체 변경 이력을 확인할 수 있게 된 것이다.

 

원하는 시점의 상태도 조회할 수 있다

같은 이벤트 흐름에서 E-1부터 E-7까지만 재생해 과거 상태를 구성하고 E-1부터 E-9까지 전체를 재생해 현재 상태를 구성하는 비교

R-1의 신청부터 이용 기록, 반납과 반납 취소, 이용 시간 정정까지 이벤트가 쌓였다고 해보자. 현재 상태를 구성할 때는 전체 이벤트를 적용하고, 과거 상태를 조회할 때는 첫 이벤트부터 해당 시점까지의 이벤트만 적용한다. 이벤트를 다시 적용하는 Replay의 범위가 달라지는 것이다.

 

이벤트는 그대로 보존하면서, 어디까지 재생하느냐에 따라 과거 상태와 현재 상태를 같은 기록에서 구성할 수 있다. 1편처럼 현재 값만 읽는 조회에 더해, 지정한 시점에 시스템에 기록돼 있던 상태를 조회할 수 있게 된 것이다.

 

정리

변경 이력을 추가하면 기존 조회 방식을 유지하면서 과거 기록을 보존할 수 있지만, 현재 상태와 이력의 일관성을 함께 관리해야 한다. 금액 원장은 이용료의 증감과 보정 근거를 설명할 수 있지만, 금액이 바뀌지 않는 처리까지 기록하려면 범위를 넓혀야 한다. 나는 변경 과정을 상태 구성의 원본으로 보존하고 과거 상태와 정정 근거를 확인하기 위해, 이벤트 관리와 재생 비용을 감수하고 Event Sourcing을 선택했다.

 

이력을 보존할 방법을 정했어도 정책 소급 반영과 잘못 적용한 정책의 복구 비용은 따로 해결해야 한다. 다음에는 이 문제를 해결하기 위해 대량 갱신을 청크 단위로 나누는 방법과 원본 기록을 정책 계산에서 분리하는 방법을 살펴본다.

 

Reference