Redis 캐시 전략 완벽 가이드 – 캐시와 세션 관리를 제대로 설계하는 법


Redis 캐시 전략을 제대로 설계하지 않으면, 캐시가 있는데도 DB에 쿼리가 쏟아지거나, 트래픽이 몰리는 순간 캐시와 DB의 데이터가 달라져 결제 금액이 잘못 표시되거나, 로그인 상태가 갑자기 풀리는 장애를 겪게 됩니다. “Redis 쓰면 빠르다던데”라는 말만 믿고 도입했다가 캐시 스탬피드(Cache Stampede), 데이터 불일치, 세션 유실 같은 장애를 경험하는 팀이 적지 않습니다. Redis는 단순한 속도 부스터가 아닙니다. 올바른 캐시 패턴과 TTL 전략, 세션 설계, 분산 락까지 이해해야 비로소 Redis의 진짜 가치를 발휘할 수 있습니다. 이 글에서는 Redis의 핵심 자료구조부터 Cache-Aside·Write-Through·Write-Behind 패턴, 세션 관리, 분산 락, 운영 전략까지 실무 예제 코드와 함께 완벽하게 정리합니다.


목차

  1. Redis란? 왜 단순한 캐시 서버가 아닌가
  2. Redis 핵심 자료구조 – 실무 사용 사례와 함께
  3. 캐시 전략 4가지 완전 정복 – 언제 무엇을 쓰나
  4. 캐시 운영의 함정 – 스탬피드·불일치·Eviction 대응법
  5. Redis 세션 관리 – 분산 환경의 로그인 설계 패턴
  6. 전문가 관점 – 분산 락·Pub/Sub·고가용성 설계 원칙

1. Redis란? 왜 단순한 캐시 서버가 아닌가

Redis의 정체 – 인메모리 데이터 구조 저장소

Redis(Remote Dictionary Server)는 2009년 Salvatore Sanfilippo가 개발한 오픈소스 인메모리 데이터 구조 저장소입니다. 흔히 “캐시 서버”로만 알려져 있지만, 공식 정의는 훨씬 넓습니다. Redis는 캐시뿐만 아니라 세션 저장소, 메시지 브로커, 실시간 순위표, 분산 락, 속도 제한기(Rate Limiter)로도 활용됩니다.

[Redis가 단순 캐시와 다른 이유]

일반 캐시 솔루션:
  ┌─────────────────────┐
  │  Key → String(Value) │  (문자열만 저장 가능)
  └─────────────────────┘

Redis:
  ┌─────────────────────────────────────────┐
  │  Key → String   (단순 값, 카운터)        │
  │  Key → Hash     (객체 필드 단위 조작)    │
  │  Key → List     (메시지 큐, 로그)        │
  │  Key → Set      (태그, 중복 없는 집합)   │
  │  Key → Sorted Set (순위표, 스케줄링)     │
  │  Key → Bitmap   (대규모 플래그 관리)     │
  │  Key → HyperLogLog (근사 카운팅)        │
  │  Key → Stream   (이벤트 로그)            │
  └─────────────────────────────────────────┘

Redis가 왜 빠른가 – 인메모리 + 단일 스레드 이벤트 루프

Redis의 압도적인 속도는 두 가지 설계 결정에서 나옵니다.

① 모든 데이터를 RAM에 저장

디스크 접근은 메모리 접근보다 수천 배 느립니다. Redis는 모든 데이터를 RAM에 올려두고 처리하므로 읽기·쓰기 지연이 마이크로초(μs) 수준입니다.

[접근 속도 비교]

RAM 접근:      100ns  (나노초)
SSD 랜덤 I/O: 100μs  (마이크로초, RAM 대비 1,000배 느림)
HDD 랜덤 I/O: 10ms   (밀리초, RAM 대비 100,000배 느림)
네트워크(LAN): 0.5ms  (밀리초)

→ Redis: 초당 100만 건 이상 읽기·쓰기 처리 가능
→ MySQL: 초당 수천~수만 건 (인덱스·디스크 I/O 포함)

② 단일 스레드 이벤트 루프

Redis는 단일 스레드로 명령을 처리하기 때문에 멀티스레드의 락 경쟁(Race Condition)이 없습니다. 모든 명령이 원자적(Atomic)으로 실행되어 동시성 문제 없이 안전합니다.

Redis vs Memcached – 실무 선택 기준

구분RedisMemcached
자료구조String·Hash·List·Set·ZSet 등 다양String만 지원
영속성RDB·AOF 지원 (선택적)없음 (재시작 시 전체 소실)
클러스터Redis Cluster·Sentinel 지원수동 샤딩만 가능
Pub/Sub지원미지원
원자적 연산INCR·GETSET 등 지원제한적
용도캐시·세션·락·큐·순위표 등 다목적단순 캐시 전용
실무 선택대부분의 신규 프로젝트레거시 단순 캐시 환경

현재 실무에서는 Redis가 사실상 표준으로 자리 잡았으며, Memcached를 신규 프로젝트에 선택하는 경우는 거의 없습니다.


2. Redis 핵심 자료구조 – 실무 사용 사례와 함께

Redis의 자료구조를 제대로 이해하면 용도에 맞는 최적의 데이터 모델을 설계할 수 있습니다.

String – 가장 기본, 가장 다재다능

java

// Spring Boot Redis 기본 설정
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        // Key: String 직렬화
        template.setKeySerializer(new StringRedisSerializer());
        // Value: JSON 직렬화 (Jackson)
        template.setValueSerializer(
            new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(
            new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

// String 활용 예시
@Service
@RequiredArgsConstructor
public class StringRedisExamples {

    private final StringRedisTemplate stringRedisTemplate;

    // ① 단순 캐싱 (TTL 포함)
    public void cacheProductName(Long productId, String name) {
        stringRedisTemplate.opsForValue().set(
            "product:name:" + productId,
            name,
            Duration.ofHours(1)     // 1시간 후 자동 만료
        );
    }

    // ② 원자적 카운터 (동시성 안전)
    public Long incrementViewCount(Long postId) {
        return stringRedisTemplate.opsForValue()
            .increment("post:views:" + postId);
        // INCR 명령은 원자적 → 동시에 1,000명이 호출해도 정확히 1,000 증가
    }

    // ③ 분산 환경 유니크 ID 생성
    public Long generateOrderId() {
        return stringRedisTemplate.opsForValue()
            .increment("global:order:sequence");
    }

    // ④ NX(Not Exists) 옵션 – 없을 때만 저장 (분산 락 기초)
    public Boolean setIfAbsent(String key, String value, Duration ttl) {
        return stringRedisTemplate.opsForValue()
            .setIfAbsent(key, value, ttl);
        // 이미 존재하면 false 반환 → 동시 요청 중 하나만 성공
    }
}

Hash – 객체를 필드 단위로 조작

java

@Service
@RequiredArgsConstructor
public class HashRedisExamples {

    private final RedisTemplate<String, Object> redisTemplate;

    // 사용자 프로필 Hash 저장 (JSON 통째로 저장 vs Hash 비교)
    public void saveUserProfile(Long userId, UserProfile profile) {
        String key = "user:profile:" + userId;

        // 방법 1: String으로 JSON 통째 저장 (단점: 하나의 필드 변경 시 전체 덮어써야 함)
        // redisTemplate.opsForValue().set(key, profile);

        // 방법 2: Hash로 필드별 저장 (장점: 특정 필드만 수정 가능)
        Map<String, Object> fields = new HashMap<>();
        fields.put("name", profile.getName());
        fields.put("email", profile.getEmail());
        fields.put("grade", profile.getGrade().name());
        fields.put("point", profile.getPoint());

        redisTemplate.opsForHash().putAll(key, fields);
        redisTemplate.expire(key, Duration.ofDays(7));
    }

    // 특정 필드만 업데이트 (포인트 적립 시 전체 재저장 불필요)
    public void updateUserPoint(Long userId, int newPoint) {
        redisTemplate.opsForHash().put(
            "user:profile:" + userId, "point", newPoint);
    }

    // 여러 필드 한 번에 조회 (네트워크 왕복 최소화)
    public Map<Object, Object> getUserProfile(Long userId) {
        return redisTemplate.opsForHash()
            .entries("user:profile:" + userId);
    }
}

Sorted Set – 실시간 순위표의 완벽한 도구

java

@Service
@RequiredArgsConstructor
public class SortedSetRedisExamples {

    private final RedisTemplate<String, Object> redisTemplate;
    private static final String WEEKLY_RANK_KEY = "rank:weekly:score";

    // 점수 업데이트 (O(log N) 시간 복잡도)
    public void updateScore(String userId, double score) {
        redisTemplate.opsForZSet().add(WEEKLY_RANK_KEY, userId, score);
    }

    // 점수 증가 (원자적)
    public Double incrementScore(String userId, double delta) {
        return redisTemplate.opsForZSet()
            .incrementScore(WEEKLY_RANK_KEY, userId, delta);
    }

    // 상위 N명 조회 (점수 내림차순)
    public Set<ZSetOperations.TypedTuple<Object>> getTopRankers(int topN) {
        return redisTemplate.opsForZSet()
            .reverseRangeWithScores(WEEKLY_RANK_KEY, 0, topN - 1);
    }

    // 특정 유저의 순위 조회 (0-based index)
    public Long getUserRank(String userId) {
        Long rank = redisTemplate.opsForZSet()
            .reverseRank(WEEKLY_RANK_KEY, userId);
        return rank != null ? rank + 1 : null;  // 1-based 순위로 변환
    }

    // 범위 조회 (점수 500~1000 사이 유저)
    public Set<Object> getUsersByScoreRange(double minScore, double maxScore) {
        return redisTemplate.opsForZSet()
            .rangeByScore(WEEKLY_RANK_KEY, minScore, maxScore);
    }
}

List – 메시지 큐와 최근 활동 로그

java

@Service
@RequiredArgsConstructor
public class ListRedisExamples {

    private final RedisTemplate<String, Object> redisTemplate;

    // 최근 본 상품 (최대 20개, 오래된 것 자동 제거)
    public void addRecentProduct(Long userId, Long productId) {
        String key = "user:recent:products:" + userId;

        // 왼쪽에 추가 (가장 최근 항목이 앞으로)
        redisTemplate.opsForList().leftPush(key, productId);

        // 20개 초과 시 오래된 항목 제거
        redisTemplate.opsForList().trim(key, 0, 19);
        redisTemplate.expire(key, Duration.ofDays(30));
    }

    // 최근 본 상품 조회
    public List<Object> getRecentProducts(Long userId) {
        return redisTemplate.opsForList()
            .range("user:recent:products:" + userId, 0, -1);
    }

    // 경량 메시지 큐 (LPUSH + BRPOP 패턴)
    public void enqueue(String queueName, Object message) {
        redisTemplate.opsForList().leftPush("queue:" + queueName, message);
    }

    public Object dequeue(String queueName, long timeoutSeconds) {
        // BRPOP: 큐가 비어있으면 블로킹 대기 (polling 없이 효율적)
        return redisTemplate.opsForList()
            .rightPop("queue:" + queueName, Duration.ofSeconds(timeoutSeconds));
    }
}

3. 캐시 전략 4가지 완전 정복 – 언제 무엇을 쓰나

Redis 캐시 전략은 크게 읽기 전략과 쓰기 전략으로 나뉩니다. 각 전략은 데이터 일관성, 성능, 구현 복잡도 사이의 트레이드오프가 다릅니다.

전략 ① Cache-Aside (Lazy Loading) – 가장 보편적인 읽기 전략

애플리케이션이 캐시를 직접 관리하는 패턴입니다. 캐시를 먼저 확인하고, 없으면(Cache Miss) DB에서 조회 후 캐시에 저장합니다.

[Cache-Aside 흐름]

클라이언트 요청
  ↓
애플리케이션 → Redis 조회
  ├── Cache HIT: Redis에서 즉시 반환 ✅ (DB 접근 없음)
  └── Cache MISS:
        ↓
        DB 조회 → 결과를 Redis에 저장(TTL 설정) → 클라이언트 반환

java

@Service
@RequiredArgsConstructor
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductRepository productRepository;

    private static final String PRODUCT_CACHE_KEY = "product:detail:";
    private static final Duration PRODUCT_TTL = Duration.ofHours(1);

    // Cache-Aside 수동 구현
    public ProductDto getProduct(Long productId) {
        String cacheKey = PRODUCT_CACHE_KEY + productId;

        // 1단계: 캐시 조회
        ProductDto cached = (ProductDto) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            log.debug("[CACHE HIT] product:{}", productId);
            return cached;
        }

        // 2단계: Cache Miss → DB 조회
        log.debug("[CACHE MISS] product:{}", productId);
        ProductDto product = productRepository.findById(productId)
            .map(ProductDto::from)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        // 3단계: 캐시 저장 (TTL 설정)
        redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_TTL);

        return product;
    }

    // 상품 수정 시 캐시 무효화 (Cache Invalidation)
    public ProductDto updateProduct(Long productId, ProductUpdateRequest request) {
        Product updated = productRepository.save(/* 업데이트 로직 */);

        // 캐시 삭제 → 다음 조회 시 DB에서 최신 데이터 가져옴
        redisTemplate.delete(PRODUCT_CACHE_KEY + productId);

        return ProductDto.from(updated);
    }
}

// Spring의 @Cacheable로 Cache-Aside 자동화
@Service
@RequiredArgsConstructor
public class ProductServiceWithAnnotation {

    private final ProductRepository productRepository;

    @Cacheable(
        value = "productDetail",        // 캐시 이름 (Redis key prefix)
        key = "#productId",             // 캐시 키
        unless = "#result == null"      // null이면 캐시 저장 안 함
    )
    public ProductDto getProduct(Long productId) {
        return productRepository.findById(productId)
            .map(ProductDto::from)
            .orElse(null);
        // 첫 호출: DB 조회 + 캐시 저장
        // 이후 호출: Redis에서 즉시 반환 (메서드 자체 실행 안 됨)
    }

    @CachePut(
        value = "productDetail",
        key = "#result.id"              // 저장 후 캐시 갱신
    )
    public ProductDto updateProduct(Long id, ProductUpdateRequest req) {
        // 업데이트 로직 → 반환값으로 캐시 자동 갱신
        return ProductDto.from(productRepository.save(/* 업데이트 */));
    }

    @CacheEvict(
        value = "productDetail",
        key = "#productId"             // 특정 키 삭제
    )
    public void deleteProduct(Long productId) {
        productRepository.deleteById(productId);
    }

    @CacheEvict(
        value = "productDetail",
        allEntries = true              // 전체 캐시 초기화 (주의: 대규모 환경에서 위험)
    )
    public void clearAllProductCache() { }
}

Cache-Aside 장단점:

장점단점
실제로 요청된 데이터만 캐싱Cache Miss 시 DB 조회 지연 발생
DB 장애 시에도 캐시로 서비스 가능처음 요청(콜드 스타트) 성능 저하
캐시 독립적 장애 허용캐시와 DB 데이터 불일치 시간 존재

전략 ② Write-Through – 쓰기 동시 갱신으로 데이터 일관성 보장

데이터를 저장할 때 DB와 캐시를 동시에 업데이트합니다. 캐시와 DB가 항상 동기화됩니다.

[Write-Through 흐름]

클라이언트 쓰기 요청
  ↓
애플리케이션 → DB 저장
            → Redis 캐시 동시 업데이트
  ↓
클라이언트 응답 (DB + 캐시 모두 최신 상태)

java

@Service
@RequiredArgsConstructor
public class UserProfileWriteThroughService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final UserRepository userRepository;

    @Transactional
    public UserProfile updateProfile(Long userId, ProfileUpdateRequest request) {
        // 1단계: DB 저장
        User user = userRepository.findById(userId).orElseThrow();
        user.updateProfile(request.getName(), request.getBio());
        User saved = userRepository.save(user);

        // 2단계: Redis 동시 업데이트 (Write-Through)
        UserProfile profile = UserProfile.from(saved);
        redisTemplate.opsForValue().set(
            "user:profile:" + userId,
            profile,
            Duration.ofDays(7)
        );

        return profile;
    }

    // 조회: 항상 캐시에 최신 데이터 존재 → Cache Miss 거의 없음
    public UserProfile getProfile(Long userId) {
        UserProfile cached = (UserProfile) redisTemplate.opsForValue()
            .get("user:profile:" + userId);

        if (cached != null) return cached;

        // 최초 조회 시 DB에서 로드 (이후 Write-Through로 항상 최신 유지)
        User user = userRepository.findById(userId).orElseThrow();
        UserProfile profile = UserProfile.from(user);
        redisTemplate.opsForValue().set(
            "user:profile:" + userId, profile, Duration.ofDays(7));
        return profile;
    }
}

전략 ③ Write-Behind (Write-Back) – 쓰기 성능 극대화

캐시에만 먼저 쓰고, 비동기로 DB에 반영합니다. 쓰기 성능이 극대화되지만 캐시 장애 시 데이터 손실 위험이 있습니다.

java

@Service
@RequiredArgsConstructor
public class ViewCountWriteBehindService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final PostRepository postRepository;

    // 조회수 증가: Redis에만 즉시 반영 (초당 수만 건 처리 가능)
    public Long incrementViewCount(Long postId) {
        String key = "post:views:" + postId;
        return redisTemplate.opsForValue().increment(key);
        // DB는 아직 업데이트 안 됨 → 비동기 배치로 처리
    }

    // 스케줄러로 주기적 DB 동기화 (Write-Behind 구현)
    @Scheduled(fixedDelay = 60000)  // 1분마다 실행
    public void flushViewCountsToDB() {
        // "post:views:*" 패턴의 키 조회
        Set<String> keys = redisTemplate.keys("post:views:*");
        if (keys == null || keys.isEmpty()) return;

        for (String key : keys) {
            try {
                String postIdStr = key.replace("post:views:", "");
                Long postId = Long.parseLong(postIdStr);

                // Redis에서 현재 값 가져오기 + 삭제 (원자적 GETDEL)
                Object viewCount = redisTemplate.opsForValue().getAndDelete(key);
                if (viewCount != null) {
                    // DB에 누적 업데이트
                    postRepository.incrementViewCount(
                        postId, Long.parseLong(viewCount.toString()));
                }
            } catch (Exception e) {
                log.error("[Write-Behind] 조회수 동기화 실패: key={}", key, e);
            }
        }
    }
}

전략 ④ Read-Through – 캐시가 DB 접근을 대신 처리

애플리케이션은 캐시에만 접근하고, Cache Miss 시 캐시 레이어가 자동으로 DB를 조회합니다. Spring에서는 @Cacheable이 이 패턴을 지원합니다.

java

// Redis Cache 설정 – TTL별 캐시 그룹 정의
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(
            RedisConnectionFactory connectionFactory) {

        // 기본 캐시 설정
        RedisCacheConfiguration defaultConfig =
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues();  // null 캐싱 방지

        // 용도별 TTL 커스터마이징
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("productDetail",
            defaultConfig.entryTtl(Duration.ofHours(1)));
        cacheConfigs.put("userProfile",
            defaultConfig.entryTtl(Duration.ofDays(1)));
        cacheConfigs.put("categoryList",
            defaultConfig.entryTtl(Duration.ofDays(7)));    // 자주 안 바뀌는 데이터
        cacheConfigs.put("stockInfo",
            defaultConfig.entryTtl(Duration.ofSeconds(30))); // 실시간성 중요

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

4가지 캐시 전략 비교 요약

전략데이터 일관성읽기 성능쓰기 성능구현 복잡도주요 용도
Cache-Aside중간 (TTL 동안 불일치 허용)높음보통낮음대부분의 읽기 캐시
Write-Through높음 (항상 일치)매우 높음낮음(지연)중간사용자 프로필, 설정
Write-Behind낮음 (지연 동기화)높음매우 높음높음조회수, 좋아요 카운터
Read-Through중간높음보통낮음(자동화)@Cacheable 자동 처리

4. 캐시 운영의 함정 – 스탬피드·불일치·Eviction 대응법

Redis 캐시 전략을 도입했어도 이 섹션의 함정들을 모르면 장애는 여전히 찾아옵니다.

함정 ① 캐시 스탬피드 (Cache Stampede) – 동시 폭격

캐시가 만료되는 순간, 수백 개의 동시 요청이 모두 Cache Miss를 경험하고 DB에 동시 쿼리를 날리는 현상입니다. DB가 순식간에 과부하에 걸립니다.

[캐시 스탬피드 발생 시나리오]

인기 상품 캐시 TTL: 1시간
→ 정각에 캐시 만료
→ 동시에 1,000개의 요청이 Cache Miss
→ 1,000개의 SELECT 쿼리 DB 동시 도달
→ DB CPU 100% → 쿼리 타임아웃 → 서비스 장애

해결책 1: Mutex Lock (분산 락으로 하나만 DB 조회)

java

@Service
@RequiredArgsConstructor
public class StampedePreventionService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductRepository productRepository;

    public ProductDto getProductSafe(Long productId) {
        String cacheKey = "product:detail:" + productId;
        String lockKey  = "lock:product:" + productId;

        // 1단계: 캐시 조회
        ProductDto cached = (ProductDto) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) return cached;

        // 2단계: 캐시 미스 → 분산 락 획득 시도
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "LOCKED", Duration.ofSeconds(5));

        if (Boolean.TRUE.equals(acquired)) {
            // 락 획득 성공 → DB 조회 후 캐시 저장
            try {
                // 다시 한번 캐시 확인 (락 대기 중 다른 스레드가 캐시 저장했을 수 있음)
                cached = (ProductDto) redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) return cached;

                ProductDto product = productRepository.findById(productId)
                    .map(ProductDto::from)
                    .orElseThrow();
                redisTemplate.opsForValue()
                    .set(cacheKey, product, Duration.ofHours(1));
                return product;
            } finally {
                redisTemplate.delete(lockKey);  // 반드시 락 해제
            }
        } else {
            // 락 획득 실패 → 잠깐 대기 후 재시도 (다른 스레드가 캐시 저장 중)
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return getProductSafe(productId);   // 재귀 재시도
        }
    }
}

해결책 2: TTL Jitter – TTL에 랜덤 값 추가

java

// 여러 캐시의 TTL을 동시에 만료되지 않도록 랜덤화
public void cacheWithJitter(String key, Object value, Duration baseTtl) {
    // 기본 TTL의 ±10% 범위에서 랜덤 TTL 설정
    long baseSeconds = baseTtl.getSeconds();
    long jitter = (long) (baseSeconds * 0.1 * (Math.random() * 2 - 1));
    Duration ttlWithJitter = Duration.ofSeconds(baseSeconds + jitter);

    redisTemplate.opsForValue().set(key, value, ttlWithJitter);
    // → 3,600초 TTL → 3,240 ~ 3,960초 랜덤 설정
    // → 동시 만료 방지
}

해결책 3: Probabilistic Early Expiration (PER)

java

// 캐시가 만료되기 전에 확률적으로 미리 갱신
public ProductDto getProductWithEarlyRefresh(Long productId) {
    String cacheKey = "product:detail:" + productId;
    String ttlKey   = "product:ttl:" + productId;

    ProductDto cached = (ProductDto) redisTemplate.opsForValue().get(cacheKey);
    Long remainingTtl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);

    if (cached != null && remainingTtl != null) {
        // 남은 TTL이 전체의 20% 이하 + 확률적 갱신
        long totalTtl = 3600L;
        double refreshProbability = Math.exp(-remainingTtl / (totalTtl * 0.2));
        if (Math.random() < refreshProbability) {
            log.debug("[PER] 조기 캐시 갱신: product:{}", productId);
            // 백그라운드 갱신 (현재 요청은 캐시 반환)
            CompletableFuture.runAsync(() -> refreshCache(productId));
        }
        return cached;
    }

    return refreshCache(productId);
}

함정 ② Cache Penetration – 없는 데이터 공격

DB에도 캐시에도 없는 데이터를 계속 요청하면, 모든 요청이 DB로 직행합니다. 악의적인 공격이나 잘못된 키 요청에 취약합니다.

java

// 해결책: Null 캐싱 (존재하지 않는 데이터도 캐시)
public ProductDto getProductWithNullCache(Long productId) {
    String cacheKey = "product:detail:" + productId;

    Object cached = redisTemplate.opsForValue().get(cacheKey);

    if (cached != null) {
        // "NULL" 문자열이면 없는 데이터 → 404 반환
        if ("NULL".equals(cached)) {
            throw new ProductNotFoundException(productId);
        }
        return (ProductDto) cached;
    }

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

    if (product.isEmpty()) {
        // 없는 데이터 → 짧은 TTL로 NULL 캐싱 (DB 부하 방지)
        redisTemplate.opsForValue()
            .set(cacheKey, "NULL", Duration.ofMinutes(5));
        throw new ProductNotFoundException(productId);
    }

    ProductDto dto = ProductDto.from(product.get());
    redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofHours(1));
    return dto;
}

함정 ③ Eviction 정책 – 메모리 가득 찰 때 무슨 일이 생기나

Redis 메모리가 가득 차면 maxmemory-policy 설정에 따라 데이터를 삭제합니다.

yaml

# redis.conf 또는 application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379

# Redis maxmemory 및 Eviction 정책 설정 (redis.conf)
# maxmemory 2gb
# maxmemory-policy allkeys-lru
정책설명권장 상황
noeviction메모리 초과 시 쓰기 거부, 오류 반환데이터 손실 절대 불가
allkeys-lru전체 키 중 최근 사용되지 않은 것 삭제일반 캐시 환경 권장
volatile-lruTTL 있는 키 중 LRU 삭제캐시·세션 혼합 환경
allkeys-lfu사용 빈도 가장 낮은 키 삭제접근 패턴이 불균일한 환경
volatile-ttlTTL이 가장 짧은 키부터 삭제TTL 기반 캐시 관리
allkeys-random랜덤 삭제비권장

java

// 메모리 사용량 모니터링
@Component
@RequiredArgsConstructor
public class RedisMemoryMonitor {

    private final RedisTemplate<String, Object> redisTemplate;

    @Scheduled(fixedDelay = 60000)
    public void checkMemoryUsage() {
        Properties info = redisTemplate.getConnectionFactory()
            .getConnection().serverCommands().info("memory");

        String usedMemory     = info.getProperty("used_memory_human");
        String maxMemory      = info.getProperty("maxmemory_human");
        String evictedKeys    = info.getProperty("evicted_keys");

        log.info("[Redis] 메모리: {}/{}, 퇴거 키: {}",
            usedMemory, maxMemory, evictedKeys);

        // 메모리 사용률 80% 초과 시 알림
        long used = Long.parseLong(info.getProperty("used_memory"));
        long max  = Long.parseLong(info.getProperty("maxmemory"));
        if (max > 0 && (double) used / max > 0.8) {
            alertService.sendMemoryAlert(used, max);
        }
    }
}

5. Redis 세션 관리 – 분산 환경의 로그인 설계 패턴

왜 분산 환경에서 세션 관리가 어려운가

단일 서버에서는 세션을 서버 메모리에 저장해도 문제가 없습니다. 하지만 서버가 여러 대로 확장되면 심각한 문제가 발생합니다.

[분산 환경 세션 문제]

로드밸런서
    ├── 서버 A (세션 저장: userId=1 로그인 완료)
    ├── 서버 B (세션 없음: userId=1 모름)
    └── 서버 C (세션 없음: userId=1 모름)

사용자 첫 요청 → 서버 A → 로그인 처리 → 세션 저장
사용자 두 번째 요청 → 서버 B로 라우팅 → "세션 없음" → 로그아웃 처리 ❌

Redis를 중앙 세션 저장소로 사용하는 방식:

[Redis 분산 세션 구조]

로드밸런서
    ├── 서버 A ─┐
    ├── 서버 B ─┼──→ Redis (중앙 세션 저장소)
    └── 서버 C ─┘        ↑
                     session:abc123 → {userId:1, role:USER, ...}
                     session:def456 → {userId:2, role:ADMIN, ...}

모든 서버가 Redis에서 세션을 읽고 씀 → 어느 서버로 요청이 가도 동일한 세션

Spring Session + Redis 완전 설정

xml

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

yaml

# application.yml
spring:
  session:
    store-type: redis
    redis:
      namespace: "myapp:session"    # Redis key prefix
      flush-mode: on-save           # 세션 변경 시에만 Redis 저장 (성능 최적화)
    timeout: 30m                    # 세션 만료 시간 (마지막 접근 기준)
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      lettuce:
        pool:
          max-active: 10
          max-idle: 5
          min-idle: 2

java

@Configuration
@EnableRedisHttpSession(
    maxInactiveIntervalInSeconds = 1800,    // 30분 세션 유지
    redisNamespace = "myapp:session"
)
public class SessionConfig {

    // Redis 세션 직렬화 설정
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

// 세션 활용 컨트롤러
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/api/login")
    public ResponseEntity<LoginResponse> login(
            @RequestBody LoginRequest request,
            HttpSession session) {

        UserInfo userInfo = authService.authenticate(
            request.getEmail(), request.getPassword());

        // Redis에 세션 데이터 저장
        session.setAttribute("userId",   userInfo.getId());
        session.setAttribute("userRole", userInfo.getRole().name());
        session.setAttribute("loginAt",  LocalDateTime.now().toString());

        // JSESSIONID 쿠키 자동 발급 → 이후 요청에 자동 포함
        return ResponseEntity.ok(LoginResponse.of(userInfo));
    }

    @PostMapping("/api/logout")
    public ResponseEntity<Void> logout(HttpSession session) {
        session.invalidate();   // Redis에서 세션 즉시 삭제
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/api/me")
    public ResponseEntity<UserInfo> getMyInfo(HttpSession session) {
        Long userId = (Long) session.getAttribute("userId");
        if (userId == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(authService.getUserInfo(userId));
    }
}

JWT + Redis 블랙리스트 – 토큰 기반 인증의 로그아웃 문제 해결

JWT는 서버 상태 없이 인증하지만, 로그아웃 후 토큰 무효화가 어렵습니다. Redis를 활용해 이 문제를 해결합니다.

java

@Service
@RequiredArgsConstructor
public class JwtBlacklistService {

    private final RedisTemplate<String, Object> redisTemplate;

    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";

    // 로그아웃 시 토큰을 블랙리스트에 추가
    public void blacklistToken(String token) {
        String jti = jwtParser.extractJti(token);      // JWT ID (고유 식별자)
        long remainingTtl = jwtParser.getRemainingTtl(token);  // 남은 유효 시간

        // 토큰이 자연 만료될 때까지만 블랙리스트 유지 (메모리 효율)
        redisTemplate.opsForValue().set(
            BLACKLIST_PREFIX + jti,
            "REVOKED",
            Duration.ofSeconds(remainingTtl)
        );
        log.info("[JWT] 토큰 무효화: jti={}, ttl={}s", jti, remainingTtl);
    }

    // 요청마다 블랙리스트 확인 (필터에서 호출)
    public boolean isBlacklisted(String token) {
        String jti = jwtParser.extractJti(token);
        return Boolean.TRUE.equals(
            redisTemplate.hasKey(BLACKLIST_PREFIX + jti));
    }
}

// JWT 인증 필터에서 블랙리스트 확인
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtBlacklistService blacklistService;
    private final JwtTokenParser jwtParser;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String token = extractToken(request);

        if (token != null) {
            // 블랙리스트 확인 (로그아웃된 토큰 차단)
            if (blacklistService.isBlacklisted(token)) {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                return;
            }

            // 정상 토큰 처리
            if (jwtParser.isValid(token)) {
                Authentication auth = jwtParser.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }

        filterChain.doFilter(request, response);
    }
}

6. 전문가 관점 – 분산 락·Pub/Sub·고가용성 설계 원칙

분산 락 – Redisson으로 안전하게 구현

단순 SETNX로 구현한 분산 락은 락 해제 실패, 만료 전 재진입 등 엣지 케이스가 많습니다. Redisson은 이를 안전하게 구현한 검증된 라이브러리입니다.

xml

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.25.2</version>
</dependency>

java

@Service
@RequiredArgsConstructor
public class DistributedLockService {

    private final RedissonClient redissonClient;

    // 재고 차감 – 동시 요청 시 하나만 처리
    public OrderResult deductStock(Long productId, int quantity)
            throws InterruptedException {

        String lockKey = "lock:stock:" + productId;
        RLock lock = redissonClient.getLock(lockKey);

        // 최대 3초 대기, 10초 후 자동 해제 (서버 장애 대비)
        boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);

        if (!acquired) {
            throw new StockLockException("재고 처리 중입니다. 잠시 후 다시 시도하세요.");
        }

        try {
            // 락 보유 중 단독 실행 보장
            Product product = productRepository.findByIdWithLock(productId);

            if (product.getStock() < quantity) {
                throw new InsufficientStockException(productId, quantity);
            }

            product.deductStock(quantity);
            productRepository.save(product);

            return OrderResult.success(productId, quantity);

        } finally {
            // 반드시 락 해제 (finally 블록에서)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    // @DistributedLock 어노테이션으로 AOP 추상화
    @DistributedLock(
        key = "'stock:' + #productId",
        waitTime = 3,
        leaseTime = 10
    )
    public void deductStockWithAop(Long productId, int quantity) {
        // 락 획득/해제를 AOP가 자동 처리
        Product product = productRepository.findById(productId).orElseThrow();
        product.deductStock(quantity);
        productRepository.save(product);
    }
}

Pub/Sub – 실시간 이벤트 브로드캐스팅

java

// 발행자 (Publisher)
@Service
@RequiredArgsConstructor
public class NotificationPublisher {

    private final RedisTemplate<String, Object> redisTemplate;

    public void publishOrderCompleted(OrderCompletedEvent event) {
        redisTemplate.convertAndSend(
            "channel:order:completed",  // 채널 이름
            event                       // JSON 직렬화 후 발행
        );
    }
}

// 구독자 (Subscriber) 설정
@Configuration
public class RedisSubscriberConfig {

    @Bean
    public RedisMessageListenerContainer container(
            RedisConnectionFactory connectionFactory,
            MessageListenerAdapter orderCompletedListener) {

        RedisMessageListenerContainer container =
            new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);

        // 채널 구독 등록
        container.addMessageListener(
            orderCompletedListener,
            new PatternTopic("channel:order:*")  // 패턴 구독
        );
        return container;
    }

    @Bean
    public MessageListenerAdapter orderCompletedListener(
            OrderEventHandler handler) {
        return new MessageListenerAdapter(handler, "handleOrderCompleted");
    }
}

@Component
@Slf4j
public class OrderEventHandler {

    public void handleOrderCompleted(OrderCompletedEvent event) {
        log.info("[Pub/Sub] 주문 완료 이벤트 수신: orderId={}", event.getOrderId());
        // 푸시 알림, 이메일 발송, 재고 업데이트 등 처리
    }
}

Redis 고가용성 – Sentinel과 Cluster

yaml

# Redis Sentinel 설정 (마스터 장애 시 자동 페일오버)
spring:
  data:
    redis:
      sentinel:
        master: mymaster           # Sentinel에 등록된 마스터 이름
        nodes:
          - sentinel1:26379
          - sentinel2:26379
          - sentinel3:26379
        password: ${SENTINEL_PASSWORD}

# Redis Cluster 설정 (수평 확장)
spring:
  data:
    redis:
      cluster:
        nodes:
          - redis-node1:6379
          - redis-node2:6379
          - redis-node3:6379
          - redis-node4:6379
          - redis-node5:6379
          - redis-node6:6379       # 3 마스터 + 3 레플리카
        max-redirects: 3
      lettuce:
        cluster:
          refresh:
            adaptive: true         # 클러스터 토폴로지 변경 자동 감지
            period: 30s

Redis 운영 배포 전 체크리스트

[Redis 실무 운영 체크리스트]

□ maxmemory 설정: 전체 RAM의 60~70%로 제한 (OS 여유분 확보)?
□ Eviction 정책: 용도에 맞는 정책 선택 (캐시→allkeys-lru 권장)?
□ persistence 설정: 캐시 전용이면 RDB/AOF 끄기 (성능 향상)?
□ TTL 필수 설정: 모든 캐시 키에 TTL 지정 (메모리 누수 방지)?
□ 키 네이밍 규칙: "서비스:도메인:ID" 형식으로 통일?
□ 연결 풀 설정: Lettuce 풀 크기 최적화 완료?
□ 직렬화 전략: JSON 직렬화로 타입 안정성 확보?
□ 고가용성: Sentinel 또는 Cluster 구성 완료?
□ 모니터링: 메모리 사용률·히트율·Eviction 수 알림 설정?
□ 슬로우 로그: slowlog-log-slower-than 설정으로 느린 명령 추적?
□ 캐시 스탬피드 방지: Mutex Lock 또는 TTL Jitter 적용?
□ 보안: requirepass 설정, 외부 포트 차단, TLS 적용?

결론

Redis 캐시 전략은 단순히 “빠르게 만들기 위해 앞에 끼워 넣는 것”이 아닙니다. Cache-Aside로 읽기 부하를 줄이고, Write-Through로 데이터 일관성을 보장하며, Write-Behind로 쓰기 성능을 극대화하는 세 가지 전략을 데이터 특성에 맞게 선택해야 합니다. 캐시 스탬피드와 Cache Penetration 같은 운영 함정을 미리 방어하고, 분산 환경에서는 Redis를 세션 저장소로 활용해 서버 간 세션 불일치 문제를 원천 차단해야 합니다. Redisson 기반 분산 락으로 동시성 문제를 해결하고, Sentinel 또는 Cluster로 고가용성을 갖추는 것까지 Redis 실무 활용의 전체 그림입니다.

지금 바로 본문의 운영 체크리스트를 적용해 현재 프로젝트에서 TTL 없이 저장되고 있는 키가 없는지, Eviction 정책이 올바르게 설정됐는지부터 점검해 보세요.


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다