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를 일관성 있게 유지하는 것은 완벽하게 동기화된 상태를 유지하는 것이 아닙니다. 목표는 어긋남 윈도우를 작고 제한된 상태로 유지하는 것입니다. 기본 공식은 네 단계로 구성됩니다:
- Cache-aside로 읽기, 항상 TTL을 설정합니다.
- Database에 쓰기, 그 후 key를 삭제합니다 (업데이트하지 않음).
- 오직 commit 후에 삭제합니다.
@TransactionalEventListener(phase = AFTER_COMMIT)를 사용하세요. - 데이터 민감도에 따라 delayed double delete, outbox, 또는 CDC를 추가합니다.
마지막으로 실제 Redis로 테스트하고, 테스트에 @Transactional을 붙여서 commit 후 동작을 숨기지 마세요.