Redis 캐싱 및 분산 시스템 아키텍처

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 항상 빠름 매우 빠름 유실 위험 로그, 조회수 등

Redis 캐시 전략 비교 다이어그램

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를 효과적으로 활용하기 위해 다음 사항들을 점검해 보세요:

  1. 캐시 전략 선택: 데이터 특성에 맞는 패턴(Cache-Aside, Write-Through, Write-Behind) 적용
  2. TTL 설계: 데이터 변경 빈도에 맞는 만료 시간 + 지터 추가
  3. 스탬피드 방지: 뮤텍스 락 또는 확률적 조기 갱신으로 DB 부하 방지
  4. 분산 락: Redisson을 활용한 안전한 동시성 제어, AOP로 보일러플레이트 제거
  5. Cluster 운영: 해시 슬롯 이해, 해시 태그 활용, 토폴로지 자동 갱신
  6. 모니터링: 히트율, 메모리, 연결 수 등 핵심 지표 상시 관찰

Redis는 단순한 Key-Value 저장소가 아닙니다. 올바른 전략과 설계를 바탕으로 활용하면, 시스템의 성능과 안정성을 한 단계 끌어올릴 수 있는 강력한 인프라가 됩니다.


참고 자료