DB

Redis 캐시 동기화 문제와 해결 전략 - 일관성, Race Condition, 분산 락 가이드와 실무에서 겪은 이슈와 대응 전략

code2772 2025. 5. 16. 14:22
728x90
반응형

 

1. Redis와 캐시의 기본 개념

 

캐시(Cache)란?

 

캐시는 자주 사용되는 데이터를 빠르게 접근할 수 있는 임시 저장소입니다. 다음과 같은 장점이 있습니다:

  • 응답 시간 단축으로 사용자 경험 향상
  • 데이터베이스와 백엔드 서비스의 부하 감소
  • 네트워크 트래픽 절감

 

Redis란?

 

Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 주요 특징은 다음과 같습니다:

  • 인메모리 저장으로 빠른 읽기/쓰기 성능 제공
  • 문자열, 해시, 리스트 등 다양한 데이터 구조 지원
  • 선택적 디스크 저장 가능(RDB 스냅샷, AOF 로그)
  • 복제 및 고가용성 지원(마스터-슬레이브, Sentinel, Cluster)
  • 원자적 연산 처리 가능

 

2. 캐시 전략의 기본 패턴

1. Cache-Aside (Lazy Loading)

 

가장 일반적인 패턴으로, 데이터가 필요할 때만 캐시에 로드합니다.

동작 방식:

  • 캐시에서 데이터 조회
  • 캐시에 없으면(Cache Miss) 데이터 소스에서 데이터 가져옴
  • 가져온 데이터를 캐시에 저장하고 반환
public Data getData(String key) {
    // 1. 캐시에서 조회
    Data cachedData = cache.get(key);
    if (cachedData != null) {
        return cachedData;  // 캐시 히트
    }

    // 2. 캐시에 없으면 DB에서 조회
    Data data = database.get(key);

    // 3. DB에서 조회한 데이터를 캐시에 저장
    if (data != null) {
        cache.put(key, data);
    }

    return data;
}

 

장점: 필요한 데이터만 캐싱하여 메모리를 효율적으로 사용

단점: 초기 요청 시 Cache Miss로 지연 발생 가능

 

 

2. Write-Through

 

데이터를 DB에 쓸 때 동시에 캐시에도 기록하는 패턴입니다.

동작 방식:

  • 데이터를 DB에 쓰기 전에 먼저 캐시에 씀
  • 캐시 쓰기가 성공하면 DB에 씀
public void saveData(String key, Data data) {
    // 1. 캐시에 데이터 저장
    cache.put(key, data);

    // 2. DB에 데이터 저장
    database.save(key, data);
}

장점: 캐시와 DB가 항상 동기화됨

단점: 모든 쓰기 작업이 두 번 발생하여 지연 시간 증가

 

3. Write-Behind (Write-Back)

 

데이터를 캐시에 먼저 쓰고, 나중에 비동기적으로 DB에 기록합니다.

동작 방식:

  • 데이터를 캐시에만 씀
  • 일정 시간 후 또는 특정 조건 충족 시 DB에 기록
public void saveData(String key, Data data) {
    // 1. 캐시에 데이터 저장
    cache.put(key, data);

    // 2. 비동기로 DB 저장 작업 큐에 추가
    writeQueue.add(new WriteTask(key, data));
}

// 백그라운드 스레드에서 실행
public void processPendingWrites() {
    while (true) {
        WriteTask task = writeQueue.take();  // 큐에서 작업 가져오기
        database.save(task.getKey(), task.getData());
    }
}

장점: 쓰기 작업이 빠르고, 대량의 쓰기를 일괄 처리하여 DB 부하 감소

단점: 캐시와 DB 간 일시적 불일치 가능성, 캐시 서버 장애 시 데이터 손실 위험

 

4. 캐시 제거 정책(Cache Eviction Policies)

 

캐시 메모리가 부족할 때 어떤 데이터를 제거할지 결정하는 정책입니다.

주요 정책:

  • LRU (Least Recently Used): 가장 오래 사용되지 않은 항목 제거
  • LFU (Least Frequently Used): 가장 적게 사용된 항목 제거
  • FIFO (First In First Out): 가장 먼저 들어온 항목 제거
  • TTL (Time To Live): 특정 시간이 지난 항목 제거

Redis에서는 maxmemory-policy 설정을 통해 다양한 제거 정책을 지정할 수 있습니다.

 

 

3. 캐시 사용 시 주요 문제점

1. 캐시 일관성(Cache Consistency)

캐시와 데이터베이스 간 데이터 일치 정도를 의미합니다.

 

일관성 유형:

  • 강한 일관성: 데이터 변경 후 모든 후속 읽기가 새 값을 반환
  • 최종 일관성: 일정 시간 후 모든 읽기가 최신 값을 반환
  • 약한 일관성: 일부 읽기는 오래된 값을 반환할 수 있음

 

2. 캐시 무효화(Cache Invalidation)

캐시된 데이터가 더 이상 유효하지 않을 때 제거하는 과정입니다.

 

무효화 전략:

  • 명시적 무효화: 데이터 변경 시 관련 캐시를 직접 삭제
  • TTL 기반 무효화: 일정 시간 후 자동 삭제
  • 이벤트 기반 무효화: 데이터 변경 이벤트 발생 시 캐시 갱신

 

3. 동시성 문제(Concurrency Issues)

여러 스레드나 프로세스가 동시에 같은 캐시 데이터에 접근할 때 발생하는 문제입니다.

 

주요 동시성 문제:

  • Race Condition: 여러 프로세스의 동시 수정으로 인한 충돌
  • Dirty Read: 커밋되지 않은 변경사항을 다른 프로세스가 읽는 현상
  • Lost Update: 한 프로세스의 업데이트가 다른 프로세스에 의해 덮어쓰이는 현상

 

4. 캐시 폭주(Cache Stampede)

TTL이 만료되는 시점에 여러 요청이 동시에 데이터 소스를 조회하는 현상입니다.

 

완화 방법:

  • Jitter 추가: TTL에 무작위성 추가
  • Background Refresh: TTL 만료 전에 백그라운드에서 캐시 갱신
  • Lock 사용: 첫 번째 요청만 DB를 조회하도록 함

 

5. 캐시 오염(Cache Pollution)

자주 사용되지 않는 데이터가 캐시를 차지하여 효율성을 떨어뜨리는 현상입니다.

 

방지 방법:

  • 적절한 TTL 설정: 자주 사용되지 않는 데이터는 짧은 TTL 적용
  • 캐시 제거 정책 최적화: 애플리케이션 특성에 맞는 정책 선택
  • 캐시 크기 제한: 메모리 사용량 제한 설정

 

6. 콜드 스타트(Cold Start)

서버 재시작이나 캐시 클리어 후 캐시가 비어있는 상태에서 시작하는 현상입니다.

대응 방법:

  • 워밍업 스크립트: 서버 시작 시 주요 데이터 미리 캐싱
  • 점진적 롤아웃: 서버를 순차적으로 재시작
  • 영구 캐시 사용: Redis의 영속성 기능 활용

 

7. 캐시 침투(Cache Penetration)

존재하지 않는 데이터를 지속적으로 요청하여 캐시와 DB에 부하를 주는 현상입니다.

 

방지 방법:

  • 부정 캐싱(Negative Caching): 존재하지 않는 결과도 캐싱
  • Bloom Filter: 존재하지 않는 키를 확률적으로 필터링
  • 요청 제한(Rate Limiting): API 요청 횟수 제한
// 부정 캐싱 예시
public Product getProduct(Long productId) {
    String key = "product:" + productId;

    // 캐시에서 데이터 조회
    Object cached = redisTemplate.opsForValue().get(key);

    // 존재하지 않는다고 이미 캐싱된 경우
    if (cached != null && cached.equals("NOT_FOUND")) {
        return null;
    }

    // 캐시에서 찾은 경우
    if (cached != null && cached instanceof Product) {
        return (Product) cached;
    }

    // DB에서 조회
    Optional<Product> productOpt = productRepository.findById(productId);

    if (productOpt.isPresent()) {
        Product product = productOpt.get();
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
        return product;
    } else {
        // 존재하지 않는 결과도 짧은 시간(5분) 캐싱
        redisTemplate.opsForValue().set(key, "NOT_FOUND", Duration.ofMinutes(5));
        return null;
    }
}

 

 

4. Redis 캐시 사용 시 자주 사용되는 패턴들

1. 다중 레벨 캐싱(Multi-Level Caching)

여러 계층의 캐시를 사용하여 성능과 비용 효율성을 높이는 패턴입니다.

 

일반적인 구성:

  • L1 캐시: 애플리케이션 내 로컬 메모리 캐시 (Caffeine)
  • L2 캐시: 분산 캐시 (Redis)
  • L3 저장소: 원본 데이터 소스 (MySQL)
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        // L1 캐시: Caffeine 로컬 캐시
        CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
        caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(Duration.ofSeconds(30)));

        // L2 캐시: Redis 분산 캐시
        RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)))
            .build();

        // 다중 캐시 매니저
        return new CompositeCacheManager(caffeineCacheManager, redisCacheManager);
    }
}

장점:

  • 더 빠른 응답 시간 (로컬 캐시는 네트워크 호출 없음)
  • Redis 부하 감소
  • 서비스 복원력 향상 (Redis 장애 시에도 로컬 캐시 사용 가능)

 

2. 캐시 프리페칭(Cache Prefetching)

 

예상되는 데이터를 미리 캐시에 로드하는 패턴입니다.

@Scheduled(fixedRate = 60000)  // 1분마다 실행
public void prefetchPopularProducts() {
    List<Long> popularProductIds = analyticService.getTop100PopularProductIds();
    for (Long productId : popularProductIds) {
        Product product = productRepository.findById(productId).orElse(null);
        if (product != null) {
            String key = "product:" + productId;
            redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
        }
    }
    log.info("Prefetched {} popular products to cache", popularProductIds.size());
}

사용 사례:

  • 아침 피크 시간 전에 인기 상품 미리 캐싱
  • 사용자 로그인 후 예상되는 다음 요청 데이터 미리 캐싱
  • 계절성 이벤트 전에 관련 데이터 미리 캐싱

 

3. 캐시 버스팅(Cache Busting)

리소스 URL에 버전이나 해시를 추가하여 캐시를 관리하는 패턴입니다.

public String getProductCacheKey(Long productId, Long version) {
    return "product:" + productId + ":v" + version;
}

public void updateProduct(Product product) {
    // 버전 업데이트
    product.setVersion(product.getVersion() + 1);
    productRepository.save(product);

    // 새 버전으로 캐시 업데이트
    String key = getProductCacheKey(product.getId(), product.getVersion());
    redisTemplate.opsForValue().set(key, product, Duration.ofHours(24));

    // 버전 정보 저장
    redisTemplate.opsForValue().set("product:" + product.getId() + ":latest_version", product.getVersion());
}

public Product getProduct(Long productId) {
    // 최신 버전 확인
    Long latestVersion = redisTemplate.opsForValue().get("product:" + productId + ":latest_version");
    if (latestVersion == null) {
        // 버전 정보가 없으면 DB에서 조회
        Product product = productRepository.findById(productId).orElse(null);
        if (product != null) {
            updateProduct(product);
        }
        return product;
    }

    // 최신 버전 캐시 조회
    String key = getProductCacheKey(productId, latestVersion);
    Product product = redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;
    }

    // 캐시 미스 시 DB 조회 후 캐싱
    product = productRepository.findById(productId).orElse(null);
    if (product != null) {
        updateProduct(product);
    }
    return product;
}

장점:

  • 캐시 무효화 없이 새 버전 사용 가능
  • 이전 버전과 새 버전을 동시에 유지 가능
  • 롤백이 쉬움

 

4. 읽기 전용 복제본 캐싱(Read-Through Replica Caching)

읽기 전용 데이터베이스 복제본에서 데이터를 캐시하는 패턴입니다.

@Service
public class ProductCacheService {

    @Autowired
    private ReplicaProductRepository replicaProductRepository;

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        Product cached = redisTemplate.opsForValue().get(key);

        if (cached != null) {
            return cached;
        }

        // 읽기 전용 복제본에서 조회
        Product product = replicaProductRepository.findById(productId).orElse(null);
        if (product != null) {
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
        }

        return product;
    }
}

장점:

  • 주 데이터베이스 부하 감소
  • 읽기 성능 향상
  • 데이터 일관성 향상 (복제 지연 시간만큼만 지연)

 

5. Spring Boot에서의 Redis 설정

1. 의존성 추가

Maven의 경우 pom.xml에 다음 의존성을 추가합니다:

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

Gradle의 경우 build.gradle에 다음을 추가합니다:

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

 

 

2. Redis 연결 설정

application.properties 또는 application.yml 파일에 Redis 연결 정보를 설정합니다:

# application.properties
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=  # 필요한 경우 비밀번호 설정
spring.redis.database=0  # 사용할 Redis DB 인덱스

또는 YAML 형식:

# application.yml
spring:
  redis:
    host: localhost
    port: 6379
    password:  # 필요한 경우 비밀번호 설정
    database: 0  # 사용할 Redis DB 인덱스

 

 

3. RedisTemplate 설정

Redis와 상호작용하기 위한 RedisTemplate 빈을 구성합니다:

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));

        template.afterPropertiesSet();
        return template;
    }
}

 

 

4. 캐시 매니저 설정 (Spring Cache와 함께 사용할 경우)

Spring의 캐시 추상화를 활용하려면 RedisCacheManager를 설정합니다:

@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))  // 기본 TTL 설정 (10분)
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new Jackson2JsonRedisSerializer<>(Object.class)
                )
            );

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

 

 

6. 실무 캐시 예제: Redis를 사용한 상품 정보 캐싱

기본 상품 정보 캐싱 구현

상품 정보를 캐시하는 가장 기본적인 구현은 다음과 같습니다:

public Product getProduct(Long productId) {
    String key = "product:" + productId;

    // 1. Redis에서 캐시 조회
    Product cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        log.debug("Cache hit for product: {}", productId);
        return cached;
    }

    // 2. 캐시 miss인 경우 DB 조회
    log.debug("Cache miss for product: {}", productId);
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

    // 3. Redis에 캐시 저장 (TTL 60초)
    redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60));

    return product;
}

이 코드는 Cache-Aside 패턴의 일반적인 구현입니다.

 

 

실제 발생한 문제 시나리오

 

  1. 관리자가 상품의 가격을 10,000원에서 8,000원으로 할인 적용
  2. DB에는 정상적으로 가격이 8,000원으로 변경됨
  3. 사용자는 여전히 웹사이트에서 10,000원의 가격을 보게 됨
  4. 소비자는 할인 적용이 안 됐다고 불만 제기
  5. 개발자가 DB를 확인해보니 가격은 이미 8,000원으로 변경되어 있음

 

 문제 원인: 캐시 무효화 누락

문제의 원인을 살펴보니 상품 수정 로직에서 DB는 업데이트하지만 캐시는 갱신하지 않고 있었습니다:

public void updateProduct(Long productId, ProductUpdateRequest request) {
    // 1. DB에서 상품 조회
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

    // 2. 상품 정보 업데이트
    product.setName(request.getName());
    product.setPrice(request.getPrice());
    product.setDescription(request.getDescription());

    // 3. DB에 저장
    productRepository.save(product);

    // ❌ 캐시 무효화 로직이 누락됨
}

 

기존 캐시는 여전히 유효하기 때문에, TTL이 만료될 때까지(60초) 사용자는 계속해서 오래된 가격 정보를 보게 됩니다. 이는 사용자 경험을 해치고 비즈니스에 직접적인 영향을 줄 수 있는 심각한 문제입니다.

 

 

캐시 동기화에서 가장 흔한 실수: DB만 업데이트하고 캐시 무효화를 잊는 것입니다.

 

해결 1단계: 캐시 무효화(Cache Invalidation) 적용

가장 간단한 해결책은 데이터가 변경될 때마다 관련 캐시를 명시적으로 제거하는 것입니다:

public void updateProduct(Long productId, ProductUpdateRequest request) {
    // 1. DB에서 상품 조회
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

    // 2. 상품 정보 업데이트
    product.setName(request.getName());
    product.setPrice(request.getPrice());
    product.setDescription(request.getDescription());

    // 3. DB에 저장
    productRepository.save(product);

    // 4. ✅ 캐시 무효화
    String key = "product:" + productId;
    redisTemplate.delete(key);
    log.info("Cache invalidated for product: {}", productId);
}

 

이렇게 하면 사용자는 항상 최신 데이터를 볼 수 있습니다. 다만, 다음 요청 시 캐시 miss가 발생하여 DB 조회가 필요하다는 단점이 있습니다.

 

대안: 캐시 갱신(Cache Update) 전략

캐시를 삭제하는 대신 갱신하는 방법도 있습니다:

public void updateProduct(Long productId, ProductUpdateRequest request) {
    // 1. DB에서 상품 조회
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

    // 2. 상품 정보 업데이트
    product.setName(request.getName());
    product.setPrice(request.getPrice());
    product.setDescription(request.getDescription());

    // 3. DB에 저장
    Product updatedProduct = productRepository.save(product);

    // 4. ✅ 캐시 갱신
    String key = "product:" + productId;
    redisTemplate.opsForValue().set(key, updatedProduct, Duration.ofSeconds(60));
    log.info("Cache updated for product: {}", productId);
}

이 방식은 다음 요청 시 캐시 miss를 방지할 수 있지만, 업데이트 시점에 항상 전체 객체를 다시 캐싱해야 한다는 단점이 있습니다.

 

 

7. 실무 캐시 문제 해결

문제 2: Race Condition

캐시 무효화를 적용한 후에도, 여전히 Race Condition 문제가 발생할 수 있습니다. 특히 트래픽이 많은 상품의 경우 더 심각합니다.

Race Condition 시나리오:

  1. 사용자 A 요청: 캐시 miss → DB 조회 시작
  2. 사용자 B 요청: 캐시 miss → DB 조회 시작
  3. 사용자 A: DB 조회 완료 → Redis에 데이터 저장
  4. 사용자 B: DB 조회 완료 → Redis에 데이터 저장 (A보다 조회가 늦었는데도 A의 결과를 덮어씀)

이로 인해 먼저 조회된 최신 데이터가 나중에 조회된 오래된 데이터로 덮어써지는 문제가 발생할 수 있습니다.

 

해결 방법: 분산 락(Distributed Lock) 적용

Redis를 활용한 분산 환경에서는 Redisson을 이용한 분산 락을 적용하는 것이 좋습니다:

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product cached = redisTemplate.opsForValue().get(key);
    if (cached != null) return cached;

    // 분산 락 적용
    RLock lock = redissonClient.getLock("lock:product:" + productId);
    try {
        // 최대 3초 동안 락 획득 시도, 획득 후 1초 후에 자동 해제
        if (lock.tryLock(3, 1, TimeUnit.SECONDS)) {
            try {
                // 락 획득 후 다시 캐시 확인 (Double-checking)
                cached = redisTemplate.opsForValue().get(key);
                if (cached != null) return cached;

                // DB 조회 및 캐시 저장
                Product product = productRepository.findById(productId).orElseThrow();
                redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60));
                return product;
            } finally {
                lock.unlock();  // 락 해제
            }
        } else {
            // 락 획득 실패 시 DB에서 직접 조회 (캐싱 없이)
            return productRepository.findById(productId).orElseThrow();
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("락 획득 중 인터럽트 발생", e);
    }
}

분산 락을 적용하면 한 번에 하나의 스레드만 캐시를 갱신할 수 있어, Race Condition 문제를 해결할 수 있습니다.

 

문제 3: Cache Stampede (동시 캐시 붕괴)

캐시의 TTL이 만료되면 수많은 요청이 동시에 DB로 몰리는 Cache Stampede 현상이 발생할 수 있습니다. 특히 인기 상품의 캐시가 만료되는 시점에 문제가 더 심각합니다.

 

 

Cache Stampede 시나리오:

  1. 인기 상품의 캐시 TTL이 정확히 60초로 설정됨
  2. 60초 후 캐시 만료
  3. 수백 개의 동시 요청이 모두 캐시 miss 발생
  4. 모든 요청이 DB에 동시에 쿼리 실행
  5. DB 부하 증가 → 서버 응답 지연 → 사용자 경험 저하

해결 방법:

1. 랜덤 TTL(Jitter) 적용

캐시 만료 시간에 랜덤성을 추가하여 동시에 만료되는 것을 방지합니다:

// TTL을 60~90초 사이의 랜덤한 값으로 설정
int jitter = new Random().nextInt(30);  // 0~29초의 추가 시간
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + jitter));

 

2. 선점적 캐시 갱신(Proactive Cache Refresh)

캐시 TTL이 만료되기 전에 백그라운드에서 미리 갱신하는 방법입니다:

@Scheduled(fixedRate = 30000)  // 30초마다 실행
public void refreshPopularProductCaches() {
    List<Long> popularProductIds = getPopularProductIds();
    for (Long productId : popularProductIds) {
        String key = "product:" + productId;
        // TTL이 15초 미만으로 남은 경우에만 갱신
        Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        if (ttl != null && ttl > 0 && ttl < 15) {
            Product product = productRepository.findById(productId).orElse(null);
            if (product != null) {
                redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + new Random().nextInt(30)));
                log.info("Proactively refreshed cache for product: {}", productId);
            }
        }
    }
}

 

3. 빈 캐시 방어(Cache Sentinel) 패턴

캐시가 없을 때 임시로 "빈" 값을 넣어 다른 요청이 DB로 가는 것을 방지합니다:

public Product getProduct(Long productId) {
    String key = "product:" + productId;

    // 캐시 조회
    Object cached = redisTemplate.opsForValue().get(key);

    // 캐시에 EMPTY_RESULT 표시가 있는 경우 (존재하지 않는 상품)
    if (cached != null && cached.equals("EMPTY_RESULT")) {
        throw new ProductNotFoundException(productId);
    }

    // 정상적인 캐시 히트
    if (cached != null && cached instanceof Product) {
        return (Product) cached;
    }

    // 분산 락 적용 코드...

    try {
        // DB 조회
        Optional<Product> productOpt = productRepository.findById(productId);

        if (productOpt.isPresent()) {
            Product product = productOpt.get();
            redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + new Random().nextInt(30)));
            return product;
        } else {
            // 존재하지 않는 상품은 EMPTY_RESULT로 캐싱 (Negative Caching)
            // 짧은 TTL(10초)을 적용하여 오래 지속되지 않도록 함
            redisTemplate.opsForValue().set(key, "EMPTY_RESULT", Duration.ofSeconds(10));
            throw new ProductNotFoundException(productId);
        }
    } finally {
        // 락 해제 코드...
    }
}

 

 

8. 캐시 동기화 전략 종합 비교

문제 발생 상황 해결 전략 장점 단점

캐시 무효화 누락 DB만 갱신되고 캐시는 그대로 명시적 캐시 삭제<br>redisTemplate.delete(key) 구현이 간단함 캐시 miss 발생
    캐시 갱신<br>redisTemplate.set(key, newData) 캐시 miss 없음 전체 객체 다시 로드 필요
Race Condition 동시에 여러 요청이 캐시 갱신 Redisson 분산 락 정확한 동시성 제어 복잡성 증가, 성능 영향
Cache Stampede 캐시 만료 시 DB에 부하 집중 랜덤 TTL (Jitter) 구현이 쉬움 완벽한 해결책 아님
    선점적 캐시 갱신 사용자 경험 향상 추가 스케줄링 필요
    캐시 없을 때 빈 값 설정 무의미한 DB 조회 방지 복잡성 증가

 

 

9. 고급 캐시 전략: 이벤트 기반 캐시 갱신

 

 

더 복잡한 시스템에서는 이벤트 기반 캐시 갱신 전략이 효과적입니다. Redis의 Pub/Sub 또는 Kafka를 활용한 방식으로, DB 변경 시 관련 서비스에 알림을 보내 캐시를 갱신합니다.

// DB 상품 업데이트 후 이벤트 발행
public void updateProduct(Long productId, ProductUpdateRequest request) {
    // DB 업데이트...

    // 이벤트 발행
    ProductUpdatedEvent event = new ProductUpdatedEvent(productId, updatedProduct);
    kafkaTemplate.send("product-updates", event);
}

// 다른 서비스에서 이벤트 수신하여 캐시 갱신
@KafkaListener(topics = "product-updates")
public void handleProductUpdate(ProductUpdatedEvent event) {
    String key = "product:" + event.getProductId();
    redisTemplate.opsForValue().set(key, event.getProduct(), Duration.ofSeconds(60 + new Random().nextInt(30)));
    log.info("Cache updated for product: {} via event", event.getProductId());
}

이 방식의 장점은 서비스 간 결합도를 낮추고 각 마이크로서비스가 자신의 캐시를 독립적으로 관리할 수 있다는 것입니다.

 

 

10. 실무에서의 추가 팁

1. 모니터링 체계 구축

캐시 hit/miss 비율, 캐시 크기, 메모리 사용량 등을 모니터링하여 캐시 전략의 효과를 지속적으로 평가하세요:

// 캐시 hit/miss 측정을 위한 메트릭 추가
@Autowired
private MeterRegistry registry;

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product cached = redisTemplate.opsForValue().get(key);

    if (cached != null) {
        registry.counter("cache.hit", "type", "product").increment();
        return cached;
    } else {
        registry.counter("cache.miss", "type", "product").increment();
        // DB 조회 및 캐싱...
    }
}

 

 

2. 다중 레이어 캐시 전략

로컬 캐시(Caffeine)와 분산 캐시(Redis)를 함께 사용하여 성능을 최적화할 수 있습니다:

@Cacheable(value = "localProductCache", key = "#productId", unless = "#result == null")
public Product getProductWithLocalCache(Long productId) {
    String key = "product:" + productId;
    Product cached = redisTemplate.opsForValue().get(key);

    if (cached != null) {
        return cached;
    }

    // Redis에 없는 경우 DB 조회 및 Redis 캐싱
    // ...
}

 

3. Spring Cache 추상화 활용

스프링의 캐시 추상화를 활용하면 더 깔끔한 코드로 캐시를 관리할 수 있습니다:

@Cacheable(value = "products", key = "#productId", unless = "#result == null")
public Product getProduct(Long productId) {
    return productRepository.findById(productId).orElseThrow();
}

@CacheEvict(value = "products", key = "#productId")
public void updateProduct(Long productId, ProductUpdateRequest request) {
    // DB 업데이트 로직...
}

 

 

11. 성능 측정 및 모니터링

Redis 캐시 활용에 있어서 중요한 점은 실제로 성능 개선이 이루어지고 있는지 측정하고 모니터링하는 것입니다.

주요 모니터링 지표

  1. Cache Hit Ratio (캐시 적중률): 캐시가 얼마나 효과적으로 사용되고 있는지를 나타내는 가장 중요한 지표입니다.보통 80-95%의 적중률이 좋은 결과로 간주됩니다. 적중률이 낮다면 캐시 전략을 재검토해야 합니다.
  2. 캐시 적중률 = 캐시 히트 수 / (캐시 히트 수 + 캐시 미스 수) × 100%
  3. 응답 시간 (Response Time): 캐시 사용 전후의 응답 시간을 측정하여 실제 성능 향상을 확인합니다.
  4. 캐시 성능 향상률 = (캐시 미사용 시 응답시간 - 캐시 사용 시 응답시간) / 캐시 미사용 시 응답시간 × 100%
  5. 메모리 사용량: Redis의 메모리 사용량을 모니터링하여 메모리 부족 문제를 예방합니다.
  6. redis-cli info memory
  7. 만료 키 수: 자동으로 만료되는 키의 수를 모니터링하여 TTL 설정이 적절한지 확인합니다.
  8. redis-cli info stats | grep expired_keys

모니터링 도구

  1. Redis INFO 명령어: 기본적인 모니터링 정보를 제공합니다.
  2. redis-cli info
  3. Redis Monitor: 실시간으로 Redis 서버에 전송되는 명령어를 모니터링합니다.(주의: 프로덕션 환경에서는 성능 영향이 있을 수 있음)
  4. redis-cli monitor
  5. Prometheus + Grafana: 장기적인 모니터링과 알림 설정에 유용합니다.
  6. @Configuration public class PrometheusConfig { @Bean public MeterRegistry meterRegistry() { PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); return registry; } }
  7. Spring Boot Actuator: Redis 캐시 지표를 쉽게 노출시킵니다.
  8. # application.properties management.endpoints.web.exposure.include=health,info,metrics,prometheus

 

12. 마무리: 캐시 전략의 핵심은 일관성과 신선도 사이의 균형

캐시는 성능을 크게 향상시키지만, 데이터 일관성과 캐시 신선도 사이에 적절한 균형을 맞추는 것이 중요합니다. 비즈니스 요구사항에 따라 다음을 고려하세요:

  • 실시간성이 중요한 데이터: TTL을 짧게 설정하거나 Write-Through 패턴으로 즉시 갱신
  • 자주 조회되지만 변경이 적은 데이터: 긴 TTL과 이벤트 기반 무효화 조합
  • 비즈니스 중요도가 높은 데이터: 엄격한 캐시 무효화와 검증 로직 추가

기억하세요: 완벽한 캐시 전략은 없습니다. 비즈니스 요구사항과 시스템 특성에 맞는 전략을 선택하고, 지속적으로 모니터링하며 개선해 나가는 것이 중요합니다.

 

 

참고 자료

반응형