同じリクエストのたびに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)の限界

CaffeineとRedisの選定基準

どのプロバイダを選ぶかは、運用要件で決まります。下表を判断材料にしてください。

観点ConcurrentHashMapCaffeineRedis
TTL不可
サイズ上限不可可(W-TinyLFU)可(maxmemory-policy)
プロセス間共有不可不可
永続化不可不可可(RDB/AOF)
追加インフラ不要不要Redisサーバが必要
想定スループットローカル限定数百万 ops/sネットワーク往復が律速
主な用途開発・PoC単一インスタンスの本番複数インスタンス・分散環境

迷ったら Caffeine から始めて、水平スケールが必要になった時点で Redis に切り替える のが安全です。Spring Cache 抽象化のおかげで、コードはほぼ変更不要です。

キャッシュヒット率を Micrometer で計測する

キャッシュの効果を定量的に把握するには、Micrometer 経由でメトリクスを取得するのが手堅い方法です。Caffeine は recordStats() を有効にすると自動的に Micrometer に登録されます。

@Bean
public CacheManager cacheManager(MeterRegistry registry) {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(1000)
        .recordStats()); // 統計収集を有効化
    return manager;
}

Actuator を有効にしていれば、/actuator/metrics/cache.gets?tag=result:hit でHIT数、tag=result:miss でMISS数を確認できます。Prometheus にエクスポートすれば、HIT率を rate(cache_gets_total{result="hit"}[5m]) / rate(cache_gets_total[5m]) のクエリで可視化できます。

HIT率が低すぎる(目安として 60% 未満)場合は、キー設計の粒度・TTL・キャッシュサイズを見直すサインです。

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

  • 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のパフォーマンス最適化Spring BootのHikariCPコネクションプールを正しく設定・チューニングする方法 も合わせて読むと、レスポンス改善の引き出しが広がります。非同期処理と組み合わせるなら Spring BootのApplicationEventでモジュール間を疎結合にする方法 、Controller層のテスト戦略は Spring BootでMockMvcを使ったControllerの単体テストを書く方法 も参考にしてください。

よくある質問(FAQ)

Spring Cache とは何ですか?

Spring Cache は Spring Framework が提供するキャッシュの抽象化層です。@Cacheable などのアノテーションをメソッドに付けるだけでキャッシュが効くようになり、裏側のプロバイダ(ConcurrentHashMap / Caffeine / Redis など)はビジネスロジックを変えずに差し替えられます。

@Cacheable と @CachePut の違いは?

@Cacheable はキャッシュにHITするとメソッドをスキップして値を返します。@CachePut は必ずメソッドを実行し、その戻り値でキャッシュを上書きします。「読み取り中心」なら @Cacheable、「更新時に最新を反映したい」なら @CachePut を使い分けます。

@CacheEvict はいつ呼ばれますか?

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

同一クラス内のメソッドを呼ぶと @Cacheable が効かないのはなぜ?

Spring Cache は AOP プロキシで実装されているため、this.method() のような自己呼び出しはプロキシをバイパスしてしまいます。別の Bean に切り出して呼び出すか、AopContext.currentProxy() を使う必要があります。

CaffeineとRedisはどちらを選ぶべき?

単一インスタンスで完結し、TTLとサイズ制限が欲しいだけなら Caffeine が手軽です。複数インスタンスでキャッシュを共有する、再起動後もデータを残したい場合は Redis を選びます。まず Caffeine で動作確認し、スケールアウトが必要になったら Redis に切り替えるのが定石です。