Reconciliation System (inventory vs financial records)

System Design

Understand it step by step (한국어로 이해 → 영어로 말하기)

오픈도어는 집을 직접 사고판다. 집의 상태는 재고(인벤토리) 시스템에, 돈의 흐름은 재무 원장에 따로 기록된다. 따로 기록되는 두 시스템은 시간이 지나면 반드시 어긋난다 — 집은 팔렸다고 돼 있는데 입금 기록이 없거나, 금액이 다르거나. 이 어긋남을 매일 자동으로 찾아 심각도별로 분류하고, 사람이 처리할 것만 검토 큐에 올리는 시스템을 설계한다. 제조업의 재고-재무 대사(reconciliation)와 정확히 같은 모양의 문제다.
  1. 1

    기능 요구사항: (1) 재고 시스템의 집 레코드와 재무 원장의 거래 레코드를 home_id로 맞춰본다. (2) 불일치 5종을 찾는다 — 재무 기록 누락, 재고 기록 누락, 금액 불일치, 상태 불일치, 중복. (3) 각 건에 심각도(HIGH/MEDIUM/LOW)를 붙이고, 런(run)마다 리포트와 사람 검토 큐를 만든다.

    🔧 도구:inventory systemfinancial ledgerhome_id join key

    🗣 영어로 말해

    I match inventory records against ledger transactions and flag missing, mismatched, and duplicate entries.

    checking microphone…

  2. 2

    비기능 요구사항: 속도보다 정확성이다. 재무 마감이 하루 단위라 탐지가 다음 날(T+1)이어도 된다. 대신 잡(job)은 (1) 멱등해야 한다 — 두 번 돌려도 불일치가 두 번 쌓이면 안 된다. (2) 중간에 죽어도 처음부터가 아니라 끊긴 데서 재개해야 한다. (3) 모든 판정은 나중에 감사(audit)로 추적할 수 있어야 한다.

    ⚖️ Trade-off: 실시간 스트리밍이면 더 빨리 잡지만, 원장이 애초에 하루 한 번 배치로 닫힌다. 일단 배치로 시작하고, HIGH 유형만 나중에 스트리밍을 얹는다.

    🔧 도구:batch (T+1)idempotent jobaudit trail

    🗣 영어로 말해

    Correctness beats latency here — daily detection is fine if the job is idempotent and restartable.

    checking microphone…

  3. 3

    스케일 추정: 보유 주택 10만 채, 집당 재무 거래 평균 20건이면 원장 약 200만 행. 하루 새로 생기는 거래는 약 5만 건. 전체 대사를 돌려도 200만 행 비교 수준 — 빅데이터가 아니다. 1만 행씩 200청크로 나누면 청크당 몇 초, 전체 30분 안에 끝난다. 불일치율 0.1%면 하루 약 2,000건, 그중 HIGH는 수십 건 수준이라 사람이 처리할 수 있는 양이다.

    🔧 도구:2M rows10k-row chunks~30 min window

    🗣 영어로 말해

    Around two million ledger rows per run, so I split the comparison into ten-thousand-row chunks.

    checking microphone…

  4. 4

    데이터 모델이 이 설계의 절반이다. 원장을 라이브 시스템에서 직접 조회하지 않고 스테이징 테이블에 스냅샷으로 떠 놓고 비교한다 — 비교 도중 원본이 변하면 결과를 못 믿는다. 런 테이블에 체크포인트 커서를 두고, 불일치 테이블엔 (run_id, home_id, type) 유니크 제약을 걸어 재실행해도 중복이 안 쌓이게 한다. 검토 큐 조회는 (severity, status, created_at) 복합 인덱스 하나로 끝나고, 목록 API는 커서 페이지네이션을 쓴다.

    🔧 도구:Postgresstaging tablecomposite indexcursor pagination

    -- fresh snapshot each run
    CREATE TABLE staging_financial (
      home_id BIGINT, txn_type TEXT, amount NUMERIC,
      txn_at TIMESTAMPTZ, src_id TEXT );
    
    CREATE TABLE recon_run (
      run_id BIGINT PRIMARY KEY, status TEXT,
      cursor_home_id BIGINT );          -- checkpoint
    
    CREATE TABLE mismatch (
      id BIGSERIAL PRIMARY KEY, run_id BIGINT, home_id BIGINT,
      type TEXT, severity TEXT, detail JSONB,
      status TEXT DEFAULT 'OPEN',       -- OPEN -> IN_REVIEW -> RESOLVED/IGNORED
      created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
      UNIQUE (run_id, home_id, type) ); -- re-run safe
    CREATE INDEX ON mismatch (severity, status, created_at);
    
    GET /mismatches?severity=HIGH&status=OPEN&cursor=...
    🗣 영어로 말해

    I snapshot both sources into staging tables first, so the comparison runs against frozen data.

    checking microphone…

  5. 5

    상위 설계: 재고 DB와 재무 원장 → (추출) → 스테이징 테이블 → 배치 워커가 home_id 순서로 청크 비교 → 불일치 테이블 → 검토 큐 UI + 알림. 배치 vs 스트리밍은 한 문장으로 정리한다: 원장이 하루 한 번 파일/배치로 닫히니 배치가 자연스럽고, 더 빠른 탐지가 필요해지면 HIGH 유형만 Kafka CDC로 실시간 감지를 얹는다. 검토 큐 화면엔 새 불일치를 SSE로 밀어주면 충분하다 — 양방향 통신이 필요 없다.

    🔧 도구:batch workerKafka CDC (later)SSERedis pub/sub fanout

    🗣 영어로 말해

    I extract to staging, compare in chunks, and push new mismatches to the review queue.

    checking microphone…

  6. 6

    딥다이브 1 — 200만 행을 어떻게 안전하게 처리하나. 워커는 home_id 오름차순으로 'WHERE home_id > 마지막커서 ORDER BY home_id LIMIT 10000'을 반복한다 — OFFSET은 뒤로 갈수록 느려지고 행이 끼어들면 빠뜨린다. 핵심은 청크 하나의 불일치 저장과 커서 갱신을 같은 Postgres 트랜잭션에서 커밋하는 것. 어느 시점에 죽어도 마지막 커밋된 커서부터 재개하면 되고, 같은 청크를 다시 처리해도 유니크 제약 + ON CONFLICT DO NOTHING 덕분에 중복이 안 생긴다.

    ⚖️ Trade-off: home_id 순서를 고정했으니 병렬화하려면 범위를 미리 쪼개야 한다. 30분 안에 끝나는 규모라 v1은 단일 워커로 충분하다.

    ✅ Fix: 결과와 체크포인트가 한 트랜잭션이라 '결과는 썼는데 커서는 못 옮긴' 어중간한 상태가 없다. 재처리는 일어나도 중복 기록은 안 일어난다 — at-least-once 실행 + 멱등 쓰기 = 사실상 exactly-once 결과.

    🔧 도구:keyset cursor (no OFFSET)single-transaction checkpointON CONFLICT DO NOTHING

    SELECT ... FROM staging_financial f
    FULL OUTER JOIN inventory_snapshot i USING (home_id)
    WHERE home_id > $last_cursor
    ORDER BY home_id LIMIT 10000;   -- keyset, not OFFSET
    
    BEGIN;
    INSERT INTO mismatch (...) ON CONFLICT DO NOTHING;
    UPDATE recon_run SET cursor_home_id = $chunk_max;
    COMMIT;  -- results + checkpoint move together
    🗣 영어로 말해

    I commit each chunk's mismatches and its cursor together, so a crash just resumes from there.

    checking microphone…

  7. 7

    딥다이브 2 — 불일치를 전부 같은 무게로 다루면 큐가 노이즈에 묻힌다. HIGH: 재무 기록 누락, 금액 불일치 — 돈이 실제로 틀린 상태라 당일 알림. MEDIUM: 상태 불일치(재고는 '판매완료'인데 원장에 아직 매각 거래가 없음) — 늦게 도착하는 데이터일 때가 많아 한 사이클 기다렸다가 다음 런에도 남아 있으면 승격. LOW: 무해한 중복(같은 거래가 두 번 적재) — 자동 해소하고 기록만 남긴다. 검토 큐는 작은 상태 머신이다: OPEN → IN_REVIEW → RESOLVED/IGNORED. 담당자가 건을 집을(claim) 때 낙관적 락으로 두 명이 같은 건을 못 잡게 한다.

    ✅ Fix: 제조-재무 시스템에서 실제로 쓰던 분류다. 사람의 시간이 가장 비싼 자원이라 큐에는 HIGH만 기본 노출하고, MEDIUM은 이틀 연속 남아 있을 때만 올린다.

    🔧 도구:severity rulesreview queue FSMoptimistic lockinglate-arriving data

    🗣 영어로 말해

    I treat a missing financial record as HIGH — money is wrong; status mismatches are usually just timing.

    checking microphone…

  8. 8

    트레이드오프 + 모니터링: 배치를 택해 최대 하루의 탐지 지연을 받아들였다 — 대신 시스템이 단순하고 원장 마감 주기와 맞는다. 더 빨라져야 하면 HIGH 유형만 CDC 스트림으로 분리한다. 모니터링 4가지: (1) 런이 제시간에 시작 안 하면 알림 — 조용히 안 도는 게 최악이다(데드맨 스위치), (2) 런 소요 시간이 윈도(30분)에 근접하면 경고, (3) 런당 심각도별 불일치 수 — 갑자기 10배 튀면 데이터가 아니라 업스트림 코드 버그일 확률이 높다, (4) OPEN 상태 HIGH 건의 나이(24시간 SLA 초과 시 에스컬레이션).

    ⚖️ Trade-off: 실시간성을 포기하고 단순함 + 멱등성 + 운영 가능성을 얻었다. 재무 대사에서는 맞는 교환이다.

    🔧 도구:dead-man's-switch alertmismatch spike alertqueue age SLArun duration metric

    🗣 영어로 말해

    I accept a day of delay, and alert on missed runs, spikes, and stale HIGH items.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

🎙 이 카드 AI랑 음성 대화 · 기록 저장