Offer / Transaction System

System Design

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

Opendoor 같은 부동산 마켓에서 집 한 채에 대해 오퍼(매수 제안)를 만들고, 보내고, 수락하고, 마감(closing)까지 가는 거래 흐름을 설계한다. 핵심은 "상태 변경은 단순한 필드 수정이 아니라 비즈니스 이벤트"라는 점이다. 돈이 걸린 거래라서 정확성(correctness)과 감사 추적(audit)이 가용성보다 더 중요하다.
  1. 1

    요구사항부터 정한다. 기능 요구: (1) 오퍼 생성/전송, (2) 상태 전이 — CREATED→SENT→ACCEPTED→CLOSED, 그리고 취소/만료, (3) 집 한 채당 동시에 살아있는 오퍼는 하나만(one active offer per home). 비기능 요구: 돈이 걸린 거래라 가용성보다 정확성과 강한 일관성(strong consistency)이 우선이다. 두 사람이 같은 오퍼를 동시에 수락하면 안 된다. 쓰기 지연은 200ms 정도면 충분하다 — 결제 시스템이 아니라 워크플로우다.

    ⚖️ Trade-off: 가용성을 약간 포기하고 일관성을 택한다. 잠깐 502가 떠서 오퍼 생성이 실패하는 건 괜찮지만, 같은 집이 두 명에게 ACCEPTED 되는 건 절대 안 된다.

    🔧 도구:Postgresstate machine

    🗣 영어로 말해

    Money is involved, so I pick consistency over availability — never accept one home twice.

    checking microphone…

  2. 2

    스케일을 대략 추정한다. 미국 전체 매물이 수백만 채, 활성 매물은 약 10만 채라고 하자. 하루 신규 오퍼 약 5만 건이면 쓰기는 0.5 QPS 수준 — 아주 낮다. 읽기는 사용자가 오퍼 상태를 자주 새로고침하니 쓰기의 50배쯤 잡아 25 QPS. 읽기:쓰기 = 50:1. 오퍼 한 건이 약 1KB라면 1년에 5만×365 ≈ 1800만 건 ×1KB ≈ 18GB — 단일 Postgres로 충분하다. 즉 이건 트래픽 문제가 아니라 정확성·동시성 문제다.

    ✅ Fix: QPS가 낮으니 샤딩이나 NoSQL은 과한 설계다. 단일 Postgres + 읽기 복제본(read replica) 정도로 시작하고, 면접에선 '병목은 트래픽이 아니라 동일 집에 대한 동시 수락'이라고 짚는다.

    🔧 도구:Postgresread replicas

    🗣 영어로 말해

    Writes are under one QPS, so the real challenge is concurrency, not traffic.

    checking microphone…

  3. 3

    API와 데이터 모델을 정한다. 핵심 엔드포인트는 4개: POST /offers (생성), POST /offers/{id}/transitions (상태 전이), GET /offers/{id}, GET /homes/{id}/active-offer. 상태 전이를 PATCH 필드 수정이 아니라 별도 엔드포인트로 두는 이유 — 상태 변경은 검증·멱등성·행위자(actor)·감사 추적이 필요한 비즈니스 이벤트라서다. 테이블은 offers (id, home_id, status, price, version, updated_at), 그리고 audit_log (offer_id, from_status, to_status, actor_id, reason, ts). 파티션/인덱스 키는 home_id — 집 단위로 active offer를 빠르게 찾기 위해서다.

    ✅ Fix: 전이 요청 본문에 idempotencyKey를 넣어, 같은 키로 재시도가 와도 한 번만 적용되고 이전 결과를 그대로 돌려준다. 에러는 404(없는 오퍼) / 409(허용 안 되는 전이) / 400(잘못된 본문).

    🔧 도구:Postgresidempotency keyaudit log

    POST /offers/{id}/transitions
    {
      "nextStatus": "ACCEPTED",
      "idempotencyKey": "a1b2-...",
      "actorId": "agent_42",
      "reason": "buyer accepted"
    }
    
    -- one partial-unique index enforces one active offer per home
    CREATE UNIQUE INDEX one_active_offer
      ON offers (home_id)
      WHERE status IN ('SENT','ACCEPTED');
    🗣 영어로 말해

    Transitions get their own endpoint because a status change is a business event, not a field edit.

    checking microphone…

  4. 4

    상위 설계는 단순하게 간다. Client(웹/내부 운영 툴) → Load Balancer → Offer Service → Postgres(진실의 원천, source of truth). Offer Service 안에 상태 머신이 있어서 모든 전이를 검증한다. 전이가 커밋될 때 같은 트랜잭션 안에서 audit_log와 outbox 테이블에 같이 쓴다. 별도 워커가 outbox를 읽어 Kafka로 발행하고, 다운스트림(검사 예약, 마감 처리, 알림)이 비동기로 소비한다. 읽기는 read replica와 Redis 캐시로 받는다.

    ✅ Fix: outbox 패턴으로 'DB 커밋 성공 + 이벤트 발행 실패' 같은 이중 쓰기 문제를 없앤다. DB 트랜잭션과 이벤트가 원자적으로 같이 커밋되고, 발행은 워커가 따로 보장한다.

    🔧 도구:Load BalancerPostgresOutbox patternKafkaRedisasync workers

    🗣 영어로 말해

    Every transition writes the row, the audit log, and an outbox event in one transaction.

    checking microphone…

  5. 5

    딥다이브 1 — 동시 수락(동일 집에 두 오퍼 ACCEPTED) 막기. 두 명의 에이전트가 같은 집의 서로 다른 오퍼를 거의 동시에 수락하면, 둘 다 'active offer 없음'을 읽고 둘 다 ACCEPTED로 써버릴 수 있다(lost update). 해결은 두 겹. (a) offers 행에 version 컬럼을 두고 낙관적 락(optimistic lock): UPDATE ... WHERE id=? AND version=?, 0행 갱신이면 409로 거절. (b) DB 차원의 마지막 방어선으로 home_id에 대한 부분 유니크 인덱스(partial unique index)를 걸어, 한 집에 SENT/ACCEPTED 오퍼가 둘이면 INSERT/UPDATE 자체가 DB에서 실패하게 한다.

    ⚖️ Trade-off: 낙관적 락은 충돌이 잦으면 재시도가 늘어 사용자에게 409가 자주 보일 수 있다. 하지만 같은 집 동시 수락은 드물어서(QPS 매우 낮음) 비관적 락(행 잠금)으로 처리량을 깎는 것보다 낙관적 락이 낫다.

    ✅ Fix: 애플리케이션 낙관적 락 + DB 부분 유니크 인덱스를 겹쳐서, 앱 버그가 있어도 DB가 데이터 불변식을 끝까지 지키게 한다.

    🔧 도구:optimistic lockversion columnpartial unique indexPostgres

    UPDATE offers
       SET status='ACCEPTED', version=version+1
     WHERE id=:id AND version=:expected
       AND status='SENT';
    -- 0 rows updated -> someone changed it first -> 409 Conflict
    🗣 영어로 말해

    I guard the invariant twice: optimistic lock in the app, and a partial unique index in the database.

    checking microphone…

  6. 6

    딥다이브 2 — 멱등성과 만료(expiry). 네트워크 재시도로 같은 수락 요청이 두 번 오면 안 된다. 해결: idempotency_keys 테이블에 (key → 결과)를 저장해, 같은 키 재요청이면 상태 전이를 다시 실행하지 않고 저장된 응답을 그대로 돌려준다. 키 저장과 전이를 같은 트랜잭션에 묶는다. 만료는 Date.now() 타이머가 아니라 expires_at 컬럼 + 주기적 배치 워커로 처리 — 워커가 'SENT이고 expires_at < now'인 행을 찾아 SENT→EXPIRED 전이를 일반 전이와 똑같이(검증·감사 포함) 실행한다.

    ⚖️ Trade-off: 배치 만료는 정확히 만료 시각에 EXPIRED가 되지 않고 워커 주기(예: 1분)만큼 늦을 수 있다. 하지만 만료 정밀도가 1분 늦는 건 거래 워크플로우에선 허용 가능하고, 타이머 수백만 개를 들고 있는 것보다 훨씬 단순하고 안정적이다.

    ✅ Fix: 멱등성 키 테이블로 중복 실행을 막고, 만료는 시간 컬럼 + 멱등한 배치 워커로 처리해 재실행해도 안전하게 만든다.

    🔧 도구:idempotency storeexpires_at columnbatch workerPostgres

    -- expiry is just another validated transition, run by a worker
    SELECT id FROM offers
     WHERE status='SENT' AND expires_at < now()
     LIMIT 500;  -- then transition each SENT -> EXPIRED
    🗣 영어로 말해

    Retries are safe via an idempotency key, and expiry is a batch worker doing a normal validated transition.

    checking microphone…

  7. 7

    트레이드오프와 모니터링으로 마무리한다. 포기한 것: 일관성을 위해 가용성을 약간 양보했고(쓰기 경로가 Postgres 단일 장애점에 의존), 아웃박스+카프카로 다운스트림은 결국 일관성(eventual consistency)이라 알림이 몇 초 늦을 수 있다. 또 NoSQL/샤딩을 의도적으로 안 써서 트래픽이 100배 커지면 재설계가 필요하다. 모니터링: 전이 실패율(409 비율), outbox 적체(미발행 이벤트 수와 지연), 만료 워커 랙(expires_at 지난 SENT 오퍼 수), 그리고 가장 중요한 불변식 알람 — '한 집에 active offer가 2개' 쿼리가 0이 아니면 즉시 페이지.

    ⚖️ Trade-off: 강한 일관성·단일 Postgres를 택해 운영은 단순하지만, 글로벌 멀티리전 저지연이나 초대형 트래픽은 못 받는다. 그건 지금 요구사항이 아니다.

    ✅ Fix: 불변식을 데이터로 계속 검증한다 — active offer가 2개인 집을 세는 쿼리를 알람으로 걸어, 설계가 깨지는 순간 바로 안다.

    🔧 도구:metricsPrometheus/Grafanaoutbox lag monitoringalerting

    -- invariant alarm: must always return 0
    SELECT home_id, count(*)
      FROM offers
     WHERE status IN ('SENT','ACCEPTED')
     GROUP BY home_id
    HAVING count(*) > 1;
    🗣 영어로 말해

    I'd alert on outbox lag and on the core invariant: any home with more than one active offer.

    checking microphone…

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

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