[Daily morning study] Event Sourcing๊ณผ CQRS ํŒจํ„ด

#daily morning study

Image


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๋Š” ๋ณต์žก์„ฑ์„ ์ˆ˜๋ฐ˜ํ•˜๋ฏ€๋กœ, ์‹ค์ œ๋กœ ๊ทธ ๋ณต์žก์„ฑ์ด ์ฃผ๋Š” ์ด์ ์ด ์žˆ๋Š” ๋„๋ฉ”์ธ์— ์„ ํƒ์ ์œผ๋กœ ์ ์šฉํ•˜๋Š” ๊ฒƒ์ด ๋งž๋‹ค.