포스트

잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계

잔고를 저장하면 무엇이 문제인가

가장 단순한 방법은 현재 잔고를 users 테이블의 balance 컬럼에 저장하는 것이다.

1
UPDATE users SET balance = balance - 100000 WHERE id = 1;

이 방법은 간단하지만 세 가지 문제가 있다.

1. 이력이 사라진다. “왜 잔고가 이렇게 됐지?”를 물으면 답을 찾을 수 없다.

2. 버그 복구가 어렵다. 집계 로직에 버그가 있었다면, 과거 잔고를 재계산할 방법이 없다.

3. 감사(Audit)가 불가능하다. 금융 시스템에서는 모든 돈의 흐름이 추적되어야 한다.

이벤트 소싱(Event Sourcing)은 이 문제를 근본적으로 해결한다.

잔고를 저장하지 않는다. 잔고를 만든 이벤트들을 저장한다.


이벤트 소싱의 원리

1
2
3
4
5
6
7
8
9
10
이벤트 스트림 (시간순):
  10:00  DEPOSIT         +100,000원
  10:05  CASH_RESERVED   -50,000원  (매수 주문)
  10:07  FILL            +100주 (50,200원에 체결)
  10:07  FEE             -75원  (수수료 0.015%)
  10:10  SETTLEMENT      정산 완료

현재 잔고 = 이벤트들을 순서대로 재생(replay)한 결과
  100,000 - 50,000 - 75 = 49,925원 (현금)
  + 100주 × 시가 (평가액)

저장하는 것은 이벤트 스트림이다. 잔고는 계산된다.


ledger_events 테이블 설계

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE ledger_events (
    id              BIGSERIAL    PRIMARY KEY,
    user_id         BIGINT       NOT NULL REFERENCES users(id),
    event_type      VARCHAR(30)  NOT NULL,
    amount          NUMERIC(18,2),        -- 양수: 입금/증가, 음수: 출금/감소
    balance_after   NUMERIC(18,2),        -- 스냅샷 (replay 가속용)
    paper_order_id  BIGINT       REFERENCES paper_orders(id),
    stock_id        BIGINT       REFERENCES stocks(id),
    description     TEXT,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);

balance_after가 있는 이유가 있다. 순수 이벤트 소싱이라면 잔고를 계산할 때 전체 이벤트를 replay해야 한다. 이벤트가 수천 개가 되면 느려진다. balance_after는 각 이벤트 시점의 잔고 스냅샷이다. 특정 시점부터의 replay를 O(1)로 시작할 수 있다.


이벤트 유형

1
2
3
4
5
6
7
8
9
10
enum class LedgerEventType {
    DEPOSIT,            // 초기 예수금 충전
    WITHDRAWAL,         // 인출
    CASH_RESERVED,      // 매수 주문 시 예약금 차감
    CASH_UNRESERVED,    // 주문 취소 시 예약금 반환
    FILL,               // 체결 (매수: 현금 차감 + 주식 증가, 매도: 주식 차감 + 현금 증가)
    FEE,                // 수수료 차감
    SETTLEMENT,         // 정산 완료 (T+2)
    DIVIDEND,           // 배당금 (향후)
}

LedgerService 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Service
class LedgerService(private val jdbcTemplate: JdbcTemplate) {

    fun recordDeposit(userId: Long, amount: BigDecimal): LedgerEvent {
        val previousBalance = getCurrentBalance(userId)
        val newBalance = previousBalance + amount

        return insertEvent(LedgerEvent(
            userId       = userId,
            eventType    = LedgerEventType.DEPOSIT,
            amount       = amount,
            balanceAfter = newBalance,
            description  = "예수금 충전",
        ))
    }

    fun recordFill(fill: Fill): LedgerEvent {
        val order = fill.order
        val previousBalance = getCurrentBalance(order.userId)

        val (amount, description) = when (order.side) {
            Side.BUY  -> {
                // 매수: 예약금에서 체결금 차감
                val cost = fill.price.multiply(fill.quantity.toBigDecimal())
                val fee  = cost.multiply(BigDecimal("0.00015"))
                Pair(-(cost + fee), "매수 체결 ${order.symbol} ${fill.quantity}주 @ ${fill.price}")
            }
            Side.SELL -> {
                // 매도: 현금 증가 (세금 차감)
                val proceeds = fill.price.multiply(fill.quantity.toBigDecimal())
                val tax      = proceeds.multiply(BigDecimal("0.002"))
                Pair(proceeds - tax, "매도 체결 ${order.symbol} ${fill.quantity}주 @ ${fill.price}")
            }
        }

        return insertEvent(LedgerEvent(
            userId       = order.userId,
            eventType    = LedgerEventType.FILL,
            amount       = amount,
            balanceAfter = previousBalance + amount,
            paperOrderId = order.id,
            stockId      = order.stockId,
            description  = description,
        ))
    }

    fun getCurrentBalance(userId: Long): BigDecimal {
        // 최신 스냅샷 조회 (balance_after)
        return jdbcTemplate.queryForObject("""
            SELECT COALESCE(balance_after, 0)
            FROM ledger_events
            WHERE user_id = ?
            ORDER BY created_at DESC
            LIMIT 1
        """, BigDecimal::class.java, userId) ?: BigDecimal.ZERO
    }
}

Wallet 집계 — 현재 상태 계산

사용자의 돈이 현재 어디에 있는지를 4가지 상태로 표현한다.

1
2
3
4
5
6
data class WalletState(
    val availableCash: BigDecimal,    // 즉시 사용 가능한 현금
    val reservedCash: BigDecimal,     // 미체결 주문에 묶인 현금
    val holdingsValue: BigDecimal,    // 보유 주식 평가액
    val settlementPending: BigDecimal,// 체결 후 정산 대기 중인 금액
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
class WalletService(
    private val jdbcTemplate: JdbcTemplate,
    private val priceService: PriceService,
) {
    fun getWalletState(userId: Long): WalletState {
        // 현금 잔고 = 최신 ledger_events balance_after
        val cash = getCurrentBalance(userId)

        // 예약금 = 미체결 매수 주문 합산
        val reserved = jdbcTemplate.queryForObject("""
            SELECT COALESCE(SUM(price * remaining_quantity), 0)
            FROM orders
            WHERE user_id = ? AND side = 'BUY' AND status IN ('PENDING', 'PARTIALLY_FILLED')
        """, BigDecimal::class.java, userId) ?: BigDecimal.ZERO

        // 평가액 = 보유 주식 × 현재가
        val holdings = getHoldings(userId)
        val holdingsValue = holdings.sumOf { h ->
            val currentPrice = priceService.getLatestPrice(h.stockId)
            currentPrice * h.quantity.toBigDecimal()
        }

        return WalletState(
            availableCash    = cash - reserved,
            reservedCash     = reserved,
            holdingsValue    = holdingsValue,
            settlementPending = BigDecimal.ZERO,
        )
    }
}

Replay 기능

하루치 투자 이벤트를 시간순으로 재생해 학습한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
class ReplayService(private val jdbcTemplate: JdbcTemplate) {

    fun getDailyReplay(userId: Long, date: LocalDate): List<ReplayEvent> {
        val events = jdbcTemplate.query("""
            SELECT le.*, o.symbol, o.side, o.quantity, o.price as order_price
            FROM ledger_events le
            LEFT JOIN paper_orders o ON le.paper_order_id = o.id
            WHERE le.user_id = ?
              AND DATE(le.created_at) = ?
            ORDER BY le.created_at ASC
        """, { rs, _ ->
            ReplayEvent(
                eventType    = LedgerEventType.valueOf(rs.getString("event_type")),
                amount       = rs.getBigDecimal("amount"),
                balanceAfter = rs.getBigDecimal("balance_after"),
                symbol       = rs.getString("symbol"),
                description  = rs.getString("description"),
                timestamp    = rs.getTimestamp("created_at").toInstant(),
            )
        }, userId, date)

        return events
    }
}

이 API를 사용하면 “어제 내가 어떤 순서로 주문했고, 잔고가 어떻게 변했는지”를 시간순 스트림으로 복기할 수 있다.


정리

  • 이벤트 소싱은 잔고를 저장하지 않고 이벤트를 저장한다. 잔고는 replay로 계산한다.
  • balance_after 스냅샷으로 replay 성능을 O(전체 이벤트 수) → O(1)로 개선한다.
  • 체결(FILL) 이벤트에는 수수료(0.015%)와 세금(0.2%)이 자동으로 차감된다.
  • Replay API로 하루치 투자 흐름을 시간순으로 복기할 수 있다.

다음 편에서는 주문 시점의 감정을 태그로 기록하고, 그 감정과 수익률의 상관관계를 분석하는 감정 태그 시스템을 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다