[Spring] Cache 동작 원리와 전략

슈퍼대디·2024년 12월 24일

CS면접대비

목록 보기
13/13

Spring Cache 동작 원리와 전략

목차

  1. 캐시 추상화 개념
  2. 캐시의 동작 방식
  3. 캐시 구현체
  4. 캐시 전략과 패턴
  5. 면접 예상 질문

1. 캐시 추상화 개념

캐시란?

캐시는 자주 사용하는 데이터를 빠르게 접근할 수 있는 곳에 임시로 보관하는 저장소입니다.

예를 들어, 도서관에서 책을 찾는 상황을 생각해보겠습니다:

  • 서고(데이터베이스): 모든 책이 보관되어 있지만, 찾으러 가는데 시간이 걸립니다.
  • 열람실 책장(캐시): 자주 보는 책들을 가까운 곳에 두어 빨리 접근할 수 있습니다.

Spring Cache 추상화가 필요한 이유

Spring Cache 추상화는 다양한 캐시 기술(EhCache, Redis, Caffeine 등)을 일관된 방식으로 사용할 수 있게 해주는 프레임워크입니다.

마치 USB 포트가 다양한 장치들을 동일한 방식으로 연결할 수 있게 해주는 것처럼, Spring Cache 추상화는 어떤 캐시 기술을 사용하더라도 동일한 코드로 캐시를 사용할 수 있게 해줍니다.

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;
    }
}

주요 인터페이스

  1. CacheManager
public interface CacheManager {
    Cache getCache(String name);  // 캐시 이름으로 Cache 객체 조회
    Collection<String> getCacheNames();  // 등록된 캐시 이름 목록
}
  1. Cache
public interface Cache {
    ValueWrapper get(Object key);  // 캐시에서 값 조회
    void put(Object key, Object value);  // 캐시에 값 저장
    void evict(Object key);  // 캐시에서 값 제거
    void clear();  // 캐시 전체 삭제
}

2. 캐시의 동작 방식

캐시 어노테이션의 내부 동작 원리

Spring의 캐시 어노테이션은 AOP(Aspect-Oriented Programming)를 기반으로 동작합니다.
프록시 패턴을 사용하여 메서드 호출을 가로채고 캐시 관련 로직을 추가합니다.

1. @Cacheable

@Service
public class UserService {
    @Cacheable(value = "users", key = "#id")
    public User findUser(Long id) {
        // 캐시에 없을 경우에만 실행
        return userRepository.findById(id).orElse(null);
    }
}

메서드에 캐시를 적용하는 가장 기본적인 어노테이션입니다.

동작 원리:
1. 프록시가 메서드 호출을 인터셉트
2. KeyGenerator를 통해 캐시 키 생성

  • SpEL 표현식 평가
  • 메서드 파라미터 분석
  1. CacheManager를 통해 캐시 조회
  2. Cache Hit/Miss 판단:
    • Hit: 캐시된 값 즉시 반환
    • Miss: 실제 메서드 실행 및 결과 캐싱

주요 속성:

  • value/cacheNames: 캐시 이름 지정
  • key: 캐시 키 생성 규칙 (SpEL 지원)
  • condition: 캐시 적용 조건
  • unless: 캐시 제외 조건
  • sync: 동기화 여부 (동시 호출 시 한 번만 실행)

2. @CachePut

@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    // 항상 실행되고 결과를 캐시에 저장
    return userRepository.save(user);
}

메서드 실행 결과를 강제로 캐시에 저장하는 어노테이션입니다.

동작 원리:
1. 무조건 메서드 실행
2. 실행 결과를 캐시에 저장

  • @Cacheable과 동일한 키 생성 메커니즘 사용
  • 기존 캐시 있어도 덮어쓰기
  1. 메서드 결과 반환

사용 시나리오:

  • 데이터 업데이트 후 캐시 갱신
  • 비동기 캐시 업데이트
  • 캐시 선행 로딩

3. @CacheEvict

@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

// 캐시 전체 삭제
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
    // 메서드 실행과 함께 users 캐시의 모든 엔트리 삭제
}

캐시 무효화를 위한 어노테이션입니다.

동작 원리:
1. beforeInvocation 설정에 따른 실행 시점 결정

  • true: 메서드 실행 전 캐시 삭제
  • false: 메서드 실행 성공 후 캐시 삭제
  1. allEntries 설정에 따른 삭제 범위 결정
    • true: 캐시 전체 삭제
    • false: 특정 키만 삭제

캐시 삭제 전략:

  • 선택적 삭제: key 속성으로 특정 항목만 삭제
  • 전체 삭제: allEntries=true로 캐시 전체 삭제
  • 조건부 삭제: condition 속성으로 삭제 조건 지정

캐시 키 생성 전략

@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를 사용하여 캐시 키를 생성합니다:

  1. 파라미터가 없는 경우: SimpleKey.EMPTY
  2. 파라미터가 하나인 경우: 해당 파라미터 값
  3. 여러 파라미터인 경우: SimpleKey(params)

커스텀 키 생성 시 고려사항:

  • 키의 고유성 보장
  • 키 생성 비용 최소화
  • 메모리 사용량 고려

3. 캐시 구현체

Local Cache (ConcurrentHashMap)

@Configuration
@EnableCaching
public class LocalCacheConfig {
    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        cacheManager.setAllowNullValues(false);
        return cacheManager;
    }
}

Java의 ConcurrentHashMap을 기반으로 하는 로컬 캐시입니다.

동작 원리:
1. JVM 메모리 내에서 동작

  • 힙 메모리에 직접 저장
  • 가비지 컬렉션의 대상
  1. Thread-Safe 보장
    • ConcurrentHashMap의 세그먼트 락킹 활용
    • 동시성 제어를 위한 별도 설정 불필요

성능 특성:

  • 접근 속도: O(1)
  • 메모리 제한: JVM 힙 크기에 종속
  • 동시성: 세그먼트 단위 락킹으로 높은 동시성 제공

EhCache

@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. 다중 계층 저장소

  • 메모리 캐시 (On-Heap, Off-Heap)
  • 디스크 캐시
  • 분산 캐시 지원
  1. 캐시 정책
    • LRU (Least Recently Used)
    • LFU (Least Frequently Used)
    • FIFO (First In First Out)

구성 요소:

<ehcache>
    <cache name="userCache"
           maxElementsInMemory="10000"
           eternal="false"
           timeToIdleSeconds="120"
           timeToLiveSeconds="120"
           memoryStoreEvictionPolicy="LRU">
    </cache>
</ehcache>

Redis Cache

@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();
    }
}

분산 캐시 시스템으로, 높은 성능과 다양한 자료구조를 제공합니다.
아키텍처 특징:

  1. 데이터 저장 구조

    • 인메모리 저장으로 고속 접근
    • 영속성 지원 (RDB, AOF)
    • 다양한 자료구조 (String, Hash, List, Set, Sorted Set)
  1. 분산 처리

    • Master-Slave 복제
    • Cluster 모드
    • Sentinel을 통한 고가용성
  1. 성능 최적화:

    • 직렬화/역직렬화 전략
    • 네트워크 타임아웃 설정
    • 커넥션 풀 관리

Caffeine Cache

@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 캐시 라이브러리로, 최신 캐싱 알고리즘을 구현합니다.
핵심 메커니즘:

  1. Window TinyLFU 알고리즘

    • 접근 빈도와 최근성 모두 고려
    • 제한된 메모리에서 최적의 캐시 효율
  1. 적응형 캐시 크기 조정

    • 실시간 히트율 모니터링
    • 자동 크기 최적화

성능 튜닝 옵션:

Caffeine.newBuilder()
    .initialCapacity(100)    // 초기 용량
    .maximumSize(1000)       // 최대 용량
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 쓰기 후 만료
    .expireAfterAccess(5, TimeUnit.MINUTES)  // 접근 후 만료
    .refreshAfterWrite(1, TimeUnit.MINUTES)  // 쓰기 후 갱신
    .recordStats()           // 통계 기록

4. 캐시 전략과 패턴

Cache-Aside Pattern (Lazy Loading)

개념과 동작 원리

애플리케이션이 직접 캐시와 데이터 저장소를 제어하는 패턴입니다.

  1. 읽기 프로세스

    • 캐시 확인
    • Cache Hit: 캐시에서 데이터 반환
    • Cache Miss: DB 조회 → 캐시 저장 → 데이터 반환
  2. 쓰기 프로세스

    • DB 업데이트 수행
    • 캐시 데이터 무효화 또는 업데이트

구현 예시

@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);
    }
}

장단점

  • 장점
    • 필요한 데이터만 캐시에 저장
    • 캐시 관리의 유연성
    • 구현이 상대적으로 단순
  • 단점
    • Cache Miss 시 지연 시간 발생
    • 데이터 일관성 관리 필요
    • 초기 접근 시 성능 저하

Write-Through Pattern

개념과 동작 원리

데이터를 쓸 때 캐시와 DB를 동시에 업데이트하는 패턴입니다.

  1. 쓰기 프로세스

    • DB 업데이트와 캐시 업데이트를 동시 수행
    • 트랜잭션 내에서 처리
  2. 읽기 프로세스

    • 항상 캐시에서 최신 데이터 조회 가능
    • Cache-Aside와 동일한 방식으로 동작

구현 예시

@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);
    }
}

장단점

  • 장점
    • 데이터 일관성 보장
    • 읽기 작업의 지연 시간 감소
    • 예측 가능한 성능
  • 단점
    • 쓰기 작업의 지연 시간 증가
    • 캐시 리소스 사용량 증가
    • 일부 불필요한 데이터도 캐시에 저장

Write-Behind Pattern (Write-Back)

개념과 동작 원리

쓰기 작업을 지연시켜 일괄 처리하는 패턴입니다.

  1. 쓰기 프로세스

    • 캐시만 즉시 업데이트
    • 쓰기 작업을 큐에 저장
  2. 배치 프로세스

    • 주기적으로 큐의 쓰기 작업 처리
    • DB 일괄 업데이트
    • 실패 처리 및 재시도 로직 포함

구현 예시

@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;
    }
}

장단점

  • 장점
    • 쓰기 작업 성능 향상
    • DB 부하 감소
    • 배치 처리를 통한 효율성
  • 단점
    • 데이터 유실 가능성
    • 구현 복잡도 증가
    • 즉시 일관성 보장 불가

캐시 동기화 전략

TTL(Time To Live) 기반

@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);
    }
}

이러한 패턴과 전략들은 시스템의 요구사항(성능, 일관성, 복잡도 등)에 따라 적절히 선택하여 사용해야 합니다.

5. 면접 예상 질문

Q: 캐시 적용 시 고려해야 할 사항들은 무엇인가요?
A: 캐시 적용 시 다음과 같은 사항들을 고려해야 합니다:

  1. 데이터 특성

    • 데이터 변경 빈도
    • 데이터 크기
    • 데이터 접근 패턴
  2. 시스템 요구사항

    • 일관성 요구 수준
    • 허용 가능한 지연 시간
    • 시스템 리소스 제약
  3. 운영 관점

    • 모니터링 방안
    • 장애 대응 전략
    • 캐시 갱신 정책

Q: Redis와 Local Cache의 차이점과 각각 어떤 상황에서 사용하면 좋은지 설명해주세요.
A: Redis와 Local Cache는 다음과 같은 차이점과 적용 상황을 가집니다:

  1. Redis

    • 특징

      • 독립적인 캐시 서버
      • 여러 서버에서 공유 가능
      • 영속성 지원
    • 적합한 상황

      • 마이크로서비스 아키텍처
      • 세션 클러스터링 필요 시
      • 대규모 데이터 캐싱
      • 서버 간 데이터 공유 필요 시
  2. Local Cache

    • 특징

      • JVM 메모리 내 저장
      • 매우 빠른 접근 속도
      • 서버마다 독립적인 캐시
    • 적합한 상황

      • 단일 서버 환경
      • 변경이 거의 없는 데이터
      • 빠른 응답 시간이 중요한 경우

Q: 분산 환경에서 캐시 일관성을 어떻게 유지하시나요?
A: 분산 환경에서의 캐시 일관성 유지 방법은 다음과 같습니다:

  1. TTL(Time To Live) 활용
@Cacheable(value = "users", ttl = 3600)
public User getUser(Long id) {
    return userRepository.findById(id);
}
  1. 이벤트 기반 동기화
@Service
public class CacheService {
    // 메시지 큐를 통한 캐시 무효화 이벤트 전파
    @EventListener
    public void handleDataChangeEvent(DataChangeEvent event) {
        cacheManager.getCache(event.getCacheName())
                   .evict(event.getKey());
    }
}
  1. Write-Through 패턴 구현
@Transactional
public void updateUser(User user) {
    // DB 업데이트
    userRepository.save(user);
    // 모든 서버의 캐시 동기화
    cacheUpdateNotifier.notifyUpdate("users", user.getId());
}

Q: 캐시 적중률(Hit Ratio)을 높이기 위한 전략은 무엇인가요?
A: 캐시 적중률을 높이기 위한 전략들은 다음과 같습니다:

  1. 데이터 분석 기반 전략

    • 접근 패턴 분석
    • 핫 데이터 식별
    • 적절한 캐시 크기 설정
  2. 캐시 정책 최적화

@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        // LRU 캐시 정책 사용
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats() // 캐시 통계 수집
            .build();
    }
}
  1. 선제적 캐시 로딩
@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: 대규모 트래픽에서의 캐시 문제 해결 방안:

  1. Cache Stampede 방지
@Cacheable(value = "data", key = "#key", sync = true)
public Data getData(String key) {
    // sync=true로 동시 호출 방지
    return repository.findByKey(key);
}
  1. Cache Warming
@PostConstruct
public void warmUpCache() {
    List<String> essentialData = getEssentialDataKeys();
    essentialData.parallelStream().forEach(key -> {
        cacheManager.getCache("data").put(key, loadData(key));
    });
}
  1. 계층형 캐시 구조
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;
    }
}

참고 자료

profile
성장하고싶은 Backend 개발자

0개의 댓글