はじめに (対象読者・この記事でわかること)
本記事は、Java(特にSpring Boot/Spring Data JPA)で開発を行っているエンジニアを対象としています。楽観ロックはデータ競合を防ぐ有力な手法ですが、ユースケースによってはロックチェックを無効にしたいケースもあります。本記事を読むことで、楽観ロック制御の有無をリクエスト単位で動的に切り替える実装方法と、その設定を安全に管理するためのベストプラクティスが理解でき、実際のプロジェクトにすぐに適用できるようになります。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Java 8 以上の基本的な文法とオブジェクト指向概念
- Spring Boot / Spring Data JPA の基本的な使い方
- AOP(Aspect Oriented Programming)の概念と Spring AOP の設定方法
楽観ロック制御の概要と切り替えが必要な背景
楽観ロックは、エンティティにバージョンカラム(@Version)を持たせ、UPDATE 時にバージョンが一致しなければ例外を投げることで「更新競合」を検知します。
- メリット:ロックテーブルを持たないため DB のスケーラビリティが高い。
- デメリット:競合が頻繁に起きるテーブルでは例外が大量に発生し、リトライロジックが煩雑になる。
実務では、バッチ処理やリードオンリー API では競合がほぼ起きないため、楽観ロックのチェックをスキップしたいケースがあります。一方で、トランザクションが複数ステップにまたがる書き込み系エンドポイントでは必ずロックが必要です。このように、処理ごとにロック有無を切り替えることが求められます。
しかし、Spring Data JPA の標準機能だけでは「このリクエストだけロックを無効化する」といった粒度の制御は提供されていません。そこで本稿では、AOP とカスタムリポジトリ、そして ThreadLocal を組み合わせて動的にロック制御を切り替える手法を紹介します。実装は以下の 3 つのポイントに分かれます。
- ロック制御フラグのスコープ管理
ThreadLocal<Boolean>により、現在のスレッド(=リクエスト)でロックを有効化/無効化できるようにします。 - AOP でリポジトリメソッドをインターセプト
@Transactionalが付与されたリポジトリメソッド呼び出し前にフラグをチェックし、ロックチェックをスキップするかどうかを決定します。 - カスタムリポジトリ実装
標準のSimpleJpaRepositoryを拡張し、save系メソッドでバージョンチェックを条件付きで実行するロジックを追加します。
この構成により、コントローラやサービス層で @EnableOptimisticLock アノテーションを付与するだけで、対象処理のロック制御が自動的に切り替わります。次章で具体的な実装手順を示します。
動的に楽観ロックを切り替える実装手順
以下では、Spring Boot 2.7 以降を前提に、Gradle (Kotlin DSL) のプロジェクト構成でコード例を示します。
1. ロック制御フラグ用のコンテキストクラス
Kotlinpackage com.example.lock object OptimisticLockContext { private val lockFlag = ThreadLocal.withInitial { true } // デフォルトはロック有効 fun isLockEnabled(): Boolean = lockFlag.get() fun setLockEnabled(enabled: Boolean) = lockFlag.set(enabled) fun clear() = lockFlag.remove() }
ThreadLocalによりリクエスト単位でフラグが保持され、他リクエストに影響しません。- フィルタやインターセプタでフラグを設定・リセットします。
2. カスタムアノテーションと AOP アドバイス
2-1. アノテーション定義
Kotlinpackage com.example.lock @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class EnableOptimisticLock(val enabled: Boolean = true)
- クラス単位(サービス全体)やメソッド単位でロック有効/無効を指定できます。
2-2. AOP アドバイス
Kotlinpackage com.example.lock import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect import org.springframework.core.annotation.Order import org.springframework.stereotype.Component @Aspect @Component @Order(0) // トランザクションより先に実行 class OptimisticLockAspect { @Around("@within(enable) || @annotation(enable)") fun around(joinPoint: ProceedingJoinPoint, enable: EnableOptimisticLock): Any? { // アノテーションの値でフラグを切り替える OptimisticLockContext.setLockEnabled(enable.enabled) return try { joinPoint.proceed() } finally { // リクエスト終了時に必ずクリア OptimisticLockContext.clear() } } }
@withinと@annotationの両方を対象にすることで、クラスレベルとメソッドレベルの両方で制御可能です。Order(0)としてトランザクション開始前に実行し、@Transactionalの内部でフラグが参照できるようにします。
3. カスタムリポジトリの実装
3-1. ベースインターフェース
Kotlinpackage com.example.lock import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.NoRepositoryBean @NoRepositoryBean interface OptimisticLockingRepository<T, ID> : JpaRepository<T, ID>
3-2. 実装クラス
Kotlinpackage com.example.lock import jakarta.persistence.EntityManager import org.springframework.data.jpa.repository.support.SimpleJpaRepository import java.io.Serializable class OptimisticLockingRepositoryImpl<T, ID : Serializable>( domainClass: Class<T>, em: EntityManager ) : SimpleJpaRepository<T, ID>(domainClass, em), OptimisticLockingRepository<T, ID> { override fun <S : T> save(entity: S): S { // ロックが無効ならバージョンチェックをスキップ return if (OptimisticLockContext.isLockEnabled()) { super.save(entity) // 通常の楽観ロックが働く } else { // バージョンフィールドが設定されていても、マージ時にバージョンを無視 val persisted = em.merge(entity) em.flush() persisted as S } } }
saveメソッドだけをオーバーライドし、OptimisticLockContext.isLockEnabled()がfalseの場合はEntityManager.mergeを直接呼び出すことでバージョンチェックを回避します。flushを明示的に呼び出すことで、トランザクション終了時に確実に永続化されます。
3-3. Spring のリポジトリ設定
Kotlinpackage com.example.lock import org.springframework.context.annotation.Configuration import org.springframework.data.jpa.repository.config.EnableJpaRepositories @Configuration @EnableJpaRepositories( basePackages = ["com.example"], repositoryBaseClass = OptimisticLockingRepositoryImpl::class ) class RepositoryConfig
repositoryBaseClassにカスタム実装を指定することで、プロジェクト全体のリポジトリが自動的にこのロジックを継承します。
4. コントローラ/サービス側での利用例
Kotlinpackage com.example.controller import com.example.lock.EnableOptimisticLock import com.example.service.OrderService import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/orders") class OrderController(private val orderService: OrderService) { // 楽観ロックを有効にしたいケース(デフォルトで有効) @PostMapping fun create(@RequestBody orderDto: OrderDto) = orderService.createOrder(orderDto) // バルクインサートなど、競合が想定外に低い場合はロック無効 @EnableOptimisticLock(enabled = false) @PostMapping("/bulk") fun bulkCreate(@RequestBody orders: List<OrderDto>) = orderService.bulkCreate(orders) }
@EnableOptimisticLock(enabled = false)が付いたエンドポイントでは、リクエスト開始時にフラグがfalseになり、リポジトリのsaveがロックチェックをスキップします。
5. ハマった点やエラー解決
| 発生した問題 | 原因 | 解決策 |
|---|---|---|
OptimisticLockException が期待したケースで発生し続けた |
AOP が @Transactional の後に実行され、フラグがリポジトリ側で参照できていなかった |
@Order(0) を付与し、AOP をトランザクションより先に実行させた |
ThreadLocal がリークし、別リクエストにフラグが残った |
finally ブロックで clear() を呼び忘れていた |
finally に必ず OptimisticLockContext.clear() を追加 |
カスタムリポジトリが全リポジトリに適用されず、save が上書きされなかった |
@EnableJpaRepositories の basePackages が狭すぎた |
basePackages をプロジェクト全体に広げ、repositoryBaseClass を正しく指定 |
6. ベストプラクティスまとめ
- フラグは必ずリクエスト終了時にクリアし、メモリリークを防止。
- AOP の実行順序はトランザクションより前に設定し、
OptimisticLockContextが正しく参照できるようにする。 - ロック無効化は本当に競合が起きにくいケースだけに限定し、データ整合性リスクを最小化する。
- テストケースはロック有効/無効の両方で用意し、エッジケース(例:バージョンカラムが
null)も検証する。
まとめ
本記事では、Spring Data JPA と AOP を活用して、リクエスト単位で楽観ロックの有無を動的に切り替える実装手順を解説しました。
- ロック制御フラグを
ThreadLocalで管理し、リクエスト終了時に必ずクリア - カスタムアノテーション
@EnableOptimisticLockと AOP により、対象メソッド/クラスのロック有効性を宣言的に切り替え - リポジトリ拡張で
save時のバージョンチェックをフラグに応じてスキップ
この手法を採用すれば、バルク処理や読み取り専用 API でのパフォーマンス向上と、整合性が必要なトランザクションでの安全なロック管理を 同一コードベースで共存させられます。今後は、マルチテナント環境や分散トランザクションでも活用できるよう、分散ロックやリトライ戦略との併用を検証していく予定です。
参考資料
- Spring Data JPA 公式ドキュメント – Optimistic Locking
- Jakarta Persistence Specification – Versioning
- Spring AOP 公式ガイド
- 書籍: 「Spring実践入門」(技術評論社, 2023年)
