Redis 사용 시 캐시와 데이터베이스 일관성 유지하기

1. 개요

Database 앞에 Redis를 두면 부하를 줄이고 읽기 속도를 높일 수 있습니다. 대신 같은 데이터를 두 곳에 저장하게 되고, 이들이 어긋날 수 있습니다. 사용자가 상품 가격을 바꾸었는데, 상품 상세 페이지는 여전히 10분 동안 기존 가격을 보여줍니다. 이것이 전형적인 일관성 오류입니다.

이 글은 cache-aside 패턴을 사용합니다: 읽을 때는 먼저 캐시에서 가져오고, 쓸 때는 database를 업데이트한 후 transaction commit 이후에 캐시를 삭제합니다. 남아있는 race condition이 무엇인지, 그리고 어떻게 줄일 수 있는지 살펴보겠습니다. 글의 끝에는 Testcontainers로 실제 Redis를 사용한 통합 테스트가 있습니다.

2. 왜 캐시와 database가 어긋날까

Database와 Redis는 하나의 transaction을 공유하지 않습니다. 두 곳에 모두 원자적으로 쓸 방법이 없습니다. 작업의 순서와 시점이 데이터가 어긋날지 말지를 결정합니다.

가장 흔한 세 가지 오류:

  • 캐시를 삭제하지 말고 업데이트하기. 두 개의 쓰기 요청이 동시에 들어오면 database에는 A→B 순서로 쓰이지만, 캐시에는 B→A 순서로 쓰일 수 있습니다. 캐시는 A의 값을 영원히 유지하거나 TTL이 만료될 때까지 유지합니다.
  • commit 전에 캐시 삭제하기. 삭제와 commit 사이에 읽기 요청이 끼어들 수 있습니다. 그 요청이 database에서 기존 값을 읽고 다시 캐시에 로드합니다.
  • 캐시 삭제 실패를 아무도 알지 못하기. Redis timeout, 예외 처리 누락 등으로 캐시가 기존 데이터를 계속 유지합니다.

이 글의 나머지 부분에서는 각 오류를 차례대로 다룹니다.

3. 프로젝트 설정

Redis, JPA, 테스트용 database에 대한 의존성을 추가합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Redis 연결 설정:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

의도적으로 timeout을 짧게 설정했습니다. Redis가 느려도 요청이 수 초 동안 대기하는 대신 빠르게 database로 폴백되길 원합니다.

Entity와 repository:

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {}
    public Product(String name, BigDecimal price) { this.name = name; this.price = price; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

public interface ProductRepository extends JpaRepository<Product, Long> {}

Entity를 직접 캐시하지 않고 불변 record를 캐시합니다:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

이 방식은 Hibernate proxy 직렬화 오류를 피합니다. 또한 캐시 형식이 table 구조와 독립적으로 유지됩니다.

4. Cache-aside로 읽기

@Service
public class ProductService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

    static String key(Long id) { return "product:" + id; }

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("상품 " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

처리 흐름:

  • redis.opsForValue().get(...) 캐시에서 가져오려고 시도합니다. 있으면 바로 반환합니다. 이를 cache hit이라 합니다.
  • 없으면 (cache miss), service가 database에서 읽고 TTL 10분으로 Redis에 저장합니다.
  • TTL은 안전망입니다. 뒤에 나오는 모든 메커니즘은 실패할 수 있습니다. TTL은 잘못된 데이터가 10분 이상 존재하지 않도록 보장합니다.

id = 1로 첫 호출 후 Redis에는:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"기계식 키보드\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. 쓰기: database 업데이트, commit 후 캐시 삭제

5.1. 업데이트하지 말고 삭제하기

가격이 변경되면 캐시에 새 가격을 쓰지 않고 key를 삭제합니다. 다음 읽기에서 database에서 데이터를 다시 로드하고, database가 진실의 원천입니다. 삭제는 idempotent 작업입니다: 두 번 삭제해도 같은 결과입니다. 삭제 명령 사이의 순서도 중요하지 않습니다. 이 덕분에 섹션 2의 “쓰기 순서 어긋남” 오류가 사라집니다.

5.2. Transaction commit을 기다리기

@Transactional method 안에서 redis.delete(...)를 호출하면, 삭제 명령은 commit 전에 실행됩니다. 이런 시나리오가 가능합니다:

T1 (쓰기)                        T2 (읽기)
UPDATE price = 900 (미커밋)
DEL product:1
                                GET product:1 -> miss
                                SELECT -> price = 1200 (기존 값)
                                SET product:1 = 1200
COMMIT
=> 캐시는 1200을 TTL까지 유지

해결책은 event를 발행하고 AFTER_COMMIT 단계에서 처리합니다:

public record ProductChangedEvent(Long productId) {}

ProductService에 쓰기 method를 추가합니다:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Listener가 캐시 삭제를 담당합니다:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("캐시 {}를 삭제하지 못했습니다. TTL에 의존합니다", key, e);
        }
    }
}

몇 가지 주의할 점:

  • @TransactionalEventListener(phase = AFTER_COMMIT)은 transaction이 성공적으로 commit될 때만 실행됩니다. Transaction이 rollback되면 캐시는 삭제되지 않습니다. 이것이 맞습니다. 데이터가 변경되지 않았으니까요.
  • 캐시 삭제 시 발생하는 오류는 쓰기 요청을 망치지 않습니다. Database가 이미 commit되었고, 이 시점에서 예외를 throw하는 것은 클라이언트가 작업이 실패했다고 생각하게 합니다.
  • 경고 로그는 오류 빈도를 추적하는 데 도움이 됩니다. 그 동안 TTL이 잘못된 데이터 존재 시간을 제한합니다.

주의: publishEvent가 transaction 없는 곳에서 호출되면, @TransactionalEventListener는 기본값으로 event를 무시하고 캐시가 절대 삭제되지 않습니다. 쓰기 method에 @Transactional이 있는지 확인하세요. 필요하면 fallbackExecution = true를 설정하세요.

6. 남은 race condition과 delayed double delete

commit 후에 삭제하더라도, 여전히 드문 시나리오가 있습니다:

T2 (읽기)                        T1 (쓰기)
GET product:1 -> miss
SELECT -> price = 1200
                                 UPDATE price = 900, COMMIT
                                 DEL product:1
SET product:1 = 1200   <- 늦은 쓰기

T2가 T1 commit 전에 기존 값을 읽지만, commit 후에 캐시에 씁니다. 이 시나리오는 T2가 GC pause나 네트워크 지연 같은 정확한 시점에 느려질 때 일어납니다. 드물지만 실제로 일어날 수 있습니다.

간단한 완화 방법은 delayed double delete입니다: 짧은 간격 후 한 번 더 삭제합니다.

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

@EnableScheduling을 설정 클래스에 추가하면 Spring Boot는 자동으로 TaskScheduler를 구성합니다. 500ms 후의 두 번째 삭제는 T2가 늦게 쓴 기존 값을 제거합니다. 지연 시간은 일반적인 database 읽기 시간보다 크게 설정하세요.

주의: Delayed double delete는 오류 윈도우를 줄일 뿐 완전히 제거하지는 않습니다. 스케줄은 메모리에 있으므로 애플리케이션 재시작 시 사라집니다. 절대 보장으로 생각하지 마세요.

7. 캐시 삭제가 반드시 성공해야 할 때

계좌 잔액이나 재고 같은 데이터에서는 “TTL에 의존”이 수용 불가능할 수 있습니다. 두 가지 일반적인 방법이 있습니다:

  • Transactional outbox: 업무 변경과 같은 transaction에서 database의 cache_invalidation 테이블에 row를 씁니다. 정기적인 job이 이 테이블을 읽고, Redis의 key를 삭제한 후, 성공할 때만 row를 완료 표시합니다. Outbox row가 업무 데이터와 함께 commit되므로 캐시 무효화 event가 절대 손실되지 않습니다.
  • Change Data Capture (CDC): 프로세스가 database의 binlog/WAL을 읽고 해당 캐시를 삭제합니다. 이 방식은 캐시 로직을 업무 코드에서 분리하지만 인프라가 더 복잡합니다.

둘 다 eventual consistency입니다. 캐시가 결국 맞을 것을 보장하지만 즉시 맞을 것을 보장하지는 않습니다.

8. 실제 Redis로 테스트하기

StringRedisTemplate을 mock하는 것은 commit 시점 관련 오류를 찾지 못합니다. Testcontainers로 실제 Redis를 테스트하세요:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("기계식 키보드", new BigDecimal("1200000"))).getId();

        productService.findById(id); // 캐시 로드
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

이 테스트는 세 가지를 확인합니다:

  • @ServiceConnection(name = "redis")는 spring.data.redis.*를 container로 자동 설정하므로 추가 설정이 필요 없습니다.
  • updatePrice 후에 key가 Redis에서 없어집니다. Listener의 AFTER_COMMIT이 실행됐음을 증명합니다.
  • 다음 읽기는 database에서 새 가격을 반환합니다.

주의: 테스트 method를 @Transactional로 표시하지 마세요. Spring이 테스트 끝에 transaction을 rollback할 것이고, AFTER_COMMIT은 절대 실행되지 않으며 assertion이 실패할 것입니다.

실행 결과:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. 이 방식을 사용하면 안 될 때

  • 강한 일관성이 필요한 데이터, 예를 들어 거래 승인 여부를 결정하는 계좌 잔액. 캐시에서 읽지 말고 적절한 lock과 함께 database에서 직접 읽으세요.
  • 쓰기가 읽기보다 많은 데이터. 캐시가 자주 무효화되어 hit rate가 낮습니다. 복잡함의 비용을 거의 얻지 못합니다.
  • 여러 entity가 섞인 key, 예를 들어 “상위 10개 상품” 목록. 한 상품이 바뀔 때 어떤 key를 삭제해야 할지 알기 어렵습니다. 이 경우 짧은 TTL이 정확한 무효화보다 더 간단하고 안전합니다.
  • 여러 서비스가 같은 테이블을 씀. 애플리케이션의 event는 다른 서비스의 변경을 볼 수 없습니다. 이 경우 CDC가 더 합리적입니다.

10. 결론

Redis와 database를 일관성 있게 유지하는 것은 완벽하게 동기화된 상태를 유지하는 것이 아닙니다. 목표는 어긋남 윈도우를 작고 제한된 상태로 유지하는 것입니다. 기본 공식은 네 단계로 구성됩니다:

  1. Cache-aside로 읽기, 항상 TTL을 설정합니다.
  2. Database에 쓰기, 그 후 key를 삭제합니다 (업데이트하지 않음).
  3. 오직 commit 후에 삭제합니다. @TransactionalEventListener(phase = AFTER_COMMIT)를 사용하세요.
  4. 데이터 민감도에 따라 delayed double delete, outbox, 또는 CDC를 추가합니다.

마지막으로 실제 Redis로 테스트하고, 테스트에 @Transactional을 붙여서 commit 후 동작을 숨기지 마세요.

rediscachespring-bootconsistencyjava