WEB

Redis 캐싱 전략과 캐시 무효화 처리

ZZJJing 2025. 6. 30. 21:42
반응형

프로젝트에서 성능 이슈가 계속 발생해서 Redis 캐싱을 적용해보기로 했다.
생각보다 전략이 다양했다.

정리해보자.

캐싱 전략들

전략 설명 장점 단점 사용 시기

Cache-Aside 애플리케이션이 직접 캐시 관리 필요한 데이터만 캐싱, 장애 격리 첫 요청 느림, 복잡한 코드 읽기 중심 애플리케이션
Write-Through 캐시와 DB 동시 저장 데이터 일관성 보장 쓰기 성능 저하 일관성이 중요한 시스템
Write-Behind 캐시 먼저, DB는 나중 빠른 쓰기 성능 데이터 손실 위험 고성능 쓰기가 필요한 경우
Read-Through 캐시가 DB 조회 대행 간단한 애플리케이션 코드 캐시 의존성 증가 읽기 패턴이 단순한 경우

실제로 써본 Cache-Aside 패턴

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    public User getUser(Long userId) {
        String cacheKey = "user:" + userId;
        
        // 1. 캐시 확인
        String cachedUser = redisTemplate.opsForValue().get(cacheKey);
        if (cachedUser != null) {
            return JsonUtils.fromJson(cachedUser, User.class);
        }
        
        // 2. DB 조회
        User user = userRepository.findById(userId);
        
        // 3. 캐시 저장 (1시간)
        redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(user), 
                                       Duration.ofHours(1));
        
        return user;
    }
}

간단하다.

하지만 캐시 무효화가 문제다.

캐시 무효화 전략

무효화 방식 구현 방법 장점 단점 적합한 상황

TTL 시간 기반 자동 만료 구현 간단, 메모리 효율적 데이터 불일치 가능 실시간성이 중요하지 않은 경우
Manual 데이터 변경시 수동 삭제 정확한 무효화 복잡한 코드, 휴먼 에러 정확성이 중요한 핵심 데이터
Tag-based 태그로 그룹화하여 일괄 삭제 관련 데이터 일괄 처리 태그 관리 복잡 연관된 데이터가 많은 경우
Event-driven 이벤트 기반 자동 무효화 실시간 반영, 자동화 이벤트 시스템 필요 마이크로서비스 환경

TTL 방식

// 1시간 후 자동 만료
redisTemplate.opsForValue().set("key", value, Duration.ofHours(1));

Manual Invalidation

@Transactional
public void updateUser(Long userId, User userData) {
    // DB 업데이트
    userRepository.save(userData);
    
    // 캐시 삭제
    String cacheKey = "user:" + userId;
    redisTemplate.delete(cacheKey);
}

Tag-based Invalidation

public void addUserCache(Long userId, User user) {
    String userKey = "user:" + userId;
    String postsKey = "user_posts:" + userId;
    String tagKey = "user_tags:" + userId;
    
    // 데이터 캐싱
    redisTemplate.opsForValue().set(userKey, JsonUtils.toJson(user));
    
    // 태그에 키 추가
    redisTemplate.opsForSet().add(tagKey, userKey, postsKey);
}

public void invalidateUserCaches(Long userId) {
    String tagKey = "user_tags:" + userId;
    Set<String> keys = redisTemplate.opsForSet().members(tagKey);
    
    // 관련 캐시 모두 삭제
    redisTemplate.delete(keys);
    redisTemplate.delete(tagKey);
}

캐시 무효화 문제들

문제 발생 원인 해결 방법 예제 코드

Race Condition 캐시 무효화와 생성 타이밍 충돌 분산 락, 버전 관리 아래 참조
Thundering Herd 캐시 만료시 동시 DB 접근 랜덤 TTL, 락 사용 아래 참조
Cache Stampede 인기 캐시 만료시 대량 요청 분산 락으로 갱신 제어 아래 참조
Stale Data 오래된 캐시 데이터 조회 버전 체크, 이벤트 기반 갱신 아래 참조

Race Condition 해결

@Component
public class CacheService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public User getUserWithLock(Long userId) {
        String lockKey = "lock:user:" + userId;
        String cacheKey = "user:" + userId;
        
        try {
            // 분산 락 획득 (10초 타임아웃)
            Boolean lockAcquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
            
            if (lockAcquired) {
                // 캐시 재확인
                String cached = redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) {
                    return JsonUtils.fromJson(cached, User.class);
                }
                
                // DB 조회 및 캐싱
                User user = userRepository.findById(userId);
                redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(user), 
                                               Duration.ofHours(1));
                return user;
            } else {
                // 락 획득 실패시 잠시 대기 후 재시도
                Thread.sleep(100);
                return getUserWithLock(userId);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}

Thundering Herd 방지

public void setCacheWithRandomTTL(String key, Object value) {
    // 기본 1시간 + 0~5분 랜덤
    Random random = new Random();
    long baseSeconds = 3600;
    long randomSeconds = random.nextInt(300);
    
    Duration ttl = Duration.ofSeconds(baseSeconds + randomSeconds);
    redisTemplate.opsForValue().set(key, JsonUtils.toJson(value), ttl);
}

Cache Stampede 해결

public String getPopularData() {
    String lockKey = "lock:popular_data";
    String cacheKey = "popular_data";
    
    // 캐시 확인
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;
    }
    
    // 락 시도
    Boolean lockAcquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
    
    if (lockAcquired) {
        try {
            // 다시 한번 캐시 확인 (Double-checked locking)
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return cached;
            }
            
            // 무거운 DB 쿼리 실행
            String data = expensiveDbQuery();
            redisTemplate.opsForValue().set(cacheKey, data, Duration.ofHours(1));
            return data;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 다른 스레드가 갱신 중이면 잠시 대기
        try {
            Thread.sleep(100);
            return getPopularData();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

캐시 무효화 패턴들

패턴 설명 구현 방식 장점 단점

Refresh-Ahead 만료 전 미리 갱신 백그라운드 스케줄링 사용자 대기시간 없음 리소스 사용량 증가
Write-Around 자주 안쓰는 데이터 제외 접근 패턴 기반 필터링 메모리 효율적 복잡한 판단 로직
Circuit Breaker 장애시 대체 로직 상태 기반 분기 처리 장애 격리 구현 복잡도 증가

Circuit Breaker 패턴

@Component
public class CacheCircuitBreaker {
    
    private enum State { CLOSED, OPEN, HALF_OPEN }
    
    private State state = State.CLOSED;
    private int failureCount = 0;
    private long lastFailureTime = 0;
    private final int FAILURE_THRESHOLD = 5;
    private final long TIMEOUT = 60000; // 1분
    
    public User getDataWithCircuitBreaker(Long userId) {
        if (state == State.OPEN) {
            if (System.currentTimeMillis() - lastFailureTime > TIMEOUT) {
                state = State.HALF_OPEN;
            } else {
                return getDefaultUser(); // 기본값 반환
            }
        }
        
        try {
            User user = getFromCache(userId);
            if (state == State.HALF_OPEN) {
                state = State.CLOSED;
                failureCount = 0;
            }
            return user;
        } catch (Exception e) {
            recordFailure();
            return getFromDatabase(userId); // 캐시 실패시 DB 직접 조회
        }
    }
    
    private void recordFailure() {
        failureCount++;
        lastFailureTime = System.currentTimeMillis();
        if (failureCount >= FAILURE_THRESHOLD) {
            state = State.OPEN;
        }
    }
}

실전 팁들

구분 항목 권장사항 예제

키 네이밍 일관된 패턴 {도메인}:{ID}:{상세} user:123:profile, post:456:comments
메모리 관리 정책 설정 LRU, TTL 조합 maxmemory-policy allkeys-lru
모니터링 히트율 추적 80% 이상 유지 hit_rate = hits / (hits + misses) * 100
압축 큰 데이터 처리 JSON → GZIP 50% 이상 용량 절약 가능
배치 처리 다중 키 조회 Pipeline 사용 네트워크 호출 최소화

압축 처리

@Component
public class CompressedCacheService {
    
    public void setCompressed(String key, Object value) throws IOException {
        String json = JsonUtils.toJson(value);
        byte[] compressed = compress(json);
        redisTemplate.opsForValue().set(key, Base64.getEncoder().encodeToString(compressed));
    }
    
    public <T> T getCompressed(String key, Class<T> clazz) throws IOException {
        String compressed = redisTemplate.opsForValue().get(key);
        if (compressed == null) return null;
        
        byte[] decoded = Base64.getDecoder().decode(compressed);
        String json = decompress(decoded);
        return JsonUtils.fromJson(json, clazz);
    }
    
    private byte[] compress(String str) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) {
            gzipOut.write(str.getBytes(StandardCharsets.UTF_8));
        }
        return baos.toByteArray();
    }
    
    private String decompress(byte[] compressed) throws IOException {
        ByteArrayInputStream bais = new ByteArrayInputStream(compressed);
        try (GZIPInputStream gzipIn = new GZIPInputStream(bais)) {
            return new String(gzipIn.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
}

Pipeline을 이용한 배치 처리

public Map<String, User> getMultipleUsers(List<Long> userIds) {
    List<String> keys = userIds.stream()
        .map(id -> "user:" + id)
        .collect(Collectors.toList());
    
    // Pipeline으로 한번에 여러 키 조회
    List<String> values = redisTemplate.opsForValue().multiGet(keys);
    
    Map<String, User> result = new HashMap<>();
    for (int i = 0; i < keys.size(); i++) {
        if (values.get(i) != null) {
            result.put(keys.get(i), JsonUtils.fromJson(values.get(i), User.class));
        }
    }
    
    return result;
}

Redis 캐싱은 단순해 보이지만 무효화가 진짜 어렵다.
특히 분산 환경에서는 더 복잡해진다.
일단 간단한 TTL부터 시작해서 점진적으로 개선하는게 좋겠다.

다음엔 Redis Cluster와 분산 캐시에 대해 공부해봐야겠다.

 

728x90
반응형