DB와 Cache 사이의 일관성 관리 전략

들어가며

캐시는 DB 부하를 줄이고 응답 속도를 높이는 핵심 기술입니다. 하지만 같은 데이터가 두 곳(DB, Cache)에 존재하기 때문에 일관성 문제가 발생합니다.

이 글에서는 캐시 전략별 특징과 일관성 문제, 그리고 해결 방법을 알아봅니다.

캐시 전략 개요

1. Cache-Aside (Lazy Loading)

가장 널리 사용되는 패턴입니다. 애플리케이션이 캐시와 DB를 직접 관리합니다.

캐시어사이드 전략

public class CacheAsideService {

    public User getUser(Long userId) {
        String cacheKey = "user:" + userId;

        // 1. 캐시 조회
        User cached = cache.get(cacheKey, User.class);
        if (cached != null) {
            return cached;  // Cache Hit
        }

        // 2. Cache Miss - DB 조회
        User user = userRepository.findById(userId)
            .orElseThrow();

        // 3. 캐시에 저장
        cache.put(cacheKey, user, Duration.ofMinutes(30));

        return user;
    }

    public void updateUser(Long userId, UserUpdateRequest request) {
        // 1. DB 업데이트
        userRepository.update(userId, request);

        // 2. 캐시 삭제 (무효화)
        cache.delete("user:" + userId);
    }
}

장점

  • 구현이 단순함
  • 읽기가 많은 워크로드에 효과적
  • 필요한 데이터만 캐싱 (Lazy)

단점

  • 첫 조회 시 Cache Miss로 지연 발생
  • 캐시 삭제와 DB 업데이트 사이에 불일치 가능

2. Write-Through

쓰기 시 캐시와 DB를 동시에 업데이트합니다.

wirte-through

public class WriteThroughService {

    public void updateUser(Long userId, UserUpdateRequest request) {
        User user = User.from(request);
        String cacheKey = "user:" + userId;

        // 캐시와 DB 동시 업데이트 (트랜잭션 내에서)
        cache.put(cacheKey, user);
        userRepository.save(user);
    }
}

장점

  • 캐시가 항상 최신 상태
  • 읽기 시 Cache Miss 감소

단점

  • 쓰기 지연 증가 (캐시 + DB 둘 다 기다림)
  • 사용되지 않는 데이터도 캐싱될 수 있음

3. Write-Behind (Write-Back)

쓰기를 캐시에만 먼저 하고, DB 반영은 비동기로 처리합니다.

롸이트 비하인드

public class WriteBehindService {

    private final Queue<WriteEvent> writeQueue = new ConcurrentLinkedQueue<>();

    public void updateUser(Long userId, UserUpdateRequest request) {
        User user = User.from(request);

        // 1. 캐시에만 즉시 저장
        cache.put("user:" + userId, user);

        // 2. 쓰기 큐에 추가 (비동기 처리)
        writeQueue.offer(new WriteEvent(userId, user));
    }

    @Scheduled(fixedRate = 1000)
    public void flushToDatabase() {
        List<WriteEvent> batch = drainQueue(100);
        if (!batch.isEmpty()) {
            userRepository.batchUpdate(batch);
        }
    }
}

장점

  • 쓰기 응답 속도 매우 빠름
  • DB 부하 감소 (배치 처리)

단점

  • 캐시 장애 시 데이터 유실 위험
  • 복잡한 구현
  • 일관성 보장 어려움

4. Read-Through

캐시가 DB 조회까지 담당합니다. 애플리케이션은 캐시만 바라봅니다.

리드 쓰로우

// Spring Cache 추상화가 Read-Through 패턴
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
    // Cache Miss 시에만 실행됨
    return userRepository.findById(userId)
        .orElseThrow();
}

일관성 문제 시나리오

문제 1: Race Condition - 동시 읽기/쓰기

두 요청이 거의 동시에 발생할 때 캐시에 오래된 데이터가 남을 수 있습니다.

캐시-레이스-컨디션

[시간 순서]
T1: Thread A가 DB 조회 시작 (name: "old" 읽음)
T2: Thread B가 DB 업데이트 (name: "new")
T3: Thread B가 캐시 삭제
T4: Thread A가 캐시에 저장 (name: "old")  <-- 문제!

결과: DB는 "new", 캐시는 "old"

문제 2: 캐시 삭제 실패

DB는 업데이트됐는데 캐시 삭제가 실패하면 불일치가 발생합니다.

public void updateUser(Long userId, UserUpdateRequest request) {
    userRepository.update(userId, request);  // 성공

    cache.delete("user:" + userId);  // 실패! (네트워크 오류 등)

    // DB는 변경됐는데 캐시에는 이전 데이터가 남아있음
}

문제 3: DB 트랜잭션 롤백

캐시는 업데이트됐는데 DB 트랜잭션이 롤백되는 경우입니다.

@Transactional
public void updateUser(Long userId, UserUpdateRequest request) {
    cache.put("user:" + userId, newUser);  // 캐시 먼저 업데이트

    userRepository.update(userId, request);  // DB 업데이트

    // 이후 로직에서 예외 발생 -> 롤백
    // DB는 롤백되지만 캐시는 이미 변경됨
    someRiskyOperation();
}

해결 전략

전략 1: Cache Invalidation (삭제)

Cache-Aside 패턴에서 DB 업데이트 후 캐시 삭제는 표준적인 베스트 프랙티스입니다.

// 캐시 삭제 
public void updateUser(Long userId, UserUpdateRequest request) {
    userRepository.update(userId, request);
    cache.delete("user:" + userId);  // 삭제
}

삭제 방식의 장점

  • Race Condition 발생 확률 감소
  • 다음 조회 시 자연스럽게 최신 데이터 로드
  • 불필요한 캐시 갱신 방지

갱신 방식의 고려사항

  • 캐시 갱신 시점에 또 다른 Race Condition 가능

전략 2: Double Delete 패턴

Race Condition을 줄이기 위해 캐시를 두 번 삭제하는 보조 전략입니다.

public void updateUser(Long userId, UserUpdateRequest request) {
    String cacheKey = "user:" + userId;

    // 1. 먼저 캐시 삭제
    cache.delete(cacheKey);

    // 2. DB 업데이트
    userRepository.update(userId, request);

    // 3. 지연 후 한 번 더 삭제 (비동기)
    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500);  // DB 복제 지연 + 진행 중인 읽기 완료 대기
            cache.delete(cacheKey);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

동작 원리

  1. 첫 번째 삭제: 기존 캐시 제거
  2. DB 업데이트 실행
  3. 두 번째 삭제: 그 사이 Race Condition으로 저장된 오래된 캐시 제거

주의사항

  • 500ms 같은 고정 지연 시간은 DB 복제 지연, 시스템 부하에 따라 효과가 달라짐
  • 실제 환경에서는 모니터링 기반으로 적절한 지연 시간을 튜닝해야 함
  • 완벽한 해결책이 아닌 Race Condition 확률을 줄이는 보조 수단으로 활용

전략 3: 분산 락을 통한 동시성 제어

동시에 같은 데이터를 수정하지 못하도록 락을 걸어 직렬화합니다. Thundering Herd 방지와 동시 갱신 제어에 효과적입니다.

분산락

@Service
public class UserServiceWithLock {

    private final RedissonClient redisson;

    public void updateUser(Long userId, UserUpdateRequest request) {
        String lockKey = "lock:user:" + userId;
        RLock lock = redisson.getLock(lockKey);

        try {
            // 락 획득 (최대 10초 대기, 락 유지 30초)
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                userRepository.update(userId, request);
                cache.delete("user:" + userId);
            } else {
                throw new ConcurrentModificationException("락 획득 실패");
            }
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    public User getUser(Long userId) {
        String cacheKey = "user:" + userId;
        User cached = cache.get(cacheKey, User.class);

        if (cached != null) {
            return cached;
        }

        // Cache Miss 시에도 락 사용 (Thundering Herd 방지)
        String lockKey = "lock:user:" + userId;
        RLock lock = redisson.getLock(lockKey);

        try {
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                // Double Check
                cached = cache.get(cacheKey, User.class);
                if (cached != null) {
                    return cached;
                }

                User user = userRepository.findById(userId).orElseThrow();
                cache.put(cacheKey, user, Duration.ofMinutes(30));
                return user;
            }
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

        // 락 획득 실패 시 DB 직접 조회
        return userRepository.findById(userId).orElseThrow();
    }
}

적용 범위 고려

  • 모든 읽기에 락을 걸면 성능이 크게 저하됨
  • 쓰기 작업이나 Cache Miss 시에만 락을 거는 것이 일반적
  • 특정 핫키(자주 접근되는 데이터)에만 선별적으로 적용하는 것을 권장

전략 4: Lease 기반 낙관적 동시성 제어

분산 락이 비관적(미리 잠금) 방식이라면, Lease낙관적(저장 시점에 검증) 방식입니다. Cache Miss 시 토큰을 발급받고, 저장 시 토큰이 유효한 경우에만 저장을 허용합니다.

[Lease 동작 원리]

T1: Thread A가 캐시 조회 → Cache Miss → Lease Token #1 발급
T2: Thread B가 DB 업데이트 (name: "new")
T3: Thread B가 캐시 삭제 → Lease Token #1 무효화
T4: Thread A가 캐시 저장 시도 (Token #1) → 토큰 무효 → 저장 거부

결과: DB "new", 캐시 비어있음 → 다음 조회 시 최신 데이터 로드
public User getUser(Long userId) {
    String cacheKey = "user:" + userId;

    // 1. 캐시 조회 시 lease token 함께 발급
    LeaseResult<User> result = cache.getWithLease(cacheKey, User.class);

    if (result.getValue() != null) {
        return result.getValue();  // Cache Hit
    }

    // 2. Cache Miss - DB 조회
    User user = userRepository.findById(userId).orElseThrow();

    // 3. lease token과 함께 저장 시도
    // 토큰이 무효화됐으면 저장 실패 (다른 쓰기가 있었다는 의미)
    cache.putIfLeaseValid(cacheKey, user, result.getLeaseToken());

    return user;
}

Lease는 Facebook이 Memcached에서 Stale SetsThundering Herd 문제를 해결하기 위해 도입한 메커니즘입니다. 자세한 내용은 Facebook의 대규모 Memcached 운영 전략에서 확인할 수 있습니다.

전략 5: 트랜잭션 커밋 후 캐시 처리

DB 트랜잭션이 확실히 커밋된 후에 캐시를 처리합니다.

@Service
public class TransactionAwareCacheService {

    @Transactional
    public void updateUser(Long userId, UserUpdateRequest request) {
        userRepository.update(userId, request);

        // 트랜잭션 커밋 후에 캐시 삭제 실행
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    cache.delete("user:" + userId);
                }
            }
        );
    }
}
// 또는 이벤트 기반으로
@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void updateUser(Long userId, UserUpdateRequest request) {
        userRepository.update(userId, request);

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new UserUpdatedEvent(userId));
    }
}

@Component
public class CacheInvalidationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleUserUpdate(UserUpdatedEvent event) {
        cache.delete("user:" + event.getUserId());
    }
}

전략 6: CDC 기반 캐시 동기화

Change Data Capture를 통해 DB 변경을 감지하고 캐시를 동기화합니다.

cdc

[CDC 아키텍처]
MySQL Binlog --> Debezium --> Kafka --> Consumer --> Redis Cache

장점:
- 애플리케이션 코드에서 캐시 로직 분리
- DB 변경은 무조건 캐시에 반영됨
- 여러 서비스가 같은 데이터를 캐싱해도 일관성 유지

단점:
- 인프라 복잡도 증가 (Debezium, Kafka 운영 필요)
- 이벤트 처리 지연 발생 가능
- 운영/모니터링 부담 증가

전략 7: TTL 기반 최종 일관성

완벽한 즉시 일관성 대신 TTL을 통해 일정 시간 내 수렴하는 최종 일관성(Eventual Consistency)을 보장합니다.

ttl

public User getUser(Long userId) {
    String cacheKey = "user:" + userId;

    User cached = cache.get(cacheKey, User.class);
    if (cached != null) {
        return cached;
    }

    User user = userRepository.findById(userId).orElseThrow();

    // 짧은 TTL 설정 - 불일치가 있어도 금방 해소됨
    cache.put(cacheKey, user, Duration.ofMinutes(5));

    return user;
}

Written by GoToBill

JVM, Java, 성능 최적화 등 개발 이야기를 다루는 기술 블로그

댓글

© 2026 GoToBill. Built with Gatsby