프론트엔드 개발자에게 익숙한 패턴, 서버로 옮기다

 

프론트엔드에서 Redux를 써본 적이 있다면, 이런 흐름에 익숙할 것이다: Action을 디스패치하면 Reducer가 현재 State에 Action을 적용해 새로운 State를 반환한다. 상태는 직접 변경하지 않고, 항상 새 객체를 만든다. 이 패턴이 서버 사이드에도 적용될 수 있다면 어떨까?

 

당근마켓(이하 당근)은 이 아이디어를 실현한 TypeScript 기반 이벤트 소싱 라이브러리 Ventyd를 만들었다. 프론트엔드와 백엔드가 같은 언어(TypeScript)를 사용하는 환경에서, 동일한 사고 모델로 비즈니스 로직을 공유할 수 있다는 점이 핵심 가치다.

 


 

이벤트 소싱이란

 

전통적인 CRUD 방식에서는 데이터의 현재 상태만 저장한다. 계좌 잔액이 10만 원이면 데이터베이스에 balance: 100000이라고 적혀 있을 뿐, 어떤 과정을 거쳐 10만 원이 되었는지는 알 수 없다.

 

이벤트 소싱은 다르다. 상태 변경을 일으킨 모든 이벤트를 순서대로 저장한다. "5만 원 입금", "3만 원 출금", "8만 원 입금" — 이 이벤트들을 처음부터 순서대로 리플레이하면 현재 잔액 10만 원을 복원할 수 있다.

 

비유하면 이렇다. CRUD는 사진이다. 현재 모습만 찍혀 있다. 이벤트 소싱은 영상이다. 처음부터 지금까지의 모든 장면이 기록되어 있어서, 되감기(rewind)로 과거 어느 시점의 모습이든 확인할 수 있다.

 

용어: 이벤트 소싱(Event Sourcing): 시스템의 상태를 "현재 값"이 아니라 "상태를 변경한 이벤트의 시퀀스"로 저장하는 아키텍처 패턴이다. 이벤트를 순서대로 재생하면 임의의 시점의 상태를 재구성할 수 있다.

 


 

Redux와 이벤트 소싱은 같은 구조다

 

Redux와 이벤트 소싱의 핵심 구조를 나란히 놓으면, 놀라울 정도로 일치한다.

 

Redux (프론트엔드) 이벤트 소싱 (백엔드) 역할
Action Event 무엇이 일어났는지 기술하는 불변 객체
Reducer Aggregate/Projection 이벤트를 받아 새로운 상태를 반환하는 순수 함수
State Current State 이벤트들을 누적 적용한 결과
Dispatch Append Event 새 이벤트를 발행하는 행위
Store Event Store 이벤트를 저장하는 저장소

 

공통점은 세 가지로 요약된다.

 

1. 불변 이벤트

 

Redux의 Action은 한번 발행되면 수정되지 않는다. 이벤트 소싱의 Event도 마찬가지다. "5만 원 입금"이라는 이벤트가 기록된 후, 그 이벤트를 "3만 원 입금"으로 수정하는 일은 없다. 잘못된 입금이 있었다면 "5만 원 입금 취소"라는 새 이벤트를 추가한다.

 

2. 순수 함수

 

Reducer는 같은 입력(현재 상태 + Action)에 대해 항상 같은 출력(새 상태)을 반환한다. 외부 상태에 의존하지 않고, 부수 효과(side effect)도 없다. 이 덕분에 테스트가 쉽고, 디버깅이 예측 가능하다.

 

3. 단방향 흐름

 

상태를 직접 변경하는 것이 아니라, 이벤트를 발행하면 → 그 이벤트가 처리되어 → 새 상태가 만들어진다. 데이터가 한 방향으로만 흐르기 때문에 상태 변경의 원인을 추적하기 쉽다.

 


 

Ventyd: 코드로 보는 Action-Reducer-State

 

Ventyd는 이 공통 구조를 TypeScript로 구현한 라이브러리다. 프론트엔드 개발자가 Redux를 쓰듯이 서버 사이드 비즈니스 로직을 작성할 수 있다.

 

계좌 도메인 예시

 

account-events.ts

// 1. Event(Action) 정의 — 무엇이 일어날 수 있는지 선언
type AccountEvent =
  | { type: 'AccountOpened'; initialBalance: number; owner: string }
  | { type: 'MoneyDeposited'; amount: number }
  | { type: 'MoneyWithdrawn'; amount: number }

// 2. State 정의 — 이벤트 누적의 결과물
interface AccountState {
  owner: string
  balance: number
  isOpen: boolean
}

 

account-reducer.ts

// 3. Reducer 정의 — 순수 함수로 상태 전이 로직 작성
function accountReducer(
  state: AccountState,
  event: AccountEvent
): AccountState {
  switch (event.type) {
    case 'AccountOpened':
      return {
        ...state,
        owner: event.owner,
        balance: event.initialBalance,
        isOpen: true,
      }
    case 'MoneyDeposited':
      return {
        ...state,
        balance: state.balance + event.amount,
      }
    case 'MoneyWithdrawn':
      return {
        ...state,
        balance: state.balance - event.amount,
      }
  }
}

 

account-usage.ts

// 4. 이벤트를 순서대로 리플레이하면 현재 상태가 복원된다
const events: AccountEvent[] = [
  { type: 'AccountOpened', initialBalance: 0, owner: '김당근' },
  { type: 'MoneyDeposited', amount: 50000 },
  { type: 'MoneyWithdrawn', amount: 20000 },
  { type: 'MoneyDeposited', amount: 80000 },
]

const initialState: AccountState = {
  owner: '',
  balance: 0,
  isOpen: false,
}

const currentState = events.reduce(accountReducer, initialState)
// → { owner: '김당근', balance: 110000, isOpen: true }

 

Redux의 store.dispatch(action)과 사실상 동일한 흐름이다. 차이점은 이 이벤트들이 메모리가 아니라 영구 저장소(Event Store)에 기록된다는 것뿐이다.

 


 

더치페이: 복잡한 비즈니스 로직 예시

 

단순한 계좌보다 복잡한 사례를 보자. 당근의 송금 서비스에서 더치페이(정산) 기능을 이벤트 소싱으로 구현하면 이런 형태가 된다.

 

dutch-pay-events.ts

type DutchPayEvent =
  | { type: 'DutchPayCreated'; totalAmount: number; organizer: string; participants: string[] }
  | { type: 'ParticipantPaid'; participant: string; amount: number }
  | { type: 'DutchPayCompleted' }
  | { type: 'DutchPayCancelled'; reason: string }

 

각 참가자가 돈을 보낼 때마다 ParticipantPaid 이벤트가 쌓이고, Reducer가 "누가 얼마를 냈는지", "남은 금액이 얼마인지"를 계산한다. 전원이 정산을 완료하면 DutchPayCompleted 이벤트가 기록된다. 이 과정에서 상태를 직접 변경하는 코드는 단 한 줄도 없다. 모든 변화는 이벤트를 통해서만 일어난다.

 


 

CQRS와의 관계

 

이벤트 소싱은 CQRS(Command Query Responsibility Segregation)와 함께 쓰이는 경우가 많다. CQRS는 "데이터를 변경하는 로직(Command)"과 "데이터를 조회하는 로직(Query)"을 분리하는 패턴이다.

 

비유하면 도서관과 같다. 새 책을 등록하고 분류하는 사서의 업무(Command)와, 책을 검색하고 대출하는 이용자의 업무(Query)가 서로 다른 시스템을 사용하는 것이다. 사서는 원본 카탈로그를 관리하고, 이용자는 검색에 최적화된 별도의 인덱스를 사용한다.

 

이벤트 소싱에서 이벤트 저장소는 쓰기(Command)에 최적화되어 있다. 이벤트를 빠르게 append할 수 있지만, "현재 잔액이 10만 원 이상인 계좌를 모두 조회해라" 같은 읽기(Query)에는 비효율적이다. 그래서 이벤트를 기반으로 읽기 전용 프로젝션(Read Model)을 별도로 만들어 조회 성능을 확보한다.

 

용어: CQRS(Command Query Responsibility Segregation): 데이터를 변경하는 경로(Command)와 조회하는 경로(Query)를 물리적·논리적으로 분리하는 아키텍처 패턴이다.

 


 

CRUD vs 이벤트 소싱: 장단점 비교

 

기준 CRUD 이벤트 소싱
저장 방식 현재 상태만 덮어쓰기 모든 이벤트를 순서대로 저장
이력 추적 별도 이력 테이블 필요 이벤트 자체가 이력
디버깅 현재 상태만 볼 수 있음 이벤트 리플레이로 과거 재현 가능
저장 공간 적음 이벤트 누적으로 증가
복잡도 단순 높음 (이벤트 설계, 스냅샷 등)
조회 성능 직접적 CQRS/프로젝션 필요
적합한 도메인 단순 CRUD 서비스 금융, 정산, 주문 등 이력이 중요한 도메인

 

모든 서비스에 이벤트 소싱이 필요한 것은 아니다. 게시판, 설정 페이지처럼 이력 추적이 중요하지 않은 도메인에서는 CRUD가 훨씬 합리적이다. 반면 돈이 오가는 금융 도메인, 주문 상태가 복잡하게 변하는 커머스 도메인, 감사 추적(audit trail)이 필수인 규제 도메인에서는 이벤트 소싱의 가치가 빛난다.

 


 

프론트와 백엔드가 같은 언어인 이점

 

Ventyd가 TypeScript로 만들어진 이유가 있다. 프론트엔드와 백엔드가 모두 TypeScript를 사용하면, 이벤트 타입 정의와 Reducer 로직을 공유할 수 있다. 프론트에서 Optimistic Update를 구현할 때 서버와 동일한 Reducer를 돌릴 수 있다는 뜻이다.

 

shared-reducer-usage.ts

// 같은 Reducer를 프론트와 백엔드에서 import
import { accountReducer, AccountEvent, AccountState } from '@ventyd/account'

// 프론트엔드: Optimistic Update에 사용
function handleDeposit(currentState: AccountState, amount: number) {
  const event: AccountEvent = { type: 'MoneyDeposited', amount }
  const optimisticState = accountReducer(currentState, event)
  // UI를 먼저 업데이트, 서버 확인은 비동기로
  return optimisticState
}

 

비즈니스 로직이 한 곳에만 존재하므로, "프론트와 백엔드의 계산 결과가 다르다"는 흔한 버그를 구조적으로 방지할 수 있다.

 


 

마무리

 

Redux를 써봤다면, 이벤트 소싱의 핵심 개념은 이미 체득한 것이나 다름없다. Action 대신 Event, Dispatch 대신 Append, Store 대신 Event Store — 용어만 다를 뿐 구조는 동일하다. 당근의 Ventyd는 이 동질성을 활용해 프론트엔드 개발자의 학습 곡선을 낮추면서, 이벤트 소싱의 이점(완전한 이력 추적, 디버깅 용이성, 상태 재현)을 서버에서 얻을 수 있게 한다. 모든 프로젝트에 필요한 것은 아니지만, "상태 변경의 이유와 과정이 중요한" 도메인에서는 강력한 선택지가 된다.