성능 최적화 3편 - Redis를 활용한 캐싱 전략
앞선 두 편에서는 N+1 문제를 Fetch Join과 @BatchSize로 해결하며 쿼리 횟수 자체를 줄이는 방법을 다뤘습니다. 이번 글에서는 한 단계 더 나아가, 자주 조회되지만 자주 바뀌지는 않는 데이터를 Redis로 캐싱해서 데이터베이스 조회 자체를 줄이는 방법을 정리합니다.
1. 캐싱이 필요한 시점
모든 데이터를 캐싱할 필요는 없습니다. 아래 조건에 해당할수록 캐싱 효과가 큽니다.
- 조회는 자주 되지만, 변경은 드문 데이터 (상품 카테고리, 공지사항, 설정값 등)
- 조회 쿼리 자체가 무거운 데이터 (여러 테이블 조인, 집계 연산이 포함된 통계성 조회)
- 같은 요청이 짧은 시간 안에 반복적으로 들어오는 API (인기글, 랭킹 등)
반대로 실시간성이 중요하거나 변경이 잦은 데이터(재고 수량, 결제 상태 등)는 캐싱 시 데이터 정합성 문제가 생기기 쉬워 신중하게 접근해야 합니다.
2. 의존성 및 기본 설정
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
entryTtl로 캐시 유효 시간을 지정하고, 값 직렬화는 JSON 형식(GenericJackson2JsonRedisSerializer)을 사용해 Redis에 저장된 값을 사람이 읽을 수 있는 형태로 관리했습니다.
3. @Cacheable로 조회 캐싱하기
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
@Cacheable(value = "categories", key = "#id")
public CategoryDto getCategory(Long id) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("카테고리를 찾을 수 없습니다."));
return CategoryDto.from(category);
}
}
처음 getCategory(1L)을 호출하면 데이터베이스를 조회해 결과를 Redis에 저장하고, 이후 동일한 id로 호출하면 데이터베이스를 거치지 않고 Redis에서 바로 값을 반환합니다.
1번째 호출 : DB 조회 -> Redis 저장 (느림)
2번째 호출 : Redis 조회 (빠름)
3번째 호출 : Redis 조회 (빠름)4. 데이터 변경 시 캐시 갱신하기
캐싱에서 가장 중요한 것은 데이터가 바뀌었을 때 캐시도 함께 갱신하는 것입니다. 이걸 놓치면 오래된 데이터를 계속 보여주는 문제가 생깁니다.
@CachePut(value = "categories", key = "#dto.id")
public CategoryDto updateCategory(CategoryUpdateDto dto) {
Category category = categoryRepository.findById(dto.getId())
.orElseThrow(() -> new EntityNotFoundException("카테고리를 찾을 수 없습니다."));
category.update(dto.getName());
return CategoryDto.from(category);
}
@CacheEvict(value = "categories", key = "#id")
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
}
@CachePut: 메서드를 실행하고, 반환값으로 캐시를 갱신합니다 (수정 시 사용)@CacheEvict: 캐시에서 해당 키를 제거합니다 (삭제 시 사용)
이렇게 조회는 @Cacheable, 수정은 @CachePut, 삭제는 @CacheEvict로 역할을 나누면 캐시와 실제 데이터 간의 정합성을 맞출 수 있습니다.
5. TTL 전략
캐시를 언제까지 유지할지도 중요한 설계 포인트입니다.
- TTL을 짧게 잡으면 데이터 정합성은 높아지지만, 캐시 적중률(Hit Rate)이 낮아져 캐싱 효과가 줄어듭니다.
- TTL을 길게 잡으면 캐싱 효과는 커지지만, 데이터가 실제로 변경된 뒤에도 한동안 오래된 값을 보여줄 수 있습니다.
변경이 거의 없는 데이터(카테고리, 배너 등)는 TTL을 길게, 변경 가능성이 있는 데이터는 TTL을 짧게 가져가거나 @CacheEvict로 명시적으로 무효화하는 방식을 함께 사용하는 것이 일반적입니다.
6. 캐시 적용 전후 비교
캐시 적용 전후로 응답 속도를 비교하면 효과를 체감하기 쉽습니다.
캐시 적용 전 : 평균 응답 속도 180ms (매 요청마다 DB 조회 + 조인)
캐시 적용 후 : 평균 응답 속도 12ms (Redis 조회)특히 조인이 여러 개 걸린 조회나, 트래픽이 몰리는 인기 API일수록 캐싱으로 인한 개선 폭이 크게 나타납니다.
7. 정리
- 자주 조회되고, 변경은 드문 데이터부터 캐싱 대상으로 선정한다
- 조회는
@Cacheable, 수정은@CachePut, 삭제는@CacheEvict로 캐시와 데이터 정합성을 함께 관리한다 - TTL은 데이터 특성에 맞게 설정하고, 정합성이 중요한 데이터는 명시적 무효화를 함께 고려한다
쿼리 자체를 최적화하는 것(Fetch Join, @BatchSize)과 조회 계층에 캐시를 두는 것(Redis)은 서로 다른 층위의 최적화입니다. 두 가지를 함께 적용하면 데이터베이스 부하를 훨씬 더 효과적으로 줄일 수 있습니다.
다음 글에서는 대량 데이터를 안정적으로 처리하는 방법으로, Spring Batch의 청크 지향 처리를 다뤄보겠습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 6 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기 (0) | 2026.07.06 |
|---|---|
| 성능 최적화 STEP 5 - 인덱스 설계와 튜닝으로 쿼리 속도 개선하기 (0) | 2026.07.06 |
| 성능 최적화 STEP 4 - Spring Batch 청크 지향 처리로 대량 데이터 안전하게 다루기 (0) | 2026.07.03 |
| 성능 최적화 STEP 2 - N+1 문제 해결 2편 - @BatchSize와 default_batch_fetch_size로 컬렉션 조회 최적화하기 (0) | 2026.07.02 |
| 성능 최적화 STEP 1 - JPA Fetch Join으로 쿼리 최적화하기 (0) | 2026.07.02 |