데이터베이스 조회나 외부 API 호출과 같이 비용이 많이 드는 작업의 결과를 캐싱하면 애플리케이션 성능을 크게 향상시킬 수 있습니다. Spring은 강력한 캐싱 추상화를 제공하여 다양한 캐시 구현체를 일관된 방식으로 사용할 수 있게 해줍니다.

Spring Cache 기본 설정

캐싱 활성화

@EnableCaching 어노테이션으로 캐싱 기능을 활성화합니다:

@SpringBootApplication
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Kotlin 예시

@SpringBootApplication
@EnableCaching
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

의존성 추가

Spring Boot Starter Cache를 추가합니다:

Gradle:

implementation 'org.springframework.boot:spring-boot-starter-cache'

Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

핵심 어노테이션

@Cacheable

메서드 결과를 캐시에 저장합니다. 동일한 파라미터로 호출되면 캐시된 값을 반환합니다:

@Service
public class UserService {

    @Cacheable("users")
    public User findById(Long id) {
        // 이 메서드는 캐시에 없을 때만 실행됩니다
        return userRepository.findById(id).orElse(null);
    }
}

캐시 키 지정

// 특정 파라미터를 키로 사용
@Cacheable(value = "users", key = "#userId")
public User findUser(Long userId, String context) {
    return userRepository.findById(userId).orElse(null);
}

// 여러 파라미터 조합
@Cacheable(value = "products", key = "#category + '_' + #page")
public List<Product> findProducts(String category, int page) {
    return productRepository.findByCategory(category, PageRequest.of(page, 20));
}

// SpEL 표현식 사용
@Cacheable(value = "users", key = "#user.id")
public UserProfile getProfile(User user) {
    return profileService.loadProfile(user);
}

조건부 캐싱

// id가 100 이상인 경우에만 캐시
@Cacheable(value = "users", condition = "#id >= 100")
public User findById(Long id) {
    return userRepository.findById(id).orElse(null);
}

// 결과가 null이 아닌 경우에만 캐시
@Cacheable(value = "users", unless = "#result == null")
public User findByEmail(String email) {
    return userRepository.findByEmail(email).orElse(null);
}

@CacheEvict

캐시에서 데이터를 제거합니다:

@Service
public class UserService {

    // 특정 키 제거
    @CacheEvict(value = "users", key = "#user.id")
    public void updateUser(User user) {
        userRepository.save(user);
    }

    // 해당 캐시의 모든 데이터 제거
    @CacheEvict(value = "users", allEntries = true)
    public void clearAllUsersCache() {
        // 캐시만 비움
    }

    // 메서드 실행 전에 캐시 제거
    @CacheEvict(value = "users", key = "#id", beforeInvocation = true)
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

@CachePut

항상 메서드를 실행하고 결과를 캐시에 저장합니다:

@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}

@Cacheable과 달리 @CachePut은 항상 메서드를 실행합니다.

@Caching

여러 캐시 작업을 조합할 때 사용합니다:

@Caching(
    cacheable = @Cacheable(value = "users", key = "#id"),
    evict = @CacheEvict(value = "allUsers", allEntries = true)
)
public User findById(Long id) {
    return userRepository.findById(id).orElse(null);
}

@Caching(evict = {
    @CacheEvict(value = "users", key = "#user.id"),
    @CacheEvict(value = "userEmails", key = "#user.email"),
    @CacheEvict(value = "allUsers", allEntries = true)
})
public void deleteUser(User user) {
    userRepository.delete(user);
}

@CacheConfig

클래스 레벨에서 공통 캐시 설정을 지정합니다:

@Service
@CacheConfig(cacheNames = "users")
public class UserService {

    @Cacheable
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    @CacheEvict
    public void deleteById(Long id) {
        userRepository.deleteById(id);
    }
}

Repository에서 캐싱 사용

Spring Data JPA Repository에서도 캐싱을 적용할 수 있습니다:

interface DictionaryDao : JpaRepository<Dictionary, Int> {

    @Cacheable("dictionary")
    override fun findAll(): MutableList<Dictionary>

    @CacheEvict(value = "dictionary", allEntries = true)
    override fun <S : Dictionary?> save(entity: S): S

    @CacheEvict(value = "dictionary", allEntries = true)
    override fun <S : Dictionary?> saveAll(entities: MutableIterable<S>): MutableList<S>
}

캐시 구현체

기본 ConcurrentMapCache

별도 설정 없이 사용하면 ConcurrentHashMap 기반의 간단한 캐시가 사용됩니다:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(Arrays.asList(
            new ConcurrentMapCache("users"),
            new ConcurrentMapCache("products")
        ));
        return cacheManager;
    }
}

Caffeine Cache

고성능 인메모리 캐시:

의존성:

implementation 'com.github.ben-manes.caffeine:caffeine'

설정:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .maximumSize(1000)
            .recordStats());
        return cacheManager;
    }
}

또는 application.properties로 설정:

spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

Redis Cache

분산 환경에서 권장되는 캐시:

의존성:

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

설정:

spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379

# TTL 설정 (밀리초)
spring.cache.redis.time-to-live=600000

Java 설정:

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .disableCachingNullValues();

        // 캐시별 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("users", config.entryTtl(Duration.ofHours(1)));
        cacheConfigs.put("products", config.entryTtl(Duration.ofMinutes(30)));

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

캐시 통계 및 모니터링

Caffeine 통계

@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager();
    cacheManager.setCaffeine(Caffeine.newBuilder()
        .recordStats());  // 통계 활성화
    return cacheManager;
}

// 통계 조회
@Autowired
CacheManager cacheManager;

public void printStats() {
    CaffeineCache cache = (CaffeineCache) cacheManager.getCache("users");
    CacheStats stats = cache.getNativeCache().stats();

    System.out.println("Hit rate: " + stats.hitRate());
    System.out.println("Miss rate: " + stats.missRate());
    System.out.println("Eviction count: " + stats.evictionCount());
}

Actuator 연동

Spring Boot Actuator와 연동하여 캐시 메트릭을 노출할 수 있습니다:

management.endpoints.web.exposure.include=caches

실전 예제

서비스 레이어에서의 캐싱

@Service
@Slf4j
public class ProductService {

    private final ProductRepository productRepository;
    private final ExternalApiClient apiClient;

    @Cacheable(value = "products", key = "#id", unless = "#result == null")
    public Product findById(Long id) {
        log.info("Fetching product from database: {}", id);
        return productRepository.findById(id).orElse(null);
    }

    @Cacheable(value = "productsByCategory", key = "#category")
    public List<Product> findByCategory(String category) {
        log.info("Fetching products by category: {}", category);
        return productRepository.findByCategory(category);
    }

    @CachePut(value = "products", key = "#product.id")
    @CacheEvict(value = "productsByCategory", key = "#product.category")
    public Product save(Product product) {
        log.info("Saving product: {}", product.getId());
        return productRepository.save(product);
    }

    @Caching(evict = {
        @CacheEvict(value = "products", key = "#id"),
        @CacheEvict(value = "productsByCategory", allEntries = true)
    })
    public void delete(Long id) {
        log.info("Deleting product: {}", id);
        productRepository.deleteById(id);
    }

    // 외부 API 결과 캐싱
    @Cacheable(value = "externalData", key = "#query", unless = "#result.isEmpty()")
    public List<ExternalData> fetchExternalData(String query) {
        log.info("Calling external API: {}", query);
        return apiClient.search(query);
    }
}

캐시 갱신 스케줄러

@Component
@Slf4j
public class CacheRefreshScheduler {

    private final CacheManager cacheManager;
    private final ProductService productService;

    @Scheduled(fixedRate = 3600000)  // 1시간마다
    public void refreshPopularProductsCache() {
        log.info("Refreshing popular products cache...");

        Cache cache = cacheManager.getCache("products");
        if (cache != null) {
            // 인기 상품만 미리 캐싱
            List<Long> popularProductIds = getPopularProductIds();
            for (Long id : popularProductIds) {
                Product product = productService.findById(id);
                cache.put(id, product);
            }
        }
    }

    @Scheduled(cron = "0 0 4 * * *")  // 매일 새벽 4시
    public void evictAllCaches() {
        log.info("Evicting all caches...");
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                cache.clear();
            }
        });
    }
}

주의사항

1. Self-Invocation 문제

같은 클래스 내에서 캐시 메서드를 호출하면 AOP 프록시를 거치지 않아 캐싱이 동작하지 않습니다:

@Service
public class UserService {

    // 외부에서 호출 시에만 캐싱 동작
    @Cacheable("users")
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    public User getUser(Long id) {
        // 캐싱이 적용되지 않음!
        return this.findById(id);
    }
}

해결 방법:

@Service
public class UserService {

    @Autowired
    private ApplicationContext context;

    @Cacheable("users")
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    public User getUser(Long id) {
        // 프록시를 통해 호출
        return context.getBean(UserService.class).findById(id);
    }
}

2. Null 값 캐싱

기본적으로 null 값도 캐시됩니다. 이를 방지하려면:

@Cacheable(value = "users", unless = "#result == null")
public User findById(Long id) {
    return userRepository.findById(id).orElse(null);
}

3. 직렬화

Redis 등 외부 캐시 사용 시 객체는 직렬화 가능해야 합니다:

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    // ...
}

4. 캐시 키 충돌

여러 메서드에서 같은 캐시 이름을 사용할 때 키가 충돌할 수 있습니다:

// 문제: 같은 id 값이면 다른 엔티티라도 같은 키
@Cacheable("entities")
public User findUser(Long id) { }

@Cacheable("entities")
public Product findProduct(Long id) { }

// 해결: 캐시 이름 분리 또는 키에 타입 포함
@Cacheable(value = "entities", key = "'user_' + #id")
public User findUser(Long id) { }

결론

Spring Cache는 간단한 어노테이션으로 애플리케이션 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 개발 환경에서는 간단한 인메모리 캐시를, 운영 환경에서는 Redis와 같은 분산 캐시를 사용하세요. 캐시 무효화 전략을 잘 설계하여 데이터 일관성을 유지하는 것이 중요합니다.

참고 자료