PostgreSQL 이벤트 스토어로 구현하는 CQRS와 Read Model on-demand 재구성
감사(Audit) 요구사항이 있는 도메인에서 "어떻게 이 상태가 됐는지"를 설명하지 못하는 시스템은 구조적 결함을 안고 있습니다. accounts 테이블에 balance 컬럼 하나를 두는 방식은 현재 잔액은 알려주지만, 누가 언제 왜 그 잔액이 됐는지는 영원히 묻힙니다. CQRS + Event Sourcing은 이 문제에 대한 구조적 답변입니다. 현재 상태 대신 변화의 이력을 원천 데이터로 삼습니다.
이 글에서 다루는 핵심은 세 가지입니다. PostgreSQL 하나로 완결되는 이벤트 스토어 스키마 설계, 이벤트 재생으로 Aggregate 상태를 복원하는 낙관적 동시성 제어, 그리고 기존 이벤트 스트림에서 Read Model을 on-demand로 재구성할 때 데이터 유실을 막는 체크포인트 전략입니다. 별도의 메시지 브로커나 전용 이벤트 DB 없이, 팀이 이미 알고 있는 PostgreSQL만으로 시작할 수 있도록 구성했습니다.
핵심 개념
CQRS: 쓰기 모델과 읽기 모델을 완전히 분리한다
CQRS(Command Query Responsibility Segregation)는 **명령(Command)**과 **조회(Query)**의 책임을 분리하는 패턴입니다. 레이어를 나누는 것보다 훨씬 강력한 분리인데, 저장소 자체가 달라질 수 있습니다.
flowchart LR
Client[클라이언트]
CMD[커맨드 핸들러]
ES[이벤트 스토어]
DBT[DB 트리거]
PW[프로젝션 워커]
RM[Read Model DB]
QH[쿼리 핸들러]
Client -->|커맨드 전송| CMD
CMD --> ES
ES --> DBT
DBT -->|pg_notify| PW
PW --> RM
Client -->|쿼리 요청| QH
QH --> RMNOTIFY를 보내는 주체가 이벤트 스토어 서비스가 아니라 PostgreSQL DB 트리거라는 점을 다이어그램에 명시했습니다. 쓰기 요청은 Command Handler가 처리하고, 읽기 요청은 Query Handler가 담당합니다. 각자 독립적으로 최적화된 데이터 모델을 쓸 수 있어서, 읽기 부하가 폭증하면 Read Model 서버만 스케일 아웃할 수 있습니다.
Event Sourcing: 현재 상태가 아니라 이벤트의 연속이 진실
익숙한 방식을 먼저 보겠습니다.
-- 기존 방식: 상태를 덮어씀
UPDATE accounts SET balance = 950 WHERE id = 'acc-001';
-- 50이 빠진 이유는? 누가 뺐지? 언제? → 알 수 없음Event Sourcing은 이렇게 바뀝니다.
-- Event Sourcing: 이벤트를 추가 (Append-Only)
INSERT INTO events (aggregate_id, event_type, event_data, sequence_number)
VALUES ('acc-001', 'MoneyWithdrawn', '{"amount": 50, "reason": "ATM"}', 5);
-- 현재 잔액 = 모든 입출금 이벤트의 합산
-- 누가, 언제, 왜 빠졌는지 → 이벤트에 다 있음현재 잔액은 DepositMade, WithdrawalMade 이벤트를 처음부터 순서대로 적용해서 계산합니다. 이벤트가 원천 데이터(Source of Truth)가 되고, 현재 상태는 파생값입니다.
핵심: 이벤트는 한 번 저장되면 절대 수정되지 않습니다. 잘못된 출금이 발생했다면 출금 이벤트를 지우는 게 아니라
WithdrawalReversed이벤트를 추가합니다. 과거를 변경하지 않고 새로운 사실을 기록하는 것입니다.
PostgreSQL을 이벤트 스토어로 쓰는 원리
전용 이벤트 DB(EventStoreDB, EventSourcingDB 등)도 좋지만, PostgreSQL로도 충분히 프로덕션 수준의 이벤트 스토어를 구축할 수 있습니다. JSONB로 이질적인 이벤트 페이로드를 수용하면서도 SQL 쿼리를 그대로 쓸 수 있다는 게 가장 큰 장점입니다.
CREATE TABLE events (
event_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
aggregate_id UUID NOT NULL,
aggregate_type TEXT NOT NULL, -- 'Account', 'Order' 등
event_type TEXT NOT NULL, -- 'MoneyWithdrawn' 등
event_data JSONB NOT NULL,
sequence_number INTEGER NOT NULL,
global_position BIGSERIAL NOT NULL, -- 전역 순서: 폴링 복구·재구성 체크포인트용
occurred_at TIMESTAMPTZ DEFAULT now()
);
-- 낙관적 동시성 제어의 핵심
CREATE UNIQUE INDEX idx_events_aggregate_seq
ON events (aggregate_id, sequence_number);
-- 조회 최적화
CREATE INDEX idx_events_aggregate_id ON events (aggregate_id, occurred_at);
CREATE INDEX idx_events_event_type ON events (event_type);
CREATE INDEX idx_events_global_pos ON events (global_position);(aggregate_id, sequence_number) 유니크 인덱스가 동시성 제어의 핵심입니다. 두 프로세스가 동시에 같은 Aggregate에 이벤트를 쓰려 할 때, 하나는 반드시 유니크 제약 위반으로 실패합니다. 실패한 쪽이 재시도하면서 충돌이 해소됩니다.
global_position은 BIGSERIAL로 DB가 자동 채우는 전역 순서 번호입니다. Read Model 재구성 시 체크포인트로, 워커 연결 복구 시 누락 이벤트 폴링으로 각각 활용합니다.
Aggregate와 이벤트 스트림
각 Aggregate—계좌(Account), 주문(Order) 등—는 고유한 aggregate_id를 기준으로 자신만의 이벤트 스트림을 가집니다. 상태 복원은 해당 Aggregate의 이벤트를 sequence_number 오름차순으로 전부 로드해서 in-memory에서 순서대로 적용하는 방식입니다.
sequenceDiagram
participant App as 애플리케이션
participant ES as 이벤트 스토어
App->>ES: aggregate_id로 이벤트 조회
ES-->>App: 이벤트 목록 seq 1~N 반환
Note over App: Aggregate는 in-memory 객체
loop 각 이벤트를 순서대로
App->>App: applyEvent로 상태 누적
end
Note over App: 현재 Aggregate 상태 완성Aggregate는 별도 서비스가 아니라 애플리케이션 프로세스 안에 있는 in-memory 객체입니다. 이벤트 수가 많아지면 매번 전체를 재생하는 비용이 커지는데, 이때 스냅샷(Snapshot) 을 도입합니다. 특정 버전의 상태를 직렬화해서 저장해두고, 그 이후 이벤트만 재생하는 방식으로 최적화합니다.
실전 적용
여기서는 금융 계좌를 예시로 씁니다. 복잡한 상태 전이와 규제 감사 요구사항이 동시에 요구되는 도메인이라 이 패턴의 장점이 가장 선명하게 드러납니다.
이벤트 타입 정의와 저장
type AccountEvent =
| { type: 'AccountOpened'; ownerId: string; initialBalance: number }
| { type: 'MoneyDeposited'; amount: number; description: string }
| { type: 'MoneyWithdrawn'; amount: number; description: string }
| { type: 'AccountClosed'; reason: string };
async function appendEvents(
aggregateId: string,
events: AccountEvent[],
expectedVersion: number, // 낙관적 동시성 제어
db: Pool
): Promise<void> {
const client = await db.connect();
try {
await client.query('BEGIN');
// 프로덕션에서는 단건 INSERT 반복 대신 멀티-row INSERT로 최적화 권장
// (이벤트 수가 늘수록 단건 루프는 병목이 됨)
for (let i = 0; i < events.length; i++) {
const event = events[i];
const nextSeq = expectedVersion + i + 1;
await client.query(
`INSERT INTO events
(aggregate_id, aggregate_type, event_type, event_data, sequence_number)
VALUES ($1, 'Account', $2, $3, $4)`,
[aggregateId, event.type, JSON.stringify(event), nextSeq]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
// PostgreSQL 유니크 제약 위반 = 동시성 충돌
if ((err as any).code === '23505') {
throw new ConcurrencyError('동시 수정 충돌. 재시도가 필요합니다.');
}
throw err;
} finally {
client.release();
}
}Command Handler: 쓰기 흐름의 핵심
CQRS + Event Sourcing에서 Command Handler는 비즈니스 규칙 검증과 이벤트 저장을 연결하는 진입점입니다. 출금 커맨드를 예시로 전체 흐름을 보겠습니다.
async function withdrawMoney(
aggregateId: string,
amount: number,
description: string,
db: Pool
): Promise<void> {
// ① 이벤트를 재생해 현재 상태 복원
const state = await loadAccount(aggregateId, db);
// ② 비즈니스 규칙 검증 (Write 모델에서 수행)
if (state.isClosed) {
throw new Error('닫힌 계좌에는 출금할 수 없습니다.');
}
if (state.balance < amount) {
throw new Error(`잔액 부족: 현재 ${state.balance}, 요청 ${amount}`);
}
// ③ 이벤트 저장. state.version = 마지막 sequence_number → 동시성 충돌 감지
await appendEvents(
aggregateId,
[{ type: 'MoneyWithdrawn', amount, description }],
state.version, // expectedVersion
db
);
}③에서 state.version을 expectedVersion으로 전달하는 부분이 낙관적 동시성 제어의 실제 동작 지점입니다. 두 요청이 동시에 같은 계좌를 읽어 같은 version을 가져왔다면, 먼저 쓰는 쪽이 성공하고 나머지는 ConcurrencyError를 받아 재시도합니다.
이벤트 재생으로 Aggregate 상태 복원
interface AccountState {
id: string;
ownerId: string; // AccountOpened 이벤트에서 추출
balance: number;
isClosed: boolean;
version: number; // 마지막으로 처리한 sequence_number
}
function applyEvent(state: AccountState, event: AccountEvent): AccountState {
switch (event.type) {
case 'AccountOpened':
return { ...state, ownerId: event.ownerId, balance: event.initialBalance };
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount };
case 'AccountClosed':
return { ...state, isClosed: true };
}
}
async function loadAccount(aggregateId: string, db: Pool): Promise<AccountState> {
const result = await db.query(
`SELECT event_type, event_data, sequence_number
FROM events
WHERE aggregate_id = $1 AND aggregate_type = 'Account'
ORDER BY sequence_number ASC`,
[aggregateId]
);
const initial: AccountState = {
id: aggregateId,
ownerId: '',
balance: 0,
isClosed: false,
version: 0,
};
return result.rows.reduce((state, row) => ({
...applyEvent(state, row.event_data as AccountEvent),
version: row.sequence_number,
}), initial);
}applyEvent의 AccountOpened 케이스에서 event.ownerId를 반드시 상태에 반영해야 합니다. 이 값이 빠지면 이후 Read Model INSERT에서 owner_id NOT NULL 제약 위반이 발생합니다.
LISTEN/NOTIFY로 비동기 프로젝션 트리거
이벤트가 저장될 때 PostgreSQL의 LISTEN/NOTIFY로 프로젝션 워커에 알림을 보낼 수 있습니다.
CREATE OR REPLACE FUNCTION notify_event_inserted()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'new_event',
json_build_object(
'event_id', NEW.event_id,
'aggregate_id', NEW.aggregate_id,
'event_type', NEW.event_type,
'global_position', NEW.global_position
)::text -- NOTIFY payload는 8,000바이트 제한. 메타데이터만 전송하고 데이터는 별도 조회
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER event_inserted_trigger
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION notify_event_inserted();// 프로젝션 워커: LISTEN + 폴링 폴백 조합
async function startProjectionWorker(db: Pool): Promise<void> {
const workerClient = await db.connect();
let lastProcessedPosition = await getLastProcessedPosition(db);
await workerClient.query('LISTEN new_event');
workerClient.on('notification', async (msg) => {
const payload = JSON.parse(msg.payload!);
await updateAccountSummaryReadModel(payload.aggregate_id, db);
lastProcessedPosition = payload.global_position;
await saveLastProcessedPosition(lastProcessedPosition, db);
});
// 연결 복구 후 NOTIFY 유실분을 폴링으로 보정
// NOTIFY는 워커가 연결되어 있을 때만 전달됨. 연결 단절 구간의 이벤트는 여기서 처리
workerClient.on('connect', async () => {
await catchUpFromPosition(lastProcessedPosition, db);
});
}
async function catchUpFromPosition(fromPosition: number, db: Pool): Promise<void> {
const missed = await db.query(
`SELECT DISTINCT aggregate_id
FROM events
WHERE aggregate_type = 'Account' AND global_position > $1
ORDER BY MIN(global_position) ASC`,
[fromPosition]
);
for (const row of missed.rows) {
await updateAccountSummaryReadModel(row.aggregate_id, db);
}
}NOTIFY payload가 8,000바이트로 제한되어 있어 이벤트 데이터 전체를 payload에 담을 수 없습니다. 코드처럼 aggregate_id와 global_position만 전달하고, 실제 상태 업데이트는 DB를 다시 조회하는 방식이 맞습니다. 워커 연결이 끊어졌다가 복구되면 connect 핸들러에서 global_position 기준으로 누락분을 폴링해 메꿉니다.
Read Model 프로젝션: 계좌 요약 뷰
-- Read Model 테이블: 조회에 최적화된 비정규화 뷰
CREATE TABLE account_summary (
account_id UUID PRIMARY KEY,
owner_id TEXT NOT NULL,
balance NUMERIC(15, 2) NOT NULL DEFAULT 0,
is_closed BOOLEAN NOT NULL DEFAULT false,
last_updated TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 0
);async function updateAccountSummaryReadModel(
aggregateId: string,
db: Pool
): Promise<void> {
const state = await loadAccount(aggregateId, db);
await db.query(
`INSERT INTO account_summary (account_id, owner_id, balance, is_closed, last_updated, version)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (account_id) DO UPDATE
SET owner_id = EXCLUDED.owner_id,
balance = EXCLUDED.balance,
is_closed = EXCLUDED.is_closed,
last_updated = EXCLUDED.last_updated,
version = EXCLUDED.version`,
[state.id, state.ownerId, state.balance, state.isClosed, state.version]
);
}INSERT ... ON CONFLICT DO UPDATE 패턴이 멱등성을 보장합니다. 같은 이벤트가 두 번 처리되어도 결과가 동일합니다.
on-demand 재구성: 체크포인트로 데이터 유실 막기
on-demand 재구성이 필요한 상황은 크게 두 가지입니다. 첫째, 새로운 비즈니스 요구가 생겼을 때입니다. 기존 이벤트만으로 즉시 새 Read Model을 구성할 수 있습니다. 둘째, Read Model 계산 로직에 버그가 있었을 때입니다. 로직 수정 후 재구성하면 원천 데이터인 이벤트를 건드리지 않고 복구됩니다.
재구성의 핵심 과제는 재구성이 진행되는 동안 유입되는 신규 이벤트를 누락하지 않는 것입니다. 재구성 루프 실행 중에도 기존 account_summary에는 새 이벤트가 계속 반영되는데, 테이블을 교체하면 그 변경이 사라집니다. global_position 기반 체크포인트로 이 문제를 해결합니다.
async function rebuildReadModel(db: Pool): Promise<void> {
const client = await db.connect();
try {
// pg_advisory_lock은 세션 레벨 락. 연결이 끊기면 자동 해제되므로
// finally 블록에서 명시적으로 unlock해 다음 재구성 요청이 즉시 진입할 수 있게 함
await client.query('SELECT pg_advisory_lock(12345)');
// CREATE TABLE IF NOT EXISTS: 중간 실패 후 재시도 시 오류 방지
await client.query(
'CREATE TABLE IF NOT EXISTS account_summary_new (LIKE account_summary INCLUDING ALL)'
);
await client.query('TRUNCATE account_summary_new');
// 체크포인트: 재구성 시작 시점의 전역 위치를 기록
const { rows: [{ checkpoint }] } = await client.query(
'SELECT COALESCE(MAX(global_position), 0) AS checkpoint FROM events'
);
// 체크포인트까지의 이벤트로 새 Read Model 구성
const aggregates = await client.query(
`SELECT DISTINCT aggregate_id FROM events
WHERE aggregate_type = 'Account' AND global_position <= $1`,
[checkpoint]
);
for (const row of aggregates.rows) {
const state = await loadAccountUpTo(row.aggregate_id, checkpoint, db);
await client.query(
`INSERT INTO account_summary_new
(account_id, owner_id, balance, is_closed, last_updated, version)
VALUES ($1, $2, $3, $4, now(), $5)`,
[state.id, state.ownerId, state.balance, state.isClosed, state.version]
);
}
// 체크포인트 이후 유입된 이벤트 보정 (재구성 중 누락분)
const lateAggregates = await client.query(
`SELECT DISTINCT aggregate_id FROM events
WHERE aggregate_type = 'Account' AND global_position > $1`,
[checkpoint]
);
for (const row of lateAggregates.rows) {
const state = await loadAccount(row.aggregate_id, db);
await client.query(
`INSERT INTO account_summary_new
(account_id, owner_id, balance, is_closed, last_updated, version)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (account_id) DO UPDATE
SET owner_id = EXCLUDED.owner_id,
balance = EXCLUDED.balance,
is_closed = EXCLUDED.is_closed,
last_updated = EXCLUDED.last_updated,
version = EXCLUDED.version`,
[state.id, state.ownerId, state.balance, state.isClosed, state.version]
);
}
// 원자적 테이블 교체: 읽기 서비스 무중단
await client.query('BEGIN');
await client.query('ALTER TABLE account_summary RENAME TO account_summary_old');
await client.query('ALTER TABLE account_summary_new RENAME TO account_summary');
await client.query('DROP TABLE account_summary_old');
await client.query('COMMIT');
} finally {
await client.query('SELECT pg_advisory_unlock(12345)');
client.release();
}
}
// global_position 상한을 지정해 특정 체크포인트까지만 재생
async function loadAccountUpTo(
aggregateId: string,
maxPosition: number,
db: Pool
): Promise<AccountState> {
const result = await db.query(
`SELECT event_type, event_data, sequence_number
FROM events
WHERE aggregate_id = $1 AND aggregate_type = 'Account'
AND global_position <= $2
ORDER BY sequence_number ASC`,
[aggregateId, maxPosition]
);
const initial: AccountState = {
id: aggregateId, ownerId: '', balance: 0, isClosed: false, version: 0,
};
return result.rows.reduce((state, row) => ({
...applyEvent(state, row.event_data as AccountEvent),
version: row.sequence_number,
}), initial);
}스냅샷으로 재생 성능 최적화
이벤트가 수백만 건으로 늘어나면 매번 전체를 재생하는 건 현실적이지 않습니다.
CREATE TABLE snapshots (
aggregate_id UUID NOT NULL,
aggregate_type TEXT NOT NULL,
state_data JSONB NOT NULL,
version INTEGER NOT NULL, -- 반영된 마지막 sequence_number
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (aggregate_id, version)
);const SNAPSHOT_THRESHOLD = 100; // 이벤트 100개마다 스냅샷 저장
async function saveSnapshot(state: AccountState, db: Pool): Promise<void> {
await db.query(
`INSERT INTO snapshots (aggregate_id, aggregate_type, state_data, version)
VALUES ($1, 'Account', $2, $3)
ON CONFLICT (aggregate_id, version) DO NOTHING`,
[state.id, JSON.stringify(state), state.version]
);
}
async function loadAccountWithSnapshot(aggregateId: string, db: Pool): Promise<AccountState> {
// 1. 최신 스냅샷 조회
const snapResult = await db.query(
`SELECT state_data, version FROM snapshots
WHERE aggregate_id = $1 AND aggregate_type = 'Account'
ORDER BY version DESC LIMIT 1`,
[aggregateId]
);
let state: AccountState;
let fromVersion: number;
if (snapResult.rows.length > 0) {
state = snapResult.rows[0].state_data as AccountState;
fromVersion = snapResult.rows[0].version;
} else {
state = { id: aggregateId, ownerId: '', balance: 0, isClosed: false, version: 0 };
fromVersion = 0;
}
// 2. 스냅샷 이후 이벤트만 재생
const events = await db.query(
`SELECT event_type, event_data, sequence_number FROM events
WHERE aggregate_id = $1 AND sequence_number > $2
ORDER BY sequence_number ASC`,
[aggregateId, fromVersion]
);
const finalState = events.rows.reduce((s, row) => ({
...applyEvent(s, row.event_data as AccountEvent),
version: row.sequence_number,
}), state);
// 3. 스냅샷 저장 여부 판단: 마지막 스냅샷 이후 이벤트가 임계치 초과 시 저장
if (events.rows.length >= SNAPSHOT_THRESHOLD) {
await saveSnapshot(finalState, db);
}
return finalState;
}스냅샷 저장 시점 전략으로 "이벤트 N개마다"가 가장 단순하고 예측 가능합니다. 특정 이벤트 타입 이후(예: AccountClosed) 저장하는 전략도 있는데, 이 경우 해당 이벤트 이후로는 상태가 변하지 않아 영구적으로 재구성 비용을 0에 가깝게 만들 수 있습니다. 두 전략을 조합하는 것도 흔합니다.
장단점 분석
장점
| 장점 | 설명 |
|---|---|
| 감사 추적 자동화 | Append-Only 이벤트 저장으로 변경 이력이 자동 보존됩니다. 별도 감사 로그 시스템이 필요 없으며, 금융·의료 규제 요구를 자연스럽게 충족합니다. |
| Time Travel 쿼리 | 특정 시점까지만 이벤트를 재생하면 과거 임의 시점의 상태를 재구성할 수 있습니다. "부정 거래 발생 직전 잔액"도 정확히 답할 수 있습니다. |
| Read Model의 유연한 진화 | 새 비즈니스 요구가 생겨도 기존 이벤트를 재생해 새 프로젝션을 생성합니다. 데이터 마이그레이션 없이 새 쿼리 패턴을 지원합니다. |
| 독립적 확장성 | 읽기와 쓰기 부하를 분리해 각각 독립적으로 확장할 수 있습니다. |
| 버그 복구 | Read Model 로직에 버그가 발견되면 로직 수정 후 이벤트 재구성으로 복구됩니다. |
| 이벤트 기반 통합 | 이벤트 스트림이 마이크로서비스 간 통신의 자연스러운 매개체가 됩니다. |
단점
| 단점 | 설명 |
|---|---|
| 구조적 복잡도 | Command Handler, Event Store, Projection Worker, Read Model, Query Handler 등 컴포넌트가 늘어납니다. 단순 CRUD에는 심각한 오버엔지니어링입니다. |
| 최종 일관성 | 비동기 프로젝션은 이벤트 저장 직후 Read Model에 즉시 반영되지 않습니다. 설계 단계에서 UX 관점으로 반드시 고려해야 합니다. |
| 이벤트 스키마 진화의 어려움 | 과거 이벤트 구조를 수정할 수 없어 버저닝(Upcasting) 전략이 필요합니다. |
| 재구성 시간 | 대규모 이벤트 스트림 재구성은 상당한 시간이 소요됩니다. 체크포인트 기반 catch-up과 Advisory Lock 조율이 필수입니다. |
| 스냅샷 도입 필요 | 이벤트가 수백만 건 이상으로 늘어나면 스냅샷 없이는 상태 복원 성능이 급격히 저하됩니다. |
| 운영 복잡도 | 멱등성 보장, 연결 복구 시 폴링 catch-up, 재시도 로직 등 운영 난이도가 높아집니다. |
실무에서 흔한 실수
1. 잘못된 도메인에 적용하기
단순 게시글 CRUD, 설정값 관리 같은 보조 도메인에까지 Event Sourcing을 도입하면 유지보수 부담만 늘어납니다. 복잡한 상태 전이와 감사 요구사항이 있는 핵심 도메인(Core Domain)에만 적용하는 것이 권장됩니다.
2. 이벤트에 파생 계산값 담기
이벤트는 "무슨 일이 일어났는가"만 기록합니다. MoneyWithdrawn에 현재 잔액(balanceAfter)을 함께 넣으면 스키마 진화가 복잡해집니다. 현재 잔액은 프로젝션에서 계산하고, 이벤트에는 변화를 유발한 값만 담는 방식이 깔끔합니다.
3. 동시성 제어 누락
expectedVersion 없이 이벤트를 저장하면 두 요청이 동시에 같은 Aggregate를 수정할 때 데이터 불일치가 발생합니다. (aggregate_id, sequence_number) 유니크 인덱스와 expectedVersion 검사는 반드시 함께 구현해야 합니다. Command Handler에서 state.version을 expectedVersion으로 전달하는 것이 이 흐름을 완성합니다.
4. 프로젝션 멱등성 미보장
이벤트가 두 번 처리되어도 결과가 동일해야 합니다. INSERT ... ON CONFLICT DO UPDATE 패턴이 가장 단순한 해결책입니다.
5. 최종 일관성을 전체 흐름에 무조건 적용하기
비동기 프로젝션이 처리되기 전에 같은 요청에서 Read Model을 조회하면 이전 상태가 보입니다. 사용자가 방금 변경한 내용을 즉시 확인해야 하는 흐름이라면, 해당 부분만 on-demand 재계산을 쓰거나 동기 프로젝션을 혼용하는 설계가 필요합니다.
6. LISTEN/NOTIFY만 믿기
NOTIFY는 워커가 연결되어 있을 때만 전달됩니다. 연결 단절 구간의 이벤트는 유실됩니다. global_position을 체크포인트로 두고 연결 복구 시 폴링으로 누락분을 메꾸는 전략을 반드시 함께 구현해야 합니다.
마치며
CQRS + Event Sourcing은 "현재 상태"가 아니라 "변화의 이력"을 원천 데이터로 삼는 패턴입니다. 이로 인해 감사 추적이 자동으로 따라오고, 새로운 비즈니스 요구에 맞는 Read Model을 언제든 재구성할 수 있으며, 쓰기와 읽기를 독립적으로 최적화할 수 있습니다. 반면 구조적 복잡도와 최종 일관성은 반드시 감수해야 하는 트레이드오프입니다.
지금 시작해볼 수 있는 3단계입니다.
-
이벤트 스토어 스키마를 PostgreSQL에 만들어보는 것이 첫 번째입니다. 기존 서비스의 핵심 도메인 하나를 골라 이벤트 타입을 정의하고,
events테이블과global_position컬럼을 실제로 생성해볼 수 있습니다. -
loadAggregate와 Command Handler를 하나 만들어보는 것이 두 번째입니다. 이벤트를 읽어서applyEvent로 상태를 쌓고, 비즈니스 규칙을 검증한 뒤appendEvents로 저장하는 흐름을 직접 구현해보면 Event Sourcing의 본질이 빠르게 와닿습니다. -
작은 프로젝션을 하나 붙여보는 것이 세 번째입니다. PostgreSQL LISTEN/NOTIFY로 이벤트 삽입을 수신하고, 비정규화된 Read Model 테이블을 업데이트하는 워커를 추가해보면 전체 CQRS 흐름이 손에 잡힙니다.
감사 요구사항이 강한 핵심 도메인 하나에서 시작해보면, 나머지 도메인에 언제 어디까지 확장할지가 자연스럽게 보이기 시작합니다.
참고 자료
- Building a Production-Ready Event Store in PostgreSQL: Schema Design, Projections, and Replay - DEV Community
- GitHub - eugene-khyst/postgresql-event-sourcing (Spring Boot 레퍼런스 구현)
- Rebuilding Event-Driven Read Models in a safe and resilient way - Event-Driven.io
- Projections - Marten .NET Document DB & Event Store
- Designing Read Models - EventSourcingDB 공식 문서
- CQRS Pattern - Azure Architecture Center
- Event Sourcing and CQRS Pattern - Microservices.io
- Event Sourcing and CQRS with Databases: EventStoreDB, Axon, Polecat
- CQRS and Event Sourcing: Practical Implementation Patterns 2026 - Calmops
- Event Sourcing, CQRS and Micro Services: Real FinTech Example - Medium
- Go EventSourcing and CQRS with PostgreSQL, Kafka, MongoDB and ElasticSearch - DEV Community
- GitHub - fraktalio/fstore-sql
- GitHub - oskardudycz/EventSourcing.NetCore
- How I Built an Aggregateless Event Store with TypeScript and PostgreSQL
- Event Sourcing and Event Storage with Apache Kafka - Confluent