Redis 캐시 전략과 동시성 문제 해결 방법

조제·2025년 4월 8일
0

읽기 전략

  • Look Aside 패턴
    • 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 확인하는 전략. 만일 캐시에 데이터가 없으면 DB에서 조회함.
    • 반복적인 읽기가 많은 호출에 적합
    • 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음.
  • Read Through 패턴
    • 캐시에서만 데이터를 읽어오는 전략
    • 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식
    • redis가 다운될 경우 서비스 이용에 차질이 생길 수 있음.

쓰기 전략

  • Write Back 패턴
    • 데이터를 저장할때 DB에 바로 쿼리하지않고, 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
    • Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
  • Write Through 패턴
    • 데이터베이스와 캐시에 동시에 데이터를 저장하는 전략
    • 데이터를 저장할 때 먼저 캐시에 저장한 다음 바로 DB에 저장
    • DB 동기화 작업을 캐시에게 위임
    • 데이터 유실이 발생하면 안되는 상황에 적합
  • Write Around 패턴
    • 모든 데이터는 DB에 저장(캐시를 갱신하지 않음)
    • Cache miss가 발생하는 경우에만 DB와 캐시에도 데이터를 저장
    • 캐시와 DB 내의 데이터가 다를 수 있음

캐시 읽기 + 쓰기 전략 조합

  • Look Aside + Write Around 조합
    - 데이터를 읽어올때 항상 캐시를 먼저 조회하고 Cache miss가 발생하는 경우에만 DB와 캐시에 저장
  • Read Through + Write Around 조합
    • 항상 DB에 쓰고, 캐시에서 읽을때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 안전 장치
  • Read Through + Write Through 조합
    • 데이터를 쓸때 항상 캐시에 먼저 쓰므로 읽어올때 최신 캐시 데이터 보장
    • 데이터를 쓸때 항상 캐시에서 DB로 보내므로 데이터 정합성 보장

대량의 정적 데이터 캐시 전략

캐시 읽기 전략

  • ✅ Look-Aside (Lazy Loading)
    • 애플리케이션이 먼저 캐시에 데이터를 요청합니다.
    • 캐시 미스(cache miss)가 발생하면 DB에서 조회하고, 조회된 데이터를 캐시에 저장합니다.
    • 자주 사용되는 데이터만 메모리에 올라오므로 메모리 효율성이 좋습니다.
    • 캐시 장애가 발생해도 DB에서 조회가 가능하므로 안정성이 높습니다.

캐시 쓰기 전략

  • ✅ Write-Around
    • 데이터 변경 시, DB에만 직접 저장하고 캐시는 갱신하지 않습니다.
    • 이후 조회 시 캐시에 해당 데이터가 없으면 다시 DB에서 읽어와 캐시에 저장됩니다.
    • 자주 변경되지 않는 정적 데이터에 적합하며, 캐시 무효화 및 갱신 부담이 줄어듭니다.

추천 조합: Look-Aside + Write-Around

  • 👍 왜 이 전략이 적합한가?
    • 읽기 비중이 매우 높은 데이터에 적합합니다.
    • 정적 데이터로서 자주 변경되지 않기 때문에, 캐시 무효화나 실시간 갱신이 필요하지 않습니다.
    • 캐시에 필요한 데이터만 올라오고, 메모리 낭비가 없습니다.

예제 코드: 정적 컨텐츠 조회

@Repository
@RequiredArgsConstructor
public class ExampleContentRepository implements ExampleContentAdapter {
    private final ExampleContentJpaRepository jpaRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final long CACHE_TTL = TimeUnit.HOURS.toSeconds(24); // 24시간 TTL

    @Override
    public List<ExampleDto> findByTypeAndAge(ContentType type, int age) {
        String cacheKey = "example:content:type:" + type.name() + ":age:" + age;

        // 1. 캐시에서 조회
        List<ExampleDto> cached = (List<ExampleDto>) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 2. 캐시에 없으면 DB 조회
        List<ExampleEntity> entities = jpaRepository.findByTypeAndAge(type, age);
        List<ExampleDto> result = entities.stream()
                .map(e -> new ExampleDto(e.getId(), e.getType(), e.getAge(), e.getRank(), e.getName()))
                .toList();

        // 3. 캐시에 저장 (Lazy Loading)
        redisTemplate.opsForValue().set(cacheKey, result, CACHE_TTL, TimeUnit.SECONDS);

        return result;
    }
}

동시성 문제 상황

예를 들어, 어떤 사용자 행동을 저장하는 API에서 동시 요청이 발생할 경우 다음과 같은 문제가 발생할 수 있습니다:

❗ 문제 예시 (User Action 상태 덮어쓰기)

  1. 요청 A: 동의서 제출 여부를 true로 변경
  2. 요청 B: 이메일 인증 여부를 true로 변경
  3. A와 B는 동시에 DB에서 기존 데이터를 조회 (false, false)
  4. A는 true, false를 저장
  5. B는 false, true를 저장
  6. 최종적으로 A 또는 B 중 하나의 데이터만 반영됨 (true, false 또는 false, true)

✅ 해결 전략 1: Redis WATCH + MULTI/EXEC (Optimistic Lock)

  • Redis 자체의 동시성 제어 기능 활용
  • WATCH를 사용하여 값이 변경되었는지 확인 후 저장
  • MULTI/EXEC를 사용하여 트랜잭션처럼 실행
public void updateUserAction(UUID userId, boolean consent, boolean emailVerified) {
    String key = "example:user-action:" + userId;

    redisTemplate.execute((RedisCallback<Void>) connection -> {
        connection.watch(key.getBytes());

        byte[] raw = connection.get(key.getBytes());
        ExampleUserAction userAction = raw == null
            ? jpaRepository.findByUserId(userId)
            : deserialize(raw);

        userAction.setConsent(consent || userAction.isConsent());
        userAction.setEmailVerified(emailVerified || userAction.isEmailVerified());

        connection.multi();
        connection.set(key.getBytes(), serialize(userAction));
        connection.exec();
        return null;
    });

    jpaRepository.save(userAction);
}

✅ 해결 전략 2: Redisson 분산 락 (Pessimistic Lock)

  • Lock을 사용하여 한 번에 하나의 요청만 처리 가능
  • WATCHMULTI/EXEC 방식은 단일 Redis 노드 환경에서는 유용하지만, 클러스터링된 Redis 환경에서는 적용이 어렵다.
  • Redisson은 분산 환경에서도 동작하는 분산 락을 제공하여 동시성 문제를 효과적으로 해결할 수 있다.
  • Redisson의 RLock을 사용하면, 여러 인스턴스에서 동일한 키에 대한 동시성 처리를 안전하게 관리할 수 있다.
  • 단점: 트랜잭션 처리보다 성능이 낮음 (락 경쟁 발생 가능)
public void updateUserAction(UUID userId, boolean consent, boolean emailVerified) {
    RLock lock = redissonClient.getLock("lock:user-action:" + userId);
    try {
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            ExampleUserAction action = jpaRepository.findByUserId(userId);
            if (consent) action.setConsent(true);
            if (emailVerified) action.setEmailVerified(true);
            jpaRepository.save(action);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        lock.unlock();
    }
}

마무리

Look-Aside + Write-Around 전략은 대량의 정적 데이터를 다룰 때 효율적이며, 읽기 성능을 최적화하는 데 매우 유리합니다. 하지만 캐시 전략만으로는 모든 문제를 해결할 수 없습니다. 사용자 상태나 변경 정보처럼 동시에 변경될 수 있는 데이터는 Optimistic Lock 또는 분산 락과 같은 명시적 동시성 제어가 필수입니다.

이 두 가지를 적절히 조합하면 캐시 성능과 데이터 정합성을 모두 만족하는 설계를 만들 수 있습니다.

profile
조제

0개의 댓글