캐싱은 애플리케이션 성능을 향상시키는 중요한 기술로, 자주 사용하는 데이터를 메모리에 저장해 반복적인 데이터베이스 접근을 줄여주는 기법이다. 특히 검색 기능처럼 비용이 많이 들고 자주 호출되는 작업에 효과적이다.
캐싱은 자주 접근하는 데이터를 빠르게 조회할 수 있도록 별도의 저장소(메모리)에 복사해두는 기술이다.
데이터베이스 쿼리, API호출, 복잡한 연산 결과 등을 캐시에 저장함으로써 동일한 요청이 반복 될 때 원본 데이터 소스에 접근하지 않고 캐시에서 바로 결과를 반환할 수 있다.
데이터베이스 조회나 네트워크 요청은 비용이 많이 드는 작업이다. 캐싱은 이러한 작업의 결과를 메모리에 저장해 빠르게 접근할 수 있게 합니다.
동일한 요청이 여러 번 발생할 때 매번 데이터베이스를 조회하지 않아도 되므로 서버 부하가 감소한다.
사용자에게 더 빠른 응답을 제공할 수 있다.
클라우드 환경에서는 데이터베이스 접근이나 CPU 사용량이 비용과 직결되므로, 캐싱을 통해 비용을 절감할 수 있다.
장점 - 매우 빠른 접근 속도, 별도 설정 없이 사용 가능
단점 - 애플리케이션 재시작시 데이터 소실, 여러 서버 간 데이터 공유 불가
장점 - 여러 서버 간 데이터 공유 가능, 애플리케이션 재시작해도 데이터 유지
단점 - 추가 인프라 필요, 네트워크 지연 발생 가능
spring은 다양한 캐싱 공급자(Provider)에 대한 추상화 레이어를 제공해, 동일한 어노테이션으로 여러 캐싱 기술을 사용할 수 있게 한다.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache'
// Redis 캐싱을 사용할 경우
// implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
애플리케이션 클래스에 @EnableCaching 어노테이션을 추가해 캐싱 기능을 활성화합니다.
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Cacheable(value = "stores", key = "#keyword")
public List<Store> searchStores(String keyword, Pageable pageable) {
// 데이터베이스 조회 로직
}
@CachePut(value = "stores", key = "#store.id")
public Store updateStore(Store store) {
// 업데이트 로직
}
@CacheEvict(value = "stores", key = "#id")
public void deleteStore(Long id) {
// 삭제 로직
}
@Caching(evict = {
@CacheEvict(value = "storesByName", allEntries = true),
@CacheEvict(value = "storesByLocation", allEntries = true)
})
public void clearAllCaches() {
// 캐시 초기화 로직
}
캐시 키는 캐시 된 데이터를 식별하는 고유 값으로, SpEL(Spring Expressiin Language)을 사용해 동적으로 생성할 수 있습니다.
@Cacheable(value = "searchResults", key = "#keyword + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
public Page<Item> search(String keyword, Pageable pageable) {
// 검색 로직
}
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<Cache> caches = new ArrayList<>();
// 캐시 추가 (여러 개 추가 가능)
caches.add(new ConcurrentMapCache("searchResults"));
caches.add(new ConcurrentMapCache("popularKeywords"));
cacheManager.setCaches(caches);
return cacheManager;
}
}
검색 API에 캐싱을 적용하는 주요 이유는 다음과 같다.
사용자들은 종종 동일한 키워드로 반복해서 검색합니다.
검색은 LIKE 연산자를 사용해 데이터베이스 전체를 스캔해야 할 수 있어 비용이 많이 듭니다. 특히 데이터가 많을수록 이 비용은 증가합니다.
인기 있는 검색어는 많은 사용자가 동일한 검색을 수행하므로, 이 결과를 캐싱하면 서버 부하를 크게 줄일 수 있습니다.
사용자가 검색 결과의 다음 페이지를 요청할 때, 동일한 검색을 다시 수행하는 대신 캐시된 결과에서 페이지만 변경하여 반환할 수 있습니다.
데이터가 변경되면 캐시도 업데이트해야 합니다. @CacheEvict나 @CachePut을 사용해 데이터 변경 시 관련 캐시를 제거하거나 업데이트하기
캐시 된 데이터가 너무 오래 유지되지 않도록 TTL을 설정하는 것이 좋다. 특히 실시간 데이터나 자주 변경되는 데이터에 중요하다.
In-memory캐시는 애플리케이션 메모리를 사용하므로, 너무 많은 데이터를 캐싱하지 않도록 주의해야한다.
효과적인 캐시 키 설계는 중요하다. 검색어, 페이지 번호, 정렬 조건 등 결과에 영향을 주는 모든 요소를 키에 포함시켜야 한다.
Local Memory Cache에서 Redis로 확장하려면 다음과 같은 설정이 필요하다
@Configuration
public class RedisCacheConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
return lettuceConnectionFactory;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // TTL 설정
.disableCachingNullValues() // null 값 캐싱 방지
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}