티스토리 뷰
서비스 트래픽이 늘어나면 가장 먼저 부하를 받는 곳은 데이터베이스입니다. 동일한 데이터를 수천, 수만 번 반복 조회한다면, 이는 시스템 리소스를 낭비하고 사용자 응답 속도도 느려지게 됩니다.
이런 반복적이고 불필요한 DB 접근을 줄이기 위한 방법이 바로 캐시(Cache)입니다.
하지만 캐시는 무조건 도입한다고 좋은 것이 아닙니다.
- 어떤 데이터를 캐시할 것인가?
- 언제 캐시를 도입할 것인가?
- 로컬 캐시와 분산 캐시 중 무엇을 사용할 것인가?
- TTL은 어떻게 설정할 것인가?
이 글에서는 이러한 질문에 답하면서, 실무에 바로 적용 가능한 Spring 기반 캐싱 전략과 코드 예제를 단계별로 정리합니다.
캐시란?
캐시(Cache) 란, 자주 사용하는 데이터를 더 빠르게 접근할 수 있도록 임시로 저장해두는 저장소입니다. 일반적으로 메모리 기반 저장소 (예: JVM 내부 메모리, Redis, Memchached 등) 에 데이터를 저장해두고, 다음에 동일한 데이터를 요청할 때 메모리에서 데이터를 가져와 더 빠르게 응답할 수 있도록 하는 기술입니다.

캐싱을 언제 도입해야 하는가?
일반적으로 반복해서 조회하는 데이터는 캐싱처리를 하는게 좋습니다. 현재 트래픽이 적어서 충분히 클라이언트의 요청을 처리할 만 하더라도 매번 디스크 접근하는 방식은 트래픽이 몰리는 순간 서비스에 부정적인 영향을 미치게 됩니다. 따라서, 반복적으로 동일한 데이터를 조회하는 경우 캐싱처리를 고려해 볼 수 있습니다.

캐싱 전략
캐싱 전략이란 데이터를 언제, 어느 시점에 캐시에 저장하고 언제 캐시에서 가져올것인지를 정의한 방법입니다.
1. 읽기 전략
1) Cache-Aside(Lazy Loading)
- 애플리케이션이 캐시를 직접 관리하는 방식
- 데이터 요청 시 캐시 먼저 확인 -> 없으면 DB 조회 후 캐시에 저장
- 가장 많이 사용되는 패턴
- 초기 데이터 읽기 시 느림
2) Read-Through
- 캐시 계층에서 자동으로 DB에서 데이터를 가져오는 방식
- 애플리케이션은 캐시 계층에서만 데이터 조회
- App → Cache → (캐시 내부에서) DB 조회 → Cache → App
- Redis, Memcached 불가
3) Cache Warming
- 시스템 시작 전에 자주 접근할 데이터를 미리 캐시에 로드
2. 쓰기 전략
1) Write-Through
- 데이터 쓰기 시 캐시와 DB에 동시에 저장
- 데이터 일관성 유지에 유리하지만 쓰기 지연 발생
2) Write-Behind(Write-Back)
- 캐시에만 먼저 쓰고 비동기로 DB 반영
- 쓰기 성능은 좋지만 캐시 장애 시 데이터 손실 위험
3) Write-Around
- 데이터 쓰기 시 캐시를 거치지 않고 DB에만 직접 저장
- 주로 쓰기 후 캐시 무효화
4) Refresh-Ahead
- 캐시 만료 전에 미리 캐시를 갱신
주요 캐시 저장소
캐시 저장소의 선택은 서버가 어떻게 구성됐느냐에 따라 다릅니다. 단일 서버환경인 경우 Map과 같은 곳에 캐시를 저장할 수도 있습니다. 하지만 여러개의 서버를 운영한다면 일반적으로 Redis나 Memcached 같은 메모리 데이터베이스를 사용합니다.

로컬 캐시

분산 캐시
| 방식 | 설명 | 대표기술 |
|---|---|---|
| 로컬 캐시 | 애플리케이션 내 JVM 메모리에 저장 | Caffeine, Ehcache, Guava |
| 분산 캐시 | 여러 서버에서 공유 가능한 캐시 | Redis, Memcached |
로컬 캐시는 빠르지만 서버 간 동기화가 불가능하여 분산 환경에서는 캐시 불일치 문제가 발생할 수 있습니다.
캐싱 예제 코드
지금부터는 단일 서버 환경의 캐싱 처리, 다중 서버환경에서의 캐싱 처리 코드를 작성해 봅시다. Spring Cache를 사용하여 캐싱 전략을 구현한 예제입니다.
로컬 캐시
1. 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
2. application.yml 설정
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=1000,expireAfterWrite=10s
3. Java 코드 기반 설정
Java 기반 설정파일에서 @EnableCaching을 활용하여 캐시 기능을 활성화하고 CacheManager를 생성해준다. CaffeineCacheManager 생성 시 생성자 인자로 캐시 저장소의 이름을 넣는데, 한개 이상의 캐시 저장소 생성이 가능하다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("localCache");
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(10)));
return manager;
}
}
4. 캐시 사용 예제 (@Cacheable)
Caffein 도 Spring Cache를 기반으로 동작할 수 있기 때문에 다른바가 없다.
예제 코드
@Cacheable(cacheNames = "localCache", key = "'category'")
public List<Category> getAllCategoriesWithLocalCache() {
System.out.println("DB에서 조회"); // 로그 찍히면 캐시 안 된 것
return categoryRepository.findAll();
}
분산 캐시
1. 의존성 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
2. application.yml 설정
spring:
cache:
type: redis
redis:
host: localhost
port: 6379
3. Java 기반 코드 설정
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5)) // TTL 설정
.disableCachingNullValues(); // null 캐싱 방지
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
4. 캐시 사용 예제
@Cacheable(cacheNames = "localCache", key = "'category'")
public List<Category> getAllCategoriesWithRedis() {
System.out.println("DB에서 조회"); // 로그 찍히면 캐시 안 된 것
return categoryRepository.findAll();
}
TTL(Time To Live) 설정
TTL(Time To Live)은 캐시된 데이터가 얼마 동안 유지될지를 결정하는 시간 설정값으로, 캐시 일관성과 메모리 사용량 관리에 중요한 요소입니다. TTL이 너무 길면 오래 데이터로 인해 신뢰성이 저하될 수 있고, 너무 짧으면 캐시의 이점을 제대로 누릴 수 없습니다. 일반적으로 TTL을 설정하여 일정 시간이 지나면 자동으로 캐시가 만료되도록 설정합니다.
TTL 설정 예제
TTL은 일반적으로 Cache Manager에서 설정이 가능합니다.
Caffeine 예시
Caffeine을 사용하는 경우 application.yml에서도 설정이 가능합니다.
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("localCache");
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(30)) // TTL 설정
.maximumSize(1000));
return manager;
}
Redis 예시
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5)); // TTL 설정
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
캐시 정리 정책
캐시는 TTL(Time To Live) 외에도, 사용 패턴이나 용량 제한에 따라 데이터를 자동으로 제거하는 다양한 정리(Eviction) 정책을 사용할 수 있습니다.
| 전략 | 설명 |
|---|---|
| LRU(Least Recently Used) | 가장 오래 안 쓰인 캐시 제거 |
| LFU(Least Frequently Used) | 사용 빈도가 낮은 항목 제거 |
| FIFO(First In First Out) | 먼저 들어온 항목부터 제거 |
- Spring에서 Caffeine은 기본적으로 LRU 기반입니다.
- Redis는 maxmemory-policy 설정으로 eviction 방식을 바꿀 수 있습니다.
Redis 예시
# redis.conf 또는 docker run 시 옵션
maxmemory 100mb
# 메모리가 초과되는 경우 모든 키를 대상으로 LRU 적용
maxmemory-policy allkeys-lru
QPS에 따른 캐싱 전략 도입
QPS는 초당 처리되는 쿼리 수를 의미하며, 캐싱 대상 데이터를 선별할 때 참고할 수 있는 정략적 기준 중 하나입니다. QPS가 높다면 캐시 도입을 검토할 수 있는 신호점이 됩니다.
| QPS 수준 | 캐싱 필요 여부 | 설명 |
|---|---|---|
| ~100 QPS | ❌ 필요 없음 (쿼리 단순한 경우) | 기본 서버/DB 자원으로 충분히 커버 가능 |
| 100~1000 QPS | ✅ 핵심 데이터 캐싱 권장 | 예: 사용자 프로필, 공통 코드, 메뉴 등 |
| 1000~10000 QPS | ✅ Redis 등 외부 캐시 도입 필수 | 세션, API 응답, 쿼리 결과 등 캐싱 |
| 10000+ QPS | ✅ 멀티 계층 캐시 필요 | local+global, 캐시 무결성 관리까지 고려해야 |
QPS는 초당 실행된 전체 총 쿼리수를 의미하기 때문에, 실제 캐싱이 가능한 데이터를 찾기 위해서는 집계 정보나 애플리케이션으로 들어오는 요청 정보를 활용해야 합니다.
자주 실행되는 쿼리 찾기: PostgreSQL 기준
PostgreSQL에서는 pg_stat_statements 확장 모듈을 통해 쿼리별 호출 횟수와 평균 응답 시간 등을 확인할 수 있습니다.
-- pg_stat_statements 확장 모듈 필요
SELECT query,
calls, -- 쿼리 실행 횟수
total_time, -- 총 실행 시간(ms)
rows, -- 총 반환 행 수
(total_time / calls) AS avg_time -- 평균 실행 시간
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
이 쿼리는 PostgreSQL 인스턴스가 시작된 이후 누적된 집계를 보여줍니다.
최근 실행된 쿼리 통계를 확인하고 싶다면 아래와 같이 통계 초기화 후 일정 시간 후 다시 조회해서 확인해야 합니다.
-- 통계 초기화
SELECT pg_stat_statements_reset();
-- (10분 후 다시 조회)
SELECT query, calls, total_time, rows, (total_time / calls) AS avg_time
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
결과값
| query | calls | total_time | rows | avg_time |
|---|---|---|---|---|
SELECT * FROM users WHERE id=$1 |
5000 | 6000 ms | 5000 | 1.2 ms |
'라이브러리&프레임워크 > Spring' 카테고리의 다른 글
| API 개발 시 중복 요청을 고려해본 적이 있나요? (0) | 2025.11.23 |
|---|---|
| JPA 엔티티에서 Setter 대신 Builder 패턴을 사용해야 하는 이유 (0) | 2025.11.10 |
| Spring Cache 사용법 정리 (0) | 2025.07.27 |
| Spring Boot Metric 수집 및 시각화 방법 (Prometheus + Grafana) (0) | 2025.07.20 |
| 동시성 문제와 해결 전략 (비관적 락 vs 낙관적 락) (0) | 2025.05.10 |
