1. 캐시란?
데이터나 값을 미리 복사해두는 임시 저장소 라고 생각하면 된다.
클라이언트가 증가하면 DB에 대한 요청이 많아지고, 이로 인해 DB가 발생해 성능이 떨어질 수 있다.
이때, 서버와 데이터베이스 사이에 캐시를 두어,
주로 자주 사용하는 데이터를 캐시에 임시로 저장해 두면,
동일한 요청에 대해서 DB에 직접 접근하지 않고 빠르게 응답할 수 있어 성능이 향상된다.
🤔 이 프로젝트에서 왜 캐시를 사용하였는가?
이번 프로젝트에서는 인기 검색어 기능 구현해야 했다.
여러 사용자가 키워드를 입력하여 제품을 검색하면, 해당 검색어는 SearchLog 테이블에 저장된다.
이 테이블을 기반으로 사용자는 인기 검색어를 조회할 수 있다.
하지만 다수의 사용자가 동시에 인기 검색어를 요청하게 되면,
SearchLog 테이블에서 실시간으로 데이터를 집계해야 하기 때문에 DB에 과부하가 발생할 수 있다.
이는 전체 시스템 성능 저하로 이어질 수 있다.
따라서 이러한 문제를 해결하기 위해,
캐시를 적용하여 인기 검색어를 빠르게 조회할 수 있도록 설계하였다.
2. 캐시 설계 및 전략
초기에는 `@Cacheable(value = "popularKeywords", key = "#days")` 와 같은 단순한 방식으로 캐시를 적용하려고 하였다.
하지만 고민 끝에 의문점이 생겨났다.
사용자가 검색할 때마다 SearchLog 테이블에 새로운 로그가 계속 쌓일텐데,
시간이 지날 수록 인기 검색어는 바뀌지 않을까?
위와 같은 방식은 한 번 캐시에 저장된 인기 검색어 데이터를 TTL(Time To Live)이 만료되기 전까지 그대로 반환하게 된다.
이로 인해 변화되지 않는 인기 검색어를 계속 보여주는 문제가 발생할 수 있다.
따라서 단순한 `@Cacheable` 만으로는 부족하다는 생각이 들었고,
검색 시점에 count를 기록하고 주기적으로 DB에 반영하는 방식으로 개선하였다.
캐시 이름 | 역할 | TTL 설정 | 예시 |
keywordCountMap | 검색마다 카운트 누적 | X | "로션” → 1 증가 |
keywordKeySet | 검색마다 키워드 저장 (중복X) | X | [”로션”, ”크림”, …] |
popularKeywords | 인기 키워드 조회 | O | 인기 키워드 리스트 |
🔁 캐시 사용 흐름 이해하기
3. 캐시 설정하기
(1) Spring에서 @Cacheable만 쓰면 캐시가 되는 걸까?
Spring 자체에는 캐시 저장소가 없다.
Spring은 Cache, CacheManager 같은 추상화된 인터페이스만 제공할 뿐이며,
실제 데이터를 저장하거나 만료시키는 기능은 존재 하지 않는다.
따라서 `@Cacheable` 어노테이션만 사용한다고 자동으로 캐시가 적용되지 않는다.
실제로 어디에 어떻게 저장되고, 얼마나 유지할지는캐시 구현체 (Caffeine, Redis, EhCache 등)가 담당한다.
따라서 `@Cacheable`을 사용하려면 캐시 구현체를 등록하고 설정해주는 작업이 필요하다.
(2) 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
(3) CacheConfig 설정하기
package com.example.eightyage.global.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
// 키워드를 카운팅하는 캐시
CaffeineCache keywordCountMap = new CaffeineCache(
"keywordCountMap",
Caffeine.newBuilder()
.maximumSize(10000)
.build()
);
// 인기 검색어를 조회하는 캐시
CaffeineCache popularKeywords = new CaffeineCache(
"popularKeywords",
Caffeine.newBuilder()
.maximumSize(365) // days 값 기준으로 최대 365개
.expireAfterWrite(5, TimeUnit.MINUTES) // TTL 5분
.build()
);
// 현재 캐시에 저장된 키워드 목록
CaffeineCache keywordKeySet = new CaffeineCache(
"keywordKeySet",
Caffeine.newBuilder()
.maximumSize(1)
.build()
);
cacheManager.setCaches(Arrays.asList(keywordCountMap, popularKeywords, keywordKeySet));
return cacheManager;
}
}
@EnableCaching
Spring에서 캐시 기능을 활성화 하기 위한 어노테이션이다.
이 어노테이션을 사용하면, `@Cacheable`, `@CacheEvict`, `@CachePut` 등의 캐시 관련 어노테이션들이 동작할 수 있게 된다.
주로 @Configuration 클래스와 함께 사용되며,
CacheManager를 빈으로 등록하여 어떤 캐시 구현체(Caffeine, Redis 등)를 사용할지 설정한다.
CacheManager
CacheManager는 Spring에서 캐시를 관리하는 인터페이스로,
어떤 캐시 구현체(Caffeine, Redis 등)를 사용할지 정하고, 그 캐시들을 등록, 조회, 관리하는 역할을 한다.
CacheManager종류 | 캐시 저장 위치 | 설명 |
SimpleCacheManager | 메모리 상에 캐시 저장 | Spring에서 제공하는 단순 캐시 구현체 |
RedisCacheManager | Redis내 DB에 캐시 저장 | Redis 기반 캐시 구현체 |
CaffeineCacheManager | 메모리 상에 캐시 저장 | 구글에서 만든 Caffeine 기반 캐시 구현체 |
💡 왜 CaffeineCacheManager 대신 SimpleCacheManager를 사용했을까?
현재 작성한 CacheConfig는 실제 캐시 저장소인 Caffeine 라이브러리를 이용해 CaffeineCache 인스턴스를 직접 생성하고,
이들을 SimpleCacheManager에 수동으로 등록하여 캐시를 관리하는 방식이다.
CaffeineCacheManager를 사용하면 캐시 이름만으로 자동 생성이 가능하지만,
TTL 등 캐시마다 서로 다른 설정을 적용하기 어렵기 때문에,
직접 생성한 캐시를 SimpleCacheManager에 등록하는 방식을 선택했다.
4. Repository 구현
package com.example.eightyage.domain.search.repository;
import com.example.eightyage.domain.search.dto.PopularKeywordDto;
import com.example.eightyage.domain.search.entity.SearchLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface SearchLogRepository extends JpaRepository<SearchLog, Long> {
@Query("SELECT new com.example.eightyage.domain.search.dto.PopularKeywordDto(s.keyword, COUNT(s))" +
"FROM SearchLog s " +
"WHERE s.searchedAt >= :since " +
"GROUP BY s.keyword " +
"ORDER BY COUNT(s) DESC ")
List<PopularKeywordDto> findPopularKeywords(@Param("since") LocalDateTime since);
}
🤔 위의 방식으로 쿼리를 작성한 이유
SearchLog 엔티티에는 keyword와 searchedAt 필드가 존재한다.
쿼리 작성시 엔티티를 모두 조회하게 되면, 불필요한 필드까지 메모리에 로드된다.
필요한 데이터인 인기 키워드와 검색 수(count)만 가져오기 위해 DTO를 사용하였다.
이렇게 DTO를 직접 조회하면 정해진 필드만 선택적으로 조회할 수 있어,
속도도 빠르고 응답을 가볍게 처리할 수 있다.
5. Service 구현
(1) SearchService
package com.example.eightyage.domain.search.v2.service;
import com.example.eightyage.domain.search.entity.SearchLog;
import com.example.eightyage.domain.search.repository.SearchLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashSet;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class SearchServiceV2 {
private final SearchLogRepository searchLogRepository;
private final CacheManager cacheManager;
// 검색 키워드를 로그에 저장
@Transactional
public void saveSearchLog(String keyword) {
if (StringUtils.hasText(keyword)) {
searchLogRepository.save(SearchLog.of(keyword));
}
}
// 검색 시 키워드 카운트 증가
public void increaseKeywordCount(String keyword) {
if (!StringUtils.hasText(keyword)) return;
Cache countCache = cacheManager.getCache("keywordCountMap");
Cache keySetCache = cacheManager.getCache("keywordKeySet");
if (countCache != null) {
Long count = countCache.get(keyword, Long.class);
count = (count == null) ? 1L : count + 1;
countCache.put(keyword, count);
}
if (keySetCache != null) {
Set<String> keywordSet = keySetCache.get("keywords", Set.class);
if (keywordSet == null) {
keywordSet = new HashSet<>();
}
keywordSet.add(keyword);
keySetCache.put("keywords", keywordSet);
}
}
@Transactional
public void logAndCountKeyword(String keyword) {
saveSearchLog(keyword);
increaseKeywordCount(keyword);
}
}
SearchServiceV2는 사용자가 제품을 검색했을 때,
검색 키워드를 로그에 저장하고, 인기 검색어 통계를 위한 키워드 카운트를 캐시에 누적하는 역할을 한다.
1️⃣ 검색 키워드 로깅 (saveSearchLog)
사용자가 검색한 키워드를 DB의 SearchLog테이블에 저장한다.
이 기록을 통해 통계, 사용자 검색 패턴 분석 등에 활용할 수 있다.
2️⃣ 키워드 카운트 누적 (increaseKeywordCount)
keywordCountMap 캐시에서 검색한 키워드를 가져와 +1 씩 증가시키고,
keywordKeySet 캐시의 “keywords”키의 키워드 목록을 Set<String> 형태로 저장한다.
DB에 바로 저장하지 않고, 캐시에 임시 저장하였다가,
KeywordCountFlushService 의 `@Scheduled` 어노테이션을 이용하여 DB에 한번에 반영(flush)한다.
그러면 DB 부하를 줄이고 성능을 향상시킬 수 있다.
(2) KeywordCountFlushService
package com.example.eightyage.domain.search.v2.service;
import com.example.eightyage.domain.search.repository.KeywordCountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class KeywordCountFlushService {
private final CacheManager cacheManager;
private final KeywordCountRepository keywordCountRepository;
@Transactional
@Scheduled(fixedRate = 5 * 60 * 1000) // 5분마다 실행
public void flushKeywordCounts() {
Cache countCache = cacheManager.getCache("keywordCountMap");
Cache keySetCache = cacheManager.getCache("keywordKeySet");
if (countCache == null || keySetCache == null) {
log.warn("캐시를 찾을 수 없습니다.");
return;
}
try {
// 키 목록 가져오기
Set<String> keywordSet = keySetCache.get("keywords", Set.class);
if (keywordSet == null || keywordSet.isEmpty()) {
log.info("flush 할 키워드가 없습니다.");
return;
}
int flushed = 0;
// 반복문을 이용하여 저장하기
for (String keyword : keywordSet) {
Long count = countCache.get(keyword, Long.class);
if (count == null || count == 0L) continue;
keywordCountRepository.findById(keyword)
.ifPresentOrElse(
exist -> exist.updateCount(exist.getCount() + count),
() -> keywordCountRepository.save(new com.example.eightyage.domain.search.entity.KeywordCount(keyword, count))
);
flushed++;
countCache.evict(keyword);
}
keySetCache.put("keywords", new java.util.HashSet<>());
log.info("{}개의 키워드 플러시 성공", flushed);
} catch (Exception e) {
log.error("플러시 실패", e);
}
}
}
✅ 왜 keywordKeySet 캐시를 추가하게 되었는가?
초기에는 사용자가 검색한 키워드에 대해 keywordCountMap 캐시에 <키워드, 카운트> 형식으로 저장하였다.
이후 5분마다 실행되는 스케줄러에서 이 캐시를 순회하여 DB에 키워드별 카운트를 저장(flush) 하려고 했는데,
문제는 keywordCountMap 캐시에 저장된 모든 키를 가져오기가 쉽지 않았다
Caffeine 캐시는 내부 데이터를 조회할 수 있도록 asMap() 메소드를 제공하지만,
이를 사용하려면 Cache 인터페이스에서 nativeCache를 꺼낸 뒤,
Caffeine 캐시 구현체로 다운 캐스팅 해야 한다는 문제가 있었다.
이 방식은 Caffeine에 강하게 의존하므로,
추후 Redis나 다른 캐시 구현체로 변경하려 할 때, 유지 보수와 확장성이 어렵다.
✅ 해결 방법: keywordKeySet 캐시 도입
이 문제를 해결하기 위해 keywordCountMap 캐시에 어떤 키워드들이 저장되어 있는지 알 수 있도록
Set<String> 형태의 키 목록을 저장하는 keywordKeySet 캐시를 추가하였다.
사용자가 키워드를 검색하면,
keywordCountMap → 키워드별 검색 횟수가 누적
keywordKeySet → 중복 없이 키워드 목록 저장
스케줄러가 실행되면,
keywordKeySet에서 키워드 목록을 가져오고
해당 키워드 별로 keywordCountMap에서 count를 조회하여 DB에 저장한다.
이 구조는 Spring의 Cache 추상화 만으로 캐시 조작이 가능하기에
Caffeine 외 다른 구현체로 유연하게 전환이 가능하다.
// Cache.get() 메소드
<T> T get(@Nullable Object key, @Nullable Class<T> type);
캐시에서 key에 해당하는 값을 지정한 타입 T로 꺼내오는 메소드이다.
`Set<String> keywordSet = keySetCache.get("keywords", Set.class);`
keywords라는 키에 저장된 값을 Set 타입으로 가져오겠다는 의미이다.
만약 해당 키에 대한 값이 없으면 null 이 반환된다.
keywordKeySet 캐시에서 Set<String> 타입으로 키워드 목록을 가져온다.
이 키워드 목록을 반복문을 통해 순회하며,
각 keyword가 keywordCountMap 캐시에 가지고 있는 count 값을 꺼낸다.
그 후, keywordCountRepository의 findbyId()메소드를 이용하여 해당 키워드 DB에 존재하는지 확인한다.
→ 존재하면, 기존 DB에 저장된 count 캐시와 캐시의 count를 더하여 업데이트 한다.
→ 존재하지 않으면, 새롭게 keywordCount 엔티티를 생성하여 저장한다.
모든 키워드에 대한 처리가 완료되면,
keywordCountMap 캐시에서 각각의 키워드를 제거하고(evict)
keywordKeySet 캐시의 "keywords" 목록도 초기화한다.
(3) PopularKeywordService
package com.example.eightyage.domain.search.v2.service;
import com.example.eightyage.domain.search.dto.PopularKeywordDto;
import com.example.eightyage.domain.search.repository.SearchLogRepository;
import com.example.eightyage.global.exception.BadRequestException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PopularKeywordServiceV2 {
private final SearchLogRepository searchLogRepository;
//캐시O 인기 검색어 조회
@Transactional
@Cacheable(value = "popularKeywords", key = "#days")
public List<PopularKeywordDto> searchPopularKeywordsV2(int days) {
if (days < 1 || days > 365) {
throw new BadRequestException("조회 일 수는 1~365 사이여야 합니다.");
}
LocalDateTime since = LocalDateTime.now().minusDays(days);
return searchLogRepository.findPopularKeywords(since);
}
}
PopularKeywordServiceV2는 최근 일정 기간(days) 동안의 검색 로그 데이터를 기반으로,
인기 검색어 리스트를 반환하는 기능을 담당한다.
@Cacheable
Spring에서 제공하는 캐싱 기능의 어노테이션으로,
캐시 정보를 메모리 상에 저장하거나, 조회하는 기능을 수행할 때 사용한다.
속성 | 설명 |
value | 사용할 캐시 저장소의 이름을 지정 |
key | 캐시에서 값을 구분하는 기준 |
condition(선택) | 특정 조건일 때만 캐시를 사용할 수 있게 설정 |
unless(선택) | 결과값이 특정 조건일 때 캐시 저장을 생략 가능 |
6. Controller 구현
// 인기 검색어 조회 (캐시 O)
@GetMapping("/api/v2/search/popular")
public ResponseEntity<List<PopularKeywordDto>> searchPopularKeywordsV2(
@RequestParam(defaultValue = "7") int days
) {
return ResponseEntity.ok(popularKeywordService.searchPopularKeywordsV2(days));
}
7. 제품 검색 기능 구현
// 제품 검색 컨트롤러
@GetMapping("/api/v2/products")
public ResponseEntity<Page<ProductSearchResponse>> searchProduct(
@RequestParam(required = false) String name,
@RequestParam(required = false) Category category,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "1") int page
) {
return ResponseEntity.ok(productService.getProducts(name, category, size, page));
}
// 제품 검색 서비스
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Page<ProductSearchResponse> getProducts(String productName, Category category, int size, int page) {
int adjustedPage = Math.max(0, page - 1);
Pageable pageable = PageRequest.of(adjustedPage, size);
Page<FakeProduct> products = productRepository.findProducts(productName, category, pageable);
if (StringUtils.hasText(productName) && !products.isEmpty()) {
searchService.logAndCountKeyword(productName); // 로그 저장 + 키워드 캐시 작업
}
return products.map(ProductSearchResponse::from);
}
// 제품 검색 쿼리
@Query("SELECT p FROM FakeProduct p WHERE p.saleState = 'ON_SALE' " +
"AND (:category IS NULL OR p.category = :category) " +
"AND (:name IS NULL OR p.name LIKE CONCAT('%', :name, '%')) " +
"ORDER BY p.name")
Page<FakeProduct> findProducts(
@Param("name")String name,
@Param("category") Category category,
Pageable pageable
);
@Transactional(propagation = REQUIRES_NEW)
제품을 조회할 때 예외가 발생하여 트랜잭션 전체가 롤백되면,
제품 목록뿐 아니라 검색 로그 저장과 키워드 카운트 캐시 누적 작업도 함께 롤백된다.
이를 방지하기 위해,
제품 겸색 결과는 기존 트랜잭션에서 처리하고,
검색 로그 저장과 키워드 카운트 캐시 누적은 새로운 트랜잭션에서 처리하여
예외가 발생하더라도 로그 저장 및 캐시 누적은 독립적으로 커밋되도록 하였다.
8. 테스트
"앰플"을 1회 검색하였을 때,
로그 테이블에 앰플이 저장된 것을 확인할 수 있으며,
keywordCountMap 캐시에 앰플이 1개 저장된 것을 알 수 있다.
또한 keywordKeySet 캐시에 저장된 키워드가 앰플 뿐 인 것을 알 수 있다.
"앰플"을 1회 더 검색하였을 때,
로그 테이블에 검색 기록이 저장되었고,
keywordCountMap 캐시에 카운팅이 누적되어 2회로 찍혀있는 것을 알 수 있다.
또한 keywordKeySet 캐시에는 키워드가 누적되어 저장되지 않는다.
"로션"을 1회 검색하였을 때,
로그 테이블에 저장된 것을 확인할 수 있다.
또한, keywordCountMap 캐시에 로션이 1회 카운팅 되어 저장되어 있는 것을 확인 할 수 있고,
keywordKeySet 캐시에 로션이 추가된 것을 알 수 있다.
처음 인기 검색어를 조회 했을 때, 아래 결과로 인기 검색어가 출력된다.
"스킨"을 검색한 후 바로 인기 검색어를 조회해보면,
스킨의 카운팅 수는 여전히 11개 인 것을 확인할 수 있다.
5분 후에 다시 인기 검색어를 조회하면,
"스킨"의 카운팅이 12개로 증가한 것을 알 수 있다.