Event Sourcing과 CQRS를 도입해야 하는 시점

Event Sourcing과 CQRS는 함께 소개되는 일이 많아 한 묶음처럼 들리지만, 둘은 서로 다른 문제를 푸는 별개의 선택이고 들어가는 비용도 다르다. 이 글은 두 패턴을 나눠서 설명하고, 도입해야 할 신호와 하지 말아야 할 신호, 도입하면 따라오는 비용, 감사 로그부터 시작하는 단계적 도입 순서를 정리한 것이다.

기록 담당자가 큰 장부 끝에 새 항목을 적고, 뒤편 탁자에서는 동료가 색인 카드를 상자별로 정리하는 모습

한눈에 보기

구분 CQRS만 Event Sourcing만 둘 다
바꾸는 것 쓰기 모델과 조회 모델을 나눈다 상태 대신 일어난 일(이벤트)을 저장한다 이벤트를 저장하고 조회 모델을 이벤트로 만든다
푸는 문제 화면이 요구하는 모양과 업무 규칙이 요구하는 모양이 다르다 "언제 왜 이렇게 됐나"를 정확히 답해야 한다 두 문제가 같이 있다
원본 데이터 기존 테이블 이벤트 스토어 이벤트 스토어
대표 비용 조회 모델 동기화, 최종 일관성 이벤트 버전 관리, 재생 성능, 삭제 위 두 비용을 모두 진다
단독 도입 흔하다 드물다(조회가 불편해 결국 CQRS가 따라온다) -

명령은 애그리거트를 거쳐 이벤트 스토어에 붙여 쓰이고, 프로젝션이 이벤트를 구독해 조회용 읽기 모델과 검색 인덱스를 갱신하며, 조회는 읽기 모델만 보는 구조

먼저 용어를 나눈다

CQRS(Command Query Responsibility Segregation) 는 상태를 바꾸는 요청(명령)과 읽는 요청(조회)을 다른 모델로 처리하는 구조다. Bertrand Meyer의 CQS(명령-조회 분리) 원칙을 객체 단위에서 시스템 단위로 넓힌 것으로, 이름은 Greg Young이 붙였다. 같은 DB 안에서 테이블만 나눠도 CQRS다. 저장소를 둘로 나누는 것은 필수가 아니다.

Event Sourcing 은 "현재 잔액 7만 원"을 저장하는 대신 "입금 10만 원", "출금 3만 원"처럼 일어난 일을 순서대로 붙여 쓰고, 현재 상태는 이벤트를 처음부터 재생해서 얻는 방식이다. 이벤트는 고치거나 지우지 않는다. 잘못된 이벤트는 반대 이벤트를 추가해 바로잡는다. Martin Fowler의 글이 이 둘을 각각 따로 설명하는 것도 같은 이유다.

정리하면 CQRS는 "모델을 몇 개 둘 것인가", Event Sourcing은 "무엇을 원본으로 저장할 것인가"에 대한 답이다. 질문이 다르므로 판단도 따로 한다.

도입 신호 네 가지

다음 중 둘 이상이 해당하면 검토할 가치가 있다.

  • 변경 이력이 업무 요구사항이다. 감사 대응, 분쟁 처리, 정산 검증처럼 "누가 언제 무엇을 왜 바꿨는지"를 법적·업무적으로 증명해야 한다. 상태만 저장하면 이력은 별도 로그에 의존하고, 그 로그는 원본과 어긋날 수 있다.
  • 특정 시점의 상태를 복원해야 한다. "지난달 말 기준 계약 상태"를 다시 계산해야 하거나, 버그로 잘못 계산된 값을 과거 이벤트로 다시 만들어야 한다. 이벤트가 원본이면 재생으로 해결된다.
  • 쓰기 모델과 읽기 모델의 모양이 크게 다르다. 쓰기는 주문 한 건의 규칙 검증이 중심인데, 화면은 고객·상품·배송을 조인한 목록과 통계를 요구한다. 한 모델로 둘을 맞추면 조인과 인덱스가 계속 늘어난다. 이 신호만 있으면 CQRS만 도입한다.
  • 업무 담당자가 이미 이벤트로 말한다. "접수됨", "심사 반려됨", "재신청됨"처럼 상태 변화가 업무 용어로 존재하면 이벤트 이름을 따로 지어낼 필요가 없다. 반대로 이벤트 이름이 RecordUpdated밖에 안 나온다면 아직 이벤트로 볼 도메인이 아니다.

하지 말아야 할 신호

  • 단순 CRUD다. 코드 테이블, 게시판, 설정 화면은 수정 이력이 필요해도 이력 테이블 하나로 충분하다. 이벤트 스토어와 프로젝션을 붙이면 코드가 몇 배로 늘어난다.
  • 저장 직후 조회가 반드시 같은 값을 보여야 한다. 프로젝션은 비동기로 갱신되므로 저장 직후 목록에 새 건이 없을 수 있다. 화면 흐름이 이를 감당할 수 없으면 읽기 모델을 같은 트랜잭션에서 갱신하는 동기 CQRS로 시작한다.
  • 팀에 운영 경험이 없다. 이벤트 버전 충돌, 프로젝션 재구축, 재생 성능 문제는 운영 중에 처음 만난다. 첫 적용을 핵심 업무 전체로 잡으면 장애를 패턴 탓으로 돌리게 된다.
  • "나중에 필요할지 몰라서"가 유일한 이유다. 이벤트 스토어는 한번 도입하면 되돌리기 어렵다. 필요해지는 시점에 감사 로그에서 이벤트를 만들어 이관하는 편이 싸다.

도입하면 생기는 비용

이벤트 스키마 버전 관리. 이벤트는 지우지 않으므로 3년 전 형식의 이벤트도 지금 코드가 읽어야 한다. 이벤트마다 스키마 버전을 저장하고, 읽을 때 옛 형식을 새 형식으로 바꾸는 업캐스터를 둔다.

스냅샷. 이벤트가 수천 개 쌓인 애그리거트를 매번 처음부터 재생하면 명령 처리가 느려진다. 일정 개수마다 상태를 스냅샷으로 저장하고, 스냅샷 이후 이벤트만 재생한다. 스냅샷은 캐시이므로 지워도 재생으로 다시 만들 수 있어야 한다.

프로젝션 재구축. 읽기 모델에 칼럼을 추가하거나 버그를 고치면 프로젝션을 처음부터 다시 돌려야 한다. 재구축 중에도 서비스는 돌아가야 하므로 새 테이블에 만들고 따라잡은 뒤 전환하는 절차가 필요하다.

최종 일관성 UX. 저장 응답을 받은 사용자가 목록으로 돌아갔을 때 방금 건이 없으면 "저장이 안 됐다"고 다시 누른다. 저장 응답에 새 버전 번호를 돌려주고 읽기 모델이 그 버전에 도달할 때까지 기다리거나, 화면이 방금 만든 건을 직접 보여 주는 식으로 설계해야 한다.

개인정보 삭제. 이벤트를 지우지 않는다는 원칙은 개인정보 삭제 요구와 충돌한다. 흔히 쓰는 방법은 crypto-shredding이다. 개인정보를 사용자별 키로 암호화해 이벤트에 넣고, 삭제 요청이 오면 키를 폐기해 해당 필드를 읽을 수 없게 만든다. 개인정보를 이벤트 밖 별도 저장소에 두고 참조만 남기는 방법도 있다.

단계적으로 도입하기

한 번에 전환하지 않는다. 앞 단계에서 효과가 확인될 때만 다음으로 간다.

1단계: 감사 로그 테이블. 기존 상태 테이블은 그대로 두고, 같은 트랜잭션에서 "무슨 일이 일어났는지"를 이벤트 형태로 기록한다. 이벤트 이름과 필드를 업무 담당자와 맞춰 보는 단계다. 이 로그가 쌓이면 나중에 이벤트 스토어로 옮길 원천이 된다.

2단계: CQRS 읽기 모델. 조회가 무거운 화면부터 전용 읽기 테이블을 만든다. 처음에는 쓰기와 같은 트랜잭션에서 갱신하고, 부하나 저장소 분리가 필요해지면 비동기 프로젝션으로 바꾼다.

3단계: 일부 애그리거트만 Event Sourcing. 이력 요구가 가장 강한 애그리거트 하나(예: 계약, 정산)만 이벤트 스토어를 원본으로 바꾼다. 나머지는 상태 저장을 유지한다. 시스템 전체를 Event Sourcing으로 만들 필요는 없다.

이벤트 스토어 테이블은 관계형 DB 하나로도 시작할 수 있다.

CREATE TABLE event_store (
  stream_id   VARCHAR(64)  NOT NULL,  -- 애그리거트 ID
  version     INT          NOT NULL,  -- 스트림 안 순번
  event_type  VARCHAR(100) NOT NULL,  -- 예: ContractSigned
  schema_ver  SMALLINT     NOT NULL,  -- 업캐스터가 읽는 형식 버전
  payload     JSON         NOT NULL,
  occurred_at TIMESTAMP    NOT NULL,
  PRIMARY KEY (stream_id, version)    -- 같은 version을 동시에 쓰면 한쪽이 실패한다(낙관적 잠금)
);

명령 처리기는 스트림을 읽어 마지막 version을 알고, 새 이벤트를 version + 1로 넣는다. 그사이 다른 요청이 먼저 넣었다면 기본키 충돌이 나므로 다시 읽고 규칙을 재검증한다. 업캐스터는 읽기 경로에 둔다.

// v1 {"amount":10000} 을 v2 {"amount":10000,"currency":"KRW"} 로 읽는다
JsonNode upcast(String type, int schemaVer, ObjectNode payload) {
    if ("PaymentRequested".equals(type) && schemaVer == 1) {
        payload.put("currency", "KRW");   // v1 시절에는 원화만 받았다
    }
    return payload;
}

판단 정리

상황 선택 이유
조회 화면이 무겁고 이력 요구는 약하다 CQRS만(동기 갱신부터) 원본은 그대로 두고 조회 비용만 떼어 낸다
이력 증명이 업무 요구지만 조회는 단순하다 감사 로그 테이블 이벤트 스토어 없이 이력을 같은 트랜잭션에 남긴다
시점 복원과 재계산이 필요하다 해당 애그리거트만 Event Sourcing + CQRS 재생이 가능해야 하고, 조회는 프로젝션이 맡는다
단순 CRUD 둘 다 쓰지 않는다 늘어나는 코드에 비해 얻는 것이 없다

자주 하는 실수

ES와 CQRS를 한 세트로 결정한다. 조회 문제만 있는 시스템에 이벤트 스토어까지 붙여 비용을 두 배로 진다.

이벤트를 테이블 변경 알림처럼 설계한다. OrderUpdated 하나에 전체 필드를 담으면 무엇이 왜 바뀌었는지 남지 않아 Event Sourcing을 쓰는 이유가 사라진다.

스키마 버전을 처음에 넣지 않는다. 형식이 처음 바뀌는 날 옛 이벤트를 구분할 방법이 없어 데이터 이관을 하게 된다.

프로젝션 재구축을 연습하지 않는다. 장애가 난 뒤에야 재구축에 몇 시간이 걸리는지 알게 된다.

개인정보를 이벤트 평문에 넣는다. 삭제 요구가 오면 이벤트를 고치지 않는다는 원칙을 깨거나 요구를 거절해야 하는 상황이 된다.

점검표

  • ES와 CQRS를 각각 도입하는 이유를 따로 한 문장씩 적었다.
  • 도입 신호 네 가지 중 해당하는 것이 둘 이상이다.
  • 저장 직후 조회 화면이 최종 일관성을 어떻게 보여 줄지 정했다.
  • 이벤트 이름을 업무 담당자가 쓰는 말로 지었다.
  • 모든 이벤트에 스키마 버전 칼럼이 있다.
  • 같은 스트림에 동시 append 시 한쪽이 실패하는 제약이 있다.
  • 스냅샷 주기와 스냅샷 삭제 후 재생 절차가 있다.
  • 프로젝션을 새 테이블에 재구축하고 전환하는 절차를 한 번 돌려 봤다.
  • 개인정보 필드의 삭제 방법(crypto-shredding 또는 외부 저장)을 정했다.
  • 적용 범위를 애그리거트 단위로 적어 두었다.

Event Sourcing은 "이력이 원본이어야 하는가", CQRS는 "조회 모델을 따로 둬야 하는가"에 각각 답한 뒤 필요한 만큼만, 감사 로그부터 한 단계씩 도입하는 것이 가장 덜 비싼 길이다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기