Redis 캐시 아키텍처 완벽 정리 (읽기/쓰기 패턴, TTL, 백업까지)

강코딩·2025년 10월 28일

레디스

목록 보기
21/21

📗 1-1. Lazy Loading (Cache-Aside) 아키텍처 완전정리
🎯 0. 목표와 전제

목표:

최초 요청 시 캐시 미스가 발생하더라도 장애 없이 정상 동작

사용자 에러율 ≈ 0%

자주 사용되는 데이터만 효율적으로 캐싱

전제:

Cache: Redis

DB: MySQL

App: Spring Boot (Caffeine + Resilience4j 병행)

📘 1. Lazy Loading (Cache-Aside)란?

캐시에 없으면(미스) 애플리케이션이 DB에서 데이터를 읽어 Redis에 저장한 뒤 반환하는 구조.
Redis는 수동적으로 채워진다.

Client → App → Redis
├─ Hit → return
└─ Miss → DB 조회 → Redis put → return

핵심:
“데이터를 직접 캐시에 넣는 책임은 애플리케이션이 진다.”

⚙️ 2. 한 장 요약: Fallback 아키텍처
[Client]
|
[Controller] ─▶ Static Default 응답
|
[Service]
| ├─ Retry / CircuitBreaker / Timeout
| └─ LocalCache(Soft TTL)
|
[Cache Facade]
├─ Redis 캐시 조회
└─ DB 조회 후 put
|
[DB Access] ─▶ Validation / Outbox Sync

요청 경로: Local → Redis → DB
갱신 경로: DB → Redis → Local (수동 업데이트)

🔁 3. 요청 흐름
상황 처리 흐름 결과
캐시 히트 Redis → 응답 ✅ 정상
캐시 미스 + DB 성공 DB → Redis put → 응답 ✅ 정상
캐시 미스 + DB 실패 LocalCache or Static 반환 ⚙️ 백그라운드 재시도
Redis 장애 DB 직접 접근 ⚙️ Redis 복구 시 Warm-up
DB 장애 LocalCache or Stale 허용 ⚙️ 재시도 큐 등록
TCP 장애 Retry + 백오프 ⚙️ 연결 복구 후 재시도
💻 4. 실전 구현 (Spring Boot)
public User getUser(Long id) {
// 1️⃣ Local Cache 우선
User local = localCache.getIfPresent(id);
if (local != null && !local.isSoftExpired()) return local;

// 2️⃣ Redis 조회
User cached = redisTemplate.opsForValue().get(id);
if (cached != null) return cached;

// 3️⃣ Redis Miss → DB 접근
return withResilience(() -> {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException());
validate(user);
redisTemplate.opsForValue().set(id, user, Duration.ofMinutes(10));
localCache.put(id, user);
return user;
}).onFailureUse(() -> Optional.ofNullable(local)
.orElse(User.DEFAULT));
}

⚙️ 5. Soft-TTL 전략

SoftTTL: “만료되었지만 응답은 허용”

HardTTL: “실제 삭제 시점”

softTTL 이후에는 백그라운드에서 재조회

효과

최초 요청만 DB Hit → 이후는 Redis

장애 시에도 Local/Static으로 응답 가능

🧱 6. 장애 유형별 체크리스트
장애 즉시 응답 백그라운드 조치 비고
Redis 다운 DB 조회 or Local Redis 복구 시 Warm-up Sentinel 권장
DB 다운 Local/Stale/Static Retry + Outbox 재시도 Event Log 기반
Network 장애 Retry → Fallback Backoff / Timeout TCP KeepAlive
App 장애 Static 응답 Resilience4j 복구 HealthCheck로 탐지
🧠 7. 데이터 무결성 가드

Validation 통과 후만 캐싱

Retry 시 idempotent 보장

Outbox Pattern으로 DB-Cache 불일치 복원

📊 8. 관측 포인트
메트릭 설명
cache_hit, cache_miss 캐시 효율성
fallback_used 장애 회피율
db_retry_count DB 연결 품질
cb_open 회로 상태 감시
🚀 9. 운영 가이드 요약

경로: Local → Redis → DB → Fallback

Soft TTL + Back Refresh

Retry + CircuitBreaker + Timeout

Outbox로 DB-Cache 동기화

TTL Jitter로 혼잡 분산


  1. 목표와 전제

목표: 사용자 관점 에러율 ≈ 0% (실패는 내부에서 흡수)

전제: 캐시는 Redis, 원장은 RDBMS(예: MySQL), 애플리케이션은 Spring Boot

  1. Read-Through란?

캐시에 없으면(미스) 캐시 계층이 DB에서 읽어와 캐시를 채우고 리턴하는 패턴.
Redis 자체는 DB 드라이버가 없으니 애플리케이션/미들웨어가 Loader 역할을 합니다.

Client → Cache get
├─ Hit → return
└─ Miss → Loader(DB) → put into cache → return

문제: DB 접근이 실패하면 캐시에 채울 데이터가 없음 → 이때를 위한 다층 Fallback이 필요.

  1. 한 장 요약: 다층 Fallback 아키텍처
    [Client]
    |
    [Controller] ──▶ (Static Default 응답, 마지막 안전망)
    |
    [Service + Loader]
    | ├─ Circuit Breaker / Retry / Timeout / Bulkhead
    | └─ Validation(이상 데이터 캐시에 금지)
    |
    [Cache Facade]
    | ├─ 1차: Redis
    | ├─ 2차: Local Cache (Caffeine 등)
    | └─ 3차: Stale Cache (Soft-TTL 허용)
    |
    [DB Access] ──▶ Outbox/Event/Retry(백그라운드 갱신)

요청 경로: Local→Redis→DB(실패 시에도 응답은 Local/Stale/Static로 보장)

갱신 경로: 백그라운드에서 재시도(soft-TTL stale-while-revalidate)

  1. 요청 흐름(정상/실패별) — 한눈에

캐시 히트
→ 바로 반환 (가장 빠름)

캐시 미스 & DB 성공
→ 캐시에 put 후 반환

캐시 미스 & DB 실패
→ 순서대로 시도: LocalCache → Stale Redis 값 → Static Default
→ 동시에 백그라운드 갱신 작업 큐잉(재시도)

Redis 장애
→ Redis 건너뛰고 DB 조회(성공 시 Local/Redis 채움). DB도 실패하면 상동.

DB 장애

soft-TTL 전략으로 만료되었어도 Stale 데이터 반환

백그라운드에서 주기 재시도

애플리케이션 일부 장애(스레드 폭주/빈번 예외)

Circuit Breaker 닫힘 → 로컬/스태틱 Fallback 즉시 반환

TCP 5~7 계층 이슈(Timeout/Reset/Handshake 오류)

Retry + 지수 백오프 + 타임아웃 상한

실패 즉시 다음 Fallback 레이어로 하강

  1. 실전 구현 포인트 (Spring Boot 예시)
    4.1 서비스 코드(의사 코드)
    @Cacheable(value = "product", key = "#id") // Redis 캐시
    public Product getProduct(Long id) {
    // 1) Local cache 먼저 확인(직접 구현 또는 Cacheable 앞단 별도 레이어)
    Product local = localCache.getIfPresent(id);
    if (local != null && !local.isSoftExpired()) return local;

    // 2) Redis 시도(스프링이 해줌) → Miss면 아래 Loader로
    return withResilience(() -> loaderFromDb(id)) // Retry/CB/Timeout 래핑
    .onFailureUse(() -> // 실패 시 대체
    Optional.ofNullable(local) // Local 최신이면
    .orElseGet(() -> staleFromRedis(id) // Soft-TTL 허용본
    .orElse(Product.DEFAULT)) // Static Default
    );
    }

private Product loaderFromDb(Long id) {
Product p = repository.findById(id)
.orElseThrow(() -> new NotFoundException());
validate(p); // 이상 데이터 가드
localCache.put(id, p); // 로컬 예열
return p; // @Cacheable이 Redis에 채움
}

4.2 Resilience4j 설정(YAML)
resilience4j:
retry:
instances:
dbLoad:
max-attempts: 3
wait-duration: 200ms
retry-exceptions:
- java.net.SocketTimeoutException
- org.springframework.dao.QueryTimeoutException
circuitbreaker:
instances:
dbLoad:
sliding-window-type: count_based
sliding-window-size: 50
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
automatic-transition-from-open-to-half-open-enabled: true
timeLimiter:
instances:
dbLoad:
timeout-duration: 800ms

Retry: 순간 네트워크/락/타임아웃 완화

CircuitBreaker: 실패 급증 시 바로 Fallback 진입

TimeLimiter: 느린 호출 차단

4.3 Lettuce/Pool(네트워크) 튜닝

connectTimeout/commandTimeout 설정

풀 크기: CPU×2~4, 최대 동시성(QPS)에 맞게

TCP KeepAlive on, 재시도 백오프

  1. Soft-TTL + Stale-While-Revalidate(SWrV)

개념

TTL을 두 단계로 쪼갠다: softTTL < hardTTL

softTTL 지나면 반환은 허용(사용자에겐 응답), 백그라운드에서 최신화

hardTTL 지나면 정말로 무효 → Local/Static로만 응답

효과

DB 장애/느림에도 사용자 에러 0에 근접

스파이크에 강함(요청 중복 최신화 방지: “single-flight”/synchronization 필요)

  1. 장애 유형별 체크리스트
    장애 즉시 응답 백그라운드 조치 비고
    Redis 다운 DB 조회→성공시 Local/Redis 갱신, 실패시 Local/Stale/Static Redis 복구 후 warm-up Sentinel/Cluster 권장
    DB 다운 Stale(softTTL) 또는 Local/Static Retry 큐, 지수 백오프로 재동기화 Outbox/이벤트 로그
    네트워크 불안정(TCP 5~7) Retry→Fallback 순차 하강 타임아웃/백오프 조정 idempotent 보장
    Loader 코드 예외 Local/Stale/Static 에러 추적, DLQ(죽은 편지) 직렬화/검증 가드
    Hot Key 폭주 Local 캐시 히트율 ↑ SWrV + single-flight request coalescing
  2. 데이터 무결성/일관성 가드

검증(Validation) 후 캐싱: 필수 필드 누락·음수·오염 데이터 차단

idempotent write/read: 재시도에도 부작용 없음

Outbox/Event Log: DB와 캐시 동기화 실패 시 재생(replay)

  1. 관측(Observability)

각 레이어 성공/실패/지연 지표: cache_hit, cache_miss, stale_served, fallback_used, db_retry_count, cb_state

경계값 알람: stale 비율 급증, CB open 지속, TTL 편차

분산 트레이싱: “어디서 Fallback이 발동했는지” 추적

  1. 혼잡 완화(Thundering Herd) 방지

서로 다른 무작위 TTL(Jitter)

Single-flight(동일 키 동시 로더 1개만 허용)

콜드 스타트 Pre-Warm (핫 키를 미리 채움)

  1. 운영 가이드(요약)

레이어 순서: Local → Redis → DB → (실패) Local/Stale/Static

SWrV로 stale 허용 + 백그라운드 최신화

Retry + CB + TimeLimiter로 느린/불안정 호출 흡수

검증/직렬화 가드로 쓰레기 캐시 방지

Outbox/Event로 나중에 꼭 동기화

모니터링 지표로 Fallback이 정상 동작하는지 상시 확인


⚙️ 2-2. Write-Back (Write-Behind) 아키텍처 완전정리
🎯 0. 목표와 전제

목표:
쓰기 속도 극대화 (Latency 최소화)

전제:

Cache: Redis

DB: MySQL

Async Queue: Kafka / Redis Stream

📘 1. Write-Back이란?

“캐시에 먼저 쓰고, 나중에 DB에 비동기로 반영하는 구조”

Client → Redis (Cache Write)
↓ (비동기)
DB Flush

⚙️ 2. Fallback 아키텍처
[Client]
|
[Service]
| ├─ Redis Write
| ├─ Async Queue(Kafka/Stream)
| └─ RetryPolicy + AOF
|
[DB Sync Worker]
├─ Batch Write
└─ Retry + DeadLetterQueue

🔁 3. 흐름
상황 처리 결과
정상 Redis Write → Async Flush ✅ 빠름
Redis 장애 Local Queue → 재시도 ⚙️ 복구 후 재적재
DB 장애 Queue 보류 / 재시도 ⚙️ 유실 방지
Network 장애 AOF Write ⚙️ 재반영
💻 4. 예시 코드
public void saveUser(User u) {
redisTemplate.opsForValue().set(u.getId(), u);
kafkaTemplate.send("user.writeback", u);
}

Kafka Consumer가 DB 반영 담당.

⚙️ 5. 장애 대응
장애 즉시 응답 백그라운드 조치
Redis 다운 LocalBuffer 저장 복구 시 flush
DB 다운 Queue 적재 유지 Retry / DeadLetterQueue
Worker 장애 Offset Commit 중단 DLQ 재처리
🚀 6. 운영 포인트

Redis AOF 반드시 ON

Kafka / Redis Stream 사용

Idempotent Update (중복 방지)

Flush 실패 시 DLQ

🔁 2-3. Write-Around 아키텍처 완전정리
🎯 0. 목표와 전제

목표:
쓰기 성능 유지 + 캐시 오염 방지

전제:

Cache: Redis

DB: MySQL

Loader: Lazy Loading

📘 1. Write-Around란?

쓰기 시에는 캐시에 쓰지 않고 DB에만 반영.
이후 조회 시점에 Lazy Loading으로 캐시 생성.

Client → DB Write

Next Read → Cache Miss → DB Load → Cache Fill

⚙️ 2. Fallback 아키텍처
[Client]
|
[Service]
| ├─ DB Write
| └─ Validation + CDC Capture
|
[CDC Stream] → Redis Cache Sync
|
[Cache Layer] → Lazy Load on Miss

🔁 3. 요청 흐름
상황 처리 흐름 결과
Write DB에만 기록 ✅ 빠름
Read (Hit) Redis → OK ✅ 빠름
Read (Miss) DB 조회 → Redis 저장 ⚙️ Lazy Load
Redis 다운 DB 접근 ⚙️ 복구 후 Warm-up
💻 4. 예시 코드
@Transactional
public void updateOrder(Order o) {
orderRepository.save(o); // DB만 반영
// 캐시는 Lazy Loading이 담당
}

CDC(Change Data Capture)를 통해 Redis 최신화 가능.
예: Debezium → Kafka → Redis Stream

🚀 5. 운영 포인트

Write는 빠름, Read는 첫 미스 시 지연 발생

캐시 오염 방지 효과 높음

CDC 또는 TTL 기반 자동 싱크 필요

Cold Start Key는 Pre-Warm으로 대응

✅ 마무리
패턴 핵심 목적 특징
Lazy Loading 단순하고 효율적 첫 요청 느림
Read-Through 관리 자동화 Loader 필요
Write-Through 일관성 우선 느림
Write-Back 속도 우선 복구 복잡
Write-Around 캐시 청결 첫 조회 지연


Outbox INSERT → Redis Insert → Dealer DB Insert → Outbox Delete

이건 직관적으로 “Redis 먼저 반영하고 DB에 확정하자” 라는 발상인데,
이 방식에는 치명적인 경계 조건이 몇 가지 있어요.
즉, “언뜻 맞는 것 같지만 실제 운영에서는 위험”한 구조예요.
아래에서 왜 그런지, 그리고 어떻게 “진짜 실패 0%” 구조로 만들어야 하는지
아키텍처 레벨로 정리해드릴게요.

🚫 왜 “Outbox → Redis → DB → Outbox 삭제” 가 위험한가

이 순서는 “비원자적(Non-Atomic) 분산 트랜잭션” 구조입니다.
각 단계를 따져보면 다음과 같습니다 👇

단계 설명 위험
1️⃣ Outbox INSERT 작업 시작 로그를 DB에 남김 OK
2️⃣ Redis 반영 Redis 반영 시 네트워크/Timeout 가능 ❌ Redis 성공 후 DB 실패 가능
3️⃣ Dealer DB INSERT DB 커밋 시점에 실패 가능 ❌ Redis에는 데이터 있지만, DB는 없음
4️⃣ Outbox DELETE DB 커밋 실패 시 삭제되지 않음 ❌ 복구 불가한 “불일치 상태” 발생

즉, 2번 Redis 반영이 DB 트랜잭션 외부에서 일어나기 때문에
DB 커밋이 실패하면 Redis에는 유령 데이터가 남습니다.

➡️ 딜러 페이지에는 “등록 성공”으로 보이지만,
Redis엔 데이터가 있고 DB엔 없음 → 가장 위험한 케이스 (데이터 consistency 깨짐)

✅ 정석적인 순서는 이렇게 해야 합니다
1️⃣ Dealer DB INSERT
2️⃣ Outbox INSERT (Redis에 반영해야 할 정보)
---- (여기까지 한 DB 트랜잭션)
3️⃣ Transaction Commit
4️⃣ Redis 반영 (성공 시 Outbox 삭제)

⚙️ 트랜잭션 레벨에서 보면

Step 1~2는 같은 DB 트랜잭션 안에서 수행 → 원자적(Atomic)

Step 3 커밋 후,
“이 데이터는 확실히 존재한다(DB가 진실)” 가 확정된 다음에

Step 4에서 Redis에 반영
→ Redis 실패 시에도 Outbox로 재시도 가능
→ 결국 DB 상태가 기준(True Source of Truth)

🧩 비교 요약
시나리오 DB Redis 결과
✅ 정석 구조 Commit 이후 Redis 반영 일관성 Eventually OK
❌ 제안 구조 Redis 먼저, DB 실패 데이터 불일치 (Redis 유령)
🔁 타임라인 예시
❌ 잘못된 흐름
시점 작업 결과
T1 Outbox 기록 OK
T2 Redis set() 성공 ✅
T3 DB Insert 실패 (Deadlock, Timeout 등) ❌
T4 Redis엔 데이터 남음 ❌ 일관성 깨짐
✅ 올바른 흐름
시점 작업 결과
T1 DB Insert + Outbox 기록 (같은 트랜잭션) ✅
T2 Commit 성공 → 데이터 확정 ✅
T3 Redis 반영 시도 성공 → Outbox 삭제
T4 Redis 실패 시 Outbox 남김 → 재시도
🔐 왜 이렇게 해야 “절대 실패 0%”가 가능한가

DB가 진실(Single Source of Truth)
→ DB 커밋이 성공한 것만 Redis에 반영하므로, 유령 데이터 없음.

Redis 실패 시에도 복원 가능
→ Outbox 테이블이 “복구 로그” 역할.

요청이 많아도 안전
→ DB 트랜잭션은 짧고, Redis 반영은 비동기 (Lock 없음).

일관성 모델 = Eventually Consistent but Never Divergent
→ 일시적으로 Redis 반영이 늦을 수는 있지만,
절대 DB/Redis가 서로 다른 상태로 영구 분리되지 않음.

🧠 그래서 실제 설계는 이렇게 구성해야 합니다
@Transactional
public void uploadDealer(Dealer dealer) {
// 1️⃣ DB 저장
dealerRepo.save(dealer);

// 2️⃣ Outbox 이벤트 기록
outboxRepo.save(new OutboxEvent("REDIS_SYNC", dealer.getId(), toJson(dealer)));
// ✅ 둘 다 같은 트랜잭션에 묶임

}

// 트랜잭션 커밋 후에 Redis 반영 (after commit)
@EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void syncToRedis(Dealer dealer) {
try {
redisTemplate.opsForValue().set("dealer:" + dealer.getId(), toJson(dealer));
outboxRepo.markProcessed(dealer.getId());
} catch (Exception e) {
log.warn("Redis 반영 실패. Outbox 유지. 다음 라운드 재시도 예정");
}
}

🔁 Redis 실패 시 자동 복구 루프
@Scheduled(fixedDelay = 30000)
public void retryRedisSync() {
List events = outboxRepo.findUnprocessed();
for (OutboxEvent e : events) {
try {
redisTemplate.opsForValue().set("dealer:" + e.getEntityId(), e.getPayload());
outboxRepo.markProcessed(e.getId());
} catch (Exception ex) {
// 여전히 실패면 다음 라운드에서 재시도
}
}
}

✅ 최종 요약
항목 잘못된 구조 정석 구조
DB/Redis 순서 Redis 먼저, DB 나중 DB 먼저, Redis 나중
원자성 없음 있음 (DB 트랜잭션 내 Outbox)
Redis 실패 시 복구 불가능 가능 (Outbox 재시도)
일관성 깨짐 (유령 데이터) 절대 깨지지 않음
부하 대응 불안정 안정적 (비동기 Redis 반영)

📘 요약 한 줄

❌ Redis → DB 순서는 “불일치 위험”
✅ DB → Outbox → Redis 순서로 해야 “절대 실패 없는 100% 일관성”을 달성할 수 있습니다.

원하신다면,
이 정석 구조를 실제 Spring Boot 예제 코드 (Entity + Repository + Scheduler + Redis Config 포함)
로 만들어드릴까요?
👉 딱 “딜러 업로드 요청 → DB + Redis 완전 일관성” 버전으로 바로 돌릴 수 있게요.

🧠 중요한 포인트 정리
장애 상황 결과
✅ 정상 DB + Redis + Outbox 삭제
💥 Redis 반영 중 예외 DB OK, Redis 미반영, Outbox 남음
💥 Spring 서버 다운 DB OK, Redis 미반영, Outbox 남음
💥 Redis 서버 다운 DB OK, Redis 미반영, Outbox 남음
💥 둘 다 다운 후 복구 Outbox 기반으로 Redis 자동 반영

profile
주니어 풀스택개발자

0개의 댓글