[Daily morning study] Event Sourcing๊ณผ CQRS ํจํด
#daily morning study
Event Sourcing
๊ฐ๋
์ผ๋ฐ์ ์ธ ๋ฐ์ดํฐ ์ ์ฅ ๋ฐฉ์์ ํ์ฌ ์ํ(state)๋ง ์ ์ฅํ๋ค. ์๋ฅผ ๋ค์ด ๊ณ์ข ์์ก์ด 10๋ง์์ด๋ฉด DB์๋ balance = 100000๋ง ๋จ๋๋ค.
Event Sourcing์ ์ํ ๋ณํ๋ฅผ ์ผ์ผํค๋ ์ด๋ฒคํธ(์ฌ๊ฑด) ์์ฒด๋ฅผ ์์๋๋ก ์ ์ฅํ๋ ํจํด์ด๋ค. ํ์ฌ ์ํ๋ ์ด๋ฒคํธ๋ค์ ์ฒ์๋ถํฐ ์ฌ์(replay)ํด์ ๊ณ์ฐํ๋ค.
# ์ ํต์ ์ธ ๋ฐฉ์
accounts ํ
์ด๋ธ: { id: 1, balance: 70000 }
# Event Sourcing ๋ฐฉ์
events ํ
์ด๋ธ:
1. AccountCreated { amount: 100000 }
2. MoneyWithdrawn { amount: 30000 }
3. MoneyWithdrawn { amount: 20000 }
โ ํ์ฌ ์์ก = 100000 - 30000 - 20000 = 50000
ํต์ฌ ํน์ง
์ด๋ฒคํธ๋ ๋ถ๋ณ(immutable)์ด๋ค. ์ด๋ฒคํธ ๋ก๊ทธ๋ append-only์ด๋ฉฐ, ๊ณผ๊ฑฐ ์ด๋ฒคํธ๋ฅผ ์์ ํ๊ฑฐ๋ ์ญ์ ํ์ง ์๋๋ค. ์๋ชป๋ ์์ ์ ์ทจ์ํ ๋๋ ๋ณด์ ์ด๋ฒคํธ(compensating event)๋ฅผ ์ถ๊ฐํ๋ค.
๋จ์ผ ์ง์ค ๊ณต๊ธ์(Single Source of Truth)์ด ์ด๋ฒคํธ ๋ก๊ทธ๋ค. ํ์ฌ ์ํ๋ ์ด๋ฒคํธ ๋ก๊ทธ๋ก๋ถํฐ ํ์๋ ๋ทฐ(projection)๋ค.
์ด๋ฒคํธ ์ ์ฅ์ ๊ตฌ์กฐ
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL, -- ๋๋ฉ์ธ ๊ฐ์ฒด ์๋ณ์
aggregate_type VARCHAR(100) NOT NULL, -- ์: "Account", "Order"
event_type VARCHAR(100) NOT NULL, -- ์: "MoneyDeposited"
event_data JSONB NOT NULL, -- ์ด๋ฒคํธ ํ์ด๋ก๋
version INT NOT NULL, -- ๋๊ด์ ์ ๊ธ์ฉ
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- aggregate_id์ version ์กฐํฉ์ unique
CREATE UNIQUE INDEX ON events(aggregate_id, version);
Aggregate ์ฌ๊ตฌ์ฑ
def get_account(account_id: str) -> Account:
events = db.query(
"SELECT * FROM events WHERE aggregate_id = ? ORDER BY version",
account_id
)
account = Account()
for event in events:
account.apply(event) # ์ด๋ฒคํธ๋ฅผ ํ๋์ฉ ์ ์ฉ
return account
์ด๋ฒคํธ๊ฐ ์๋ฐฑ๋ง ๊ฐ ์์ด๋ฉด ์ฌ์ ์๊ฐ์ด ๊ธธ์ด์ง๋ ๋ฌธ์ ๊ฐ ์๋ค. ์ด๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ์ค๋ ์ท(Snapshot)์ ์ฌ์ฉํ๋ค.
์ค๋ ์ท ํจํด
def get_account(account_id: str) -> Account:
# ๊ฐ์ฅ ์ต๊ทผ ์ค๋
์ท ์กฐํ
snapshot = db.query(
"SELECT * FROM snapshots WHERE aggregate_id = ? ORDER BY version DESC LIMIT 1",
account_id
)
if snapshot:
account = Account.from_snapshot(snapshot)
# ์ค๋
์ท ์ดํ ์ด๋ฒคํธ๋ง ์ฌ์
events = db.query(
"SELECT * FROM events WHERE aggregate_id = ? AND version > ? ORDER BY version",
account_id, snapshot.version
)
else:
account = Account()
events = db.query(...)
for event in events:
account.apply(event)
return account
Event Sourcing์ ์ฅ๋จ์
์ฅ์
- ์์ ํ ๊ฐ์ฌ ์ถ์ (Audit Trail): ๋ชจ๋ ์ํ ๋ณ๊ฒฝ ์ด๋ ฅ์ด ๋จ๋๋ค. ๊ธ์ต, ์๋ฃ ๋ฑ ๊ท์ ์ค์๊ฐ ํ์ํ ๋๋ฉ์ธ์ ์ ๋ฆฌํ๋ค.
- ์๊ฐ ์ฌํ(Time Travel): ํน์ ์์ ์ ์ํ๋ฅผ ์ฌํํ ์ ์๋ค.
- ์ด๋ฒคํธ ๊ธฐ๋ฐ ํตํฉ: ๋ค๋ฅธ ์๋น์ค๊ฐ ์ด๋ฒคํธ๋ฅผ ๊ตฌ๋ ํด ๋น๋๊ธฐ๋ก ์ฒ๋ฆฌํ ์ ์๋ค.
- ๋ฒ๊ทธ ์ฌํ ๋ฐ ๋๋ฒ๊น : ์ด๋ฒคํธ ์ํ์ค๋ฅผ ์ฌ์ํด ๋ฒ๊ทธ ๋ฐ์ ๋น์ ์ํ๋ฅผ ๋ณต์ํ ์ ์๋ค.
๋จ์
- ์ฟผ๋ฆฌ์ ๋ณต์ก์ฑ: โํ์ฌ ์์ก์ด 10๋ง์ ์ด์์ธ ๋ชจ๋ ๊ณ์ขโ๋ฅผ ์กฐํํ๋ ค๋ฉด ๋ชจ๋ ๊ณ์ข์ ์ด๋ฒคํธ๋ฅผ ์ฌ์ํด์ผ ํ๋ค.
- ์ด๋ฒคํธ ์คํค๋ง ์งํ: ์ด๋ฒคํธ ํ์์ด ๋ฐ๋๋ฉด ๊ธฐ์กด ์ด๋ฒคํธ์์ ํธํ์ฑ ์ฒ๋ฆฌ๊ฐ ๋ณต์กํ๋ค.
- ๊ฒฐ๊ณผ์ ์ผ๊ด์ฑ: ํ๋ก์ ์ ์ด ํญ์ ์ต์ ์ํ๊ฐ ์๋ ์ ์๋ค.
CQRS (Command Query Responsibility Segregation)
๊ฐ๋
๋ช ๋ น(Command)๊ณผ ์กฐํ(Query)๋ฅผ ๋ค๋ฅธ ๋ชจ๋ธ๋ก ๋ถ๋ฆฌํ๋ ํจํด์ด๋ค.
- Command: ์ํ๋ฅผ ๋ณ๊ฒฝํ๋ค. ๋ฐํ๊ฐ์ ์๊ฑฐ๋ ์ฑ๊ณต/์คํจ ์ฌ๋ถ๋ง ๋ฐํํ๋ค.
- Query: ์ํ๋ฅผ ์ฝ๋๋ค. ์ํ๋ฅผ ๋ณ๊ฒฝํ์ง ์๋๋ค.
๊ธฐ์กด CRUD ๋ฐฉ์์ ๊ฐ์ ๋ชจ๋ธ๋ก ์ฝ๊ธฐ์ ์ฐ๊ธฐ๋ฅผ ๋ชจ๋ ์ฒ๋ฆฌํ๋ค. CQRS๋ ์ด๋ฅผ ๋ถ๋ฆฌํด ๊ฐ๊ฐ ์ต์ ํํ๋ค.
๊ธฐ๋ณธ ๊ตฌ์กฐ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ํด๋ผ์ด์ธํธ โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ Command โ Query
โผ โผ
โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ Command Handler โ โ Query Handler โ
โ (์ฐ๊ธฐ ๋ชจ๋ธ) โ โ (์ฝ๊ธฐ ๋ชจ๋ธ) โ
โโโโโโโโโโฌโโโโโโโโโโ โโโโโโโโโโโโฌโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
โ Write Store โ โ Read Store โ
โ (์ ๊ทํ DB) โโโโโโโโบ โ (๋น์ ๊ทํ ๋ทฐ) โ
โโโโโโโโโโโโโโโโโโ sync โโโโโโโโโโโโโโโโโโโโโโ
๊ตฌํ ์์
Command ์ชฝ (์ฐ๊ธฐ)
class TransferMoneyCommand:
from_account_id: str
to_account_id: str
amount: int
class TransferMoneyHandler:
def handle(self, cmd: TransferMoneyCommand):
# ๋น์ฆ๋์ค ๋ก์ง ๊ฒ์ฆ
from_account = self.account_repo.get(cmd.from_account_id)
if from_account.balance < cmd.amount:
raise InsufficientFundsError()
# ์ํ ๋ณ๊ฒฝ (๋๋ ์ด๋ฒคํธ ๋ฐํ)
from_account.withdraw(cmd.amount)
to_account = self.account_repo.get(cmd.to_account_id)
to_account.deposit(cmd.amount)
self.account_repo.save(from_account)
self.account_repo.save(to_account)
Query ์ชฝ (์ฝ๊ธฐ)
class AccountSummaryQuery:
account_id: str
class AccountSummaryHandler:
def handle(self, query: AccountSummaryQuery) -> AccountSummaryView:
# ์ฝ๊ธฐ์ ์ต์ ํ๋ ๋ทฐ ํ
์ด๋ธ์์ ์ง์ ์กฐํ
return self.read_db.query(
"SELECT * FROM account_summary_view WHERE id = ?",
query.account_id
)
์ฝ๊ธฐ ๋ชจ๋ธ์ ์กฐํ ์ฉ๋์ ๋ง๊ฒ ๋น์ ๊ทํ(denormalization)ํ๊ฑฐ๋ Redis ๊ฐ์ ๋ณ๋ ์ ์ฅ์๋ฅผ ์ฌ์ฉํ๋ค.
CQRS + Event Sourcing ์กฐํฉ
Event Sourcing๊ณผ CQRS๋ ๋ณ๊ฐ์ ํจํด์ด์ง๋ง ํจ๊ป ์ฌ์ฉํ ๋ ์๋์ง๊ฐ ํฌ๋ค.
Command โ Aggregate โ ์ด๋ฒคํธ ์ ์ฅ์ (write store)
โ
โผ ์ด๋ฒคํธ ๋ฐํ
Event Handler
โ
โผ ํ๋ก์ ์
์
๋ฐ์ดํธ
Read Model (view)
โ
โผ
Query
ํ๋ก์ ์ (Projection): ์ด๋ฒคํธ ๋ก๊ทธ๋ก๋ถํฐ ์ฝ๊ธฐ ๋ทฐ๋ฅผ ๋ง๋๋ ๊ณผ์ .
class AccountBalanceProjection:
def on(self, event: MoneyDeposited):
self.read_db.execute(
"UPDATE account_view SET balance = balance + ? WHERE id = ?",
event.amount, event.account_id
)
def on(self, event: MoneyWithdrawn):
self.read_db.execute(
"UPDATE account_view SET balance = balance - ? WHERE id = ?",
event.amount, event.account_id
)
ํ๋ก์ ์ ์ ์ธ์ ๋ ์ง ์ด๋ฒคํธ ๋ก๊ทธ๋ฅผ ์ฒ์๋ถํฐ ์ฌ์ํด์ ๋ค์ ๋ง๋ค ์ ์๋ค. ์๋ก์ด ์ฝ๊ธฐ ์๊ตฌ์ฌํญ์ด ์๊ธฐ๋ฉด ์ ํ๋ก์ ์ ๋ทฐ๋ฅผ ์ถ๊ฐํ๋ฉด ๋๋ค.
์ธ์ ์ธ๊น?
| ์ํฉ | ์ ํฉ์ฑ |
|---|---|
| ๋๋ฉ์ธ์ด ๋ณต์กํ๊ณ ์ด๋ ฅ ์ถ์ ์ด ์ค์ํ ๊ฒฝ์ฐ (๊ธ์ต, ์๋ฃ, ์ปค๋จธ์ค) | ๋์ |
| ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ถํ ๋น์จ์ด ํฌ๊ฒ ๋ค๋ฅผ ๋ | ๋์ |
| ๋จ์ํ CRUD ์ ํ๋ฆฌ์ผ์ด์ | ๋ฎ์ (์ค๋ฒ์์ง๋์ด๋ง) |
| ํ์ด DDD(Domain-Driven Design)์ ์ต์ํ ๊ฒฝ์ฐ | ๋์ |
Event Sourcing๊ณผ CQRS๋ ๋ณต์ก์ฑ์ ์๋ฐํ๋ฏ๋ก, ์ค์ ๋ก ๊ทธ ๋ณต์ก์ฑ์ด ์ฃผ๋ ์ด์ ์ด ์๋ ๋๋ฉ์ธ์ ์ ํ์ ์ผ๋ก ์ ์ฉํ๋ ๊ฒ์ด ๋ง๋ค.