fos-blog/study
01 / 홈02 / 카테고리
01 / 홈02 / 카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/F&B 설계로 번역하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/architecture/[초안] 대규모 커머스 트래픽 처리 패턴 —…
system

[초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계

대규모 커머스 백엔드는 평상시와 이벤트 시점의 트래픽 프로파일이 극단적으로 다르다. CJ 올리브영의 경우 멤버십 회원 규모가 1,600만 명을 넘고, 한 해에 수 차례 진행되는 올영세일과 같은 메가 프로모션에서는 상시 TPS의 510배가 수십 분 안에 몰린다. 이 조건에서 단순히 서버를 늘리는 것으로는 해결되지 않는다. 트래픽이 집중되는 자원(인기 상품 상...

2026.04.18·14 min read·162 views

1. 이 주제가 왜 중요한가

대규모 커머스 백엔드는 평상시와 이벤트 시점의 트래픽 프로파일이 극단적으로 다르다. CJ 올리브영의 경우 멤버십 회원 규모가 1,600만 명을 넘고, 한 해에 수 차례 진행되는 올영세일과 같은 메가 프로모션에서는 상시 TPS의 5~10배가 수십 분 안에 몰린다. 이 조건에서 단순히 서버를 늘리는 것으로는 해결되지 않는다. 트래픽이 집중되는 자원(인기 상품 상세, 재고 차감 로직, 쿠폰 발급 API, 결제 초입)이 반드시 존재하고, 이 hot path가 시스템 전체를 끌고 들어간다.

시니어 백엔드 엔지니어에게 요구되는 역량은 명확하다. "장애가 났다"가 아니라 "이 구간은 이래서 쏠리고, 이래서 버티거나 버티지 못했으며, 이 설계로 바꾸면 이렇게 완화된다"를 설명할 수 있어야 한다. 이 문서는 커머스 도메인에 한정해 재고·쿠폰·타임세일·읽기·쓰기·핫키·장애 격리를 하나의 흐름으로 엮는 실전 플레이북을 만든다.

본인이 과거 슬롯팀에서 다중 서버 인메모리 캐시 정합성(RabbitMQ Fanout + StampedLock), Kafka 비동기 발행(AFTER_COMMIT + Dead Letter Store) 패턴을 실무에서 다뤘던 경험은 이 토픽과 정확히 연결된다. 면접에서 "블랙프라이데이에 주문이 10배 들어옵니다. 어디부터 보시겠어요?"라는 질문을 받으면, 아래 구조대로 답해 나가면 된다.

2. 커머스 트래픽 프로파일의 세 가지 축

커머스 트래픽은 하나의 커브가 아니다. 세 가지 성질이 겹쳐 있다.

① 상시 read-heavy. 일반 시간대에도 상품 상세·목록·검색 조회 트래픽이 쓰기 대비 50~200배다. 전시 상품 ID 기준으로 캐시 적중률이 성패를 가른다.

② 프로모션 spike. 올영세일이 열리는 순간 +5~10배 증가가 수 초 내에 일어난다. 이 스파이크는 등속이 아니라 시작 시각 ±30초에 날카로운 에지가 생긴다. 예열이 되어 있지 않으면 JVM JIT도, DB 커넥션 풀도, 캐시도 동시에 미준비 상태에서 맞는다.

③ Hot key. 세일 기간의 TOP 20 상품이 전체 상품 상세 조회의 30~50%를 먹는다. 이 20개 키가 캐시 노드, DB 파티션, Redis 샤드에 고르게 분산되지 않으면 특정 노드 한 장이 병목이 된다. 커머스에서 "평균 레이턴시는 괜찮은데 p99가 튄다"의 90%는 핫키다.

세 가지는 각각 다른 대응이 필요하다. 상시 read-heavy는 캐시 계층 설계, spike는 대기열·자동 스케일·사전 워밍, hot key는 2-tier cache와 키 분할로 푼다. 한 가지 기술로 다 풀려 들면 반드시 구멍이 난다.

3. 재고 차감 동시성 — 같은 상품을 100명이 동시에 집는다

재고 차감은 커머스에서 가장 자주 면접 질문으로 나오는 동시성 문제다. 네 가지 전형적 해법을 비교한다.

3-1. DB row lock (비관적 락)

sql
START TRANSACTION;
SELECT stock FROM product_stock WHERE product_id = 9001 FOR UPDATE;
-- 애플리케이션에서 재고 > 요청수량 검증
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 9001;
COMMIT;

가장 단순하고 정확하다. 하지만 FOR UPDATE가 같은 행에 직렬화되므로 TPS가 단일 행의 락 대기 시간으로 캡된다. 평균 락 보유 시간이 5ms라면 이론 최대 TPS는 200이다. 인기 상품 하나에 1만 명이 몰리면 대기열이 DB 커넥션을 다 잠그고 다른 요청까지 연쇄 지연된다.

3-2. 조건부 UPDATE (낙관적)

sql
UPDATE product_stock
SET stock = stock - 1
WHERE product_id = 9001 AND stock >= 1;

affected rows = 0이면 재고 없음이다. 락 없이 원자적 차감이 가능하고, MySQL InnoDB가 row-level 락을 짧게 잡았다 놓는다. 그래도 인기 상품은 여전히 해당 행에 직렬화된다. 차이는 "락을 애플리케이션이 오래 쥐지 않는다"는 점이다. 실무에서는 이 방식이 비관적 락보다 거의 항상 더 빠르다.

3-3. Redis DECR 기반 차감

lua
-- KEYS[1] = stock:product:9001, ARGV[1] = 차감수량
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current < tonumber(ARGV[1]) then
  return -1
end
return redis.call('DECRBY', KEYS[1], ARGV[1])

Redis는 싱글 스레드라 Lua 스크립트 단위로 원자적이다. 단일 키 기준 수만 TPS가 나온다. DB는 "최종 일관성" 경로로 빠진다(이벤트 큐로 반영). 주의할 점은 Redis가 소스 오브 트루스가 되는 순간 장애 시 데이터 손실 위험이 생긴다는 것이다. AOF fsync everysec + Sentinel/Cluster 구성과, DB로의 비동기 체크포인트(정합성 복구용)는 반드시 필요하다.

3-4. 큐잉 (선착순 기반)

plaintext
Client → API Gateway → Kafka(order-request) → Consumer(순차 처리) → DB/Redis

요청을 받자마자 큐로 넘기고 즉시 "대기 중" 응답을 돌려준다. Consumer는 파티션 단위로 순차 처리하므로 동시성이 자연스럽게 조절된다. 트레이드오프는 실시간성 포기다. 사용자는 "주문 접수 완료"가 아니라 "대기번호 N번"을 본다. 플래시 세일·한정판·드롭에 적합하다.

비교 요약

방식정합성최대 TPS(단일 키)구현 복잡도언제 쓰나
FOR UPDATE강함낮음낮음평상시 낮은 동시성
조건부 UPDATE강함중간낮음중간 트래픽 기본값
Redis DECR중간매우 높음중간프로모션·이벤트
큐잉강함(지연된)매우 높음높음플래시 세일·한정판

면접에서 "재고 동시성 어떻게 할까요"는 답이 하나가 아니라 상품 성격에 따른 선택이라고 말해야 정답에 가깝다.

4. 타임세일·플래시 세일 — 시작 시각 ±초 구간 전투

19시 정각 시작 세일은 18:59:58~19:00:03 구간에 트래픽이 완전히 수렴한다. 이 구간은 오토스케일링이 따라오지 못한다. 따라서 사전 예열과 입장 대기열이 정답이다.

4-1. 사전 예열 (warming)

이벤트 시작 T-10분에:

  • 세일 대상 상품 ID를 캐시에 미리 로드한다(products:sale:20260418_19).
  • Redis 재고 키를 DB에서 복사해 둔다.
  • JVM에 대한 synthetic 요청을 넣어 JIT을 활성화하고 커넥션 풀을 덥힌다.
  • CDN에 상세 페이지 정적 리소스를 푸시한다.

캠페인 시작 전 핵심 키를 미리 채워두면, 시작 직후 cache miss → DB 몰림이 사라진다. 이 패턴은 커머스 현장의 표준 기법이다.

4-2. 가상 대기열 (virtual queue)

plaintext
Client ──(GET /waiting-room)──> Edge
         <─ {token: "xyz", position: 14820, polling_interval: 3s} ─
 
Edge(Redis ZSET, score=enter_time)
  └─ 매초 N명씩 score를 "passed"로 이동
      └─ Passed 클라이언트만 실제 API 호출 가능

ZADD waiting:saleA <ts> <userId> 로 입장 시각 순서를 기록하고, 초당 N명씩 ZPOPMIN 해 통과시킨다. 통과 토큰(JWT나 Redis key)이 있는 요청만 실제 주문 API로 라우팅한다. 나머지는 Edge에서 끊어낸다. 실제 오리진 TPS는 N으로 고정되므로 뒤쪽 시스템이 숨을 쉰다.

4-3. Token bucket

가상 대기열이 없어도 되는 규모라면 초당 rate를 제한하는 token bucket을 Edge에 둔다. Redis 단일 키로 구현하는 가장 단순한 형태:

lua
local key = KEYS[1]
local now, rate, capacity = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local delta = math.max(0, now - ts) * rate
tokens = math.min(capacity, tokens + delta)
if tokens < 1 then return 0 end
redis.call('HMSET', key, 'tokens', tokens - 1, 'ts', now)
redis.call('EXPIRE', key, 60)
return 1

유저별·상품별·IP별 조합으로 버킷을 분리할 수 있다.

5. 쿠폰 동시 발급·사용 — 1인 1매 + 수량 제한

쿠폰은 재고와 비슷하지만 유저별 제약이 추가된다. 두 가지 원자 연산이 필요하다. "총 발급량 차감"과 "해당 유저의 중복 수령 방지".

5-1. 분산 락 + 멱등성 키

java
String lockKey = "coupon:lock:" + couponId + ":" + userId;
String idempotencyKey = request.getHeader("Idempotency-Key");
 
// SET NX PX 로 락 획득
Boolean ok = redis.setIfAbsent(lockKey, idempotencyKey, Duration.ofSeconds(3));
if (!Boolean.TRUE.equals(ok)) {
    throw new DuplicateRequestException();
}
try {
    // 1) 이미 발급받았는가 (SETNX + 멤버십)
    Boolean firstIssue = redis.opsForSet().add("coupon:issued:" + couponId, String.valueOf(userId)) == 1L;
    if (!firstIssue) return AlreadyIssued();
 
    // 2) 총 수량 원자 차감
    Long remain = redis.opsForValue().decrement("coupon:remaining:" + couponId);
    if (remain == null || remain < 0) {
        redis.opsForSet().remove("coupon:issued:" + couponId, String.valueOf(userId));
        return SoldOut();
    }
 
    // 3) 비동기 영속화 (Outbox → Kafka → DB)
    outbox.enqueueCouponIssued(couponId, userId, idempotencyKey);
    return Ok();
} finally {
    // 내가 건 락만 해제 (값 비교)
    releaseLock(lockKey, idempotencyKey);
}

포인트 세 가지다.

  • 멱등성 키를 락 값으로 사용해 같은 요청의 재시도를 구분한다.
  • SET 멤버십(SADD)으로 "이 유저가 받은 적 있는가"를 O(1)에 검증한다.
  • 실제 DB insert는 Kafka Outbox로 위임해 응답 시간을 짧게 유지한다.

Outbox 패턴은 트랜잭션 내에서 이벤트 row를 만들고 별도 프로세스가 Kafka로 publish하는 방식이다. 본인이 Kafka Outbox를 쓴 경험에서 얻는 가장 큰 이득은 분산 트랜잭션 없이 at-least-once 보장이 된다는 점이다. Consumer는 idempotency_key UNIQUE 제약으로 중복을 흡수한다.

6. Read storm 방지 — 캐시의 세 가지 심화 패턴

6-1. Cache-Aside + Negative caching

조회 트래픽은 기본적으로 cache-aside(lazy loading)를 쓴다.

java
public Product find(long id) {
    String key = "product:" + id;
    Product cached = redis.get(key, Product.class);
    if (cached != null) return cached == NULL_MARKER ? null : cached;
 
    Product db = productRepository.findById(id).orElse(null);
    redis.set(key, db == null ? NULL_MARKER : db, ttlWithJitter(Duration.ofMinutes(10)));
    return db;
}

Negative caching이 중요하다. 존재하지 않는 상품 ID로 스캔형 공격이 오면, null 응답도 짧게라도 캐시해야 DB가 보호된다. TTL은 짧게(30~60초) 둔다.

6-2. Request coalescing (single-flight)

같은 키에 대해 동시 cache miss가 100개 발생하면 DB로 100번 가선 안 된다. 하나의 요청만 DB에 가고 나머지는 그 결과를 공유한다.

java
ConcurrentMap<String, CompletableFuture<Product>> inflight = new ConcurrentHashMap<>();
 
public Product find(long id) {
    String key = "product:" + id;
    Product cached = redis.get(key, Product.class);
    if (cached != null) return cached;
 
    CompletableFuture<Product> future = inflight.computeIfAbsent(key, k ->
        CompletableFuture.supplyAsync(() -> loadFromDbAndCache(id))
                         .whenComplete((v, e) -> inflight.remove(k))
    );
    return future.join();
}

프로세스 내 single-flight는 JVM 단위에서 한 번, Redis 분산 락을 얹으면 클러스터 전역에서 한 번만 DB를 친다. 비용과 단순성의 균형을 보고 결정한다.

6-3. Stale-while-revalidate

캐시 만료 직전 또는 직후에 "낡은 값 반환 + 백그라운드 재계산"을 한다.

java
CacheEntry e = redis.getEntry(key);
if (e != null && e.isFresh()) return e.value;
if (e != null && e.isStaleButUsable()) {
    refreshAsync(key);     // 백그라운드로 갱신
    return e.value;        // 낡았지만 반환
}
return loadAndCache(key);

상품 상세처럼 1~2분 낡아도 치명적이지 않은 데이터에 적합하다. 재고·가격은 예외다.

7. Thundering herd / Cache stampede 방지

핫키의 TTL이 동시에 만료되면 다음 1초에 DB로 수천 요청이 몰린다.

① TTL jitter. 모든 키에 같은 10분이 아니라 10분 ± 30초 분포를 준다. 올리브영 테크 블로그에서도 jitter 적용 시 40% 수준의 피크 리소스 감소 사례가 공유됐다. 원리는 단순하다. 만료 시각을 흩뿌리면 미스 이벤트가 시간축에서 평탄화된다.

② Probabilistic early expiration (XFetch). 만료 시각이 가까울수록 확률적으로 "내가 지금 갱신할게"를 결정한다.

java
double xfetch = Math.log(ThreadLocalRandom.current().nextDouble()) * beta * computeTimeMs;
if (System.currentTimeMillis() - xfetch >= expireAt) {
    refreshAsync(key);
}

beta가 클수록 더 일찍 갱신된다. 한 프로세스만 확률적으로 먼저 뽑히므로 동시 갱신을 줄인다.

③ Lock + single-flight. 앞서 다룬 request coalescing을 Redis 분산 락으로 올리면 클러스터 전역 stampede까지 막는다.

8. Hot key 완화 — 핵심은 "한 노드에 몰리지 않게"

8-1. 2-tier cache (Caffeine + Redis)

plaintext
App JVM (Caffeine, TTL 10s) → Redis (TTL 10m) → DB

상위 계층에 로컬 캐시를 두면 hot key에 대한 Redis 요청도 줄어든다. JVM 인스턴스 수만큼 fan-out이 자연 분산된다. 주의할 점은 정합성이다. 가격·재고처럼 최신성이 중요한 데이터는 짧은 TTL(수 초)과 이벤트 기반 invalidation(Kafka)으로 보완한다.

8-2. Key 분할 (suffix sharding)

카운터·랭킹처럼 단일 키로 쏠리는 경우 "viewcount:9001:{0..15}" 처럼 16개로 쪼갠다. 쓰기는 hash(userId) % 16, 읽기는 16개 합산. 단일 샤드 핫스팟을 16분의 1로 낮춘다.

8-3. Read replica 분산

Redis Cluster에서 READONLY 모드로 replica에서 읽는다. MySQL도 조회 전용 replica를 두고, 핫 상품 읽기는 replica로 라우팅한다. 일관성 이슈(lag)가 허용되는 경로에서만 쓴다.

9. Write storm 완화 — 쓰기를 직접 맞지 말기

주문·장바구니·조회수 같은 쓰기는 batching + async로 완화한다.

Write-behind. 조회수는 실시간 DB 반영이 필요없다. Redis에 INCR → 30초마다 배치로 DB flush.

비동기 위임. 주문 성사 이후 파생 작업(쿠폰 차감, 포인트 적립, 알림톡 발송, 추천 갱신)은 Kafka 토픽으로 넘긴다. 주문 API는 DB에 한 번만 쓰고 이벤트 하나만 publish한다.

plaintext
POST /orders → INSERT order + outbox(ORDER_CREATED) → 200 OK
              └─ outbox relay → Kafka(order.created)
                                   ├── coupon-consumer
                                   ├── point-consumer
                                   ├── alimtalk-consumer
                                   └── recommender-consumer

Outbox를 끼우면 DB 트랜잭션과 이벤트 publish가 같은 트랜잭션에서 원자적으로 묶인다. publish 실패 시 재시도는 relay가 담당한다.

10. 알림톡·푸시 폭주

쿠폰 발급 완료·주문 완료 알림은 수십만 건이 한 번에 쏠린다. 동기 호출로 외부 알림톡 API를 찌르면 그 API가 throttle을 걸고, 우리 스레드 풀이 전부 blocked 된다.

  • Kafka 토픽으로 분리, 소비자가 초당 N건으로 rate limit.
  • Circuit breaker(Resilience4j)로 외부 API 실패율이 임계치를 넘으면 열고 fallback(일단 DB에만 기록, 나중에 재시도).
  • Bulkhead로 알림 전송 스레드 풀을 주문 스레드 풀과 분리. 알림이 막혀도 주문은 산다.

11. 장애 격리 — bulkhead · feature flag · degraded mode

Bulkhead

서비스별 스레드 풀/커넥션 풀을 분리한다. 추천 API가 느려지면 추천용 풀만 고갈되고, 상품 상세는 영향을 받지 않는다. Hystrix는 deprecated이고 Resilience4j의 Bulkhead 모듈을 쓴다.

Feature flag

신규 기능을 배포는 해두되 플래그로 꺼둔다. 트래픽 피크 직전에 켰다가, 문제가 보이면 즉시 끈다. 배포 롤백보다 훨씬 빠르다. 내부 configuration service 또는 Unleash·LaunchDarkly 같은 도구.

Degraded mode

전체 다 죽이지 않는 부분 실패 설계. 예:

  • 추천 영역 장애 시 "오늘의 MD 추천" 정적 목록 노출.
  • 리뷰 서비스 장애 시 리뷰 탭 "잠시 후 다시 시도" 배너.
  • 가격 계산 장애 시 정가만 노출하고 장바구니 진입 차단.

완전 장애보다 부분 기능만 죽이는 것이 매출 손실을 최소화한다.

12. 로컬 실습 환경

docker-compose.yml 예시:

yaml
version: "3.8"
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: commerce
    ports: ["3306:3306"]
  redis:
    image: redis:7
    ports: ["6379:6379"]
  kafka:
    image: bitnami/kafka:3.6
    environment:
      - KAFKA_ENABLE_KRAFT=yes
      - KAFKA_CFG_NODE_ID=1
      - KAFKA_CFG_PROCESS_ROLES=broker,controller
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@localhost:9093
    ports: ["9092:9092"]

재고 테이블(MySQL 8):

sql
CREATE TABLE product_stock (
  product_id BIGINT PRIMARY KEY,
  stock      INT NOT NULL,
  version    INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
 
INSERT INTO product_stock(product_id, stock) VALUES (9001, 100);

13. 실행 가능한 부하 테스트

k6로 재고 차감에 500 VU로 10초간 찌른다.

javascript
import http from 'k6/http';
import { check } from 'k6';
export const options = { vus: 500, duration: '10s' };
export default function () {
  const res = http.post('http://localhost:8080/orders',
    JSON.stringify({ productId: 9001, qty: 1 }),
    { headers: { 'Content-Type': 'application/json',
                 'Idempotency-Key': `${__VU}-${__ITER}` } });
  check(res, { '200 or sold_out': r => [200, 409].includes(r.status) });
}

DB 조건부 UPDATE 방식, Redis DECR 방식, 큐잉 방식 세 가지를 동일 스크립트로 비교한다. p50·p95·p99 레이턴시와 성공률을 기록하면 왜 이 방식을 골랐는가가 데이터로 남는다. JMH 같은 마이크로벤치는 메서드 수준 단위 병목 확인용이고, 종단 처리량은 k6·Gatling 쪽이 맞다 — 두 도구는 측정 대상의 층위가 달라 섞어 쓰면 안 된다는 경계 감각이 필요하다.

14. 면접 답변 프레이밍 — "블프에 10배 들어옵니다, 어디부터 보시겠어요?"

대답 템플릿:

  1. 트래픽 프로파일 먼저 나눈다. "저는 먼저 그 10배가 읽기인지 쓰기인지, 스파이크인지 분산된 증가인지 확인하겠습니다. 커머스는 보통 read가 5~10배, write가 2~3배, hot key 한두 개가 30% 이상을 먹는 구조라서 대응이 다릅니다."

  2. 읽기부터 막는다. "Edge의 CDN·API Gateway 캐시 설정을 확인하고, 상품 상세는 Redis + 로컬 Caffeine 2-tier로 방어합니다. 핫키는 TTL jitter와 stale-while-revalidate로 DB stampede를 차단합니다."

  3. 쓰기는 비동기 위임. "주문 자체만 DB에 찍고, 쿠폰·포인트·알림은 Kafka outbox로 분리합니다. 알림톡 API 같은 외부 의존은 circuit breaker와 bulkhead로 격리합니다."

  4. 재고는 상품 성격으로 결정. "일반 상품은 조건부 UPDATE, 한정판·드롭은 Redis DECR 또는 큐잉을 씁니다. 둘 다 idempotency key로 재시도를 흡수합니다."

  5. 관측과 킬 스위치. "TPS·p99·에러율·cache hit rate·DB active connections을 대시보드에 띄우고, feature flag로 부하가 큰 기능(실시간 추천, 리뷰 집계)을 즉시 끌 수 있게 합니다."

  6. degraded mode 언급. "완전히 죽이지 않는 부분 실패를 미리 설계합니다. 추천이 죽으면 정적 MD pick으로 대체하는 식이에요."

이렇게 1~6을 흐름으로 말하면, 면접관이 깊이 찌를 수 있는 고리를 준다. "그럼 Redis DECR 쓰면 소스 오브 트루스가 Redis가 되는데요?", "jitter로 40% 감소 사례는 왜 그런 수치인가요?" 같은 질문이 들어온다. 답은 이 문서 본문이다.

15. 흔한 실수 패턴

  • 전역 TTL 고정. 같은 TTL이 동시에 만료 → stampede. 반드시 jitter.
  • 로컬 캐시 invalidation 누락. Caffeine 갱신이 Kafka 이벤트에 연결되지 않아 10초간 낡은 가격을 보여준다.
  • Idempotency key 없이 재시도. 쿠폰이 두 번 발급된다.
  • 분산 락을 TTL 없이 건다. 프로세스 크래시 시 영원히 락이 남는다.
  • Outbox relay 단일 장애. relay가 죽으면 이벤트가 쌓이기만 한다. HA 구성 필수.
  • 서킷 브레이커 없는 동기 호출. 외부 알림 API 지연이 주문 API까지 삼킨다.
  • 읽기 replica 지연 무시. 주문 직후 "주문 내역" 조회에서 빈 응답이 나온다. 쓰기 직후 조회는 primary로.
  • hot key를 모니터링하지 않음. Redis --hotkeys, CLIENT LIST, slowlog, 그리고 애플리케이션 레벨 top-N 카운터 모두 필요.

16. 체크리스트

  • 상시/프로모션/핫키 세 가지 트래픽 프로파일로 구간별 대응을 분리했는가
  • 재고 차감은 상품 성격(일반/한정판/드롭)에 따라 다른 전략을 매핑했는가
  • 타임세일 시작 T-10분 워밍 스크립트가 준비돼 있는가
  • 가상 대기열 또는 token bucket으로 Edge에서 오리진을 보호하는가
  • 쿠폰 발급에 idempotency key + 분산 락 + SADD 멤버십 체크가 있는가
  • 모든 캐시 TTL에 jitter가 적용돼 있는가
  • request coalescing 또는 single-flight가 핫 키 경로에 들어가 있는가
  • Caffeine + Redis 2-tier 구조와 이벤트 기반 invalidation이 연결돼 있는가
  • Write path에 Outbox + Kafka가 있고 consumer는 idempotent한가
  • 외부 의존(알림톡·PG)에 circuit breaker와 bulkhead가 있는가
  • Feature flag로 실시간 기능을 끌 수 있는가
  • Degraded mode UI가 각 서비스 장애 시 정의돼 있는가
  • p50·p95·p99, cache hit rate, DB conn, queue lag 대시보드가 준비됐는가
  • k6 부하 테스트로 세 가지 재고 전략을 실측 비교한 데이터가 있는가
  • "블프 10배" 질문에 6단계 프레이밍으로 4분 안에 설명할 수 있는가

관련 문서

  • 캐시 설계 전략 총정리 — 2-tier 캐시와 Stampede 방어
  • 분산 트랜잭션과 Outbox 패턴 — Write path의 원자성 보장
  • Resilience 패턴 — Circuit Breaker, Bulkhead로 외부 의존 격리
  • MSA 서비스 간 통신 — Cache-Aside × Kafka 이벤트 하이브리드
on this page
  • 011. 이 주제가 왜 중요한가
  • 022. 커머스 트래픽 프로파일의 세 가지 축
  • 033. 재고 차감 동시성 — 같은 상품을 100명이 동시에 집는다
  • 3-1. DB row lock (비관적 락)
  • 3-2. 조건부 UPDATE (낙관적)
  • 3-3. Redis DECR 기반 차감
  • 3-4. 큐잉 (선착순 기반)
  • 비교 요약
  • 044. 타임세일·플래시 세일 — 시작 시각 ±초 구간 전투
  • 4-1. 사전 예열 (warming)
  • 4-2. 가상 대기열 (virtual queue)
  • 4-3. Token bucket
  • 055. 쿠폰 동시 발급·사용 — 1인 1매 + 수량 제한
  • 5-1. 분산 락 + 멱등성 키
  • 066. Read storm 방지 — 캐시의 세 가지 심화 패턴
  • 6-1. [Cache-Aside](../database/redis/cache-aside.md) + Negative caching
  • 6-2. Request coalescing (single-flight)
  • 6-3. Stale-while-revalidate
  • 077. Thundering herd / Cache stampede 방지
  • 088. Hot key 완화 — 핵심은 "한 노드에 몰리지 않게"
  • 8-1. 2-tier cache (Caffeine + Redis)
  • 8-2. Key 분할 (suffix sharding)
  • 8-3. Read replica 분산
  • 099. Write storm 완화 — 쓰기를 직접 맞지 말기
  • 1010. 알림톡·푸시 폭주
  • 1111. 장애 격리 — bulkhead · feature flag · degraded mode
  • Bulkhead
  • Feature flag
  • Degraded mode
  • 1212. 로컬 실습 환경
  • 1313. 실행 가능한 부하 테스트
  • 1414. 면접 답변 프레이밍 — "블프에 10배 들어옵니다, 어디부터 보시겠어요?"
  • 1515. 흔한 실수 패턴
  • 1616. 체크리스트
  • 17관련 문서

댓글 (0)