AI 요약
Redis를 그냥 '빠른 캐시'로 보면 편하지만, Cache Aside·TTL·캐시 만료 동시성, Negative Caching, 메모리 정책, 복구 방식, 복잡해지는 분산 락 등 쓰면서 맞닥뜨리는 여러 문제를 함께 고려해야 하는 것이 핵심 주제입니다. 글은 DB로 해결할 수 있는 부분은 DB에 맡기고, Redis를 도입할 때는 얻는 이득과 새로 생길 비용을 현실적으로 따져야 한다고 조용히 짚어준다.
Redis를 처음 접하면 보통 이렇게 배운다.
메모리를 사용해서 빠른 key-value 저장소
단순히 'Redis는 빠르다' 보다 왜 쓰는지, 그리고 쓰면서 어떤 문제가 생기는지가 더 중요하다.
상품 조회를 예로 들면 다음과 같다.
이런 방식을 Cache Aside 패턴이라고 한다.
Spring 코드로 단순하게 표현하면 이런 느낌이다.
01public ProductResponse getProduct(Long productId) {02 String key = "product:" + productId;03 04 String cached = redisTemplate.opsForValue().get(key);05 06 if (cached != null) {07 return deserialize(cached);08 }09 10 Product product = productRepository.findById(productId)11 .orElseThrow(ProductNotFoundException::new);12 13 ProductResponse response = ProductResponse.from(product);14 15 redisTemplate.opsForValue()16 .set(key, serialize(response), Duration.ofMinutes(10));17 18 return response;19}핵심은 간단하다.
Cache Hit은 Redis에서 바로 반환하고, Cache Miss는 DB를 조회 후 Redis에 저장 후 반환 하는 것이다.
데이터 수정 시에는 캐시도 같이 생각해야 한다.
01@Transactional02public void updateProduct(Long productId, String name) {03 Product product = productRepository.findById(productId)04 .orElseThrow(ProductNotFoundException::new);05 06 product.updateName(name);07 08 redisTemplate.delete("product:" + productId);09}DB를 수정한 뒤 캐시를 삭제하고 다음 조회에서 최신 데이터를 다시 채우는 방식이다.
캐시를 직접 수정할 수도 있지만 DB와 Redis를 동시에 맞춰야 하기 떄문에 더 복잡하다.
Redis의 데이터는 보통 영원히 저장하는게 아니라 TTL을 둔다.
01redisTemplate.opsForValue()02 .set(key, value, Duration.ofMinutes(10));그런데 인기 상품 하나의 TTL이 만료되는 순간 수천 개의 요청이 동시에 들어오면 어떻게 될까?
수천 개의 Cache Miss가 나고 전부 DB 조회를 할 것이다.
결국 DB의 부하가 급증하게 된다.
이런 현상을 Cache Breakdown, 조금 더 넓게는 Cache Stampede라고 한다.
또 여러 캐시가 비슷한 시간에 만료되면 Cache Avalanche가 발생할 수도 있다.
이를 완화하는 간단한 방법 중 하나가 TTL Jiter다.
01long baseTtl = 600;02long jitter = ThreadLocalRandom.current().nextLong(0, 60);03 04redisTemplate.opsForValue().set(05 key,06 value,07 Duration.ofSeconds(baseTtl + jitter)08);모든 캐시를 정확히 10분 뒤에 만료시키는 대신 랜덤 값으로 만료 시점을 흩어주는 것이다.
이런 작은 랜덤 값 하나가 순간적인 DB 부하를 줄이는 데 도움을 줄 수 있다.
존재하지 않는 사용자 id를 계속 요청한다고 해보자.
01GET /users/999999999캐시를 사용하고 있는데도 요청이 계속 DB까지 내려간다.
간단한 대응 방법은 Negative Caching이다.
01String cached = redisTemplate.opsForValue().get(key);02 03if ("NOT_FOUND".equals(cached)) {04 throw new UserNotFoundException();05}DB에도 데이터가 없다면
01Optional<User> user = userRepository.findById(userId);02 03if (user.isEmpty()) {04 redisTemplate.opsForValue().set(05 key,06 "NOT_FOUND",07 Duration.ofSeconds(30)08 );09 10 throw new UserNotFoundException();11}없는 결과도 짧은 시간 동안 캐싱한다.
왜 TTL을 짧게 줄까?
나중에 실제 데이터가 생성될 수도 있기 때문이다.
즉
01존재하는 데이터 -> 비교적 긴 TTL02 03존재하지 않는 데이터 -> 짧은 TTL정도로 생각하면 된다.
Redis의 메모리는 유한하다.
메모리가 부족하면 어떤 데이터를 버릴지 결정해야 한다.
대표적인 방식이 LRU, LFU 다.
01LRU02- Least Recently Used03- 가장 오래 사용하지 않은 데이터 제거04 05LFU06- Least Frequently Used07- 사용 빈도가 가장 낮은 데이터 제거Redis 설정에서도 선택할 수 있다.
01maxmemory-policy allkeys-lru02or03maxmemory-policy allkeys-lfu서비스에 따라 정책이 달라질 수 있다.
Redis는 주로 메모리에 데이터를 저장한다.
따라서 장애가 발생하면 데이터가 사라질 가능성이 있다.
이를 위해 대표적으로 두 가지 방법이 있다.
RDB
특정 시점의 데이터를 스냅샷으로 저장한다.
복구가 비교적 빠르고 파일 크기도 작은 편이지만, 마지막 스냅샷 이후의 데이터는 유실될 수 있다.
AOF
데이터 변경 명령을 기록한다.
AOF에서는 fsync 주기에 따라 성능과 데이터 안정성의 Trade-off가 생긴다.
01always02- 매번 반영03- 안전하지만 느림04 05everysec06- 약 1초마다07- 성능과 안정성 절충08 09no10- OS에게 맡김11- 빠르지만 유실 범위 증가 가능간단하게 정리하자면,
Replication는 Primary의 데이터를 복제해서 Replica를 만드는 것이다.
Sentinel은 Primary가 장애가 발생하면 Sentinel이 감지해서 Replica를 Primary로 승격시킨다.
즉, 장애 감지와 Failover를 담당한다.
Cluster는 데이터를 여러 노드에 나눠 저장한다.
01데이터1 -> Redis A02데이터2 -> Redis B03데이터3 -> Redis C서버가 한 대라면 synchronized 같은 방식으로 하나의 JVM 안에서 동기화를 할 수 있다.
하지만 서버가 여러 대라면 각 JVM의 락은 서로 모른다.
이때 Redis 같은 공유 저장소를 이용한 분산 락을 고려할 수 있다.
하지만 Redis 분산 락을 먼저 떠올리는게 좋은 습관은 아니다.
단순 재고 감소라면
01update product02set stock = stock - 103where id = :id04and stock > 0;이렇게 DB Atomic Update가 훨씬 단순하다.
JPA라면
01@Modifying02@Query("""03 update Product p04 set p.stock = p.stock - 105 where p.id = :id06 and p.stock > 007""")08int decreaseStock(Long id);그리고
01int updated = productRepository.decreaseStock(productId);02 03if (updated == 0) {04 throw new OutOfStockException();05}이렇게 처리하면 된다.
Redis 분산 락을 추가하면
락 획득 실패
락 해제
TTL
Redis 장애
네트워크 장애
락 소유권
등 새로운 문제도 같이 생긴다.
Redis는 단순하게 DB보다 빠른 캐시 정도로 생각했다.
하지만 Redis 하나를 추가하는 순간 같이 고민해야 하는 것도 많아진다.
결국 Redis를 잘 쓴다는 건 명령어를 많이 아는 것이 아니라,
DB만으로 충분한 문제인지, Redis를 추가할 만큼 성능과 구조적인 이점이 있는지 판단하는 것에 더 가깝다고 생각한다.
특히 개인적으로 가장 중요하게 느낀 것이 있다.
어떤 기술이던, 새 기술을 도입하면 문제 하나를 해결하는 대신 새로운 문제도 같이 들어온다는 것이다.