Understand it step by step (한국어로 이해 → 영어로 말하기)
- 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
스케일을 대략 추정한다. 미국 전체 매물이 수백만 채, 활성 매물은 약 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
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
상위 설계는 단순하게 간다. 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
딥다이브 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
딥다이브 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
트레이드오프와 모니터링으로 마무리한다. 포기한 것: 일관성을 위해 가용성을 약간 양보했고(쓰기 경로가 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단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.