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:1 key를 생성합니다.
  • 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:dirty set에 추가합니다. 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 정책, 명확한 재시도 메커니즘을 함께 배포하세요.

rediscachespring-bootjava성능