0Pricing
SQL Academy · 강의

이벤트에서 상태 재구성하기

이벤트를 접어 현재 상태로 만듭니다

이벤트에서 상태 재구성하기은(는) CoddyKit의 무료 SQL Academy 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 SQL Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. SQL Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

상태 재구성이란 무엇인가요

이벤트 소싱에서는 데이터를 변경 가능한 행이 아니라 변경할 수 없는 이벤트 로그로 저장합니다. 어떤 대상의 현재 상태를 알려면 해당 이벤트를 재생해 하나의 결과로 합쳐야 합니다.

이를 이벤트에서 상태 재구성하기라고 합니다. 은행 계좌를 생각해 보세요. 잔액을 저장하는 대신 모든 입금과 출금을 저장하면 잔액은 언제나 이러한 이벤트 금액의 합이 됩니다.

간단한 이벤트 테이블

은행 계좌 시스템을 위한 최소한의 이벤트 로그를 만드는 것부터 시작하겠습니다. 각 행은 금액과 타임스탬프를 포함해 입금이나 출금처럼 발생한 일을 나타냅니다.

이 테이블은 절대 갱신되거나 삭제되지 않습니다. 새로운 사실은 언제나 새로운 행으로 추가됩니다.

CREATE TABLE account_events (
  event_id   SERIAL PRIMARY KEY,
  account_id INT NOT NULL,
  event_type VARCHAR(20) NOT NULL,  -- 'deposit' or 'withdrawal'
  amount     NUMERIC(12, 2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
  (1, 'deposit',    1000.00, '2024-01-01 09:00:00+00'),
  (1, 'deposit',     500.00, '2024-01-03 14:00:00+00'),
  (1, 'withdrawal',  200.00, '2024-01-05 10:00:00+00'),
  (1, 'deposit',     300.00, '2024-01-07 11:00:00+00'),
  (1, 'withdrawal',  150.00, '2024-01-09 16:00:00+00');

이벤트를 누적 집계해 잔액 계산하기

현재 잔액을 다시 계산하려면 모든 이벤트를 집계합니다. 입금은 잔액에 더하고 출금은 잔액에서 뺍니다. CASE 표현식을 사용하면 합산하기 전에 각 이벤트 유형에 올바른 부호를 적용할 수 있습니다.

이 단일 쿼리는 전적으로 과거 이벤트 로그에서 도출한 현재 상태를 보여 줍니다.

SELECT
  account_id,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) AS current_balance
FROM account_events
WHERE account_id = 1
GROUP BY account_id;

특정 시점의 상태

이벤트 소싱의 가장 강력한 기능 중 하나는 어떤 시점에서든 상태를 재구성할 수 있다는 점입니다. 집계하기 전에 WHERE created_at <= :target_time 필터를 추가하기만 하면 됩니다.

이렇게 하면 추가 스키마 변경 없이도 시간 이동 쿼리를 사용할 수 있습니다. 이력이 이미 이벤트 로그에 저장되어 있기 때문입니다.

-- What was the balance at the end of January 5th?
SELECT
  account_id,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) AS balance_at_snapshot
FROM account_events
WHERE account_id = 1
  AND created_at <= '2024-01-05 23:59:59+00'
GROUP BY account_id;

윈도 함수를 사용한 누적 잔액

단일 총합 대신 누적 잔액을 계산할 수 있습니다. 즉, 각 이벤트가 발생한 후의 잔액을 구하는 것입니다. SUM(...) OVER (ORDER BY ...) 윈도 함수는 이벤트가 시간순으로 누적될 때 누적 합계를 계산합니다.

이는 감사 추적과 상태 전환 디버깅에 매우 유용합니다.

SELECT
  event_id,
  created_at,
  event_type,
  amount,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) OVER (PARTITION BY account_id ORDER BY created_at, event_id)
    AS running_balance
FROM account_events
WHERE account_id = 1
ORDER BY created_at, event_id;

상태를 스냅샷 테이블로 구체화하기

로그가 커지면 모든 쿼리에서 이벤트를 재생하는 작업이 비싸질 수 있습니다. 흔히 사용하는 최적화 방법은 현재 상태를 스냅샷 테이블에 구체화하고, 이를 주기적으로 또는 필요할 때 다시 만드는 것입니다.

스냅샷에는 누적 집계 결과가 저장되므로, 쿼리는 매번 전체 로그를 재생하는 대신 스냅샷에서 읽습니다.

CREATE TABLE account_snapshots (
  account_id      INT PRIMARY KEY,
  current_balance NUMERIC(12, 2) NOT NULL,
  as_of_event_id  INT NOT NULL,
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Populate / refresh the snapshot from the event log
INSERT INTO account_snapshots (account_id, current_balance, as_of_event_id, updated_at)
SELECT
  account_id,
  SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END),
  MAX(event_id),
  NOW()
FROM account_events
GROUP BY account_id
ON CONFLICT (account_id) DO UPDATE
  SET current_balance = EXCLUDED.current_balance,
      as_of_event_id  = EXCLUDED.as_of_event_id,
      updated_at      = EXCLUDED.updated_at;

스냅샷을 점진적으로 업데이트하기

새 이벤트가 도착해도 전체 이력을 다시 재생할 필요는 없습니다. 스냅샷에 마지막으로 처리한 event_id를 기록해 두었다면 변경분, 즉 스냅샷이 생성된 후 도착한 이벤트만 적용할 수 있습니다.

이러한 점진적 패턴을 사용하면 대규모 로그에서도 스냅샷을 빠르게 갱신할 수 있습니다.

-- Apply only new events since the last snapshot
UPDATE account_snapshots AS snap
SET
  current_balance = snap.current_balance + delta.net,
  as_of_event_id  = delta.max_event_id,
  updated_at      = NOW()
FROM (
  SELECT
    ae.account_id,
    SUM(CASE ae.event_type WHEN 'deposit' THEN ae.amount WHEN 'withdrawal' THEN -ae.amount ELSE 0 END) AS net,
    MAX(ae.event_id) AS max_event_id
  FROM account_events ae
  JOIN account_snapshots s ON s.account_id = ae.account_id
  WHERE ae.event_id > s.as_of_event_id
  GROUP BY ae.account_id
) AS delta
WHERE snap.account_id = delta.account_id;

시간 테이블과 시스템 버전 관리

SQL:2011에서는 데이터베이스가 직접 관리하는 시스템 버전 관리 시간 테이블을 도입했습니다. 각 행에는 엔진이 관리하는 valid_from 및 valid_to 열이 자동으로 추가됩니다.

PostgreSQL은 이 기능을 기본으로 지원하지 않지만 직접 흉내 낼 수 있습니다. MariaDB나 SQL 서버 같은 다른 데이터베이스는 WITH SYSTEM VERSIONING을 직접 지원합니다.

-- Emulating a temporal table in PostgreSQL
CREATE TABLE account_state_history (
  account_id      INT NOT NULL,
  current_balance NUMERIC(12, 2) NOT NULL,
  valid_from      TIMESTAMPTZ NOT NULL,
  valid_to        TIMESTAMPTZ NOT NULL DEFAULT 'infinity'
);

-- Insert initial state
INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1000.00, '2024-01-01 09:00:00+00');

-- On update: close old row, insert new row
UPDATE account_state_history
  SET valid_to = '2024-01-03 14:00:00+00'
WHERE account_id = 1 AND valid_to = 'infinity';

INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1500.00, '2024-01-03 14:00:00+00');

시간 이력 조회

시간 테이블을 흉내 내어 구성하면 유효 범위를 기준으로 필터링하여 과거 어느 시점의 잔액이었는지 확인할 수 있습니다. 대상 타임스탬프를 포함하는 범위의 행이 해당 시점의 상태입니다.

이 패턴을 사용하면 쿼리 로직과 이벤트 재생을 분리할 수 있습니다. 상태 이력 테이블에는 이미 누적 집계된 상태가 저장되어 있기 때문입니다.

-- What was the account balance on January 4th?
SELECT
  account_id,
  current_balance,
  valid_from,
  valid_to
FROM account_state_history
WHERE account_id = 1
  AND valid_from <= '2024-01-04 00:00:00+00'
  AND valid_to   >  '2024-01-04 00:00:00+00';

여러 엔터티를 사용하는 이벤트 소싱

실제 시스템에서는 여러 엔터티의 이벤트를 동시에 추적합니다. entity_id와 entity_type 열이 있는 공유 이벤트 로그를 사용하면 하나의 테이블에서 어떤 객체의 상태든 재구성할 수 있습니다.

여기서는 여러 제품의 재고 이동을 추적합니다. 각 제품의 현재 재고를 다시 계산하는 작업도 그룹별 집계만으로 처리할 수 있습니다.

CREATE TABLE inventory_events (
  event_id    SERIAL PRIMARY KEY,
  product_id  INT NOT NULL,
  event_type  VARCHAR(20) NOT NULL,  -- 'received', 'shipped', 'adjusted'
  quantity    INT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

INSERT INTO inventory_events (product_id, event_type, quantity, created_at) VALUES
  (101, 'received',  200, '2024-03-01 08:00:00+00'),
  (101, 'shipped',    50, '2024-03-02 12:00:00+00'),
  (101, 'shipped',    30, '2024-03-04 15:00:00+00'),
  (102, 'received',  150, '2024-03-01 08:00:00+00'),
  (102, 'adjusted',  -10, '2024-03-03 09:00:00+00');

-- Rebuild current stock for all products
SELECT
  product_id,
  SUM(CASE event_type WHEN 'received' THEN quantity WHEN 'shipped' THEN -quantity ELSE quantity END) AS stock_on_hand
FROM inventory_events
GROUP BY product_id
ORDER BY product_id;

명확성을 위해 CTE 사용하기

상태를 재구성하는 쿼리는 복잡해질 수 있습니다. 누적 집계 단계를 CTE로 감싸면 가독성이 좋아지고, 재구성한 상태를 다른 테이블과 깔끔하게 조인할 수 있습니다.

여기서는 계정 잔액을 다시 계산한 다음 계정 참조 테이블과 조인하여 출력에 소유자 이름을 포함합니다.

CREATE TABLE accounts (
  account_id INT PRIMARY KEY,
  owner_name VARCHAR(100) NOT NULL
);

INSERT INTO accounts (account_id, owner_name) VALUES
  (1, 'Alice'),
  (2, 'Bob');

INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
  (2, 'deposit',   2000.00, '2024-01-02 10:00:00+00'),
  (2, 'withdrawal', 400.00, '2024-01-06 11:00:00+00');

WITH rebuilt_balances AS (
  SELECT
    account_id,
    SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END) AS balance
  FROM account_events
  GROUP BY account_id
)
SELECT
  a.account_id,
  a.owner_name,
  rb.balance
FROM accounts a
JOIN rebuilt_balances rb USING (account_id)
ORDER BY a.account_id;

이해도 확인

SQL에서 이벤트로부터 상태를 재구성하는 방법을 얼마나 이해했는지 확인해 보세요.

레슨 요약

이번 레슨에서는 SQL을 사용해 변경할 수 없는 이벤트 로그에서 현재 상태와 과거 상태를 재구성하는 방법을 배웠습니다.

핵심 내용:

  • 부호를 적용한 CASE 표현식을 SUM 안에서 사용해 이벤트를 누적 집계하여 상태를 도출합니다.
  • 타임스탬프 필터를 추가하면 별도의 작업 없이도 특정 시점의 쿼리를 실행할 수 있습니다.
  • 윈도 함수는 각 이벤트가 발생한 후의 누적 상태를 만들어 줍니다.
  • 스냅샷 테이블은 성능을 위해 누적 집계 결과를 구체화하며, 점진적 업데이트는 새 이벤트만 적용합니다.
  • 흉내 내어 구성한 시간 테이블은 빠른 과거 조회를 위해 유효 범위가 있는, 미리 누적 집계된 상태 행을 저장합니다.
  • 파생된 상태를 다른 테이블과 조인해야 할 때 CTE를 사용하면 재구성 쿼리를 읽기 쉽게 유지할 수 있습니다.

이러한 패턴은 이벤트 소싱 기반이면서 감사에 적합한 데이터베이스 설계의 기반입니다.

자주 묻는 질문

“이벤트에서 상태 재구성하기” 강의는 무료인가요?

네 — “이벤트에서 상태 재구성하기” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 SQL Academy 강의 전체를 잠금 해제할 수 있습니다. SQL Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

“이벤트에서 상태 재구성하기”에서 뭘 배우나요?

이벤트를 접어 현재 상태로 만듭니다 브라우저에서 직접 실행하는 실습 코드로 SQL Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

SQL Academy을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 SQL Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“이벤트에서 상태 재구성하기” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 SQL Academy 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 SQL Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 이력을 보관해야 하는 이유
  2. 추가 전용 이벤트 테이블
  3. 시간 및 버전 관리 행
  4. 이벤트에서 상태 재구성하기
← SQL Academy(으)로 돌아가기