CustomCache - 1탄

Choi Wang Gyu·2025년 5월 6일

문제 상황

현재 우리 회사는 Redis 클러스터 모드를 사용하지 않고 있습니다.
이러한 상황에서 Redis가 장애가 나면 어떻게 대응할 것인지 고민하게 되었고, 성능 또한 함께 개선하고 싶었습니다.

들어가기 전에 용어 정리:

  • L1 = Local Cache (Caffeine 등 JVM 내 캐시)
  • L2 = Global Cache (Redis)
    환경상 다른 MQ(Kafka 등)는 사용할 수 없고, Redis만으로 해결해야 했습니다.

해결 방법

그래서 Local Cache(L1) 도입을 결정했고, 도입 전에 다양한 시나리오를 바탕으로 구조를 설계해보았습니다.

💡 생각 1: Redis 장애 대비 - L1 활용 전략

Redis(L2)에 장애가 생겼을 경우, DB로 바로 가지 않고 JVM 내 L1(Local Cache) 를 통해 1차적으로 버틴다는 전략입니다.
하지만 L1은 JVM의 힙 메모리를 사용하는 만큼, 사이즈가 큰 데이터를 무분별하게 저장하면 OOM 위험이 있습니다.
따라서 다음과 같은 정책을 고려했습니다:

데이터 크기처리 방식
소용량 데이터L1에 저장하여 빠르게 재사용
대용량 데이터L1에는 저장하지 않고, DB에서 직접 조회 후 필요 시 L2(Redis)에만 저장

⚠️ "사이즈가 크다"의 기준은 테스트와 JVM 힙 사용량에 따라 조정해야 합니다.

캐시 동작 흐름

1. L2(Redis) 캐시 조회

2. Redis 장애 발생 시:
   ├─ 2-1. L1 캐시 조회 (Caffeine)
   │     ├─ Hit: 결과 리턴
   │     └─ Miss: DB 조회
   │               ├─ 데이터 작음: L1 저장 후 리턴
   │               └─ 데이터 큼: 저장 없이 리턴

📌 생각 1 - 장점 정리

장점설명
🧹 L1에 대용량 데이터 미저장GC/OOM 리스크 차단
🚀 L1에서 빠른 조회성능 개선
📉 L2/DB만 대용량 처리안정성 확보
💰 Redis 호출 감소ElastiCache 비용 절감 효과

❗ 생각 1 - 문제점: Cache Cascade Failure

"Cache Cascade Failure"란 한 계층이 터지면 다음 계층이 부하를 받다가 함께 터지는 현상입니다.

  1. 평소에는:
    • 작은 데이터 → L1(Caffeine)
    • 큰 데이터 → L2(Redis)
  2. Redis 장애 발생
  3. L1은 큰 데이터를 저장 안 하므로 → 곧바로 DB 조회
  4. 수천 RPS가 몰리면? → DB 커넥션 부족 → Lock/SQ 병목 → 결국 DB 다운

해결 방법

  • 방법 1: 큰 데이터도 L1에 짧게 저장 (임시 생존용)
  • 방법 2: DB Fallback 시 Circuit Breaker 또는 Rate Limiter 적용

💬 개인적인 생각

개인적으로는 방법 2가 이상적이지만, 방법 1이 현실적인 선택이라고 생각합니다.
Scale-out 환경에서는 L1 정합성 유지가 어렵습니다.
그 이유는 L1 동기화를 위해 Redis Pub/Sub을 사용하는데, Redis가 장애를 일으키면 동기화 자체가 불가능하기 때문입니다.

따라서 L1에는 짧은 TTL(Time To Live) 을 설정하여 최소한의 생존 용도로만 활용하는 것이 좋다고 판단했습니다.
더 나아가면 Kafka 등 MQ로 Pub/Sub을 대체하는 것이 최선이지만,
현재 환경에서는 Redis 외 대안이 없으므로, 장애 상황에는 AWS SNS 같은 서버리스 기반으로 비용까지 절감할 수 있는 구조가 더 나은 방향이라고 봅니다.

물론 이 모든 구조는 향후 유지보수자가 관리 가능해야 한다는 점도 함께 고려해야 합니다.

✅ 최종 결정 흐름

[요청 발생]
     │
     ▼
[1. L1(Local Cache) 조회]
     │
     ├─▶ Hit → ✅ 응답 리턴
     │
     ▼ Miss
[2. L2(Redis) 조회]
     │
     ├─▶ 성공 → L1에 put → ✅ 응답 리턴
     │
     ▼ Miss or 실패 (CircuitBreaker)
[3. DB 조회 수행]
     │
     ├─ 결과 있음
     │     ├─ L1에 put
     │     ├─ L2에 저장
     │     └─ ✅ 응답 리턴
     │
     └─ 결과 없음 → null 리턴

🛠️ 구현: SmartCache

@SmartCacheable이라는 이름의 AOP 기반 커스텀 어노테이션을 활용해, 위의 로직을 선언형으로 적용했습니다.
이 어노테이션이 붙은 메서드는 자동으로 L1 → L2 → DB 흐름을 따릅니다.

🔗 구현 코드 보기

@SmartCacheable(cacheName = "board", key = "#id", ttlSeconds = 300)
public BoardResponse.Get getById(Long id){
    BoardEntity board = boardRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다."));
    return new BoardResponse.Get(board.getId(), board.getContent(), board.getCreatedAt(), board.getUpdatedAt());
}

🧭 회고

이 방식은 강력하지만, 단점도 존재합니다.

  • 기존 코드에 어노테이션을 붙이거나 @CacheEvict 같은 추가 구현이 필요함
  • 정합성, TTL 전략 등을 잘못 설정하면 오히려 더 복잡해질 수 있음

그래서 2탄에서는 Spring Cache 기반 구조로 코드 변경 없이 적용하는 방식을 소개할 예정입니다.
보다 실용적이고 유지보수 가능한 방향을 함께 고민해보려 합니다.

📁 구현 자료

GitHub: https://github.com/cwangg897/smart

TMI: 글의 내용은 모두 제 생각이며, 디자인이나 맞춤법 검사는 GPT의 도움을 받았습니다.

0개의 댓글