Spring Bootのキャッシュ機能で高速化する方法 - @Cacheableと@CacheEvictの使い方


同じユーザー情報を何度もDBから取得していて、レスポンスが遅いと感じたことはありませんか。毎回DBにアクセスするのは無駄が多いですよね。Spring Bootのキャッシュ機能を使えば、簡単にこの問題を解決できます。

この記事では、Spring Cache Abstractionを使ってアプリケーションのパフォーマンスを改善する方法を実例で解説します。

Spring Cache Abstractionとは

Spring Cache Abstractionは、Spring Frameworkが提供するキャッシュの統一インターフェースです。アノテーションを使ってメソッドの戻り値を簡単にキャッシュできます。

特徴は以下の通りです。

  • プロバイダの切り替えが簡単 - CaffeineやRedisなど、実装を変更してもコードはそのまま
  • アノテーションベース - @Cacheableなどを付けるだけで動作
  • Spring Bootとの統合 - 依存関係を追加するだけで自動設定

キャッシュプロバイダを後から変更できるので、最初は軽量なインメモリキャッシュで始めて、後から分散キャッシュに移行することもできます。

キャッシュ機能の有効化

まず pom.xml に依存関係を追加しましょう。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

次に、設定クラスに @EnableCaching を追加します。

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

これだけで、デフォルトのConcurrentHashMapを使ったキャッシュが有効になります。

@Cacheableでメソッドをキャッシュする

@Cacheable を使うと、メソッドの戻り値を自動的にキャッシュできます。同じ引数で呼ばれたときは、DBアクセスせずキャッシュから値を返します。

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Cacheable("users")
    public User findById(Long id) {
        System.out.println("DB accessed for user: " + id);
        return userRepository.findById(id).orElse(null);
    }
}

最初の呼び出しではログが出力されますが、2回目以降は出力されません。これはキャッシュから値が返されているためです。

@Cacheable("users")"users" はキャッシュ名で、複数のキャッシュを区別するために使います。引数が1つの場合、その値がキャッシュキーになります。引数が複数の場合はSimpleKeyというオブジェクトが自動生成され、それがキーになります。

@Cacheableが動作しないケース

@Cacheable はSpringのAOPプロキシを使って実装されているため、同じクラス内からメソッドを呼び出した場合は動作しません。

// これは動作しない例
@Service
public class UserService {
    
    @Cacheable("users")
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    public User getUserWithCache(Long id) {
        // 同じクラス内からの呼び出しではキャッシュが効かない
        return this.findById(id);
    }
}

対策としては、キャッシュメソッドを別のクラスに分離するか、ApplicationContext から自分自身のBeanを取得して呼び出す方法があります。

キャッシュキーのカスタマイズ

複数のパラメータがある場合や、特定のフィールドだけをキーにしたい場合は、SpEL(Spring Expression Language)を使ってキーをカスタマイズできます。

@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
    return userRepository.findByEmail(email);
}

@Cacheable(value = "users", key = "#user.id + '-' + #user.email")
public User findByUser(User user) {
    return userRepository.findById(user.getId()).orElse(null);
}

複数の引数を持つメソッドでキーを明示的に指定しない場合、全ての引数を組み合わせたSimpleKeyが自動的に使われます。

// key属性を指定しない場合、idとnameの組み合わせがキーになる
@Cacheable("users")
public User findByIdAndName(Long id, String name) {
    return userRepository.findByIdAndName(id, name);
}

// 明示的に指定する場合
@Cacheable(value = "users", key = "#id + '-' + #name")
public User findByIdAndName(Long id, String name) {
    return userRepository.findByIdAndName(id, name);
}

条件付きでキャッシュすることもできます。

@Cacheable(value = "users", condition = "#id > 10")
public User findById(Long id) {
    return userRepository.findById(id).orElse(null);
}

この例では、IDが10より大きいときだけキャッシュされます。

@CacheEvictで更新時にキャッシュをクリア

データを更新したときは、古いキャッシュをクリアする必要があります。@CacheEvict を使いましょう。

@CacheEvict(value = "users", key = "#id")
public void updateUser(Long id, String name) {
    User user = userRepository.findById(id).orElse(null);
    if (user != null) {
        user.setName(name);
        userRepository.save(user);
    }
}

@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

全てのキャッシュをクリアしたい場合は allEntries = true を使います。

@CacheEvict(value = "users", allEntries = true)
public void deleteAllUsers() {
    userRepository.deleteAll();
}

デフォルトでは、メソッド実行後にキャッシュがクリアされます。そのため、メソッドが例外をスローした場合はキャッシュが残ります。メソッド実行前にクリアしたい場合は beforeInvocation = true を指定します。

@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void updateUser(Long id, String name) {
    // 例外が発生してもキャッシュはクリアされる
    User user = userRepository.findById(id).orElseThrow();
    user.setName(name);
    userRepository.save(user);
}

@CachePutでキャッシュを更新

@CachePut は、メソッドを常に実行してその結果でキャッシュを更新します。@Cacheable と違って、キャッシュがあってもメソッドは実行されます。

@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}

@Cacheable はキャッシュヒット時にメソッドをスキップしますが、@CachePut は必ずメソッドを実行して結果をキャッシュに保存します。更新メソッドで新しいデータをキャッシュに反映したいときに便利です。

Caffeineで高性能化

デフォルトのConcurrentHashMapは機能が限られています。Caffeineに切り替えることで、TTL(有効期限)や最大サイズの設定ができるようになります。

まず依存関係を追加します。

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

application.yml で設定を記述します。

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

これで、最大1000エントリまでキャッシュし、書き込み後10分で自動削除されます。

キャッシュごとに異なる設定をしたい場合は、Configurationクラスで設定します。

@Configuration
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products");
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES));
        return cacheManager;
    }
}

Caffeineは高性能で、Google Guavaの後継として広く使われています。

Redisで分散キャッシュ

複数のサーバーでアプリケーションを動かす場合、各サーバーが独立したキャッシュを持つと不整合が発生します。Redisを使えば、全サーバーで同じキャッシュを共有できます。

依存関係を追加します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.yml でRedisの接続先を設定します。

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis
    redis:
      time-to-live: 10m

Redisでキャッシュするための準備

Redisはデータをシリアライズして保存するため、キャッシュするエンティティクラスは Serializable を実装する必要があります。

@Entity
public class User implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String email;
    
    // getter/setter
}

キャッシュごとにTTLを変えたい場合は、RedisCacheManagerをカスタマイズします。

@Configuration
public class RedisCacheConfig {
    
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10));
        
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        cacheConfigurations.put("users", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        cacheConfigurations.put("products", defaultConfig.entryTtl(Duration.ofMinutes(5)));
        
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
}

CaffeineとRedisの使い分け

どちらを選ぶかは、以下の基準で判断しましょう。

Caffeineを選ぶケース

  • 単一サーバーでの運用
  • レイテンシを極限まで小さくしたい(数ミリ秒のオーバーヘッドも許容できない)
  • データサイズが小さく、メモリに収まる

Redisを選ぶケース

  • 複数サーバーでの運用(ロードバランサー配下など)
  • キャッシュデータが大きく、サーバーのメモリだけでは不足する
  • キャッシュの永続化やレプリケーションが必要

最初はCaffeineで始めて、スケールアウトの必要が出てきたらRedisに移行するのがおすすめです。

キャッシュ効果の測定と実践戦略

キャッシュがどれだけ効いているか確認しましょう。最も簡単な方法は、メソッド内にログを仕込むことです。

@Cacheable("users")
public User findById(Long id) {
    logger.info("Cache miss - loading user from DB: {}", id);
    return userRepository.findById(id).orElse(null);
}

このログが出るたびにDBアクセスが発生しています。2回目以降の呼び出しでログが出なければ、キャッシュが効いています。

Spring Boot Actuatorを使えば、より詳細なメトリクスを取得できます。詳しくはSpring Boot Actuatorの記事をご覧ください。

簡単なベンチマークの例を示します(環境や実装により結果は大きく異なります)。

【キャッシュなし】
1回目: 152ms
2回目: 148ms
3回目: 151ms
平均: 150ms

【キャッシュあり】
1回目: 153ms (キャッシュミス)
2回目: 2ms (キャッシュヒット)
3回目: 1ms (キャッシュヒット)
平均: 52ms

キャッシュヒット時は50〜100倍高速になることもあります。

実践的なキャッシュ戦略

キャッシュを適用するメソッドを選ぶときは、以下のポイントを考慮しましょう。

キャッシュに適したメソッド

  • 読み取り頻度が高いメソッド(ユーザー情報、商品情報など)
  • 計算コストが高いメソッド
  • 外部APIへのアクセスが発生するメソッド

キャッシュに不向きなメソッド

  • 更新頻度が高いデータ
  • リアルタイム性が求められるデータ
  • ユーザーごとに結果が異なるデータ(認証情報など)

キャッシュサイズは、データの特性に合わせて調整します。メモリ使用量を監視しながら、適切な最大サイズを設定しましょう。

N+1問題の解決には、キャッシュだけでなくJPAのfetch戦略も重要です。詳しくはSpring Data JPAのパフォーマンス最適化の記事をご覧ください。

まとめ

Spring Cache Abstractionを使えば、アノテーションだけで簡単にキャッシュを導入できます。まずは @Cacheable でよく読み込むデータをキャッシュし、@CacheEvict で更新時にクリアする基本パターンから始めましょう。

最初はCaffeineで十分です。複数サーバーで動かす必要が出てきたら、Redisへの移行を検討しましょう。コードの変更はほとんど必要ありません。

キャッシュは強力ですが、データの鮮度とのトレードオフです。適切なTTLを設定して、アプリケーションの要件に合わせて調整してください。