[MSA] 7. MSA의 트랜잭션 이야기 3 - 이벤트 소싱과 CQRS

반응형

이벤트 소싱을 처음 접하게 된 것은 2017 SpringCamp에서였습니다. 당시에는 MSA라는 개념에 대해 잘 알지도 못했고, MSA는 대기업에서나 쓸 수 있고, 적용가능한 엄청나게 큰 아키텍처였다. 라고만 인식하고 무작정 배웠을 때였습니다.

 

그런데, 최근 제가 다니고 있는 회사에서 이벤트 소싱과 CQRS라는 주제로 이야기를 했었는데요. 다시금 CQRS를 보려고 하니, 그 개념이 잘 기억나지 않고, 어떤 특징을 가지고 있었는지를 파악하기가 어려워서 이렇게 정리하게 되었습니다.

 

그럼 MSA의 트랜잭션 이야기 3번째 이벤트 소싱과 CQRS 시작하도록 하겠습니다.

 

 

 

 

전통적인 CRUD

가끔은 이런 생각이 들 때가 있습니다. 전통적인 CRUD를 사용함으로써 백엔드 엔지니어가 데이터베이스를 사용할 때 트랜잭션 로직에 대한 고민을 덜어주고, 이 때문에 데이터베이스를 사용하는 게 아닌가 생각했습니다.

 

그러나 최근 MSA를 하면서 도메인 별로 서비스를 개발하고, 그 데이터베이스 또한 분리하면서 데이터베이스에서 제공하는 트랜잭션은 그야말로 무용지물이 되었습니다. 서로 같은 인스턴스를 사용하지 않다보니 참조가 되어 있는 데이터를 확인할 수가 없었기 때문입니다. 그래서 우리는 지난 트랜잭션 이야기 2편에서 Two-Phase Commit이라는 걸 이야기하기도 했었지요.

 

2020.11.02 - [Architecture/MSA] - [MSA] 6. MSA의 트랜잭션 이야기 2 - Two-Phase commit과 Saga

 

[MSA] 6. MSA의 트랜잭션 이야기 2 - Two-Phase commit과 Saga

이전 글에 이어서 MSA 내에서 트랜잭션을 원활히 하는 방법 2가지를 소개해드리고자 합니다. 관계형 데이터베이스와 더불어 모놀리틱 아키텍처를 도입한 서버 애플리케이션은 DB 서버에서 제공

blog.neonkid.xyz

그런데, 이러한 방법이 MSA에서 어떤 문제가 될까요? 이렇게 연결되어 있기 때문에 서로 관계를 맺은 데이터를 불러오는 것은 굉장히 쉬워졌습니다. 하지만 서비스를 분리해서 사용하는 MSA의 목적은 서로의 의존성을 가능한 느슨하게 결합하고, 그렇게 함으로써 장애가 발생했을 때 전파가 되지 않도록 하는 것이 목적이지만 전통적인 CRUD는 동시성을 강하게 요구하는 백엔드 애플리케이션에서 데이터를 동시 접근할 때 발생하는 동시성 이슈를 오히려 더욱 가중시킵니다.

 

 

전통적인 CRUD에서 두 테이블이 있을 떄 A 테이블이 B 테이블을 참조하는 경우 그 참조하는 테이블이 제거되거나 참조되는 데이터가 누락되는 경우 오류를 발생하고, 만약 두 인스턴스가 떨어지는 경우에라도 두 테이블 간에 데이터가 관계성립 되었을 때 결합을 강력하게 매꿔준다면 서비스가 분리되었다하더라도 오류 전파에 대한 문제는 지속되는 것이죠.

 

 

 

 

이벤트 소싱

이렇게 데이터베이스의 트랜잭션을 사용할 수가 없다면 우리가 백엔드 애플리케이션을 개발했을 때 사용했던 데이터베이스의 트랜잭션 특성을 고려해야 합니다. 그 중 가장 대표적인 것이 원자성인데요.

 

  • 원자성 

    하나의 트랜잭션은 모두 성공했을 때 성공이고, 하나라도 실패하면 그 트랜잭션은 적용되지 않아야 하는 원칙

예를 들어보면, 영화관에서 좌석을 예약하는 시스템이 있다고 가정해보겠습니다. 그렇다면 좌석 예약을 위해 좌석 테이블이 필요하고, 결제 내역을 확인하기 위해서 결제 테이블이 필요합니다. 두 테이블은 관계가 성립 되어 있고, 고객이 좌석을 예약했다면 좌석 테이블에 해당 고객의 번호가 있고, 그 좌석을 예약한 결제 내역은 결제 테이블에 있을 것입니다.

 

그런데, 좌석 예약은 성공했지만 결제는 실패했다면? 이런 경우 이 좌석의 예약은 성립되지 않아야 합니다. 이러한 특성을 우리는 원자성이라고 이야기 합니다.

 

이러한 일련의 트랜잭션을 관리하기 위해서는 각각의 이벤트를 저장해야 할 필요가 있습니다. 이를 테면 A 고객이 좌석을 예약함 -> A 고객이 해당 좌석에 대해 결제를 진행함. 처럼 일련의 이벤트를 저장하고 관리하는 패턴을 우리는 이벤트 소싱이라고 합니다. 

 

간단하게 영화관의 좌석을 예약하는 시스템을 만든다고 본다면 전통적인 CRUD를 생각했을 때 우리는 간단히 이 사람이 어떤 좌석을 예약했고, 어느 시간 때에 어느 영화를 상영하는지 정도만 볼 수 있습니다. 

 

그런데, MSA에서는 이러한 트랜잭션을 이벤트별로 일괄 저장함으로써 일부 트랜잭션에서 Fail이 발생했을 때 이를 순차적으로 롤백할 수 있게 함으로써 원자성을 확보할 수가 있습니다. 기존의 전통적인 CRUD에서는 어떻게 정형화된 데이터를 만드는 게 목표였다면 지금은 로우 데이터를 설계하고 차후 정형화를 한다는 것까지를 고려해야하는 엄청 큰 비용이 드는 작업이라고 보네요.

 

그렇다면 이 이벤트들은 어디에 저장을 해야할까요? 그리고 이 이벤트를 어떤 형태로 저장해야 할까요? 각 서비스마다 모든 이벤트를 보관한다면 이걸 색인할 때 부하가 엄청 크지 않을까요?

 

다양한 의문점들이 있습니다. 이벤트를 보관하여 이를 처리한다고 하지만 모든 이벤트를 보관한다면 일단 두 가지 문제점이 생깁니다.

 

  1. 각 이벤트 별로 ID가 주어질텐데, 이벤트가 무수하게 쌓이면 충돌이 일어날 수 있다. ID 처리는 어떻게 하는 것이 좋은가?
  2. 일부 특수한 이벤트를 조회하여 복귀하거나 트랜잭션에 반영하는 등을 진행할텐데, 데이터가 대량으로 쌓이면 색인이 느려질 것이다. 이에 대한 전략이 있는가?
  3. 2번을 이용하여 과거의 이벤트를 회생하는 방법은 어떠한 것이 있을까?

 

 

 

Event Store

먼저 이벤트를 저장하는 방법에는 아래의 3가지 방법이 있습니다.

 

  • 이벤트 저장에 특화된 데이터 저장소 사용 (e.g. eventstore.org)
  • NoSQL 사용 (MongoDB, Redis)
  • 관계형 데이터베이스 사용 (MySQL, PostgreSQL)

이벤트의 구조는 단순합니다. 데이터가 변화되는 내용 (payload)와 해당 이벤트 타입을 기본적으로 사용하면 되고, 그 하위는 서비스에 따라 여러분들이 자유롭게 결정하실 수 있습니다. 개인적으로 저는 NoSQL을 추천하지만 PostgreSQL과 같은 관계형 데이터베이스에서 JSONB와 같은 데이터 타입을 이용하는 것도 괜찮은 선택이라고 봅니다.

 

 

 

 

Snapshot

만약 이벤트가 무수히 많아지면 어떨까요? 이벤트가 쌓여서 수 천만개, 억개 이렇게 저장이 된다면 탐색 시간이 엄청 느려집니다. 그러면 데이터 처리 시간이 그만큼 길어지고, 동기화가 느려지게 되는데요. 이 때 쓰는 전략이 바로 스냅샷입니다.

 

스냅샷은 수많은 이벤트 중에서 특정 이벤트 구간을 별도로 저장하고, 조회할 때 그 구간의 데이터만을 조회해서 조회 시간을 빠르게 해주는 기법입니다. 만약 Event Store 전용 저장소라면 이러한 기능을 지원해주겠지만 NoSQL, 관계형 데이터베이스와 같이 이벤트 특화된 저장소가 아닌 걸 사용한다면 이러한 작업을 수동으로 처리해줘야 하는 번거로움이 있습니다.

 

 

 

 

CQRS

이러한 이벤트 소싱에는 CQRS를 활용하는 것을 권장합니다. CQRS는 명령과 조회 책임을 분리하는 패턴으로 Command and Query Reponsibility Segregation의 약자입니다. 

 

명령과 조회의 책임을 분리한다? 무슨 이야기일까요. 간단하게 얘기하면 CRUD에서 조회는 R(Read)를 말합니다. 나머지는 저장과 관련이 있지요. 보통의 CRUD는 명령과 조회를 같이 하지만, CQRS는 생성, 삭제, 수정과 조회(Read)를 분리해서 하도록 하는 걸 말합니다.

 

 

위 그림을 보시면 클라이언트가 조회와 생성/수정을 위해 별개의 API를 부르고 있음을 볼 수 있습니다. Event Store에서는 해당 이벤트를 기록하고, 이벤트를 기록한 뒤에는 Event Broker 등을 통해 그 결과를 View Store로 저장하여 클라이언트가 조회할 수 있도록 하는 형태입니다.

 

그런데, CQRS를 사용해야 할 이유가 있을까요? Event Store에서 이미 데이터를 저장하고 활용하고 있는데, Event Store에서 타입을 정해주고, 그에 맞는 컬럼과 함께 Payload만 부를 수 있다면 그걸로 족하지 않을까요?

 

Event Store에서 각 비즈니스 로직에 해당하는 모든 로우 데이터를 담고 있고, 여기서도 물론 조회는 가능합니다. 하지만 이들 데이터가 무수히 쌓이기 시작하면 데이터의 조회는 그만큼 딜레이가 걸리고, 모든 이벤트를 탐색해서 해당 API에 맞는 이벤트 타입을 또 검색해야하는데, 이렇게 되면 시간이 엄청 길어집니다.

 

이럴 때는 해당 비즈니스 로직에 해당하는 테이블에서 바로 조회하는 것이 현명합니다. 따라서 비즈니스 레벨에서 고객이 데이터를 볼 때는 그 결과만을 바라보 되, 시스템을 운영하는 입장에서는 그 과정을 보는 것이 중요한데, 이들 둘을 모두 만족하기 위해서 이러한 패턴을 사용할 수도 있습니다.

 

 

 

 

마치며...

여기까지 매우 긴 개념의 MSA 트랜잭션 이야기를 좀 더 이어나가 봤습니다. 언듯보면 Saga 패턴과 유사하지만 이벤트를 전달하는 방법에서는 유사하고, 여기에 저장하는 방식을 추가한 것이 이벤트 소싱, 이걸 더해서 읽기에 좀 더 최적화 된 개념을 붙인 것이 CQRS디 라고 보신다면 이해하는 데 도움이 될지도 모르겠네요.

 

덧붙여서 스냅샷을 통한 처리를 좀 더 보충해서 설명드리자면, 1만 개의 이벤트 데이터가 있는 곳에서더 1000번 ID에 해당하는 이벤트를 조회하고자 할 때, 900번째 데이터 값부터 스냅샷으로 저장한다고 한다면 900번부터 쌓인 데이터만을 조회하면 쉽게 찾을 수 있습니다.

 

끝으로 CQRS에 대한 이야기입니다만 Event Sourcing 패턴은 Write에 집중된 전략이었습니다. 하지만 이를 다시 불러오기 위해서는 수많은 절차가 필요하며 그를 검색하는 것은 REST API 서비스에 있어 시간적인 소모가 매우 크리티컬하게 작용되게 됩니다. 물론 데이터가 적은 곳에서는 이러한 문제를 감수해야 할 필요가 없겠지만 비즈니스 로직이 방대하고 큰 서비스라면 이야기가 달라집니다.

 

따라서 Event Sourcing을 위해 CQRS는 필수라고 볼 수 있습니다. 다만 반대로 CQRS를 위해 이벤트 소싱을 사용할 필요는 없습니다. 주체를 쓰기와 읽기로 구분지어서 사용하는 것은 그럴 수 있겠지만 그것을 반드시 이벤트로 사용해야 하는 것은 필수가 아니며. 이는 소프트웨어 엔지니어를 하고 계신 여러분들의 재량입니다.

 

다음 포스트는 이를 이어서 본격적인 Spring + Axon Framework를 다뤄보도록 하겠습니다.

(이전부터 위 주제에 대해 글을 써보기로 했는데, 많이 늦어진 점 죄송합니다.)

 

 

 

 

참고: https://edykim.com/ko/post/eventsourcing-pattern-cleanup/

반응형