Spring Bootのキャッシュ機能を使う方法 - @Cacheableで手軽にパフォーマンス改善


同じリクエストのたびにDBや外部APIを叩いていると、だんだんレスポンスが遅くなってきますよね。Redisを本格導入する前に、まずアプリ側でキャッシュを試してみたい。そんなときに便利なのが Spring Cache Abstraction です。

アノテーションを数行追加するだけでキャッシュが動き始め、後からCaffeineやRedisに差し替えてもコードを変えずにプロバイダを切り替えられます。今回はその使い方を一通り解説します。

Spring Cache Abstractionとは

Spring FrameworkはキャッシュをDIで差し替えられる抽象化層として提供しています。コードはアノテーションベースで書き、実際にデータを保持するのはConcurrentHashMap・Caffeine・Redisなどのプロバイダです。プロバイダを変更したくなっても、ビジネスロジック側のコードはそのままでOKです。

依存追加と@EnableCaching

まず spring-boot-starter-cache を追加します。

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

@EnableCaching をアプリケーションクラスに付けると有効になります。

@SpringBootApplication
@EnableCaching
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

これだけでデフォルトプロバイダ(ConcurrentHashMap)が使えます。

@Cacheableの基本的な使い方

@Cacheable を付けたメソッドは、初回呼び出し時だけ実行されてキャッシュに格納され、以降は同じキーで呼ばれるとキャッシュから値が返ります。

@Service
public class ProductService {

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @Cacheable(cacheNames = "products", key = "#category + '-' + #page")
    public List<Product> findByCategory(String category, int page) {
        return productRepository.findByCategory(category, page);
    }
}

key 属性にはSpELを使います。#id は引数の値、#user.id のようにオブジェクトのフィールドも参照できます。

なお、nullはデフォルトでキャッシュされます。nullを返す可能性があるメソッドには unless = "#result == null" を付けることを推奨します。

@CacheEvictでキャッシュを削除する

データを更新・削除したときは、古いキャッシュを削除する必要があります。

// 特定キーのキャッシュを削除
@CacheEvict(cacheNames = "products", key = "#id")
public void deleteProduct(Long id) {
    productRepository.deleteById(id);
}

// キャッシュ全体を削除
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() { ... }

デフォルトでは メソッド実行後 にキャッシュが削除されます。例外が出ても必ず削除したい場合は beforeInvocation = true を指定します。

@CachePutで常に更新する

@CachePut はメソッドを必ず実行して、その戻り値でキャッシュを上書きします。@Cacheable との違いは、キャッシュにHITしてもメソッドをスキップしない点です。

@CachePut(cacheNames = "products", key = "#product.id")
public Product updateProduct(Product product) {
    return productRepository.save(product);
}

エンティティを更新した後、最新のデータをすぐキャッシュに反映したい場面で使います。

よくある落とし穴

Spring CacheはAOPプロキシベースのため、次のケースでキャッシュが効きません。

// NG: 同一クラス内からの呼び出しはプロキシをバイパスするためキャッシュが効かない
public void process(Long id) {
    this.findById(id); // キャッシュされない
}

// NG: privateメソッドはAOPプロキシの対象外
@Cacheable(cacheNames = "products", key = "#id")
private Product findInternal(Long id) { ... } // 効かない

対処は別のBeanから呼び出す構成にすることです。

condition・unless属性で条件付きキャッシュ

すべての呼び出しをキャッシュするわけにいかない場合もあります。

// condition: 引数を評価してキャッシュ処理自体を制御する
@Cacheable(cacheNames = "products", key = "#category", condition = "#page == 0")
public List<Product> findByCategory(String category, int page) { ... }

// unless: 戻り値を評価してキャッシュへの書き込みをスキップする
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Product findById(Long id) { ... }

condition はキャッシュの読み取りも含めて処理全体を無効化します。一方 unless は書き込みのみをスキップするため、既存のキャッシュHITには影響しません。

デフォルトプロバイダ(ConcurrentHashMap)の限界

開発・動作確認には十分ですが、本番用途には向きません。

  • TTL(有効期限)が設定できず、エントリが永続します
  • アプリ再起動でキャッシュがリセットされます
  • 複数インスタンス構成でキャッシュを共有できません

本番環境に投入する前に、CaffeineかRedisへ切り替えましょう。

CaffeineキャッシュへのTTL付き切り替え

単一インスタンスでTTLを設定したいなら、Caffeineが手軽です。依存を追加します。

implementation 'com.github.ben-manes.caffeine:caffeine'

application.properties に書くだけで動きます。

spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m

複数のキャッシュに異なるTTLを設定したい場合はBean定義します。

// Caffeine は com.github.ben-manes.caffeine.cache.Caffeine(Spring側のクラスとは別)
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.registerCustomCache("products",
            Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).maximumSize(500).build());
        manager.registerCustomCache("categories",
            Caffeine.newBuilder().expireAfterWrite(60, TimeUnit.MINUTES).maximumSize(100).build());
        return manager;
    }
}

RedisキャッシュへのTTL付き切り替え

複数インスタンスでキャッシュを共有するなら、Redisを使います。

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring.data.redis.host=localhost
spring.data.redis.port=6379

TTLやシリアライザはBean定義で設定します。

@Configuration
public class RedisCacheConfig {

    @Bean
    // 複数CacheManager Beanがある場合は@Primaryを付与
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    // デフォルトのJDKシリアライザは可読性が低くクラス変更時に互換問題が起きやすいため推奨
                    // ※クラス名がJSONに埋め込まれるためクラスのリネーム時は注意
                    new GenericJackson2JsonRedisSerializer()
                )
            );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

プロファイルごとにプロバイダを切り替えたい場合は Spring BootのProfileを使って環境ごとに設定を切り替える方法 も参考にしてください。

キャッシュが効いているか確認する方法

ログで確認するにはDEBUGレベルを有効にします。

logging.level.org.springframework.cache=DEBUG

found in cacheNo cache entry といったキーワードを含むログが出ればHIT/MISSを判断できます(バージョンや設定により表記が異なります)。テストで確認したい場合はこんな感じで書けます。

@SpringBootTest
class ProductServiceCacheTest {

    @Autowired ProductService productService;
    @MockBean ProductRepository productRepository;

    @Test
    void キャッシュが効くこと() {
        when(productRepository.findById(1L))
            .thenReturn(Optional.of(new Product(1L, "テスト商品"))); // お使いのProductエンティティのコンストラクタに合わせて変更してください

        productService.findById(1L);
        productService.findById(1L); // 2回目はキャッシュから返るはず

        // リポジトリは1回しか呼ばれていないことを検証
        verify(productRepository, times(1)).findById(1L);
    }
}

Redisを使ったテストは Testcontainersを使ったSpring Bootの統合テスト も参考にしてください。

まとめ

Spring Cache Abstractionを使えば、アノテーション数行でメソッド単位のキャッシュが実現できます。まずはデフォルトプロバイダで動作を確認して、TTLが必要になったらCaffeine、分散環境になったらRedisへ切り替えましょう。迷ったらまずCaffeineから試してみてください。

DBアクセス自体の最適化については Spring Boot Data JPAのパフォーマンス最適化 も合わせて読んでみてください。