캐시는 자주 사용하는 데이터를 빠르게 접근할 수 있는 곳에 임시로 보관하는 저장소입니다.
예를 들어, 도서관에서 책을 찾는 상황을 생각해보겠습니다:
Spring Cache 추상화는 다양한 캐시 기술(EhCache, Redis, Caffeine 등)을 일관된 방식으로 사용할 수 있게 해주는 프레임워크입니다.
마치 USB 포트가 다양한 장치들을 동일한 방식으로 연결할 수 있게 해주는 것처럼, Spring Cache 추상화는 어떤 캐시 기술을 사용하더라도 동일한 코드로 캐시를 사용할 수 있게 해줍니다.
Spring은 캐시 기술에 대한 추상화 계층을 제공하여 다양한 캐시 구현체를 일관된 방식으로 사용할 수 있게 합니다.
// 캐시 활성화
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// 캐시 매니저 구현체 선택
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(Arrays.asList(
new ConcurrentMapCache("users"),
new ConcurrentMapCache("roles")
));
return cacheManager;
}
}
public interface CacheManager {
Cache getCache(String name); // 캐시 이름으로 Cache 객체 조회
Collection<String> getCacheNames(); // 등록된 캐시 이름 목록
}
public interface Cache {
ValueWrapper get(Object key); // 캐시에서 값 조회
void put(Object key, Object value); // 캐시에 값 저장
void evict(Object key); // 캐시에서 값 제거
void clear(); // 캐시 전체 삭제
}
Spring의 캐시 어노테이션은 AOP(Aspect-Oriented Programming)를 기반으로 동작합니다.
프록시 패턴을 사용하여 메서드 호출을 가로채고 캐시 관련 로직을 추가합니다.
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User findUser(Long id) {
// 캐시에 없을 경우에만 실행
return userRepository.findById(id).orElse(null);
}
}
메서드에 캐시를 적용하는 가장 기본적인 어노테이션입니다.
동작 원리:
1. 프록시가 메서드 호출을 인터셉트
2. KeyGenerator를 통해 캐시 키 생성
주요 속성:
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
// 항상 실행되고 결과를 캐시에 저장
return userRepository.save(user);
}
메서드 실행 결과를 강제로 캐시에 저장하는 어노테이션입니다.
동작 원리:
1. 무조건 메서드 실행
2. 실행 결과를 캐시에 저장
사용 시나리오:
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
// 캐시 전체 삭제
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
// 메서드 실행과 함께 users 캐시의 모든 엔트리 삭제
}
캐시 무효화를 위한 어노테이션입니다.
동작 원리:
1. beforeInvocation 설정에 따른 실행 시점 결정
캐시 삭제 전략:
@Cacheable(value = "users", key = "#root.target.prefix + #id")
public User findUser(Long id) {
// SpEL을 사용한 동적 키 생성
return userRepository.findById(id).orElse(null);
}
// 커스텀 키 생성기
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return target.getClass().getSimpleName() + "_" +
method.getName() + "_" +
StringUtils.arrayToDelimitedString(params, "_");
}
}
Spring은 기본적으로 SimpleKeyGenerator를 사용하여 캐시 키를 생성합니다:
커스텀 키 생성 시 고려사항:
@Configuration
@EnableCaching
public class LocalCacheConfig {
@Bean
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
cacheManager.setAllowNullValues(false);
return cacheManager;
}
}
Java의 ConcurrentHashMap을 기반으로 하는 로컬 캐시입니다.
동작 원리:
1. JVM 메모리 내에서 동작
성능 특성:
@Configuration
@EnableCaching
public class EhCacheConfig {
@Bean
public CacheManager cacheManager() {
ResourcePatternsResolver patternsResolver = new PathMatchingResourcePatternResolver();
Resource configLocation = patternsResolver.getResource("classpath:ehcache.xml");
EhCacheCacheManager cacheManager = new EhCacheCacheManager();
cacheManager.setCacheManager(
EhCacheManagerFactoryBean.create(configLocation)
);
return cacheManager;
}
}
Java에서 가장 널리 사용되는 오픈소스 로컬 캐시 라이브러리입니다.
주요 특징:
1. 다중 계층 저장소
구성 요소:
<ehcache>
<cache name="userCache"
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
memoryStoreEvictionPolicy="LRU">
</cache>
</ehcache>
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultConfiguration()
.entryTtl(Duration.ofMinutes(10)) // TTL 설정
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
분산 캐시 시스템으로, 높은 성능과 다양한 자료구조를 제공합니다.
아키텍처 특징:
데이터 저장 구조
분산 처리
성능 최적화:
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return cacheManager;
}
}
고성능 Java 캐시 라이브러리로, 최신 캐싱 알고리즘을 구현합니다.
핵심 메커니즘:
Window TinyLFU 알고리즘
적응형 캐시 크기 조정
성능 튜닝 옵션:
Caffeine.newBuilder()
.initialCapacity(100) // 초기 용량
.maximumSize(1000) // 최대 용량
.expireAfterWrite(10, TimeUnit.MINUTES) // 쓰기 후 만료
.expireAfterAccess(5, TimeUnit.MINUTES) // 접근 후 만료
.refreshAfterWrite(1, TimeUnit.MINUTES) // 쓰기 후 갱신
.recordStats() // 통계 기록
애플리케이션이 직접 캐시와 데이터 저장소를 제어하는 패턴입니다.
읽기 프로세스
쓰기 프로세스
@Service
public class UserService {
@Cacheable(value = "users", unless = "#result == null")
public User findUser(Long id) {
return userRepository.findById(id).orElse(null);
}
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
}
데이터를 쓸 때 캐시와 DB를 동시에 업데이트하는 패턴입니다.
쓰기 프로세스
읽기 프로세스
@Service
@Transactional
public class UserService {
@CachePut(value = "users", key = "#user.id")
public User saveUser(User user) {
// DB 저장과 캐시 업데이트가 동시에 발생
return userRepository.save(user);
}
@Cacheable(value = "users")
public User getUser(Long id) {
return userRepository.findById(id).orElse(null);
}
}
쓰기 작업을 지연시켜 일괄 처리하는 패턴입니다.
쓰기 프로세스
배치 프로세스
@Service
public class UserWriteBehindService {
private final Queue<User> writeQueue = new ConcurrentLinkedQueue<>();
@Scheduled(fixedRate = 5000)
public void processBatch() {
List<User> batch = new ArrayList<>();
User user;
while ((user = writeQueue.poll()) != null) {
batch.add(user);
}
if (!batch.isEmpty()) {
userRepository.saveAll(batch);
}
}
@CachePut(value = "users", key = "#user.id")
public User saveUser(User user) {
writeQueue.offer(user);
return user;
}
}
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultConfig()
.entryTtl(Duration.ofMinutes(30));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}
@Service
public class CacheEventService {
private final CacheManager cacheManager;
@EventListener
public void handleUserUpdateEvent(UserUpdateEvent event) {
cacheManager.getCache("users").evict(event.getUserId());
}
}
@Entity
public class User {
@Version
private Long version;
// 엔티티 필드
}
@Service
public class UserService {
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User findUser(Long id) {
return userRepository.findById(id).orElse(null);
}
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
// 버전 정보가 자동으로 업데이트되어 캐시됨
return userRepository.save(user);
}
}
이러한 패턴과 전략들은 시스템의 요구사항(성능, 일관성, 복잡도 등)에 따라 적절히 선택하여 사용해야 합니다.
Q: 캐시 적용 시 고려해야 할 사항들은 무엇인가요?
A: 캐시 적용 시 다음과 같은 사항들을 고려해야 합니다:
데이터 특성
시스템 요구사항
운영 관점
Q: Redis와 Local Cache의 차이점과 각각 어떤 상황에서 사용하면 좋은지 설명해주세요.
A: Redis와 Local Cache는 다음과 같은 차이점과 적용 상황을 가집니다:
Redis
특징
적합한 상황
Local Cache
특징
적합한 상황
Q: 분산 환경에서 캐시 일관성을 어떻게 유지하시나요?
A: 분산 환경에서의 캐시 일관성 유지 방법은 다음과 같습니다:
@Cacheable(value = "users", ttl = 3600)
public User getUser(Long id) {
return userRepository.findById(id);
}
@Service
public class CacheService {
// 메시지 큐를 통한 캐시 무효화 이벤트 전파
@EventListener
public void handleDataChangeEvent(DataChangeEvent event) {
cacheManager.getCache(event.getCacheName())
.evict(event.getKey());
}
}
@Transactional
public void updateUser(User user) {
// DB 업데이트
userRepository.save(user);
// 모든 서버의 캐시 동기화
cacheUpdateNotifier.notifyUpdate("users", user.getId());
}
Q: 캐시 적중률(Hit Ratio)을 높이기 위한 전략은 무엇인가요?
A: 캐시 적중률을 높이기 위한 전략들은 다음과 같습니다:
데이터 분석 기반 전략
캐시 정책 최적화
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// LRU 캐시 정책 사용
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats() // 캐시 통계 수집
.build();
}
}
@Scheduled(fixedRate = 3600000) // 1시간마다
public void preloadCache() {
List<String> hotKeys = analyzeHotData();
hotKeys.forEach(key -> cacheManager.getCache("data")
.put(key, dataService.getData(key)));
}
Q: 대규모 트래픽 환경에서 캐시 문제(Cache Stampede 등)를 어떻게 해결하시나요?
A: 대규모 트래픽에서의 캐시 문제 해결 방안:
@Cacheable(value = "data", key = "#key", sync = true)
public Data getData(String key) {
// sync=true로 동시 호출 방지
return repository.findByKey(key);
}
@PostConstruct
public void warmUpCache() {
List<String> essentialData = getEssentialDataKeys();
essentialData.parallelStream().forEach(key -> {
cacheManager.getCache("data").put(key, loadData(key));
});
}
public class LayeredCacheService {
private final Cache localCache; // L1 캐시
private final Cache redisCache; // L2 캐시
public Object get(String key) {
// L1 -> L2 -> DB 순으로 조회
Object value = localCache.get(key);
if (value == null) {
value = redisCache.get(key);
if (value != null) {
localCache.put(key, value);
}
}
return value;
}
}