Redis: cache-aside, write-through, write-behind 선택하기
1. 개요
데이터베이스 앞에 Redis를 추가하는 것은 시스템의 지연 시간을 줄이고 부하를 감소시키는 가장 빠른 방법입니다. 어려운 부분은 다릅니다: 캐시와 데이터베이스가 동기화되지 않도록 데이터를 어떻게 작성할지 결정해야 한다는 것입니다. cache-aside, write-through, write-behind라는 세 가지 일반적인 전략이 있습니다.
이 글에서는 Spring Boot 3과 Redis로 세 가지 모두를 구현합니다. 그 후 일관성, 성능, 데이터 손실 위험 측면에서 비교합니다. 이 글을 읽은 후, 각 데이터 유형에 어떤 전략을 선택해야 할지 알게 될 것입니다.
2. 준비
2.1. 의존성
Spring Data Redis, Spring Data JPA와 샘플 데이터베이스로 H2, 테스트용 Testcontainers를 사용합니다:
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<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>
</dependencies>
로컬에서 실행할 때 Redis 연결 설정:
spring.data.redis.host=localhost
spring.data.redis.port=6379
2.2. Entity, repository, RedisTemplate
@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;
}
// getters와 setters
}
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Spring Boot는 StringRedisTemplate을 자동으로 생성합니다. Product를 JSON으로 저장하려면 별도의 RedisTemplate을 선언합니다:
@Configuration
@EnableScheduling
public class RedisConfig {
@Bean
RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
var template = new RedisTemplate<String, Product>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
return template;
}
}
key는 product:{id} 형식이고, value는 Product의 JSON입니다. @EnableScheduling annotation은 write-behind 부분에서 사용됩니다.
세 전략 모두 transaction commit 이후에 실행할 코드가 필요합니다. 이를 helper로 정리합니다:
public final class AfterCommit {
public static void run(Runnable action) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
}
transaction 내에 있으면 action은 commit이 성공했을 때만 실행됩니다. transaction이 rollback되면 캐시는 건드려지지 않습니다.
3. Cache-aside (lazy loading)
cache-aside에서는 애플리케이션이 캐시를 직접 관리합니다:
- 읽기: Redis를 먼저 확인합니다. 캐시 미스면 데이터베이스에서 읽고 TTL과 함께 Redis에 저장합니다.
- 쓰기: 데이터베이스를 업데이트한 후 Redis에서 key를 삭제합니다.
@Service
public class ProductCacheAsideService {
private static final Duration TTL = Duration.ofMinutes(10);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductCacheAsideService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
public Optional<Product> findById(Long id) {
Product cached = redis.opsForValue().get(key(id));
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
return fromDb;
}
@Transactional
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(newPrice);
AfterCommit.run(() -> redis.delete(key(id)));
return product;
}
static String key(Long id) {
return "product:" + id;
}
}
이 코드의 동작:
findById(1L)첫 호출은 데이터베이스를 읽고 TTL 10분으로product:1key를 생성합니다.- 10분 이내의 후속 호출은 Redis에서 반환되며 데이터베이스에 접근하지 않습니다.
updatePrice는 entity를 수정하고, JPA의 dirty checking이 commit 시 데이터베이스에 작성하며, 그 후 key가 삭제됩니다. 다음 읽기는 새 값을 다시 로드합니다.
redis-cli로 확인:
redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"기계식 키보드\",\"price\":1200000.00}"
(integer) 587
주의: 쓰기 시 key를 삭제하세요, 새 값으로
set하지 마세요. 두 request가 동시에 업데이트한다고 가정합니다. 둘 다set하면 Redis에 쓰는 순서가 데이터베이스에 commit하는 순서와 반대가 될 수 있고, 캐시는 무기한 오래된 값을 유지합니다. key를 삭제하면 다음 읽기는 항상 최신 버전을 가져옵니다. 또한 commit 이후에 삭제해야 합니다. 삭제 전에 하면, 그 사이에 끼어든 읽기 request가 데이터베이스에서 오래된 데이터를 다시 로드해서 캐시에 넣을 수 있습니다.
올바르게 구현해도 cache-aside는 여전히 작은 race 조건이 있습니다: 한 request가 데이터베이스에서 오래된 데이터를 읽고 key가 방금 삭제된 후 캐시에 씁니다. TTL은 이 경우의 마지막 안전망입니다. TTL 없이 캐시하지 마세요.
간단한 데이터의 경우 Spring Cache의 @Cacheable과 @CacheEvict를 사용해서 적은 코드로 같은 효과를 낼 수 있습니다. 본질적으로 이것도 cache-aside입니다.
4. Write-through
write-through에서는 매번 쓰기 시 데이터베이스와 캐시를 동시에 동기식으로 업데이트합니다. request는 둘 다 완료될 때만 반환됩니다. 읽기 부분은 여전히 cache-aside와 같지만, 최근에 쓴 데이터가 이미 Redis에 있어서 캐시 hit 비율이 더 높습니다.
@Service
public class ProductWriteThroughService {
private static final Duration TTL = Duration.ofMinutes(30);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductWriteThroughService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
@Transactional
public Product save(Product product) {
Product saved = repository.saveAndFlush(product);
AfterCommit.run(() -> redis.opsForValue()
.set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
return saved;
}
}
saveAndFlush 라인은 id를 얻고 제약 조건 오류를 조기에 감지하기 위해 데이터베이스에 즉시 씁니다. commit 후 레코드는 Redis에 저장됩니다. 새로 만든 product를 다시 읽으면 첫 번째부터 캐시 hit입니다.
write-through의 장점:
- 캐시는 항상 최근 쓴 데이터로 “따뜻한” 상태이고, 첫 캐시 미스가 없습니다.
- 쓴 직후에 읽는 데이터, 예를 들어
Customer프로필이나 장바구니에 적합합니다.
단점:
- 매번 쓰기 시 Redis로의 추가 round-trip이 소요되므로 쓰기 지연이 증가합니다.
- 누구도 읽지 않는 레코드로 캐시가 가득 찹니다. TTL이 정리하는 데 도움이 되지만 여전히 메모리를 낭비합니다.
- 여러 writer가 동시에
set할 때 여전히 race 조건이 있습니다(섹션 3 참조). 많은 동시 업데이트가 있는 데이터의 경우,@Version을 사용해서 새 버전일 때만 덮어쓰거나 key 삭제로 돌아가는 등 추가 대책이 필요합니다.
주의: 데이터베이스 commit 후 Redis에 오류가 나면 캐시에 최신 버전이 없습니다. exception을 잡고 key를 삭제하세요(fallback 방안). 데이터베이스가 이미 썼는데 request가 실패하게 하지 마세요.
5. Write-behind (write-back)
write-behind에서는 애플리케이션이 Redis에만 쓰고 즉시 반환합니다. background process가 변경 사항을 모아서 배치로 데이터베이스에 씁니다. 데이터베이스는 더 이상 request의 경로에 있지 않아서 쓰기가 매우 빠릅니다. 대신 Redis가 임시로 원본 데이터를 보유합니다.
@Service
public class ProductWriteBehindService {
private static final String DIRTY_SET = "products:dirty";
private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
private final StringRedisTemplate stringRedis;
public ProductWriteBehindService(ProductRepository repository,
RedisTemplate<String, Product> redis,
StringRedisTemplate stringRedis) {
this.repository = repository;
this.redis = redis;
this.stringRedis = stringRedis;
}
public void updatePrice(Long id, BigDecimal newPrice) {
String key = ProductCacheAsideService.key(id);
Product product = Optional.ofNullable(redis.opsForValue().get(key))
.or(() -> repository.findById(id))
.orElseThrow();
product.setPrice(newPrice);
redis.opsForValue().set(key, product); // TTL 없음
stringRedis.opsForSet().add(DIRTY_SET, id.toString());
}
@Scheduled(fixedDelay = 5000)
public void flush() {
String id;
while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
if (product == null) {
continue;
}
try {
repository.save(product);
} catch (RuntimeException ex) {
log.warn("product {}의 flush 실패, 나중에 재시도", id, ex);
stringRedis.opsForSet().add(DIRTY_SET, id);
break;
}
}
}
}
처리 흐름:
updatePrice는 Redis의 레코드를 업데이트하고id를products:dirtyset에 추가합니다. request는 몇 밀리초 안에 끝납니다.- 5초마다
flush()는 각id를SPOP으로 꺼내고 해당 레코드를 데이터베이스에 씁니다. - 한 product가 5초 동안 100번 업데이트되면, 데이터베이스는 한 번만 씁니다. 이것이 write-behind의 가장 큰 이점입니다.
- 쓰기에 실패하면
id가 set에 반환되어 다음 차례에 재시도됩니다.
이것은 세 전략 중 가장 위험한 모델입니다. 필수적으로 처리해야 할 몇 가지 사항:
- Redis 다운 시 데이터 손실. AOF를
appendfsync everysec또는always로 활성화하고 Redis replica를 실행해야 합니다. 그래도 약 1초의 데이터는 손실될 수 있습니다. - Eviction. dirty key에는 TTL이 없습니다.
maxmemory-policy를noeviction또는volatile-*로 설정하세요, 그렇지 않으면 Redis가 flush되지 않은 데이터를 삭제할 수 있습니다. - 여러 instance.
SPOP이 atomic이므로 두 instance가 같은id를 가져가지 않습니다. 그러나SPOP직후,save전에 instance가 죽으면 그 변경 사항은 다음 업데이트까지 Redis에 갇혀 있습니다. 더 확실히 하려면 consumer group이 있는 Redis Streams와XACK를 사용하세요. - 데이터베이스에서의 쿼리는 오래된 데이터를 봅니다. 보고서, 다른 작업, 또는 다른 service가 데이터베이스를 직접 읽으면 실제보다 몇 초 뒤처집니다.
주의: 계좌 잔액,
Order,PaymentService를 통한 결제, 또는 하나의 레코드 손실이 사건인 데이터에는 write-behind를 사용하지 마세요. 이것은 view count, like count, 읽기 위치, last-seen 같은 데이터, 즉 매우 자주 쓰여지고 약간의 손실을 견딜 수 있는 데이터에 적합합니다.
6. Cache-aside 동작 테스트
아래 테스트는 Testcontainers에서 실행되는 실제 Redis를 사용합니다. @ServiceConnection은 Spring Boot에 대해 host와 port를 자동으로 설정합니다:
@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
ProductCacheAsideService service;
@Autowired
ProductRepository repository;
@Autowired
RedisTemplate<String, Product> redis;
@Test
void shouldCacheOnReadAndEvictAfterUpdate() {
Product saved = repository.save(new Product("기계식 키보드", new BigDecimal("1200000")));
String key = ProductCacheAsideService.key(saved.getId());
service.findById(saved.getId());
assertThat(redis.hasKey(key)).isTrue();
service.updatePrice(saved.getId(), new BigDecimal("990000"));
assertThat(redis.hasKey(key)).isFalse();
Product reloaded = service.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
assertThat(redis.hasKey(key)).isTrue();
}
}
test는 key의 전체 생명 주기를 검증합니다: 읽기 시 생성, 업데이트 후 삭제, 새 값으로 재로드. test 클래스는 @Transactional이 붙지 않습니다. 붙으면 test의 transaction이 rollback되고 afterCommit callback이 절대 실행되지 않으며 두 번째 assertion이 실패합니다.
7. 비교 및 선택 방법
| 기준 | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| 첫 읽기 지연 | 높음(미스) | 낮음 | 낮음 |
| 쓰기 지연 | 낮음 | 중간(2곳에 쓰기) | 매우 낮음 |
| DB와의 일관성 | 좋음(TTL이 안전망) | 좋음 | Eventual, 몇 초 지연 |
| 데이터 손실 위험 | 없음 | 없음 | 있음 |
| 복잡도 | 낮음 | 중간 | 높음 |
| DB 쓰기 부하 감소 | 없음 | 없음 | 있음(배치) |
선택 시 참고할 몇 가지 팁:
- 기본은 cache-aside를 사용하세요. 단순하고, 장애에 잘 견딥니다(Redis가 죽어도 데이터베이스에서 읽을 수 있음). 제품 카탈로그나 설정 같은 읽기가 많고 쓰기가 적은 대부분의 데이터에 충분합니다.
- 쓰기 직후 읽기가 자주 있고 첫 미스를 피하고 싶을 때는 write-through를 선택하세요. 실제로 많은 시스템은 혼합합니다: write-through로 쓰고 cache-aside로 읽습니다.
- 데이터베이스가 정말로 쓰기의 병목일 때, 그리고 데이터가 손실을 견딜 수 있을 때만 write-behind를 선택하세요. 데이터베이스 병목을 증명하는 지표가 없으면 이 복잡성을 감수하지 마세요.
- 시스템 전체를 위해 하나의 전략을 선택할 필요는 없습니다. 각 데이터 유형이 다른 전략을 사용할 수 있습니다.
세 전략 모두에서 자주 발생하는 오류:
- Cache stampede. 자주 쓰는 key가 만료되고, 수천 개의 request가 동시에 미스되어 데이터베이스에 쏟아집니다. jitter가 있는 임의의 TTL을 사용하거나
SET key value NX PX로 lock을 걸어서 한 request만 재로드하게 해서 해결할 수 있습니다. - Cache penetration. request가 계속 존재하지 않는
id를 묻고, 매번 미스됩니다. 빈 결과도 짧은 TTL로 캐시하세요. - class 구조를 변경하고 캐시를 정리하지 않음. 오래된 JSON은 새 class로 deserialize되지 않습니다. key에 version을 추가하세요, 예를 들어
product:v2:{id}.
8. 결론
이 글은 Redis와 Spring Boot 3로 세 가지 캐시 전략을 구현했습니다. Cache-aside는 필요할 때 데이터를 로드하고 commit 후 key를 삭제합니다. Write-through는 쓰기 시 두 곳을 동시에 동기화합니다. Write-behind는 데이터 내구성을 안정성과 맞바꾸어 높은 쓰기 속도를 제공합니다.
간단한 규칙: cache-aside로 시작하고 항상 TTL을 설정하세요. 캐시를 따뜻하게 유지해야 할 때는 write-through로 올리세요. write-behind는 데이터 손실을 견딜 수 있는 데이터에만 사용하고, AOF, 적절한 eviction 정책, 명확한 재시도 메커니즘을 함께 배포하세요.