
Redis는 단순한 캐시 서버를 넘어 현대 분산 시스템의 핵심 인프라로 자리 잡았습니다. 초당 수십만 건의 요청을 처리하는 서비스에서 Redis 없이 성능을 보장하기란 거의 불가능합니다. 이 글에서는 Redis 캐시 전략의 설계 원칙부터 분산 락, Cluster 아키텍처, Pub/Sub 메시징까지 실전에서 바로 적용할 수 있는 패턴들을 코드 예제와 함께 깊이 있게 살펴보겠습니다.
Redis 캐시 전략의 이해: Cache-Aside vs Write-Through
캐싱 전략을 선택하는 것은 시스템 설계에서 가장 중요한 결정 중 하나입니다. 잘못된 전략은 오히려 데이터 정합성 문제를 일으키고, 장애 시 복구를 어렵게 만듭니다.
Cache-Aside (Lazy Loading) 패턴
가장 널리 사용되는 캐시 전략입니다. 애플리케이션이 캐시를 직접 관리하며, 캐시 미스 시 DB에서 데이터를 가져와 캐시에 저장합니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private static final String CACHE_PREFIX = "product:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
public Product getProduct(Long productId) {
String cacheKey = CACHE_PREFIX + productId;
// 1. 캐시에서 먼저 조회
Product cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 2. 캐시 미스 → DB 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. 캐시에 저장
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
return product;
}
public Product updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.update(request);
productRepository.save(product);
// 캐시 무효화 (삭제 후 다음 조회 시 갱신)
String cacheKey = CACHE_PREFIX + productId;
redisTemplate.delete(cacheKey);
return product;
}
}
장점: 필요한 데이터만 캐싱하므로 메모리 효율적, 구현이 단순 단점: 최초 요청 시 항상 DB 히트, 데이터 갱신 시 일시적 불일치 가능
Write-Through 패턴
데이터 쓰기 시 캐시와 DB에 동시에 기록합니다. 캐시가 항상 최신 상태를 유지하므로 읽기 성능이 일관됩니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final RedisTemplate<String, Order> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofHours(1);
@Transactional
public Order createOrder(OrderCreateRequest request) {
Order order = Order.create(request);
// DB와 캐시에 동시 기록
Order saved = orderRepository.save(order);
String cacheKey = "order:" + saved.getId();
redisTemplate.opsForValue().set(cacheKey, saved, CACHE_TTL);
return saved;
}
@Transactional
public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.changeStatus(newStatus);
Order saved = orderRepository.save(order);
// 캐시도 함께 갱신
String cacheKey = "order:" + orderId;
redisTemplate.opsForValue().set(cacheKey, saved, CACHE_TTL);
return saved;
}
}
Write-Behind (Write-Back) 패턴
Write-Through의 변형으로, 캐시에 먼저 쓰고 DB 기록을 비동기로 처리합니다. 쓰기 성능이 극대화되지만 데이터 유실 위험이 있으므로 주의가 필요합니다.
@Component
@RequiredArgsConstructor
public class WriteBehindCacheManager {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// 쓰기 큐에 추가 (비동기 DB 반영)
public void writeAsync(String key, Object value) {
try {
String json = objectMapper.writeValueAsString(value);
redisTemplate.opsForValue().set(key, json, Duration.ofHours(2));
// 쓰기 큐에 등록
redisTemplate.opsForList().rightPush("write-behind:queue",
key + "::" + json);
} catch (JsonProcessingException e) {
throw new CacheWriteException("직렬화 실패", e);
}
}
}
// 별도 워커가 큐를 소비하여 DB에 반영
@Scheduled(fixedDelay = 1000)
public void processWriteBehindQueue() {
String entry = redisTemplate.opsForList()
.leftPop("write-behind:queue");
while (entry != null) {
String[] parts = entry.split("::", 2);
persistToDatabase(parts[0], parts[1]);
entry = redisTemplate.opsForList()
.leftPop("write-behind:queue");
}
}
| 전략 | 읽기 성능 | 쓰기 성능 | 데이터 정합성 | 적합한 시나리오 |
|---|---|---|---|---|
| Cache-Aside | 캐시 히트 시 빠름 | DB 직접 쓰기 | 일시적 불일치 가능 | 읽기 비율 높은 서비스 |
| Write-Through | 항상 빠름 | DB+캐시 동시 | 강한 정합성 | 주문/결제 등 중요 데이터 |
| Write-Behind | 항상 빠름 | 매우 빠름 | 유실 위험 | 로그, 조회수 등 |

TTL 설계와 캐시 스탬피드 방지 전략
효과적인 TTL 설계 원칙
TTL(Time-To-Live)은 단순히 “몇 분으로 설정할까?”의 문제가 아닙니다. 데이터 특성, 트래픽 패턴, 비즈니스 요구사항을 종합적으로 고려해야 합니다.
@Configuration
public class CacheTtlPolicy {
// 데이터 변경 빈도에 따른 TTL 분류
public enum CachePolicy {
HOT_DATA(Duration.ofMinutes(5)), // 실시간 랭킹, 재고
WARM_DATA(Duration.ofMinutes(30)), // 상품 상세, 사용자 프로필
COLD_DATA(Duration.ofHours(6)), // 카테고리 목록, 공지사항
STATIC_DATA(Duration.ofDays(1)); // 코드 테이블, 설정값
private final Duration ttl;
CachePolicy(Duration ttl) { this.ttl = ttl; }
public Duration getTtl() { return ttl; }
}
}
캐시 스탬피드(Cache Stampede)란 무엇인가?
캐시 스탬피드는 인기 있는 캐시 키가 만료되는 순간, 수많은 요청이 동시에 DB를 조회하여 DB에 순간적으로 과부하가 걸리는 현상입니다. 대규모 서비스에서 흔히 발생하며, 최악의 경우 DB 장애로 이어질 수 있습니다.
해결 1: 확률적 조기 갱신 (Probabilistic Early Recomputation)
@Component
@RequiredArgsConstructor
public class ProbabilisticCacheManager {
private final RedisTemplate<String, String> redisTemplate;
private static final double BETA = 1.0;
public <T> T getWithEarlyRefresh(String key, Duration ttl,
Supplier<T> loader, Class<T> type) {
String cached = redisTemplate.opsForValue().get(key);
Long remainTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (cached != null && remainTtl != null) {
double random = Math.random();
double threshold = Math.exp(-BETA * remainTtl / ttl.getSeconds());
if (random >= threshold) {
return deserialize(cached, type);
}
}
T value = loader.get();
redisTemplate.opsForValue().set(key, serialize(value), ttl);
return value;
}
}
해결 2: 뮤텍스 락을 활용한 갱신
public <T> T getWithMutex(String key, Duration ttl,
Supplier<T> loader, Class<T> type) {
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return deserialize(cached, type);
}
String lockKey = "lock:" + key;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(acquired)) {
try {
T value = loader.get();
redisTemplate.opsForValue().set(key, serialize(value), ttl);
return value;
} finally {
redisTemplate.delete(lockKey);
}
} else {
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
return getWithMutex(key, ttl, loader, type);
}
}
해결 3: TTL 지터(Jitter) 추가
동시에 많은 키가 만료되는 것을 방지하기 위해 TTL에 랜덤 오프셋을 추가합니다.
public Duration addJitter(Duration baseTtl) {
long baseSeconds = baseTtl.getSeconds();
long jitter = (long) (baseSeconds * 0.1 * (Math.random() * 2 - 1));
return Duration.ofSeconds(baseSeconds + jitter);
}
Redisson 분산 락으로 동시성 문제 해결하기
분산 환경에서 여러 서버가 동일한 리소스에 동시에 접근할 때, 데이터 정합성을 보장하려면 분산 락(Distributed Lock)이 필수입니다. Redisson은 Redis 기반의 강력한 분산 락 구현을 제공합니다.

Redisson 설정
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://YOUR_REDIS_HOST:6379")
.setPassword("YOUR_PASSWORD")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5);
return Redisson.create(config);
}
}
기본 분산 락 사용
@Service
@RequiredArgsConstructor
public class StockService {
private final RedissonClient redissonClient;
private final StockRepository stockRepository;
public void decreaseStock(Long productId, int quantity) {
String lockKey = "lock:stock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS);
if (!acquired) {
throw new StockLockException("재고 락 획득 실패: " + productId);
}
Stock stock = stockRepository.findByProductId(productId)
.orElseThrow(() -> new StockNotFoundException(productId));
if (stock.getQuantity() < quantity) {
throw new InsufficientStockException(productId, quantity);
}
stock.decrease(quantity);
stockRepository.save(stock);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new StockLockException("락 대기 중 인터럽트 발생", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
AOP 기반 분산 락 어노테이션
반복되는 분산 락 코드를 AOP로 추상화하면 비즈니스 로직에 집중할 수 있습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long waitTime() default 5;
long leaseTime() default 3;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
String lockKey = resolveKey(distributedLock.key(), joinPoint);
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new DistributedLockException("락 획득 실패: " + lockKey);
}
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String resolveKey(String keyExpression,
ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
StandardEvaluationContext context = new StandardEvaluationContext();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return parser.parseExpression(keyExpression)
.getValue(context, String.class);
}
}
// 사용 예시
@Service
public class CouponService {
@DistributedLock(key = "'lock:coupon:' + #couponId")
public void issueCoupon(Long couponId, Long userId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issue(userId);
couponRepository.save(coupon);
}
}
RedLock 알고리즘
단일 Redis 인스턴스의 장애 시에도 락의 안전성을 보장하려면, 여러 독립적인 Redis 노드를 활용하는 RedLock 알고리즘을 고려해야 합니다.
@Bean
public RedissonClient redissonMultiLock() {
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://YOUR_REDIS_NODE_1:6379");
RedissonClient client1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://YOUR_REDIS_NODE_2:6379");
RedissonClient client2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://YOUR_REDIS_NODE_3:6379");
RedissonClient client3 = Redisson.create(config3);
RLock lock1 = client1.getLock("my-lock");
RLock lock2 = client2.getLock("my-lock");
RLock lock3 = client3.getLock("my-lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
return client1;
}
Redis Cluster 아키텍처 이해하기
프로덕션 환경에서 Redis를 안정적으로 운영하려면 Redis Cluster는 선택이 아닌 필수입니다. Cluster는 데이터를 여러 노드에 분산 저장하고, 자동 페일오버를 지원합니다.
Cluster의 핵심 개념: 해시 슬롯
Redis Cluster는 16,384개의 해시 슬롯을 사용하여 키를 분산합니다. 각 마스터 노드가 슬롯의 일부를 담당합니다.
슬롯 할당 예시 (3 마스터):
Master 1: 슬롯 0 ~ 5460
Master 2: 슬롯 5461 ~ 10922
Master 3: 슬롯 10923 ~ 16383
키의 슬롯 결정: CRC16(key) mod 16384
Spring Boot에서 Redis Cluster 연동
# application.yml
spring:
data:
redis:
cluster:
nodes:
- YOUR_NODE_1:6379
- YOUR_NODE_2:6379
- YOUR_NODE_3:6379
- YOUR_NODE_4:6379
- YOUR_NODE_5:6379
- YOUR_NODE_6:6379
max-redirects: 3
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
@Configuration
public class RedisClusterConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory(
RedisProperties properties) {
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration(
properties.getCluster().getNodes()
);
clusterConfig.setMaxRedirects(
properties.getCluster().getMaxRedirects()
);
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(2))
.build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
}
해시 태그를 활용한 키 그룹핑
같은 슬롯에 키를 모으고 싶을 때 해시 태그 {} 를 사용합니다.
// {user:1001}이 해시 태그 → 같은 슬롯에 저장
redisTemplate.opsForValue().set("{user:1001}:profile", profileJson);
redisTemplate.opsForValue().set("{user:1001}:settings", settingsJson);
redisTemplate.opsForValue().set("{user:1001}:cart", cartJson);
// 같은 슬롯이므로 MGET으로 한 번에 조회 가능
List<String> values = redisTemplate.opsForValue()
.multiGet(List.of(
"{user:1001}:profile",
"{user:1001}:settings",
"{user:1001}:cart"
));
Redis Pub/Sub로 실시간 이벤트 처리하기
Redis Pub/Sub은 가벼운 메시징 시스템으로, 실시간 알림, 캐시 무효화 전파, 채팅 등에 활용됩니다.
Publisher 구현
@Service
@RequiredArgsConstructor
public class EventPublisher {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
public void publishCacheInvalidation(String cacheKey) {
CacheInvalidationEvent event = new CacheInvalidationEvent(
cacheKey, Instant.now()
);
try {
String message = objectMapper.writeValueAsString(event);
redisTemplate.convertAndSend("cache:invalidation", message);
} catch (JsonProcessingException e) {
log.error("이벤트 발행 실패: {}", cacheKey, e);
}
}
}
Subscriber 구현
@Configuration
public class RedisSubscriberConfig {
@Bean
public RedisMessageListenerContainer redisContainer(
RedisConnectionFactory connectionFactory,
CacheInvalidationListener cacheListener) {
RedisMessageListenerContainer container =
new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(cacheListener,
new ChannelTopic("cache:invalidation"));
return container;
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheInvalidationListener implements MessageListener {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
CacheInvalidationEvent event = objectMapper.readValue(
message.getBody(), CacheInvalidationEvent.class
);
log.info("캐시 무효화 수신: {}", event.getCacheKey());
redisTemplate.delete(event.getCacheKey());
} catch (Exception e) {
log.error("캐시 무효화 처리 실패", e);
}
}
}
Pub/Sub vs Redis Streams 비교
| 항목 | Pub/Sub | Redis Streams |
|---|---|---|
| 메시지 영속성 | 없음 (fire-and-forget) | 있음 (로그 구조) |
| 컨슈머 그룹 | 미지원 | 지원 |
| 메시지 재처리 | 불가 | 가능 |
| 적합한 용도 | 실시간 알림, 캐시 무효화 | 이벤트 소싱, 작업 큐 |
Redis 운영 시 주의사항과 모니터링
메모리 관리
# maxmemory 설정 (전체 메모리의 70~80% 권장)
maxmemory 4gb
maxmemory-policy allkeys-lru
주요 eviction 정책:
- allkeys-lru: 가장 오래 사용되지 않은 키부터 삭제 (범용적)
- volatile-lru: TTL이 설정된 키 중 LRU 삭제
- allkeys-lfu: 가장 적게 사용된 키부터 삭제 (Redis 4.0+)
- noeviction: 메모리 초과 시 쓰기 거부
핵심 모니터링 지표
@Component
@RequiredArgsConstructor
public class RedisHealthIndicator {
private final RedisTemplate<String, String> redisTemplate;
@Scheduled(fixedRate = 60000)
public void checkRedisHealth() {
RedisConnection connection = redisTemplate
.getConnectionFactory().getConnection();
Properties info = connection.serverCommands().info();
String usedMemory = info.getProperty("used_memory_human");
String maxMemory = info.getProperty("maxmemory_human");
long hits = Long.parseLong(info.getProperty("keyspace_hits"));
long misses = Long.parseLong(info.getProperty("keyspace_misses"));
double hitRate = (double) hits / (hits + misses) * 100;
String connectedClients = info.getProperty("connected_clients");
log.info("Redis 상태 - 메모리: {}/{}, 히트율: {:.1f}%, 연결: {}",
usedMemory, maxMemory, hitRate, connectedClients);
}
}
마무리: Redis 캐싱과 분산 락의 실전 적용 체크리스트
Redis를 효과적으로 활용하기 위해 다음 사항들을 점검해 보세요:
- 캐시 전략 선택: 데이터 특성에 맞는 패턴(Cache-Aside, Write-Through, Write-Behind) 적용
- TTL 설계: 데이터 변경 빈도에 맞는 만료 시간 + 지터 추가
- 스탬피드 방지: 뮤텍스 락 또는 확률적 조기 갱신으로 DB 부하 방지
- 분산 락: Redisson을 활용한 안전한 동시성 제어, AOP로 보일러플레이트 제거
- Cluster 운영: 해시 슬롯 이해, 해시 태그 활용, 토폴로지 자동 갱신
- 모니터링: 히트율, 메모리, 연결 수 등 핵심 지표 상시 관찰
Redis는 단순한 Key-Value 저장소가 아닙니다. 올바른 전략과 설계를 바탕으로 활용하면, 시스템의 성능과 안정성을 한 단계 끌어올릴 수 있는 강력한 인프라가 됩니다.
참고 자료
- Redis 공식 문서 - Caching
- Redisson 공식 GitHub
- Redis Cluster 튜토리얼
- Martin Kleppmann - How to do distributed locking
- Redis Pub/Sub 문서