Action-Reducer-State의 귀환 — 프론트엔드 패턴이 서버를 점령한 이유
Redux를 써본 프론트엔드 개발자라면 이미 이벤트 소싱을 알고 있다. 본인이 모를 뿐이다. Action을 디스패치하고, Reducer가 새 State를 만들고, Store에 저장하는 흐름 — 이걸 서버로 옮기면 이벤트 소싱이 된다. 당근이 만든 TypeScript 라이브러리 Ventyd가 정확히 이 아이디어를 실현했다.
사진과 영상의 차이
데이터를 저장하는 방식은 크게 두 가지로 나뉜다.
CRUD는 스냅샷 사진이다. 계좌 잔액이 10만 원이면 데이터베이스에 balance: 100000이라고 찍혀 있다. 어떤 과정을 거쳐 10만 원이 되었는지는 사진에 담기지 않는다.
이벤트 소싱은 타임랩스 영상이다. "5만 원 입금", "3만 원 출금", "8만 원 입금" — 이 이벤트들이 시간순으로 전부 기록되어 있다. 영상을 되감으면 과거 어느 시점의 잔액이든 복원할 수 있다.
[💡 잠깐! 이 용어는?] 이벤트 소싱(Event Sourcing): 시스템 상태를 "현재 값"이 아니라 "상태를 변경한 이벤트의 시퀀스"로 저장하는 아키텍처 패턴. 이벤트를 순서대로 재생하면 임의의 시점 상태를 재구성할 수 있다.
두 세계의 용어를 나란히 놓으면
Redux와 이벤트 소싱은 이름만 다를 뿐 뼈대가 동일하다.
| Redux (프론트엔드) | 이벤트 소싱 (백엔드) | 하는 일 |
|---|---|---|
| Action | Event | 무엇이 일어났는지 기술하는 불변 객체 |
| Reducer | Aggregate/Projection | 이벤트를 받아 새 상태를 반환하는 순수 함수 |
| State | Current State | 이벤트 누적 적용 결과 |
| Dispatch | Append Event | 새 이벤트를 발행하는 행위 |
| Store | Event Store | 이벤트를 보관하는 저장소 |
공통 원칙은 세 가지다.
1. 이벤트는 불변이다
Redux Action은 한번 발행되면 수정되지 않는다. 이벤트 소싱의 Event도 마찬가지다. 잘못된 입금이 있었다면 기존 이벤트를 고치는 게 아니라 "5만 원 입금 취소"라는 보상 이벤트를 새로 추가한다. 회계 장부에서 수정 테이프로 숫자를 지우는 게 아니라, 다음 줄에 마이너스 항목을 기입하는 것과 같다.
2. 순수 함수로 상태를 전이한다
Reducer는 같은 입력(현재 상태 + Action)에 항상 같은 출력(새 상태)을 반환한다. 외부 상태 의존이 없고, 부수 효과도 없다. 테스트가 쉽고 디버깅이 예측 가능한 이유다.
3. 데이터는 한 방향으로 흐른다
상태를 직접 건드리지 않는다. 이벤트 발행 → 이벤트 처리 → 새 상태 생성. 이 단방향 흐름 덕분에 상태 변경의 원인을 역추적하기가 수월하다.
Ventyd — 익숙한 문법, 다른 무대
Ventyd는 이 공통 구조를 TypeScript로 구현한 이벤트 소싱 라이브러리다. Redux를 쓰듯이 서버 비즈니스 로직을 작성할 수 있다.
계좌 도메인으로 확인하기
// 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
}// 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,
}
}
}// 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 }store.dispatch(action) 대신 events.reduce(reducer, initialState). 흐름은 동일하고, 차이는 이벤트가 메모리가 아닌 **영구 저장소(Event Store)**에 기록된다는 점뿐이다.
더치페이 — 현실 세계의 복잡한 상태 전이
단순 계좌를 넘어서, 당근 송금 서비스의 더치페이(정산) 기능을 이벤트 소싱으로 구현하면 이런 형태가 된다.
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가 기록된다. 상태를 직접 변경하는 코드는 한 줄도 없다. 마치 공용 회계 장부에 거래 내역만 적어 넣는 것처럼, 모든 변화는 이벤트 기록을 통해서만 일어난다.
[💡 잠깐! 이 용어는?] Aggregate(애그리게이트): 이벤트 소싱에서 관련된 이벤트들을 하나의 단위로 묶어 관리하는 개념. 더치페이의 경우 하나의 정산 건이 하나의 Aggregate가 되고, 해당 정산에 관련된 모든 이벤트가 이 단위에 귀속된다.
CQRS — 쓰는 길과 읽는 길을 분리하다
이벤트 소싱은 보통 **CQRS(Command Query Responsibility Segregation)**와 짝을 이룬다. 도서관으로 비유하면 명쾌하다. 사서가 신간을 등록하고 분류하는 **카탈로깅 시스템(Command)**과, 이용자가 검색하고 대출하는 **검색 시스템(Query)**이 별개의 인프라 위에서 돌아가는 구조다.
이벤트 저장소는 쓰기에 최적화되어 있다. 이벤트를 빠르게 append하는 데 강하지만, "잔액 10만 원 이상인 모든 계좌"를 조회하기에는 비효율적이다. 그래서 이벤트를 기반으로 **읽기 전용 프로젝션(Read Model)**을 별도로 생성해 조회 성능을 확보한다.
[💡 잠깐! 이 용어는?] CQRS(Command Query Responsibility Segregation): 데이터 변경 경로(Command)와 조회 경로(Query)를 물리적·논리적으로 분리하는 아키텍처 패턴. 각 경로를 독립적으로 최적화할 수 있다.
CRUD와 이벤트 소싱, 어디에 쓸까
| 기준 | CRUD | 이벤트 소싱 |
|---|---|---|
| 저장 방식 | 현재 상태만 덮어쓰기 | 모든 이벤트를 순서대로 저장 |
| 이력 추적 | 별도 이력 테이블 필요 | 이벤트 자체가 이력 |
| 디버깅 | 현재 상태만 볼 수 있음 | 이벤트 리플레이로 과거 재현 가능 |
| 저장 공간 | 적음 | 이벤트 누적으로 증가 |
| 복잡도 | 단순 | 높음 (이벤트 설계, 스냅샷 등) |
| 조회 성능 | 직접적 | CQRS/프로젝션 필요 |
| 적합한 도메인 | 단순 CRUD 서비스 | 금융, 정산, 주문 등 이력 중요 도메인 |
게시판이나 설정 페이지처럼 이력이 중요하지 않은 곳에서는 CRUD가 훨씬 합리적이다. 반면 돈이 오가는 금융 도메인, 상태가 복잡하게 전이하는 커머스 도메인, 감사 추적이 필수인 규제 도메인에서 이벤트 소싱의 가치가 드러난다.
같은 Reducer를 프론트와 백엔드에서
Ventyd가 TypeScript인 이유가 있다. 프론트와 백엔드가 같은 언어를 쓰면 이벤트 타입 정의와 Reducer를 그대로 공유할 수 있다.
// 같은 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는 이 동질성을 활용해 프론트엔드 개발자의 진입 장벽을 낮추면서, 완전한 이력 추적과 상태 재현이라는 이벤트 소싱의 강점을 서버에서 확보할 수 있게 한다. 모든 프로젝트에 필요한 건 아니지만, "왜 이 상태가 되었는가"가 중요한 도메인에서는 강력한 무기가 된다.
관심 있을 만한 포스트
Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다
Bun으로 바꿔도 p99가 개선되지 않는 이유. 런타임 선택보다 먼저 봐야 할 진짜 병목 지점들.
중앙 교환기를 세워라 — 당근이 AI 난립을 하나의 플랫폼으로 정돈한 전략
'AI 활용에 가장 앞선 당근' 비전 아래 여러 제품 팀이 AI를 더 잘 활용할 수 있도록 구축한 GenAI 플랫폼.
Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)
런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.
세 번의 리모델링 — 당근페이가 아키텍처를 갈아엎은 진짜 이유
당근페이 백엔드가 Layered에서 Hexagonal을 거쳐 Clean Architecture + Monorepo로 진화한 과정과 각 단계의 트레이드오프를 다룬다.
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.