이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 읽기 비율이 높아도 쓰기 병목이 먼저 오는 이유는 무엇인가?
- 피크 트래픽을 시간대, 이벤트, retry, batch로 나누면 어떤 설계가 달라지는가?
- read QPS와 write TPS를 각각 어떤 보호 장치로 연결해야 하는가?
개요
read/write 비율은 단순한 통계가 아니라 병목 위치를 찾는 도구다.
읽기 95%, 쓰기 5%라는 문장만 보면 읽기 최적화가 전부처럼 보인다.
하지만 쓰기 5%가 같은 재고 row, 같은 사용자 포인트, 같은 결제 승인 API에 몰리면 쓰기 경로가 전체 설계를 지배한다.
읽기는 scale-out이 쉬운 편이다.
cache, CDN, read replica, precomputed read model로 우회할 여지가 있다.
쓰기는 대개 scale-out이 어렵다.
transaction, lock, unique constraint, idempotency, 외부 API 응답이 한 경로에 묶이기 때문이다.
이 문서가 바꾸는 설계 축
| 축 | 바뀌는 내용 |
|---|---|
| 성능 | read path와 write path의 병목을 따로 잡아 cache와 DB write capacity를 분리한다. |
| 정합성 | write TPS가 inventory, payment, point 같은 불변식을 건드리면 강한 검증이 필요하다. |
| 장애 격리 | 피크, retry, batch가 같은 경로에 몰리면 rate limit과 queue로 유입을 끊어야 한다. |
| 운영 복잡도 | 피크 유형별 대시보드와 runbook을 따로 둬야 한다. |
먼저 나눌 것
read/write 비율을 계산하기 전에 요청을 다음 네 묶음으로 나눈다.
| 묶음 | 예시 | 핵심 질문 |
|---|---|---|
| Read | 상품 상세, 검색, 피드 조회 | stale을 허용할 수 있는가? |
| Critical Write | 주문, 결제, 재고 차감 | 중복 실행과 순서 충돌을 어떻게 막는가? |
| Append Write | 로그, 이벤트, 클릭 수집 | 유실, 지연, 중복을 어디까지 허용하는가? |
| Derived Update | 랭킹, 집계, 추천 업데이트 | 실시간 반영이 필요한가? |
| 모든 쓰기가 같은 쓰기가 아니다. | ||
| 주문 생성은 한 번만 성공해야 한다. | ||
| 클릭 이벤트는 중복 제거가 가능하면 나중에 처리해도 된다. | ||
| 랭킹 집계는 늦어도 되지만 오래 밀리면 사용자 경험이 달라진다. |
대표 시나리오
한정 수량 상품 오픈을 설계한다고 하자. 평상시와 이벤트 피크는 완전히 다르다.
| 항목 | 평상시 | 이벤트 피크 |
|---|---|---|
| 상품 상세 조회 | 500 QPS | 30,000 QPS |
| 장바구니 추가 | 30 TPS | 1,200 TPS |
| 주문 시도 | 10 TPS | 600 TPS |
| 결제 승인 | 8 TPS | 350 TPS |
| 재고 수량 | 10,000개 | 10,000개 |
| read 비율만 보면 상품 상세 조회가 압도적이다. | ||
| 하지만 장애를 만드는 경로는 주문 시도와 재고 차감이다. | ||
| 상품 조회는 캐시로 흘릴 수 있지만, 재고 차감은 같은 상품 id에 몰리는 hot row가 된다. |
read peak 계산
상품 상세 API는 읽기 부하가 크다.
product_detail_peak_qps = 30,000
cache_hit_ratio = 0.98
db_read_qps = 30,000 * (1 - 0.98)
= 600 QPS캐시 hit ratio가 98%면 DB read는 600 QPS로 줄어든다. 하지만 hit ratio가 90%로 떨어지면 DB read는 3,000 QPS가 된다. 피크 설계에서는 cache miss를 정상 상황보다 보수적으로 본다. cache warm-up 실패, key 만료 동시 발생, 배포 직후 local cache 비어 있음 같은 상황이 있기 때문이다.
write peak 계산
주문 시도는 단순히 600 TPS로 끝나지 않는다. 하나의 주문은 여러 쓰기를 만든다.
order_attempt_tps = 600
write_per_order = order_row(1) + inventory_update(1) + payment_record(1)
db_write_ops = 600 * 3 = 1,800 ops/sec
payment_call_tps = 350
retry_factor = 1.5
planned_payment_tps = 350 * 1.5 = 525 TPS쓰기 부하는 request 수보다 write operation 수가 중요하다. DB는 row 수보다 index 갱신, lock 대기, replication lag에서 먼저 포화될 수 있다. 결제 호출은 외부 API quota와 timeout 정책이 병목이 된다.
hot row를 따로 본다
재고가 상품 id 하나에 묶이면 모든 주문이 같은 row를 갱신한다. 이때 전체 DB write capacity가 충분해도 해당 row lock 때문에 대기열이 생긴다.
UPDATE product_stock
SET quantity = quantity - 1
WHERE product_id = :productId
AND quantity > 0;이 쿼리는 단순해 보이지만 피크에는 설계 질문을 만든다. 재고 정확성이 최우선이면 lock 대기를 받아들여야 한다. 사용자 대기를 줄이고 싶으면 reservation, queue, token allocation을 검토한다. 정합성을 낮출 수 없다면 cache로 쓰기 병목을 해결할 수 없다.
피크 유형을 분리한다
피크는 하나가 아니다.
| 피크 유형 | 원인 | 설계 대응 |
|---|---|---|
| Daily Peak | 출퇴근, 점심, 저녁 사용 | autoscaling, cache warm-up |
| Event Peak | 쿠폰, 티켓, 한정 상품 | waiting room, rate limit, pre-warm |
| Retry Peak | timeout 후 client 재시도 | retry budget, idempotency key |
| Batch Peak | 정산, 집계, 데이터 적재 | batch window, resource isolation |
| Fanout Peak | 한 요청이 여러 downstream 호출 | queue, async fanout, bulkhead |
| event peak와 retry peak를 같은 peak factor에 합치면 위험하다. | ||
| event peak는 예고된 유입이고 retry peak는 실패가 만든 2차 유입이다. | ||
| retry peak는 이미 시스템이 느린 상태에서 발생하므로 더 치명적이다. |
batch 겹침을 확인한다
온라인 요청만 보고 용량을 잡으면 야간 batch나 정산 작업에 당할 수 있다. 예를 들어 이벤트 오픈 직후 주문 데이터 정산 batch가 돌면 같은 DB의 write I/O와 CPU를 같이 쓴다.
online_write_ops = 1,800 ops/sec
settlement_batch_write_ops = 700 ops/sec
replication_apply_capacity = 2,200 ops/sec
total_write_ops = 2,500 ops/sec
replication_lag 발생 가능이 경우 해결은 서버 증설만이 아니다. batch window를 옮기거나, batch 처리량을 제한하거나, online과 batch의 DB resource를 격리한다.
설계 선택으로 연결한다
| 관찰 | 가능한 선택 | 새로 생기는 비용 |
|---|---|---|
| read peak가 크다 | cache, CDN, read replica | stale data, invalidation, warm-up |
| write hot row가 있다 | queue, reservation, optimistic lock | 대기, 실패 응답, 재처리 |
| retry가 많다 | timeout 조정, retry budget, idempotency | client 계약 변경, 운영 지표 증가 |
| batch가 겹친다 | batch window 분리, rate limit | 처리 지연, 운영 스케줄 관리 |
| fanout이 크다 | async event, bulkhead | 중복, 순서, 관측 복잡도 |
| 선택은 성능만 바꾸지 않는다. | ||
| 캐시는 정합성을 바꾸고, queue는 사용자 응답 의미를 바꾸며, replica는 lag를 만든다. | ||
| 그래서 read/write 비율은 구조 그림보다 API 계약과 운영 지표에 먼저 반영되어야 한다. |
Spring 정책 예시
critical write에는 idempotency와 timeout을 붙인다.
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestHeader("Idempotency-Key") String key,
@RequestBody CreateOrderRequest request
) {
OrderResult result = orderService.createWithIdempotency(key, request);
return ResponseEntity.status(result.httpStatus()).body(result.toResponse());
}read API와 write API는 같은 limiter를 쓰지 않는다.
traffic-policy:
product-read:
limit-per-second: 30000
stale-cache-allowed: true
order-write:
limit-per-second: 600
idempotency-required: true
timeout-ms: 1200읽기를 과하게 제한하면 사용자 탐색 경험이 깨진다. 쓰기를 과하게 열어 두면 재고, 결제, DB lock이 먼저 무너진다.
위험 신호
- read/write 비율을 전체 요청 수 기준으로만 말한다.
- peak traffic을 daily peak와 event peak로 나누지 않는다.
- retry factor를 계산하지 않고 client retry를 허용한다.
- 쓰기 TPS가 작다는 이유로 hot row와 lock wait를 보지 않는다.
- batch 작업이 online peak와 같은 DB, 같은 시간대에 도는지 확인하지 않는다.
- cache 도입으로 write 정합성 문제가 해결된다고 설명한다.
확인 질문
확인 질문
- 확인 질문: 읽기 95%, 쓰기 5%인 서비스에서 먼저 볼 쓰기 위험은 무엇인가?
- 답변: 쓰기 요청 수가 아니라 같은 row, 같은 partition, 같은 외부 API에 몰리는지 확인해야 한다.
- 확인 질문: 피크 트래픽을 나눠야 하는 이유는 무엇인가?
- 답변: daily peak, event peak, retry peak, batch peak는 원인과 완화 방법이 다르기 때문이다.
- 확인 질문: 이 문서의 핵심 trade-off는 무엇인가?
- 답변: 읽기 성능을 높이는 선택은 stale 위험을 만들고, 쓰기 보호 선택은 대기와 운영 복잡도를 늘린다는 점이다.