Spring Boot のキャッシュとは、メソッドに @Cacheable などのアノテーションを付けるだけで、同じ引数の2回目以降の呼び出しを保存済みの結果で返す仕組みです。 保存先(Caffeine、Redis など)は依存関係と spring.cache.* の設定で切り替えられ、メソッド側のコードは変わりません。

この記事では、5つのアノテーションの属性、SpEL で使える変数、プロバイダーの検出順と設定プロパティを一覧にし、Caffeine 版と Redis 版のコピペ用一式を載せます。「キャッシュが効かない」ときの原因早見表と、同じクラス内の呼び出しで効かない理由を Java 単体のデモ(出力つき)でも確かめます。

確認環境:Spring Boot 4.1.1・Spring Framework 7.0.9・Spring Data Redis 4.1.1 の公式リファレンスと API ドキュメントで確認(確認日 2026年10月7日)。Java 単体で再現できる部分(プロキシとキーのデモ)は Ubuntu 24.04・OpenJDK 21.0.10 で実行し、YAML は Python の yaml で構文を確認しています。

Spring Boot キャッシュの早見表

アノテーション メソッド本体 キャッシュへの操作 主な用途
@Cacheable ヒットしたら実行しない 無ければ結果を保存 読み取り
@CachePut 必ず実行する 結果で上書き 更新
@CacheEvict 必ず実行する エントリを削除 削除・無効化
@Caching 中身による 複数の操作をまとめる 1メソッドで複数操作
@CacheConfig — クラス共通の設定 キャッシュ名の共通化

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot キャッシュ|@Cacheableの属性一覧と設定例」

最小構成で動かす

必要なものは3つです。スターターを入れる、@EnableCaching を付ける、メソッドに @Cacheable を付ける。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
package com.example.demo;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfig {
}
package com.example.demo;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

@Component
public class MathService {

    @Cacheable("piDecimals")
    public int computePiDecimal(int precision) {
        // 時間のかかる計算
        return 0;
    }
}

キャッシュ用のライブラリを何も足さない場合は、ConcurrentHashMap を使う Simple プロバイダーが選ばれます。有効期限や件数の上限は持たないので、長く動かすアプリでは次の章の Caffeine や Redis を選びます。

公式リファレンスは、@EnableCaching を main メソッドのあるアプリケーションクラスに付けないよう案内しています。付けるとキャッシュが必須の機能になり、テスト実行時にも常に有効になるためです。上のように専用の設定クラスに分けておきます。

アノテーションと属性の一覧

属性 Cacheable CachePut CacheEvict 意味
value/cacheNames ○ ○ ○ 使うキャッシュの名前(複数可)
key ○ ○ ○ キーを決める SpEL 式
keyGenerator ○ ○ ○ キー生成用 Bean の名前(key と同時指定不可)
cacheManager ○ ○ ○ 使う CacheManager の Bean 名
cacheResolver ○ ○ ○ 使う CacheResolver の Bean 名(cacheManager と同時指定不可)
condition ○ ○ ○ true のときだけ操作する(実行前に評価)
unless ○ ○ — true のとき保存しない(実行後に評価)
sync ○ — — 同じキーの読み込みを1スレッドにまとめる
allEntries — — ○ キャッシュ内の全エントリを削除する
beforeInvocation — — ○ メソッド実行前に削除する

既定値は、文字列の属性が空文字、sync・allEntries・beforeInvocation が false です。残りの2つのアノテーションの属性は次のとおりです。

アノテーション 属性 内容
@Caching cacheable @Cacheable の配列
@Caching put @CachePut の配列
@Caching evict @CacheEvict の配列
@CacheConfig value/cacheNames クラス共通のキャッシュ名
@CacheConfig keyGenerator クラス共通のキー生成 Bean
@CacheConfig cacheManager クラス共通の CacheManager
@CacheConfig cacheResolver クラス共通の CacheResolver

設定の優先順位は、全体の設定(CachingConfigurer など)< クラスの @CacheConfig < メソッドのアノテーションです。メソッドに書いたものが常に優先されます。

キーの既定の決まり方

key を書かない場合、引数から次のようにキーが作られます。

引数 キー
なし SimpleKey.EMPTY
1つ その引数そのもの
2つ以上 全引数を持つ SimpleKey

キーに使う引数は、正しい hashCode() と equals() を実装している必要があります。検索条件の一部だけをキーにしたいときは key を書きます。

@Cacheable(cacheNames = "books", key = "#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

syncを使うときの制約

sync = true は、複数のスレッドが同じキーを同時に読み込もうとしたとき、1つだけがメソッドを実行し、ほかは待つ動きにします。API ドキュメントには次の制約が書かれています。

  • unless と併用できない
  • 指定できるキャッシュは1つだけ
  • ほかのキャッシュ操作と組み合わせられない
  • プロバイダーが同期に対応しているとは限らない(ヒントとして扱われる)

SpELで使える変数の一覧

key・condition・unless の式の中で使える変数です。

変数 内容 例
#root.methodName 呼び出されたメソッドの名前 #root.methodName
#root.method 呼び出されたメソッド #root.method.name
#root.target 呼び出し対象のオブジェクト #root.target
#root.targetClass 呼び出し対象のクラス #root.targetClass
#root.args 引数の配列 #root.args[0]
#root.caches 対象のキャッシュの一覧 #root.caches[0].name
引数名 引数を名前で参照 #isbn
#a0/#p0 引数を位置で参照(0 始まり) #a0、#p1
#result メソッドの戻り値 #result
  • 引数名は、コンパイル時に -parameters を付けていない場合は使えません。その場合は #a0/#p0 の形で参照します。
  • #result が使えるのは、unless、@CachePut の key、@CacheEvict(beforeInvocation = false のとき)です。
  • 戻り値が Optional のとき、#result は中身のオブジェクトを指します。空の可能性があるので #result?.hardback のように ?. を使います。

conditionとunlessの使い分け

condition は実行前に引数で判断し、unless は実行後に結果で判断します。

観点 condition unless
評価のタイミング メソッド実行の前 メソッド実行の後
使える情報 引数 引数と #result
true のとき キャッシュを使う 保存しない
向く用途 対象の引数を絞る 結果によって保存を見送る
// 名前が 32 文字未満のときだけキャッシュを使い、ハードカバーの本は保存しない
@Cacheable(cacheNames = "book", condition = "#name.length() < 32", unless = "#result.hardback")
public Book findBook(String name)

「見つからなかった結果(null)を保存したくない」場合は unless = "#result == null" を使います。

CachePutとCacheEvictの判断基準

どちらもメソッド本体を必ず実行します。違いは、実行後にキャッシュを新しい値で上書きするか、消すかです。

状況 選ぶもの 理由
更新メソッドが更新後の値を返す @CachePut 次の読み取りがそのままヒットする
更新メソッドが void や件数を返す @CacheEvict 保存する値が手元に無い
削除した @CacheEvict エントリを消す
一覧のキャッシュに影響する変更 @CacheEvict(allEntries = true) どのキーが影響を受けるか特定しにくい
一括取り込みのあと @CacheEvict(allEntries = true) まとめて作り直す
メソッドが失敗しても必ず消したい @CacheEvict(beforeInvocation = true) 実行前に削除される

@CacheEvict の既定(beforeInvocation = false)は、メソッドが正常に終わったあとで削除します。公式リファレンスは、同じメソッドに @CachePut と @Cacheable を一緒に付けないよう案内しています。前者は必ず実行し、後者は実行を省こうとするので、動きが食い違うためです。

コピペ用一式(Caffeine版)

1つのアプリの中で完結するインメモリのキャッシュです。件数の上限と有効期限を設定で指定できます。

依存関係

Maven:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache-test</artifactId>
  <scope>test</scope>
</dependency>

Gradle(Kotlin DSL):

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("com.github.ben-manes.caffeine:caffeine")
    testImplementation("org.springframework.boot:spring-boot-starter-cache-test")
}

spring-boot-starter-cache は spring-context-support を含みます。Caffeine や JCache のサポートにはこのモジュールが必要です。

application.yml

spring:
  cache:
    cache-names: "items,itemLists"
    caffeine:
      spec: "maximumSize=500,expireAfterWrite=10m"

spec に書けるキーは initialCapacity、maximumSize、maximumWeight、expireAfterAccess、expireAfterWrite、refreshAfterWrite、weakKeys、weakValues、softValues、recordStats です。期間は 50s、11m、6h、3d のように書けます。expireAfterWrite は書き込みからの時間、expireAfterAccess は最後のアクセスからの時間で期限を決めます。

Caffeine の設定は、次の順で適用されます。

  1. spring.cache.caffeine.spec に書いた spec
  2. CaffeineSpec の Bean
  3. Caffeine の Bean

設定クラス

package com.example.demo.item;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfig {
}

モデルとリポジトリ

package com.example.demo.item;

import java.io.Serializable;

public record Item(Long id, String name, int stock) implements Serializable {
}
package com.example.demo.item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {

    Optional<Item> findById(Long id);

    List<Item> findAll();

    Item save(Item item);

    void deleteById(Long id);
}

サービス全文

package com.example.demo.item;

import java.util.ArrayList;
import java.util.List;

import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;

@Service
@CacheConfig("items")
public class ItemService {

    private final ItemRepository repository;

    public ItemService(ItemRepository repository) {
        this.repository = repository;
    }

    // 読み取り:id をキーに保存する。見つからなかった結果は保存しない
    @Cacheable(key = "#id", unless = "#result == null")
    public Item find(Long id) {
        return repository.findById(id).orElse(null);
    }

    // 一覧:引数が無いのでキーは SimpleKey.EMPTY になる
    @Cacheable("itemLists")
    public List<Item> findAll() {
        return new ArrayList<>(repository.findAll());
    }

    // 更新:実行して結果で上書きし、一覧のキャッシュは消す
    @Caching(
            put = @CachePut(key = "#result.id()"),
            evict = @CacheEvict(cacheNames = "itemLists", allEntries = true))
    public Item save(Item item) {
        return repository.save(item);
    }

    // 削除:対象のエントリと一覧のキャッシュを消す
    @Caching(evict = {
            @CacheEvict(key = "#id"),
            @CacheEvict(cacheNames = "itemLists", allEntries = true)})
    public void delete(Long id) {
        repository.deleteById(id);
    }

    // 一括取り込みのあとなどに全消去する
    @CacheEvict(cacheNames = {"items", "itemLists"}, allEntries = true)
    public void clearAll() {
    }
}

@CacheConfig("items") でクラス共通のキャッシュ名を決めているので、find・save・delete では名前を省いています。一覧だけ別のキャッシュ(itemLists)にしているのは、1件の更新で一覧全体を作り直せるようにするためです。

コピペ用一式(Redis版)

複数のインスタンスでキャッシュを共有したいときは Redis を使います。サービスのコードは Caffeine 版と同じで、変わるのは依存関係と設定だけです。

依存関係

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}

spring-boot-starter-data-redis の既定のクライアントは Lettuce で、接続先の既定は localhost:6379 です。

application.yml

spring:
  data:
    redis:
      host: "localhost"
      port: 6379
  cache:
    cache-names: "items,itemLists"
    redis:
      time-to-live: "10m"
      cache-null-values: false

キャッシュ名ごとにTTLを変える

spring.cache.redis.time-to-live は全キャッシュ共通の既定値です。キャッシュごとに変えるときは RedisCacheManagerBuilderCustomizer を Bean にします。

package com.example.demo.item;

import java.time.Duration;

import org.springframework.boot.cache.autoconfigure.RedisCacheManagerBuilderCustomizer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;

@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return (builder) -> builder
                .withCacheConfiguration("items", RedisCacheConfiguration
                        .defaultCacheConfig().entryTtl(Duration.ofMinutes(30)))
                .withCacheConfiguration("itemLists", RedisCacheConfiguration
                        .defaultCacheConfig().entryTtl(Duration.ofSeconds(60)));
    }
}

Redisの既定の動き

Spring Data Redis の RedisCacheConfiguration の既定値です。

項目 既定
有効期限 なし
null の保存 する
キーの接頭辞 付ける(キャッシュ名)
キーのシリアライザー StringRedisSerializer
値のシリアライザー JdkSerializationRedisSerializer
  • 値は JDK のシリアライズで保存されるので、キャッシュする型は Serializable を実装します(上の Item はそのために implements Serializable を付けています)。JSON などで保存したい場合は、RedisCacheConfiguration の serializeValuesWith でシリアライザーを差し替えられます。既定の設定をまるごと置き換えるには、自分の RedisCacheConfiguration を Bean として定義します。
  • キーの接頭辞は、別々のキャッシュが同じキーを使っても Redis 上で重ならないようにするためのものです。公式リファレンスは、この設定を有効のままにしておくことを強く勧めています。
  • 最後のアクセスから数えて期限を延ばしたい(TTI)ときは enableTimeToIdle() を使います。Redis 6.2.0 以上が必要です。

プロバイダーの自動検出順と設定プロパティ

CacheManager や CacheResolver の Bean を自分で定義していない場合、Spring Boot は次の順でプロバイダーを探し、最初に見つかったものを使います。

順 種類 選ばれる条件 spring.cache.type
1 Generic Cache 型の Bean が1つ以上ある generic
2 JCache(JSR-107) CachingProvider がクラスパスにある jcache
3 Hazelcast HazelcastInstance が自動構成され hazelcast-spring がある hazelcast
4 Infinispan Infinispan がある infinispan
5 Couchbase Spring Data Couchbase があり設定済み couchbase
6 Redis Redis があり設定済み redis
7 Caffeine Caffeine がある caffeine
8 Cache2k Cache2k の Spring 連携がある cache2k
9 Simple ほかに何も無い simple

Redis は Caffeine より先に検出されます。 両方がクラスパスにあって Caffeine を使いたいときは、spring.cache.type=caffeine で明示します。キャッシュを使わない環境では spring.cache.type=none にします。

spring.cache.* の一覧(4.1.1)

プロパティ 既定 内容
spring.cache.type 自動検出 使うプロバイダーを明示する
spring.cache.cache-names 空 起動時に作るキャッシュ名(カンマ区切り)
spring.cache.caffeine.spec なし Caffeine の spec 文字列
spring.cache.redis.time-to-live なし エントリの有効期限
spring.cache.redis.cache-null-values true null を保存するか
spring.cache.redis.key-prefix なし キーの接頭辞
spring.cache.redis.use-key-prefix true 接頭辞を付けるか
spring.cache.redis.enable-statistics false 統計を有効にするか
spring.cache.couchbase.expiration なし エントリの有効期限
spring.cache.infinispan.config なし 設定ファイルの場所
spring.cache.jcache.config なし 設定ファイルの場所
spring.cache.jcache.provider なし CachingProvider の完全修飾名

cache-names の扱いはプロバイダーで少し違います。Simple では、cache-names を書くとそこに無い名前のキャッシュは使えなくなり、必要になった時点(起動時ではなく実行時)でエラーになります。書かなければ、必要に応じて作られます。

細かく調整するためのカスタマイザー

対象 Bean の型
自動構成された CacheManager 全般 CacheManagerCustomizer<T>
Redis RedisCacheManagerBuilderCustomizer
Couchbase CouchbaseCacheManagerBuilderCustomizer
Cache2k Cache2kBuilderCustomizer

いずれも org.springframework.boot.cache.autoconfigure パッケージにあります。たとえば Simple で null を保存しないようにするには、次のように書きます。

package com.example.demo;

import org.springframework.boot.cache.autoconfigure.CacheManagerCustomizer;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class SimpleCacheCustomizerConfig {

    @Bean
    public CacheManagerCustomizer<ConcurrentMapCacheManager> cacheManagerCustomizer() {
        return (cacheManager) -> cacheManager.setAllowNullValues(false);
    }
}

キャッシュが効かないときの原因早見表

症状 原因 直し方
毎回メソッドが実行される 同じクラス内から呼んでいる 別の Bean に分ける、またはプロキシ経由で呼ぶ
毎回メソッドが実行される @EnableCaching が無い 設定クラスに付ける
毎回メソッドが実行される メソッドが public ではない public にする(エラーは出ず設定が無視される)
毎回メソッドが実行される spring.cache.type=none になっている 対象の環境の設定を確認する
毎回メソッドが実行される キーの型に equals/hashCode が無い record にするか実装する
同じデータなのにヒットしない 既定のキーが全引数の組になっている key でキーにする引数を絞る
key = "#id" が効かない 引数名の情報が無い -parameters を付けるか #a0 を使う
更新したのに前の値が返る 更新メソッドにキャッシュ操作が無い @CachePut か @CacheEvict を付ける
更新したのに前の値が返る 読み取りと更新でキーが違う 両方の key をそろえる
null が返り続ける null が保存されている unless = "#result == null" を付ける
起動時の処理で効かない @PostConstruct から呼んでいる 初期化後に呼ぶ(プロキシの準備が終わっていない)
キャッシュ名のエラーになる cache-names に無い名前を使った cache-names に追加する
Redis で保存時にエラーになる 型が Serializable ではない Serializable を実装するかシリアライザーを替える

アノテーションは、インターフェースではなく具象クラス(とそのメソッド)に付けることを公式は勧めています。

同じクラス内の呼び出しで効かない理由をデモで確かめる

既定の proxy モードでは、キャッシュの処理はBean を包むプロキシが行います。外からの呼び出しはプロキシを通りますが、同じクラスの中から this.find(…) と呼ぶとプロキシを通らず、本体が直接実行されます。

この動きは Spring が無くても再現できます。ConcurrentHashMap と JDK の動的プロキシで作った小さなデモです(@Cached・@Put・@Evict は、それぞれ @Cacheable・@CachePut・@CacheEvict に当たる自作の目印です)。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SelfInvocationDemo {

    // @Cacheable / @CachePut / @CacheEvict に当たる目印
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Cached {}
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Put {}
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Evict {}

    interface ItemService {
        @Cached String find(int id);
        String findViaThis(int id);
        @Put String rename(int id, String name);
        @Evict void delete(int id);
        void setSelf(ItemService self);
        String findViaSelf(int id);
    }

    static class ItemServiceImpl implements ItemService {
        private final Map<Integer, String> table = new ConcurrentHashMap<>(Map.of(1, "ノート", 2, "ペン"));
        private ItemService self = this;
        int loads = 0;

        public String find(int id) {
            loads++;
            System.out.println("    [本体] find(" + id + ") を実行(" + loads + "回目)");
            return table.get(id);
        }
        public String findViaThis(int id) { return this.find(id); }   // 同じクラス内の呼び出し
        public String findViaSelf(int id) { return self.find(id); }   // プロキシ経由の呼び出し
        public String rename(int id, String name) { table.put(id, name); return name; }
        public void delete(int id) { table.remove(id); }
        public void setSelf(ItemService self) { this.self = self; }
    }

    static final Object NULL = "(nullの目印)";   // ConcurrentHashMap は null を入れられないので目印に置き換える

    // キャッシュ用プロキシ:外から来た呼び出しだけがここを通る
    static ItemService withCache(ItemService target, Map<Object, Object> cache) {
        return (ItemService) Proxy.newProxyInstance(
                ItemService.class.getClassLoader(), new Class<?>[] {ItemService.class},
                (proxy, method, args) -> {
                    Object key = args == null ? "EMPTY" : args[0];
                    if (method.isAnnotationPresent(Cached.class)) {
                        if (cache.containsKey(key)) {
                            System.out.println("    [プロキシ] キー " + key + " はキャッシュから返す");
                            Object hit = cache.get(key);
                            return hit == NULL ? null : hit;
                        }
                        Object value = method.invoke(target, args);
                        cache.put(key, value == null ? NULL : value);
                        return value;
                    }
                    Object value = method.invoke(target, args);
                    if (method.isAnnotationPresent(Put.class)) cache.put(key, value);   // 実行して上書き
                    if (method.isAnnotationPresent(Evict.class)) cache.remove(key);     // 実行して削除
                    return value;
                });
    }

    public static void main(String[] args) {
        Map<Object, Object> cache = new ConcurrentHashMap<>();
        ItemServiceImpl impl = new ItemServiceImpl();
        ItemService service = withCache(impl, cache);

        System.out.println("1) 外から find(1) を2回");
        System.out.println("  -> " + service.find(1));
        System.out.println("  -> " + service.find(1));

        System.out.println("2) 同じクラス内(this.find)で find(2) を2回");
        System.out.println("  -> " + service.findViaThis(2));
        System.out.println("  -> " + service.findViaThis(2));

        System.out.println("3) 自分自身のプロキシを持たせて find(2) を2回");
        service.setSelf(service);
        System.out.println("  -> " + service.findViaSelf(2));
        System.out.println("  -> " + service.findViaSelf(2));

        System.out.println("4) rename(1)(Put:実行して上書き)のあとに find(1)");
        service.rename(1, "方眼ノート");
        System.out.println("  -> " + service.find(1));

        System.out.println("5) delete(2)(Evict:実行して削除)のあとに find(2)");
        service.delete(2);
        System.out.println("  -> " + service.find(2));
        System.out.println("  -> " + service.find(2));

        System.out.println("本体の find が動いた回数: " + impl.loads + " / キャッシュの中身: " + cache);
    }
}

OpenJDK 21.0.10 での実行結果です。

$ java SelfInvocationDemo.java
1) 外から find(1) を2回
    [本体] find(1) を実行(1回目)
  -> ノート
    [プロキシ] キー 1 はキャッシュから返す
  -> ノート
2) 同じクラス内(this.find)で find(2) を2回
    [本体] find(2) を実行(2回目)
  -> ペン
    [本体] find(2) を実行(3回目)
  -> ペン
3) 自分自身のプロキシを持たせて find(2) を2回
    [本体] find(2) を実行(4回目)
  -> ペン
    [プロキシ] キー 2 はキャッシュから返す
  -> ペン
4) rename(1)(Put:実行して上書き)のあとに find(1)
    [プロキシ] キー 1 はキャッシュから返す
  -> 方眼ノート
5) delete(2)(Evict:実行して削除)のあとに find(2)
    [本体] find(2) を実行(5回目)
  -> null
    [プロキシ] キー 2 はキャッシュから返す
  -> null
本体の find が動いた回数: 5 / キャッシュの中身: {1=方眼ノート, 2=(nullの目印)}

読み取れることは4つです。

  1. 外からの呼び出しは、2回目がプロキシでキャッシュから返り、本体は1回しか動いていません。
  2. this.find(2) での呼び出しは、2回とも本体が動いています。プロキシを通っていないためです。
  3. プロキシ経由(self.find(2))に変えると、2回目がキャッシュから返ります。
  4. Put は実行して上書きするので次の読み取りがヒットし、Evict は実行して削除するので次の読み取りで本体が動きます。削除後の find(2) は null を返し、その null も保存されて2回目はキャッシュから返っています。

4つ目の後半が「null が返り続ける」の正体です。ConcurrentHashMap は null を値にできないため、デモでは目印のオブジェクトに置き換えて保存しています。Spring のキャッシュでも、Simple や Redis は既定で null を保存します。

同じクラス内の呼び出しを直す

方法 内容 向く場面
Bean を分ける キャッシュするメソッドを別のクラスに移す 基本の方法
プロキシ経由で呼ぶ 自分自身の Bean を注入して呼ぶ クラスを分けにくいとき
aspectj モード @EnableCaching(mode = AdviceMode.ASPECTJ) でクラス自体を織り込む public 以外のメソッドにも付けたいとき

Bean を分ける形は次のようになります。

package com.example.demo.item;

import org.springframework.stereotype.Service;

@Service
public class ItemReportService {

    private final ItemService itemService;   // キャッシュ付きのメソッドを持つ別の Bean

    public ItemReportService(ItemService itemService) {
        this.itemService = itemService;
    }

    public String label(Long id) {
        Item item = itemService.find(id);     // プロキシを通るのでキャッシュが使われる
        return item == null ? "(なし)" : item.name() + " x" + item.stock();
    }
}

aspectj モードを使うには、クラスパスに spring-aspects.jar があり、ロード時またはコンパイル時のウィービングが有効になっている必要があります。

キーが一致しない例をデモで確かめる

キャッシュは「キーが等しいか」で引き当てます。ConcurrentHashMap で同じことを確かめます。

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class KeyDemo {

    // equals / hashCode を実装していないクラス
    static class PlainCriteria {
        final String word;
        PlainCriteria(String word) { this.word = word; }
    }

    // record は equals / hashCode が自動で用意される
    record RecordCriteria(String word) {}

    public static void main(String[] args) {
        Map<Object, String> cache = new ConcurrentHashMap<>();

        cache.put(new PlainCriteria("ノート"), "結果A");
        System.out.println("equals なしのクラス : " + cache.get(new PlainCriteria("ノート")));

        cache.put(new RecordCriteria("ノート"), "結果B");
        System.out.println("record            : " + cache.get(new RecordCriteria("ノート")));

        // 引数が複数あるときは「全引数の組」がキーになる
        cache.put(List.of(1, true), "結果C");
        System.out.println("引数 (1, true)    : " + cache.get(List.of(1, true)));
        System.out.println("引数 (1, false)   : " + cache.get(List.of(1, false)));
    }
}
$ java KeyDemo.java
equals なしのクラス : null
record            : 結果B
引数 (1, true)    : 結果C
引数 (1, false)   : null

equals/hashCode の無いクラスをキーにすると、中身が同じでも別のキーとして扱われます。また、引数が複数あるメソッドでは全引数の組がキーになるので、結果に影響しない引数(フラグなど)が違うだけでヒットしなくなります。 そのときは key = "#id" のようにキーにする引数を絞ります。

テスト時にキャッシュを無効にする

キャッシュが有効なままだと、前のテストで保存された値が次のテストに影響することがあります。自動構成のキャッシュを使っている場合、方法は2つです。

方法1:テスト用の設定で none にする

src/test/resources/application.yml:

spring:
  cache:
    type: "none"

方法2:@AutoConfigureCache を付ける

package com.example.demo.item;

import org.springframework.boot.cache.test.autoconfigure.AutoConfigureCache;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@AutoConfigureCache
class ItemServiceTests {
    // 自動構成の CacheManager が何もしない実装に置き換わる
}

@AutoConfigureCache は spring-boot-starter-cache-test に含まれ、既定で NoOpCacheManager を構成します。cacheProvider 属性(spring.cache.type に対応)で、テスト用に別のプロバイダーを指定することもできます。

状況 方法
自動構成のキャッシュを使っている spring.cache.type=none または @AutoConfigureCache
CacheManager を自分で定義している キャッシュの設定を独立した @Configuration に分け、テストで上書きする
スライステスト(@WebMvcTest など) キャッシュの設定を独立したクラスにしておけば読み込まれない

CacheManager を自分で定義している場合、@AutoConfigureCache は効きません。@EnableCaching を専用の設定クラスに分けておくことが、ここでも役に立ちます。

よくある質問

Q. @Cacheable を付けたのに毎回 SQL が実行されます。最初に見るところは? A. 呼び出し元が同じクラスの中かどうかを確認します。既定の proxy モードでは、同じクラス内からの呼び出しはプロキシを通らないのでキャッシュされません。次に @EnableCaching の有無、メソッドが public かどうかを見ます。

Q. @CachePut と @CacheEvict の違いは何ですか? A. どちらもメソッドを必ず実行します。@CachePut は戻り値でキャッシュを上書きし、@CacheEvict はエントリを削除します。更新後の値を返すメソッドには @CachePut、void のメソッドや一覧に影響する変更には @CacheEvict が合います。

Q. 有効期限(TTL)はアノテーションで指定できますか? A. アノテーションの属性に有効期限はありません。Caffeine は spring.cache.caffeine.spec の expireAfterWrite/expireAfterAccess、Redis は spring.cache.redis.time-to-live や RedisCacheManagerBuilderCustomizer の entryTtl のように、プロバイダー側の設定で指定します。

Q. Caffeine と Redis はどう選べばよいですか? A. 1つのインスタンスの中で完結してよいなら Caffeine、複数のインスタンスで同じキャッシュを共有したいなら Redis が合います。どちらを選んでもアノテーションを付けたコードは変わらないので、あとから切り替えられます。

まとめ

  • 3つで動く:spring-boot-starter-cache、設定クラスの @EnableCaching、メソッドの @Cacheable。
  • @Cacheable は実行を省き、@CachePut は実行して上書き、@CacheEvict は実行して削除する。
  • condition は実行前に引数で、unless は実行後に #result で判断する。sync は unless と併用できない。
  • 既定のキーは全引数の組。キーにする引数を絞るときは key、引数名が使えないときは #a0/#p0。
  • プロバイダーは9種を順に検出し、Redis は Caffeine より先。明示するなら spring.cache.type。
  • 同じクラス内の呼び出しはプロキシを通らない。Bean を分けるのが基本。
  • テストでは spring.cache.type=none か @AutoConfigureCache で無効にできる。

参考・出典

確認日はいずれも 2026年10月7日です。

  • Spring Boot リファレンス Caching(4.1.1): https://docs.spring.io/spring-boot/reference/io/caching.html
  • Spring Framework リファレンス Declarative Annotation-based Caching(7.0.9): https://docs.spring.io/spring-framework/reference/integration/cache/annotations.html
  • Cacheable API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html
  • CachePut API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CachePut.html
  • CacheEvict API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CacheEvict.html
  • Caching API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Caching.html
  • CacheConfig API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CacheConfig.html
  • AutoConfigureCache API(4.1.1): https://docs.spring.io/spring-boot/4.1.1/api/java/org/springframework/boot/cache/test/autoconfigure/AutoConfigureCache.html
  • CacheProperties のソース(v4.1.1): https://raw.githubusercontent.com/spring-projects/spring-boot/v4.1.1/module/spring-boot-cache/src/main/java/org/springframework/boot/cache/autoconfigure/CacheProperties.java
  • Spring Data Redis リファレンス Redis Cache(4.1.1): https://docs.spring.io/spring-data/redis/reference/redis/redis-cache.html
  • Spring Boot リファレンス NoSQL(Redis、4.1.1): https://docs.spring.io/spring-boot/reference/data/nosql.html
  • Caffeine Wiki Specification: https://github.com/ben-manes/caffeine/wiki/Specification
  • Spring Boot 4.0 Migration Guide(スターター一覧): https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot キャッシュ|@Cacheableの属性一覧と設定例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?