본문 바로가기

Engineering

Event Sourcing에서 CQRS까지 (4) - 트레이드오프

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

이번 편은 시리즈의 네 번째 이야기로, 앞서 선택한 저장 구조에서 감수하게 된 조회 비용을 살펴봅니다. 이벤트에서 상태를 복원하고 정책으로 금액을 계산하는 작업이 목록의 필터링과 정렬, 집계에 어떤 영향을 주는지 다룹니다. 데이터 규모는 조회 과정을 설명하기 위한 가정입니다.

 

2편에서는 변경 이력과 과거 상태를 보존하기 위해 Event Sourcing을 선택했다. 3편에서는 이용 기록과 정책 계산을 분리해 정책을 소급 적용할 때 원장을 대량 갱신하지 않는 구조를 살펴봤다. 두 선택으로 기존 문제를 해결할 수 있었지만, 현재 상태와 금액을 읽는 방식도 달라졌다. 이번에는 현재 상태와 계산 결과를 별도로 저장하지 않고 조회할 때 구성하는 구조에서, 어떤 비용을 감수하게 됐는지 살펴보자.

 

앞선 선택으로 무엇이 달라졌나

기존 구조에서는 Rental에서 현재 대여 상태를, DailyUsage에서 정정이 반영된 이용 시간과 이용료를 읽었다. 변경 과정이 사라지고 정책을 바꿀 때 대량 갱신이 필요했지만, 조회에서는 이미 계산된 결과를 읽을 수 있었다.

 

Event Sourcing을 선택하면서 대여 상태와 이용 시간은 이벤트를 순서대로 적용해 구성하게 됐다. 또한 정책 계산을 분리하면서 이용료는 복원한 이용 시간에 정책을 적용해 구하게 됐다. 이력을 보존하고 정책 변경에 따른 원장의 대량 쓰기를 없애는 대신, 조회에서는 상태 복원과 금액 계산을 수행하게 된 것이다.

 

현재 값을 읽는 데도 복원과 계산이 필요하다

이벤트를 읽고 현재 상태를 복원한다

현재 대여 상태를 알려면 신청과 승인, 반납과 취소 이벤트를 적용해야 한다. 이용 시간도 최초 기록만 읽으면 되는 것이 아니라, 이후 정정까지 반영해야 한다. 이처럼 이벤트를 순서대로 다시 적용하는 과정을 Replay라고 한다.

 

조회 요청을 처리할 때는 DB가 이벤트 레코드를 읽어 반환하고, 애플리케이션은 전달받은 데이터를 객체로 변환한 뒤 상태에 적용한다. 이벤트가 많아질수록 DB 읽기와 데이터 전달, 객체 변환, 재생할 작업도 늘어난다. 계약 식별자와 이벤트 순서에 맞는 인덱스로 기록을 빠르게 찾아도, 찾아낸 이벤트를 적용하는 작업은 필요하다.

 

이용 상태를 복원한 뒤 정책으로 금액을 계산한다

현재 이용 시간을 구했다고 해서 이용료까지 결정되는 것은 아니다. 각 이용일에 적용할 정책을 선택하고, 시간당 요금에 이용 시간을 곱한 뒤 계약 단위로 합산해야 한다. 예를 들어 10월 3일에 5시간 이용했다면 시간당 10,000원 정책에서는 50,000원이고, 시간당 8,000원 정책에서는 40,000원이다.

 

이용 이벤트가 추가되지 않아도 소급 정책이 바뀌면 계산 결과가 달라진다. 3편에서 정책 변경 시 이용 내역의 금액을 다시 쓰던 작업을 없앴지만, 새로운 기준으로 금액을 계산하는 작업은 조회 시점에 남은 것이다. 이벤트로 상태를 복원하는 비용과, 그 상태에 정책을 적용해 금액을 계산하는 비용은 구분해서 봐야 한다.

 

계약당 이벤트가 많다면 스냅샷을 검토할 수 있다

계약 하나에 이벤트가 많이 쌓이면 처음부터 재생하는 부담을 줄이기 위해 스냅샷을 사용할 수 있다. 스냅샷은 특정 순서까지 이벤트를 반영한 상태를 저장한 것으로, 다음에는 그 상태를 읽고 이후 이벤트만 적용한다. Microsoft 공식 문서에서도 다음과 같이 설명한다.

To rehydrate the entity, load the most recent snapshot and replay only the events that occur after it — Microsoft, Event Sourcing pattern

 

가장 최근 스냅샷을 읽고 그 이후 이벤트만 재생한다는 뜻이다. 예를 들어 이벤트 10만 개 중 99,900번째까지 반영한 상태를 저장했다면, 이후 이벤트 100개만 추가로 적용해 현재 상태를 복원할 수 있다.

 

99,900번째까지 반영한 스냅샷에 이후 이벤트 100개만 적용하는 흐름

 

스냅샷을 생성하고 저장하는 비용이 추가되는 대신, 이미 반영한 이벤트를 다시 읽고 적용하는 작업을 줄인다. 여기서는 스냅샷으로 이용 상태를 복원한 뒤 정책을 적용해 그림의 최종 이용료를 계산한다.

 

다만 실제 경험에서는 계약당 쌓이는 이벤트가 많지 않아, 스냅샷은 깊게 검토하지 않았다. 이 글에서도 개별 계약의 재생 비용을 줄일 수 있다는 정도로만 다룬다. 집중해서 볼 문제는 여러 계약을 대상으로 현재 상태와 이용료를 비교해야 하는 목록 조회다.

 

목록 조회에서는 반환할 개수보다 많은 계약을 확인한다

이용료가 높은 계약 20개를 조회한다면?

‘2025년에 생성한 계약 중 현재 대여 중인 계약을 이용료가 높은 순서로 20개 조회한다’고 해보자. 해당 기간에 생성한 계약은 100만 건이고 계약마다 평균 20개의 이벤트가 쌓여 있다고 가정한다. 전체 이벤트는 2,000만 건이다. 계약 하나를 복원하는 작업이 작더라도, 조회 대상 계약이 많으면 전체 처리량은 커질 수 있다.

 

계약 생성 기간으로 후보를 좁힐 수 있도록 계약 식별자와 생성일은 조회할 수 있다고 가정한다. 현재 대여 상태와 총 이용료는 저장하지 않았으며, 이용료는 현재 적용 정책으로 계산한다.

 

기존 구조에서는 저장된 결과로 조회할 수 있었다

현재 대여 상태와 총 이용료가 저장돼 있다면, DB는 그 값을 기준으로 필터링하고 정렬할 수 있다. 상태와 이용료순 조회에 맞는 인덱스를 사용하면 대여 중인 계약을 이용료가 높은 순서로 읽으면서 생성일 조건에 맞는 20개를 찾을 수 있다. 인덱스 순서를 이용해 정렬 조건을 처리할 수 있다는 뜻이다. 기간 밖의 계약이 많으면 더 많은 인덱스 항목을 읽어야 하지만, 조회할 때 계약별 이용료를 다시 계산할 필요는 없다. 이미 저장된 값으로 조회 대상을 판단할 수 있었다.

 

바뀐 구조에서는 조회 대상을 고르기 위한 계산부터 필요하다

현재 상태와 총 이용료를 저장하지 않았다면, 어떤 계약이 대여 중인지 판단하기 위해 후보 계약의 이벤트를 읽고 상태를 복원해야 한다. 대여 중인 계약에는 정책을 적용해 이용료를 구하고, 그 결과를 비교해 상위 20개를 선택한다.

 

후보 계약의 상태와 금액을 구성한 뒤 조건에 맞는 계약을 이용료순으로 비교하는 흐름

 

20개를 반환한다고 해서 임의의 계약 20개만 먼저 계산할 수는 없다. 아직 확인하지 않은 계약에 더 높은 이용료가 있을 수 있기 때문이다. 그림처럼 상태와 금액을 구한 뒤 필터링할 수도 있고, 상태를 먼저 확인해 반납 완료 계약의 요금 계산을 생략할 수도 있다. 어느 순서든 후보 전체의 대여 상태를 확인하고, 대여 중인 계약들의 이용료를 비교해야 한다.

 

스냅샷 없이 계약별 이벤트를 처음부터 재생하는 구현이라면, 이 예시에서는 후보 100만 건에 쌓인 이벤트 2,000만 건을 읽고 적용한다. 상위 20개만 유지하면서 비교하면 모든 계산 결과를 정렬할 필요는 없지만, 후보의 상태를 확인하고 대상 계약의 이용료를 구하는 작업은 남는다. 스냅샷으로 계약 하나의 재생 범위를 줄이더라도 여러 계약의 상태를 확인하고 금액을 비교하는 작업까지 없어지지는 않는다.

 

집계에도 같은 계산이 필요하다

대여 중인 계약의 이용료 합계나 평균을 구하는 경우도 마찬가지다. 저장된 금액 열을 합산하는 대신, 조건에 맞는 계약들의 이용료를 먼저 계산해야 한다. 이용료가 일정 금액 이상인 계약 수를 구하려면 금액을 계산한 뒤 그 조건에 해당하는지도 판단해야 한다.

 

이 구조에서는 목록과 집계 요청이 들어올 때마다 이벤트 읽기와 상태 복원, 정책 계산이 반복된다. 개별 계약의 이벤트가 적어도 조회 범위가 넓고 요청이 반복되면 DB와 애플리케이션이 처리해야 할 총량은 커진다. 화면에 보여줄 결과는 작아도, 그 결과를 결정하기 위해 확인하고 계산할 데이터는 많다는 것이 문제다.

 

조회와 계산이 많으면 왜 문제가 될까?

응답 속도가 느려진다

이용료가 높은 계약 20개를 반환하려면 후보 계약의 상태와 금액을 확인하고 비교하는 작업이 먼저 끝나야 한다. 이벤트를 읽고 전달하는 시간, 상태를 복원하는 시간, 정책을 적용하고 결과를 비교하는 시간이 응답 경로에 포함된다. 조회 범위가 넓어질수록 사용자는 목록의 첫 페이지를 보기 위해 더 오래 기다릴 수 있다. 응답으로 보내는 데이터가 20개뿐이어도, 그 결과를 결정하는 과정이 길면 화면은 늦게 열린다.

 

DB와 애플리케이션의 자원 경합이 증가한다

대량의 이벤트를 조회하면 DB의 CPU를 사용하고, 필요한 데이터 페이지가 버퍼 풀에 없으면 디스크 I/O도 발생한다. 애플리케이션에서는 이벤트 역직렬화와 상태 재구성, 정책 계산에 CPU를 사용하며, 이벤트 객체와 중간 상태를 유지하기 위한 메모리도 필요하다.

 

계약 승인과 반납, 이용 시간 정정도 같은 DB와 애플리케이션 서버에서 처리한다면 이 자원을 공유한다. 목록 조회가 CPU나 I/O 처리 용량을 소진할수록 다른 요청도 자원 경합으로 처리 시간이 늘어날 수 있다. 서로 다른 계약을 처리해 행 잠금이 충돌하지 않더라도, 조회 부하가 쓰기 요청의 지연으로 이어질 수 있다.

 

처리 용량을 초과하면 요청 대기와 타임아웃이 증가한다

요청마다 이벤트 조회와 계산을 반복하면 요청 한 건에 필요한 처리 비용이 커지고, 같은 서버 자원으로 단위 시간에 완료할 수 있는 요청 수가 줄어든다. CPU나 디스크 I/O가 포화된 상태에서는 동시 요청 수를 늘려도 처리량이 그에 비례해 증가하지 않는다. 처리 가능한 속도보다 빠르게 요청이 들어오면 대기 중인 요청이 쌓이고, 응답 시간에 대기 시간이 더해진다.

 

이 상태가 지속되면 요청 제한 시간 안에 처리를 마치지 못해 타임아웃이 발생할 수 있다. 단일 요청에서는 감당할 수 있었던 조회 비용도, 동시 요청이 늘어나면 서비스가 수용할 수 있는 요청량을 제한하는 병목이 된다.

 

따라서 이 구조의 비용은 개별 조회의 실행 시간에만 한정되지 않는다. 요청당 CPU와 I/O 사용량이 증가하면서 동일 자원에서 감당할 수 있는 처리량이 줄고, 부하가 처리 용량에 도달하면 자원 경합과 대기 누적으로 다른 요청의 응답 지연과 타임아웃까지 유발할 수 있다.

 

정리

Event Sourcing으로 변경 과정과 과거 상태를 보존하고, 정책 계산을 분리해 정책 소급 반영에 따른 원장의 대량 갱신을 없앴다. 그 대신 현재 값을 별도로 저장하지 않는 구조에서는 조회할 때 이벤트를 읽어 상태를 복원하고 정책으로 금액을 계산해야 한다.

 

개별 상태의 복원 비용이 작더라도 조회 범위가 넓어지면 필터링과 정렬, 집계에 필요한 전체 읽기와 계산량은 커질 수 있다. 이력 보존과 정책 변경 시 쓰기 감소를 얻는 대신, 조회 시 상태 재구성과 계산에 필요한 자원 사용량이 증가하는 트레이드오프를 감수한 것이다. 다음 편에서는 이 조회 비용을 줄일 방법을 살펴본다.