개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Architecture

Expand-Contract 패턴으로 무중단 PostgreSQL 스키마 마이그레이션 구현하기

컬럼 이름 변경·테이블 분리·외래키 추가를 롤백 가능한 3단계로 배포하는 실전 가이드

배포 전날 팀 채널에 이런 메시지가 올라온 경험, 많을 겁니다. "내일 DB 마이그레이션 있으니 새벽 트래픽 낮을 때 진행합시다." RENAME COLUMN 하나 때문에 서비스 점검 공지를 올리고 온 팀이 새벽 2시에 슬랙을 보고 있는 상황.

Expand-Contract 패턴을 적용하면 컬럼 이름 변경, 테이블 분리, 외래키 추가 같은 파괴적(breaking) 스키마 변경을 서비스 다운타임 없이, 각 단계에서 완전히 롤백 가능한 형태로 배포할 수 있습니다. 이 글은 직접 ALTER TABLE이 왜 위험한지부터 시작해, 3단계 구조가 그 위험을 어떻게 제거하는지, 그리고 실제 시나리오와 함께 CI/CD 파이프라인에 통합하는 방법을 다룹니다.


핵심 개념

왜 직접 ALTER TABLE이 위험한가

users 테이블에서 name 컬럼을 full_name으로 바꾸고 싶다고 해봅시다. 직관적으로는 이렇게 하고 싶을 겁니다.

sql
-- 아래에서 이 방식의 문제를 설명합니다
ALTER TABLE users RENAME COLUMN name TO full_name;

DDL 자체는 원자적이고 빠릅니다. 문제는 **배포 간격(deploy gap)**에 있습니다. 쿠버네티스 롤링 업데이트 환경에서 구 버전 앱 인스턴스가 아직 name을 참조하는 동안 이 명령이 실행되면, 구 코드는 즉각 column "name" does not exist 에러를 뱉습니다.

다이어그램 1

대용량 테이블에서는 락 문제도 있습니다. PostgreSQL 11 이전에서 ADD COLUMN NOT NULL DEFAULT '값'을 수억 건 테이블에 실행하면 ACCESS EXCLUSIVE 락이 테이블 전체 리라이트 동안 유지됩니다. PostgreSQL 11+에서는 상수 기본값에 한해 메타데이터 변경만으로 처리되지만, 잘못된 패턴으로 대규모 테이블을 건드리면 여전히 위험합니다.

Expand-Contract 패턴의 3단계 구조

Expand-Contract(Parallel Change 패턴이라고도 합니다)의 핵심 아이디어는 파괴적 변경을 절대로 한 번에 하지 않는 것입니다. 구 스키마와 신 스키마가 잠시 공존하는 "중간 상태"를 허용하고, 그 사이에 데이터와 코드를 조용히 전환합니다.

단계 명칭 동작 원칙
1단계 Expand 확장 기존 구조를 유지하면서 새로운 컬럼·테이블·제약조건을 추가만 함. 구 코드는 깨지지 않음
2단계 Migrate 전환 기존 데이터를 배치로 백필하고, 듀얼 라이트 후 점진적으로 신 스키마로 전환
3단계 Contract 수축 모든 앱 인스턴스가 신 스키마를 사용함이 확인된 뒤, 구 컬럼·테이블을 DROP

다이어그램 2

이 구조에서 각 단계가 끝난 시점에 시스템은 항상 정상 동작 상태입니다. Expand 단계는 추가만 하기 때문에 문제가 생겨도 DROP COLUMN으로 완전히 되돌릴 수 있습니다.

Expand 단계에서 반드시 NULL 허용으로 추가해야 하는 이유

구 코드는 새로 추가된 컬럼을 모릅니다. 만약 NOT NULL로 컬럼을 추가하면, 구 코드가 해당 컬럼을 포함하지 않은 INSERT를 날리는 순간 바로 오류가 납니다. Expand 단계의 신규 컬럼은 반드시 NULL 허용으로 추가해야 구 코드와 신 코드가 같은 DB에서 안전하게 공존할 수 있습니다.


실전 적용

시나리오 1 — 컬럼 이름 변경: name → full_name

Phase 1: Expand — 신 컬럼 추가

sql
-- 마이그레이션 파일: V001__expand_add_full_name.sql
SET lock_timeout = '5s';
SET statement_timeout = '30s';
 
ALTER TABLE users ADD COLUMN full_name TEXT; -- 반드시 nullable

이 마이그레이션을 적용한 뒤, 앱 코드를 듀얼 라이트로 변경해 배포합니다.

python
# 앱 코드: 구 컬럼과 신 컬럼 모두에 쓰기
def create_user(name: str):
    db.execute(
        "INSERT INTO users (name, full_name) VALUES (%s, %s)",
        [name, name]
    )
 
def update_user_name(user_id: int, name: str):
    db.execute(
        "UPDATE users SET name = %s, full_name = %s WHERE id = %s",
        [name, name, user_id]
    )

Phase 2: Migrate — 배치 백필 + 읽기 전환

기존 행들에 full_name 값이 없으니 채워줘야 합니다. 고정 범위 슬라이딩 방식(id > last_id AND id <= last_id + BATCH_SIZE)은 ID 갭이 있는 테이블에서 첫 빈 범위를 만나는 즉시 루프를 종료하는 버그가 있습니다. 커서 기반 페이지네이션을 사용해야 안전합니다.

python
# scripts/backfill_full_name.py
import time
import psycopg2
 
BATCH_SIZE = 10_000
SLEEP_SECONDS = 0.1
 
conn = psycopg2.connect(DATABASE_URL)
last_id = 0
 
while True:
    with conn.cursor() as cur:
        cur.execute("""
            UPDATE users
            SET full_name = name
            WHERE id IN (
                SELECT id FROM users
                WHERE full_name IS NULL AND id > %s
                ORDER BY id
                LIMIT %s
            )
            RETURNING id
        """, [last_id, BATCH_SIZE])
        rows = cur.fetchall()
        conn.commit()
 
    if not rows:
        break
 
    last_id = max(row[0] for row in rows)
    time.sleep(SLEEP_SECONDS)  # 복제 지연 방지
 
print("백필 완료")

백필이 끝나면 앱 코드에서 읽기를 full_name으로 전환하고, 배포 후 안정을 확인합니다. 이어서 name으로의 듀얼 라이트를 중단하고 full_name에만 쓰는 코드를 배포한 뒤 Phase 3로 넘어갑니다.

Phase 3: Contract — 구 컬럼 제거

모든 앱 인스턴스가 full_name만 사용하고 있음이 확인된 뒤 진행합니다.

sql
-- 마이그레이션 파일: V003__contract_drop_name.sql
SET lock_timeout = '5s';
 
ALTER TABLE users DROP COLUMN name;

시나리오 2 — 테이블 분리: orders → order_headers + order_items

하나의 orders 테이블을 order_headers와 order_items로 나누는 작업은 좀 더 복잡합니다. 트리거를 활용한 듀얼 라이트가 핵심인데, 순서와 중복 처리 방식이 중요합니다.

Phase 1: Expand — 신규 테이블 생성 + 트리거

sql
CREATE TABLE order_headers (
    id          BIGINT PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE order_items (
    id               BIGSERIAL PRIMARY KEY,
    order_id         BIGINT NOT NULL REFERENCES order_headers(id),
    product_id       BIGINT NOT NULL,
    quantity         INT NOT NULL,
    price            NUMERIC(10,2) NOT NULL,
    source_order_id  BIGINT UNIQUE  -- 백필과 트리거 간 멱등성 보장
);
 
CREATE OR REPLACE FUNCTION sync_orders_to_new_tables()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO order_headers (id, user_id, created_at)
        VALUES (NEW.id, NEW.user_id, NEW.created_at)
        ON CONFLICT (id) DO NOTHING;
 
        INSERT INTO order_items (order_id, product_id, quantity, price, source_order_id)
        VALUES (NEW.id, NEW.product_id, NEW.quantity, NEW.price, NEW.id)
        ON CONFLICT (source_order_id) DO NOTHING;
 
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO order_headers (id, user_id, created_at)
        VALUES (NEW.id, NEW.user_id, NEW.created_at)
        ON CONFLICT (id) DO UPDATE
            SET user_id    = EXCLUDED.user_id,
                created_at = EXCLUDED.created_at;
 
        UPDATE order_items
        SET product_id = NEW.product_id,
            quantity   = NEW.quantity,
            price      = NEW.price
        WHERE source_order_id = NEW.id;
    END IF;
 
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER trg_sync_orders
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_orders_to_new_tables();

TG_OP 분기가 핵심입니다. UPDATE 시에도 INSERT INTO order_items를 실행하면 호출될 때마다 행이 추가되어 중복 데이터가 생깁니다.

Phase 2: Migrate — 기존 데이터 배치 복사 + 뷰 노출

트리거는 신규 데이터만 동기화하므로, 기존 데이터를 별도로 백필합니다. source_order_id의 UNIQUE 제약조건 덕분에 트리거가 이미 처리한 행을 백필이 다시 삽입해도 중복 없이 멱등하게 처리됩니다.

sql
INSERT INTO order_headers (id, user_id, created_at)
SELECT id, user_id, created_at FROM orders
ON CONFLICT (id) DO NOTHING;
 
INSERT INTO order_items (order_id, product_id, quantity, price, source_order_id)
SELECT id, product_id, quantity, price, id FROM orders
ON CONFLICT (source_order_id) DO NOTHING;
 
-- 앱 코드 전환이 완료될 때까지 하위 호환용 뷰 노출
CREATE VIEW orders_view AS
SELECT
    h.id,
    h.user_id,
    h.created_at,
    i.product_id,
    i.quantity,
    i.price
FROM order_headers h
JOIN order_items i ON i.order_id = h.id;

이 단계에서 앱 코드를 order_headers와 order_items를 직접 조회하는 방식으로 전환해 배포합니다. orders_view는 전환이 완전히 완료될 때까지 하위 호환 경로로 유지됩니다.

Phase 3: Contract — 구 테이블 정리

앱 코드가 order_headers와 order_items를 직접 조회하도록 전환이 완료된 것을 확인한 뒤 진행합니다.

sql
DROP TRIGGER trg_sync_orders ON orders;
DROP FUNCTION sync_orders_to_new_tables();
DROP VIEW orders_view;
DROP TABLE orders;

시나리오 3 — 외래키 추가: NOT VALID 패턴

외래키를 단번에 추가하면 기존 행 전체 검증 스캔 동안 SHARE ROW EXCLUSIVE 락이 유지됩니다. 이 락은 읽기(SELECT)와는 충돌하지 않지만, INSERT·UPDATE·DELETE 같은 쓰기 연산을 차단합니다. 대용량 테이블에서 스캔이 수 분 이상 걸린다면 그 시간 동안 쓰기가 모두 대기합니다.

2단계로 나누면 쓰기 차단 시간을 최소화할 수 있습니다.

다이어그램 3

sql
-- Phase 1: 기존 행 검증 없이 제약조건 추가
-- SHARE ROW EXCLUSIVE 락이지만 스캔 없이 순간적으로 완료
SET lock_timeout = '5s';
ALTER TABLE orders
    ADD CONSTRAINT fk_user
    FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
 
-- Phase 2: 별도 트랜잭션에서 기존 행 검증
-- SHARE UPDATE EXCLUSIVE 락 — 읽기와 쓰기 모두 차단하지 않음
ALTER TABLE orders VALIDATE CONSTRAINT fk_user;

ADD CONSTRAINT NOT VALID와 단순 ADD CONSTRAINT의 차이는 락의 강도가 아닌 유지 시간에 있습니다. 두 경우 모두 같은 SHARE ROW EXCLUSIVE 락을 획득하지만, NOT VALID는 테이블 스캔을 건너뛰기 때문에 락 유지 시간이 극히 짧습니다. 이후 VALIDATE CONSTRAINT는 더 약한 SHARE UPDATE EXCLUSIVE 락으로 장시간 스캔을 수행하며, 이 락은 일반 읽기·쓰기와 충돌하지 않습니다.

NOT VALID 상태의 제약조건도 신규 INSERT와 UPDATE에는 정상적으로 적용됩니다. 기존 데이터의 정합성만 나중에 검증하는 방식이라 실제로 위험하지 않습니다.


CI/CD 파이프라인에 통합하기

3단계를 각각 별도 PR과 배포로 관리하는 게 핵심입니다. 한 가지 중요한 점은 DB 마이그레이션과 앱 코드 배포가 반드시 순서에 맞게 이루어져야 한다는 것입니다. CI/CD YAML 파일만으로는 앱 배포 단계를 완전히 표현하기 어렵지만, 흐름은 이렇게 구성할 수 있습니다.

yaml
# .github/workflows/db-migration.yml
jobs:
  phase1-expand:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Phase 1 - Expand 마이그레이션 적용
        run: flyway migrate -locations=filesystem:migrations/phase1
      # 이 단계 완료 후: 듀얼 라이트 앱 코드를 별도 배포 파이프라인으로 배포
 
  phase2-backfill:
    needs: phase1-expand
    runs-on: ubuntu-latest
    steps:
      - name: Phase 2 - 배치 백필 실행
        run: python scripts/backfill_full_name.py
        # 전제: 듀얼 라이트 앱 배포가 이미 완료된 상태
      # 이 단계 완료 후: 신 컬럼만 쓰는 앱 코드를 별도 배포 파이프라인으로 배포
 
  phase3-contract:
    needs: phase2-backfill
    environment: production  # 수동 승인 게이트
    runs-on: ubuntu-latest
    steps:
      - name: Phase 3 - Contract 마이그레이션 적용
        run: flyway migrate -locations=filesystem:migrations/phase3
        # 전제: 모든 앱 인스턴스가 신 컬럼만 쓰는 코드로 전환 완료

실제 배포 순서를 정리하면 이렇습니다. Phase 1 DB 마이그레이션 적용 → 듀얼 라이트 앱 배포 → 백필 실행 → 읽기 전환 및 듀얼 라이트 종료 앱 배포 → Phase 3 DB 마이그레이션 적용. Phase 3는 environment: production으로 수동 승인 게이트를 두어, 데이터 검증과 앱 전환 완료 여부를 사람이 한 번 더 확인하는 것을 권장합니다.


pgroll로 이 모든 걸 자동화하기

Xata가 개발한 오픈소스 CLI 도구 pgroll(현재 v0.11+)은 Expand-Contract 전체 라이프사이클을 자동화합니다. PostgreSQL 뷰로 구 스키마와 신 스키마를 동시에 노출하는 멀티 버전 스키마 방식이 인상적입니다. 구 버전 앱과 신 버전 앱이 같은 PostgreSQL 인스턴스에서 서로 다른 스키마 뷰를 바라보도록 해줘서, 블루-그린 배포와 롤링 업데이트 모두 자연스럽게 지원됩니다.

yaml
# pgroll 마이그레이션 파일 예시
version: "1"
operations:
  - rename_column:
      table: users
      from: name
      to: full_name

이 파일 하나로 pgroll이 내부적으로 Expand-Contract 전 과정을 처리합니다. 수동으로 트리거를 짜고, 배치 스크립트를 만들고, 뷰를 생성하는 작업을 자동화해줍니다.


장단점 분석

장점

장점 설명
무중단 배포 각 단계가 독립적으로 배포 가능하고, 구·신 코드가 동일 DB 상태에서 공존
롤백 안전성 Expand 단계는 추가만 하므로 DROP COLUMN으로 완전 롤백 가능
점진적 검증 데이터 이중화 기간 동안 신 스키마의 정합성을 실제 트래픽으로 검증
롤링 업데이트 호환 쿠버네티스 롤링 업데이트 환경과 자연스럽게 통합

단점 및 실무에서 흔한 실수

고려사항 설명
배포 복잡성 단일 마이그레이션이 2~3번의 별도 PR/배포로 분리됨. 팀 규율과 추적 체계 필요
듀얼 라이트 오버헤드 전환 기간 동안 구·신 컬럼 모두에 쓰므로 I/O·스토리지 증가
Schema Debt Contract 단계를 잊고 구 컬럼을 방치할 위험
트리거 복잡성 TG_OP 분기 누락, 멱등성 미확보 같은 엣지 케이스 버그 유발 가능
배치 타이밍 배치 크기와 슬립 간격을 잘못 설정하면 복제 지연 악화

실무에서 가장 흔히 저지르는 실수는 lock_timeout을 설정하지 않는 것입니다. lock_timeout이 없으면 DDL이 장기 실행 쿼리 뒤에서 무한정 대기하면서 새로운 커넥션까지 막아 커넥션 풀을 고갈시킵니다. GoCardless가 대규모 결제 레코드 마이그레이션 경험을 다룬 글(Zero-downtime Postgres migrations: the hard parts)에서도 이 문제를 상세히 다루고 있습니다.

sql
-- 모든 마이그레이션 스크립트 시작부에 추가
SET lock_timeout = '5s';
SET statement_timeout = '30s';

한 가지 주의할 점이 있습니다. SET은 세션 레벨로 적용되어 커넥션 풀로 반환된 뒤에도 설정이 유지될 수 있습니다. 트랜잭션 내에서 실행하는 경우라면 SET LOCAL이 더 안전합니다. Flyway나 Liquibase는 보통 각 마이그레이션을 별도 트랜잭션으로 실행하므로 SET LOCAL이 적합합니다.

lock_timeout은 너무 짧으면 DDL이 반복 실패하고, 너무 길면 커넥션 풀이 고갈될 수 있습니다. 팀의 p99 쿼리 시간을 참고해 적절한 값을 설정하는 게 좋습니다.

두 번째로 흔한 실수는 Contract 단계를 미루다 잊어버리는 것입니다. Phase 2 배포까지 잘 해놓고 몇 달 뒤에 보면 구 컬럼이 그대로 남아 있는 경우가 정말 많습니다. PR이나 티켓에 "이 PR 머지 후 N일 안에 Phase 3 진행" 같은 명시적 추적 체계를 만들어두면 이를 방지하는 데 도움이 됩니다.


마치며

핵심을 정리하면 이렇습니다.

  • 파괴적 스키마 변경을 한 번에 하지 말고, Expand → Migrate → Contract 세 단계로 나눠 배포합니다.
  • 신규 컬럼은 반드시 nullable로 추가해야 구 코드와 공존할 수 있습니다.
  • 모든 DDL 스크립트 시작부에 lock_timeout을 설정하는 습관이 커넥션 풀 고갈을 막는 가장 빠른 개선입니다.
  • 외래키 추가는 NOT VALID + VALIDATE CONSTRAINT 두 단계로 나눠 쓰기 차단 시간을 최소화합니다.
  • 트리거로 듀얼 라이트를 구현할 때는 TG_OP 분기로 INSERT와 UPDATE를 반드시 구분하고, 멱등성을 보장하는 자연키를 두어야 합니다.

바로 시작할 수 있는 세 가지를 제안합니다. 다음 컬럼 추가 작업에 Expand-Contract를 적용해보는 것, 모든 마이그레이션 스크립트에 lock_timeout을 추가하는 것, pgroll을 로컬 환경에서 시험해보는 것입니다. 도구가 패턴을 어떻게 자동화하는지 직접 보면, 수동 구현을 이해하는 데도 큰 도움이 됩니다.

패턴을 처음 팀에 도입할 때는 "배포를 세 번 나눠서 한다고?"라는 반응이 나옵니다. 그 복잡성이 팀 전체에 분산되고 위험이 줄어드는 경험을 한 번 해보면, 그 질문이 다른 방향으로 바뀝니다.


참고 자료

  • pgroll 공식 문서 — Zero-downtime reversible schema migrations for Postgres
  • Xata 블로그 — Schema changes and the power of expand-contract with pgroll
  • GitHub — xataio/pgroll
  • GoCardless — Zero-downtime Postgres migrations: the hard parts
  • Fabian Lindfors — Zero-downtime schema migrations in Postgres using views
  • postgres.ai — lock_timeout and retries for zero-downtime migrations
  • Michal Drozd — Zero-Downtime PostgreSQL Migrations: Expand/Contract, Backfill and Rollback Strategies
  • Stormatics — Zero-Pain PostgreSQL DDL Migrations: Avoiding Locks & Long-running Queries
  • DEV Community — Zero-Downtime PostgreSQL Schema Migrations: Expand/Contract vs Blue-Green Deployment
  • Nexis Ltd — Zero-Downtime Database Migrations: Expand-and-Contract Pattern Explained
  • datasops Blog — Database Migrations Without Downtime: Expand-Contract, Shadow Tables, and Feature Flags
  • Thomas Skowron — Migrating Foreign Keys in PostgreSQL
  • Bytebase — Flyway vs. Liquibase: The Definitive Comparison in 2026
  • Reliable Penguin — Database Migrations Without Drama: Expand/Contract in Practice
  • Hacker News 토론 — pgroll: zero-downtime, reversible schema migrations for Postgres
#PostgreSQL#Expand-Contract#스키마 마이그레이션#무중단 배포#CI/CD#Database
공유하기

목차

핵심 개념왜 직접 ALTER TABLE이 위험한가Expand-Contract 패턴의 3단계 구조Expand 단계에서 반드시 NULL 허용으로 추가해야 하는 이유실전 적용시나리오 1 — 컬럼 이름 변경: name → full_name시나리오 2 — 테이블 분리: orders → order_headers + order_items시나리오 3 — 외래키 추가: NOT VALID 패턴CI/CD 파이프라인에 통합하기pgroll로 이 모든 걸 자동화하기장단점 분석장점단점 및 실무에서 흔한 실수마치며참고 자료

추천 포스트

Kafka와 PostgreSQL로 Event Sourcing + CQRS 실전 구현하기
Architecture

Kafka와 PostgreSQL로 Event Sourcing + CQRS 실전 구현하기

금융 도메인 프로젝트에서 "3개월 전 이 계좌의 잔액이 정확히 얼마였나요?"라는 요청을 받았을 때, 당시 시스템은 현재 잔액만 DB에 저장했고 변경 이력은 별도 감사 테이블에 남겼는데 — 그 테이블이 마이그레이션 과정에서 일부 유실된 상태였습니다. 결국 로그 파일을 뒤…

2026년 07월 20일읽는 데 27분
PostgreSQL 이벤트 스토어로 구현하는 CQRS와 Read Model on-demand 재구성
Architecture

PostgreSQL 이벤트 스토어로 구현하는 CQRS와 Read Model on-demand 재구성

감사(Audit) 요구사항이 있는 도메인에서 "어떻게 이 상태가 됐는지"를 설명하지 못하는 시스템은 구조적 결함을 안고 있습니다. accounts 테이블에 balance 컬럼 하나를 두는 방식은 현재 잔액은 알려주지만, 누가 언제 왜 그 잔액이 됐는지는 영원히 묻힙니다…

2026년 07월 18일읽는 데 28분
ElectricSQL Shapes API로 PostgreSQL을 브라우저에 실시간 동기화하기 — Local-First 웹 앱에서 오프라인 쓰기와 서버 DB를 연결하는 sync-engine 패턴
Architecture

ElectricSQL Shapes API로 PostgreSQL을 브라우저에 실시간 동기화하기 — Local-First 웹 앱에서 오프라인 쓰기와 서버 DB를 연결하는 sync-engine 패턴

실시간 기능을 WebSocket으로 직접 구현하다 보면 어느 순간 재연결 로직이 비즈니스 로직의 두 배가 된 파일을 마주하게 됩니다. 연결 상태 추적, 오프라인 큐잉, 재연결 시 상태 재조정 — 이 인프라성 코드가 서비스의 핵심 기능을 잠식합니다. ElectricSQL…

2026년 07월 18일읽는 데 21분
TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지
Architecture

TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지

CQRS와 Event Sourcing을 처음 접했을 때 솔직히 이런 생각을 했습니다. "이벤트를 전부 저장한다고? 그냥 현재 상태만 DB에 박으면 안 되나?" 그런데 핀테크 프로젝트에서 "3개월 전 이 계좌의 잔액이 왜 이랬는지 추적해주세요"라는 요구사항을 받고 나서…

2026년 07월 17일읽는 데 25분
TypeScript와 KurrentDB로 주문·결제 상태를 이벤트로 저장하고 임의 시점에서 재구성하기
Architecture

TypeScript와 KurrentDB로 주문·결제 상태를 이벤트로 저장하고 임의 시점에서 재구성하기

"어제 오후 2시 32분에 이 주문의 결제 상태가 정확히 어땠나요?" 고객사 감사팀에서 이런 질문을 받으면 어떻게 답하시겠습니까? updated at 컬럼으로 충분하다고 생각하다가 막상 DB를 열어보면 현재 상태만 남고 그 전의 흔적은 사라져 있는 경우가 많습니다. 이…

2026년 07월 15일읽는 데 24분
이벤트 소싱 + CQRS TypeScript 구현 가이드 — 명령과 조회를 분리하고 모든 상태 변화를 이벤트 로그로 보존하는 아키텍처 실전
Architecture

이벤트 소싱 + CQRS TypeScript 구현 가이드 — 명령과 조회를 분리하고 모든 상태 변화를 이벤트 로그로 보존하는 아키텍처 실전

업캐스터(Upcaster) 로 읽는 시점에 변환합니다. 버전 관리 전략은 두 가지입니다. 1. 타입명에 버전 포함 : OrderPlacedEvent@1 , OrderPlacedEvent@2 처럼 이벤트 타입명에 버전을 포함시킵니다. 명시적이고 이벤트 레코드만 봐도 버전…

2026년 07월 12일읽는 데 8분