[Spring] Redis를 활용한 캐싱

easyone·2026년 2월 25일

📚 NOOK 월별 통계 Redis 캐싱 도입기

캐싱 고민 배경

독서 집중 및 기록 서비스 NOOK에서는
홈 화면에 월별 독서 통계를 제공한다.

서재 월별 통계 API는 다음과 같은 특징을 가진다.

  • 포커스 기록은 로그 형태로 계속 쌓임 (삭제는 거의 없음)
  • 홈 진입 시마다 월별 통계 API 호출 가능
  • 월 통계는 group by, sum 집계 연산 필요
  • 현재 월은 계속 변경됨
  • 지난 월은 거의 변경되지 않음 (서재에서 책을 삭제하는 경우만 변경)

즉, 읽기 연산은 반복되고, 쓰기 연산은 비교적 적은 구조이다.

이 특성 때문에 단순 DB 조회만으로 운영하기에는
장기적으로 비효율적일 수 있다고 판단했다.

그래서 캐싱을 도입하기로 결정했고,
그 전에 캐싱 전략을 정리해보기로 했다.


캐싱 전략 조사

캐싱 전략은 대표적으로 다음과 같다.

Cache-Aside (Lazy Loading)

  • 조회 시 캐시 확인
  • 없으면 DB 조회 후 캐시에 저장
  • 데이터 변경 시 캐시 무효화

✔ 읽기 트래픽이 많고
✔ 데이터 변경이 자주 일어나지 않을 때 적합

→ 통계 API에 가장 일반적인 방식


Write-Through

  • DB 저장 시 캐시도 함께 저장
  • 조회는 항상 캐시에서만 수행

✔ 데이터 정합성이 매우 중요할 때
✔ 캐시 미스를 최소화하고 싶을 때 사용


Write-Back

  • Redis에 먼저 반영
  • 일정 시간 후 DB에 반영

✔ 실시간 랭킹, 조회수, 좋아요 카운트 등에 적합
✔ 트래픽이 매우 많을 때 사용


TTL 기반 캐싱

  • 일정 시간이 지나면 자동 만료
  • 가장 단순한 전략

NOOK의 월 통계는:

  • 실시간성보다 "빠른 조회"가 중요
  • 삭제/수정이 거의 없음
  • 집계 비용 존재

따라서 Cache-Aside 전략 + TTL + 이벤트 기반 무효화가 적합하다고 판단했다.


캐싱 도입 기준 점검

캐싱을 적용해도 되는 요구사항인지 먼저 확인했다.

다음 조건을 만족하면 캐싱을 적용하는 것이 좋다.

  • 데이터 변경이 자주 발생하지 않을 때
  • 조회 트래픽이 반복적으로 발생할 때
  • 집계 연산 비용이 존재할 때

NOOK의 월 통계는 위 조건을 만족한다.

특히 포커스 기록은 로그 형태로 쌓이는 구조이기 때문에
수정/삭제가 거의 없다는 점이 캐싱에 매우 유리하다.


로컬 캐시 vs 글로벌 캐시(Redis)

구분로컬 캐시Redis
저장 위치JVM 메모리외부 인메모리 서버
속도매우 빠름빠름
서버 여러 대불일치 발생 가능공유 가능
블루그린 배포캐시 초기화됨유지 가능

NOOK는 블루그린 배포 전략으로 갈 예정이었기 때문에
글로벌 캐시인 Redis를 선택했다.

이유:

  • 배포 시 캐시 유지 가능
  • 향후 서버 확장 대비 가능
  • 캐시 무효화 일관성 유지 가능

최종 캐싱 전략

전략 선택

  • Cache-Aside 적용
  • Redis 사용
  • 월에 따라 TTL 차등 적용
  • 이벤트 기반 캐시 무효화

현재 월

  • 포커스가 계속 추가됨
  • 자주 변경됨
  • TTL: 3~5분
  • 포커스 생성/삭제 시 해당 월만 evict
@Cacheable(
    value = "libraryMonthlyCurrent",
    key = "#userId + ':' + #yearMonth"
)

🔹 지난 월

  • 거의 변경 없음
  • 조회는 반복 가능
  • TTL: 1시간~24시간
  • 서재 삭제 시에만 evict

무효화 전략

  • 포커스 생성 / 삭제 / 수정 → 현재 월 캐시만 evict
  • 서재 삭제 → 해당 월(또는 전체 월) 캐시 evict

CacheManager 구성

캐시별 TTL을 다르게 설정하기 위해
RedisCacheManager를 커스터마이징했다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration defaultConfig =
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(5));

        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();

        cacheConfigs.put("libraryMonthlyCurrent",
                defaultConfig.entryTtl(Duration.ofMinutes(3)));

        cacheConfigs.put("libraryMonthlyPast",
                defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

집계 테이블 고민

트래픽이 증가할 경우를 대비해 집계 테이블을 도입하는 것도 고려했다.

  • 원본 로그 (focus)
  • 월 집계 테이블 (user_monthly_stats)
  • 일자별 책 집계 테이블 (user_daily_book_stats)

하지만 현재 트래픽 규모에서는
캐시 기반 집계로 충분하다고 판단하여 보류했다.

향후 트래픽 증가 시,

로컬 캐시+레디스 캐시를 혼합해서 사용하는 방식도 고려할 예정이다.


느낀 점

통계 관련 API를 개발하면서, 당연히 캐시를 도입해야겠지 라고만 생각하고 있었다.
그렇지만 생각보다 고려할 게 많았다.

  • 데이터 변경 빈도
  • 월별 데이터 특성
  • 배포 전략
  • 서버 확장 가능성
  • 무효화 정책

까지 고려해야 한다는 점을 알게 되었다.

profile
백엔드 개발자 지망 대학생

0개의 댓글