[DB] Redis 활용 패턴 1️⃣

cup-wan·2025년 8월 10일
3

Database

목록 보기
2/3

Intro

Redis는 초저지연(1ms 미만)과 다양한 자료구조 지원 덕분에 캐시, 세션 관리, 분산 락, 작업 큐, 실시간 순위 집계 등 정말 다양한 패턴을 소화합니다.
저는 프로젝트에서 주로 사용자 토큰 관리 같은 특정 기간이 지나면 사라지는 데이터들을 저장하는 용도로 자주 사용했던 것 같습니다.

많은 개발자가 Redis를 선호하고 있는데 왜 redis를 사용할까요?

이번 글에서는 자주 쓰이는 Redis 활용 패턴 7가지 중 Cache-Aside, 분산 락, Rate Limiting에 대해 정리하려 합니다.
언제 이 패턴을 쓰고, 왜 필요한지, 어떻게 구현하는지를 예제 코드와 함께 살펴보겠습니다. 예제는 모두 Spring Boot + Spring Data Redis 사용을 기본으로 합니다.

1. Cache-Aside (Lazy Loading캐시)

DB를 원본 (Source of Truth)으로 두고, 조회할 때만 캐시에 적재하는 가장 표준적인 캐싱 전략

  • 조회가 많은 서비스에 효과적입니다.
  • 캐시 장애 시, DB가 살아있다면 기능 유지가 가능합니다. (성능은 저하)
  • 반대로 쓰기(Write)가 잦고 강한 일관성이 필요하면 다른 패턴을 고려해야 합니다.

How?

기본 동작 흐름
1. 캐시에서 데이터 조회
2. 없으면 DB에서 조회
3. 조회 결과를 캐시에 저장 (TTL 포함)
4. 결과 반환

[의존성 + 설정]

implementation("org.springframework.boot:spring-boot-starter-data-redis")
// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.fasterxml.jackson.core:jackson-databind")
// RedisConfig.java
@Configuration
public class RedisConfig {

  @Bean
  public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
    RedisTemplate<String, Object> tpl = new RedisTemplate<>();
    tpl.setConnectionFactory(cf);
    var serializer = new GenericJackson2JsonRedisSerializer();
    tpl.setKeySerializer(new StringRedisSerializer());
    tpl.setValueSerializer(serializer);
    tpl.setHashKeySerializer(new StringRedisSerializer());
    tpl.setHashValueSerializer(serializer);
    tpl.afterPropertiesSet();
    return tpl;
  }

  @Bean
  public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory cf) {
    return new StringRedisTemplate(cf);
  }
}

[서비스 : Cache-Aside]

@Service
@RequiredArgsConstructor
public class UserService {
  private final UserRepository userRepository;
  private final RedisTemplate<String, Object> redisTemplate;
  private static final Duration TTL = Duration.ofMinutes(10);

  private String keyForUser(Long id) { return "user:" + id; }

  @Transactional(readOnly = true)
  public UserDto getUser(Long id) {
    String key = keyForUser(id);

    // 1) 캐시 조회
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached != null) return (UserDto) cached;

    // 2) DB 조회
    User user = userRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("user not found"));

    UserDto dto = UserDto.from(user);

    // 3) 캐시에 저장 + TTL
    redisTemplate.opsForValue().set(key, dto, TTL);

    // 4) 응답
    return dto;
  }
}

문제 상황

1) 캐시 스탬피드 (Thundering Herd)
TTL이 동시에 만료되면 다수의 요청이 한번에 DB로 몰립니다. 이를 해결하기 위해 잠금 / Redission 락, Soft TTL + Hard TTL을 활용할 수 있습니다.

  1. 잠금 (SET NE EX) / Redisson 락
// Redisson 사용 예
@Service
@RequiredArgsConstructor
public class UserServiceWithLock {
  private final UserRepository userRepository;
  private final RedisTemplate<String, Object> redisTemplate;
  private final RedissonClient redisson;
  private static final Duration TTL = Duration.ofMinutes(10);

  @Transactional(readOnly = true)
  public UserDto getUser(Long id) {
    String key = "user:" + id;

    Object cached = redisTemplate.opsForValue().get(key);
    if (cached != null) return (UserDto) cached;

    RLock lock = redisson.getLock("lock:" + key);
    boolean locked = false;
    try {
      locked = lock.tryLock(200, 5_000, TimeUnit.MILLISECONDS); // 대기 200ms, 보유 5s
      // 잠금 획득 후 다시 캐시 확인(중복 DB 접근 방지)
      cached = redisTemplate.opsForValue().get(key);
      if (cached != null) return (UserDto) cached;

      User user = userRepository.findById(id).orElseThrow();
      UserDto dto = UserDto.from(user);
      redisTemplate.opsForValue().set(key, dto, TTL);
      return dto;
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new RuntimeException(e);
    } finally {
      if (locked) lock.unlock();
    }
  }
}
  1. Soft TTL + Hard TTL (stale-while-revalidate)
  • Hard TTL : 데이터가 너무 오래되면 무조건 재계산
  • Soft TTL : 만료 임박 시, 첫 번째 요청만 백그라운드에서 갱신 시도, 나머지는 오래된 값 (stale) 즉시 반환 -> 체감 지연 시간 감소
  • 구현은 캐시 값에 fetchedAt을 같이 저장한 후 Soft TTL 비동기 리프레시 수행

2) 캐시 관통 (Cache Penetration)
존재하지 않는 키를 계속 요청 : DB 성능 타격

  1. Null 캐싱 : DB에 없으면 NULL도 짧은 TTL로 캐시
redisTemplate.opsForValue().set(key, NullMarker.INSTANCE, Duration.ofSeconds(30));
  1. Bloom Filter : 존재 가능성 검사 후 DB 접근 (RedisBloom 모듈 사용)

쓰기 시나리오 + 일관성

Cache-Aside에선 Write 시 캐시를 직접 갱신하지 않고 무효화를 하는 것이 일반적입니다.

  1. (기본) Update/Delete -> 트랜잭션 성공 후 캐시 삭제
@Transactional
public void updateUserName(Long id, String name) {
  userRepository.updateName(id, name); // DB 정본
  redisTemplate.delete("user:" + id);  // 캐시 무효화
}
  1. (레이스 컨디션 방지) 지연 이중 삭제
    요청 A가 DB를 갱신하기 직전, 요청 B가 캐시 미스를 내고 DB의 이전 값을 가져와 캐시할 수 있습니다. 방지책으로 캐시를 한번 더 삭제합니다.
@Transactional
public void updateUserName(Long id, String name) {
  userRepository.updateName(id, name);
  String key = "user:" + id;
  redisTemplate.delete(key);           // 1차 삭제
  // 약간 지연 후 2차 삭제 (스케줄러/메시지 큐/간단한 sleep 등)
  CompletableFuture.runAsync(() -> {
    try { Thread.sleep(200); } catch (InterruptedException ignored) {}
    redisTemplate.delete(key);         // 2차 삭제
  });
}
  1. 이벤트 기반 무효화
    더 견고하게 하기 위해 DB 변경 이벤트를 메시지 큐 (kafka 등)로 발행할 수 있습니다.

TTL 설계 방법

  • 랜덤 지터(±10~20%)를 섞어 동시 만료 폭을 줄이기 (예: 600초 ± 60초)
  • 데이터 성격별로 TTL 차등 설정(프로필 10m, 카운트 30s …)
  • Hot Key는 TTL을 더 짧게 + Soft TTL 리프레시

@Cacheable vs 수동 구현

항목@Cacheable수동 구현
구현 속도빠름느림
세밀 제어제한적자유
스탬피드 방지직접 구현 필요가능
무효화 제어제한적가능

2. 분산 락 (Distributed Lock)

분산 락은 여러 서버 인스턴스가 동시에 접근하는 공유 자원을 한번에 하나의 작업만 처리하도록 제어하는 기술입니다. 쇼핑몰 재고 관리, 예약 시스템 좌석 배정, 결제 중복 방지 등에 사용됩니다.

Redis 기반 분산 락 특징

  • 단일 Redis 인스턴스에서 가능 (SET NX EX)
  • 네트워크로 연결된 다수의 서버 간 동기화 가능
  • 락 만료 시간 (TTL)로 영구 락 방지

How?

동작 흐름
1. 락 획득 시도 -> 키 없으면 생성 (SET NX) + 만료 시간 설정 (EX)
2. 작업 수행
3. 락 해제 -> 락 소유자만 해제

[서비스 - 분산 락 SET NX EX]

@Service
@RequiredArgsConstructor
public class InventoryService {
  private final RedisTemplate<String, String> redisTemplate;
  private static final String LOCK_KEY = "lock:inventory";
  private static final Duration LOCK_TTL = Duration.ofSeconds(5);

  public boolean tryLock(String lockValue) {
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(LOCK_KEY, lockValue, LOCK_TTL);
    return Boolean.TRUE.equals(success);
  }

  public void unlock(String lockValue) {
    String currentValue = redisTemplate.opsForValue().get(LOCK_KEY);
    if (lockValue.equals(currentValue)) {
      redisTemplate.delete(LOCK_KEY);
    }
  }

  public void processOrder(Long productId) {
    String lockValue = UUID.randomUUID().toString();
    if (!tryLock(lockValue)) throw new RuntimeException("다른 서버에서 처리 중입니다.");

    try {
      // 재고 차감 로직
    } finally {
      unlock(lockValue);
    }
  }
}

[Redisson 기반 구현]

@Service
@RequiredArgsConstructor
public class InventoryServiceWithRedisson {
  private final RedissonClient redisson;

  public void processOrder(Long productId) {
    RLock lock = redisson.getLock("lock:inventory:" + productId);

    boolean acquired = false;
    try {
      // 최대 200ms 대기, 락 점유 5초, Watchdog이 자동 연장
      acquired = lock.tryLock(200, 5, TimeUnit.SECONDS);
      if (!acquired) throw new RuntimeException("다른 서버에서 처리 중입니다.");

      // 재고 차감 로직
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new RuntimeException(e);
    } finally {
      if (acquired) lock.unlock();
    }
  }
}

락 해제 시 반드시 락 소유자만 삭제해야한다. (다른 스레드 락 오염 방지)

문제 상황

  1. TTL보다 작업이 오래 걸리는 경우
  • TTL 만료 -> 다른 서버가 락 획득 -> 중복 실행 가능성
  • 해결책
    • Redisson의 락 자동 연장 (Watchdog)기능 사용
    • TTL을 충분히 길게 설정, 과도하게 길면 데드락 가능성이 있으니..적당히...
  1. Redis 장애 시
  • 단일 노드 다운 -> 락 무효화
  • 해결책 : Redlock 알고리즘 (여러 노드 과반수 동의 시 락 획득)
    • 네트워크 지연, 시계 드리프트 문제로 논쟁이 많음
    • 단일 인스턴스 + 고가용성 구성이 일반적

설계 고려사항

  • 락 키 설계 : lock:{resourceId}
  • 락 소유자 식별 : UUID 등 고유값 저장
  • TTL 설정 시 지터 적용 가능
  • 장기 작업은 락 분할 고려 (큰 작업 -> 작은 청크로 나누기_

3. Rate Limiting

Rate Limiting은 일정 시간 동안 허용되는 요청 횟수를 제한하는 기법이다. 서버 자원 보호, API 남용 방지, 보안 강화 등에 활용된다.

효도란 무엇일까요. 그건 임영웅 콘서트 예매입니다. 물론 278469번째는 효도를 할 수 없습니다.

이런 상황에서 Rate Limiting이란 기법을 사용할 수 있습니다. 수 많은 사람이 콘서트 예매를 위해 몰린다 생각하면 백엔드 개발자는 머리가 아플 수 밖에 없습니다.
로그인 시도 제한, 구매 API 초당 호출 수 제한, SMS 인증 요청 제한 등 모든 과정의 API를 얼마나 제한해야할지를 고민해야합니다.

Redis 기반 Rate Limiting 특징

  • 초저지연 : 메모리 기반이라 카운트 처리 속도가 빠름
  • *분산 환경 대응 : 여러 서버에서 동시에 접근해도 Redis 단일 키로 제어 가능
  • 다양한 구현 방식 : Fixed Window, Sliding Window, Token Bucket

구현 방식

1. Fixed Window

  • 일정 시간 간격 (예: 1분) 내 요청 횟수 제한
  • 간단하지만 시간 경계에서 순간 폭주 가능
// 예: user:login:12345 → 60초 동안 5회 제한
Boolean exists = redisTemplate.hasKey(key);
Long count = redisTemplate.opsForValue().increment(key);
if (Boolean.FALSE.equals(exists)) {
    redisTemplate.expire(key, Duration.ofSeconds(60));
}
if (count > 5) {
    throw new RuntimeException("요청 횟수 초과");
}

2. Sliding Widnow (Sorted Set)

  • 요청 타임 스탬프를 기록하고, 윈도우 범위 밖 데이터 제거
  • 시간 경계 문제 해소 : 어느 시점이든 직전 N초 기준으로 계산
public boolean isAllowed(String userId) {
    String key = "rate:" + userId;
    long now = System.currentTimeMillis();
    long windowMillis = 60_000;
    long limit = 5;

    ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();

    // 현재 요청 시간 기록
    zSetOps.add(key, String.valueOf(now), now);

    // 윈도우 밖 데이터 삭제
    zSetOps.removeRangeByScore(key, 0, now - windowMillis);

    // 현재 윈도우 요청 수 확인
    Long count = zSetOps.zCard(key);
    redisTemplate.expire(key, Duration.ofMillis(windowMillis));

    return count <= limit;
}

3. Token Bucket

  • 버킷에 토큰이 일정 속도로 채워지고, 요청 시 토큰 사용
  • 부드러운 속도 제어, burst 허용
  • Redis 스크립트 (Lua)로 원자적 구현이 대다수

문제 상황

  1. 시간 경계 폭주 (Fixed Window)
  • 경계 시점에 요청, 직후에도 요청하면 2배 처리 가능
  • 해결 : 다른 방법 사용 (Sliding Window, Token Bucket)
  1. 원자성 문제
  • INCR + EXPIRE를 따로 호출하면 중간에 장애 시 TTL 미설정
  • 해결 : SET key value NX EX 또는 Lua 스크립트로 원자화
  1. 대규모 사용자 처리
  • 많은 유저가 동시 호출 시 Redis 키가 급증
  • 해결 : TTL로 키 자동 삭제, 샤딩 또는 Cluster 분산

설계 고려사항

  • 키 패턴 : rate:{userId}:{endPoint} 또는 rate:{IP}
  • TTL은 제한 시간과 동일하게 설정
  • 제한 값과 시간은 환경 변수나 DB에서 관리 -> 실시간 조정 가능
  • 모니터링 : 제한 횟수, 차단 횟수, 차단된 사용자 리스트 추적 등

Outro

그저 토큰 저장소로 사용했던 Redis가 서비스를 고도화할수록 얼마나 다양한 역할을 할 수 있는지 살펴봤습니다.
다음 글에서는 세션/토큰 스토어, 작업 큐, Pub/Sub, 리더보드 등 이번에 다루지 못한 나머지 패턴도 살펴보겠습니다.

profile
아무것도 안해서 유죄 판결 받음

0개의 댓글