はじめに
この記事をご覧いただきありがとうございます。
筆者は業務で Spring Boot(Kotlin)を用いてサーバーサイドの開発を行っています。
今回は JPA のテストを書いていた際に筆者が実際に遭遇した「UPDATEしたのに、なぜか値が変わらない」という問題と、その原因・解決策について紹介します。
発生した問題
ユーザーのプロフィール画像のファイル名(S3のObjectKey)を登録する処理をテストしていたところ、登録した値が反映されない問題に遭遇しました。
実装コード
UserService.kt
@Service
class UserServiceImpl(
val userRepository: UserRepository
) : UserService {
@Transactional
override fun registerProfileImage(id: Long, objectKey: String): String {
userRepository.updateProfileImageObjectKey(id, objectKey)
return objectKey
}
}
UserRepository.kt
interface UserRepository : JpaRepository<UserEntity, Long> {
@Modifying
@Query(
"""
update users u set u.profileImageObjectKey = :objectKey
where u.id = :id
"""
)
fun updateProfileImageObjectKey(
id: Long,
objectKey: String,
)
}
UserEntity.kt
@Entity(name = "users")
data class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val name: String = "",
val profileImageObjectKey: String = "",
)
テストコード
UserServiceTest.kt
@DataJpaTest
class UserServiceTest {
@Autowired
lateinit var userRepository: UserRepository
@Test
fun `given user exist when call registerProfileImage then save profile image`() {
var user = userRepository.save(UserEntity(name = "taro", profileImageObjectKey = ""))
val profileImageObjectKey = "profile_images/test.png"
val userService = UserServiceImpl(userRepository)
userService.registerProfileImage(user.id, profileImageObjectKey)
user = userRepository.findById(user.id).get()
assertThat(user.profileImageObjectKey).isEqualTo("profile_images/test.png")
}
}
実行結果(エラー)
org.opentest4j.AssertionFailedError:
expected: "profile_images/test.png"
but was: ""
「更新したのに反映されてない?」
これは一見バグのように見えますが、原因は JPAのキャッシュの仕組み(永続化コンテキスト) にありました。
解決法:EntityManagerのキャッシュをクリアする
この問題は、テストコードに以下のようなキャッシュの明示的なクリア処理を入れることで解決します。
@DataJpaTest
class UserServiceTest {
@Autowired
lateinit var userRepository: UserRepository
@Autowired
lateinit var entityManager: EntityManager //ここを追加
//ここを追加
fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
@Test
fun `given user exist when call registerProfileImage then save profile image`() {
var user = userRepository.save(UserEntity(name = "taro", profileImageObjectKey = ""))
val profileImageObjectKey = "profile_images/test.png"
val userService = UserServiceImpl(userRepository)
userService.registerProfileImage(user.id, profileImageObjectKey)
flushAndClear() //ここを追加
user = userRepository.findById(user.id).get()
assertThat(user.profileImageObjectKey).isEqualTo("profile_images/test.png")
}
}
解説:なぜflush & clearが必要なのか?
これは、JPAが「Persistence Context(以下、永続化コンテキスト)」と呼ばれるキャッシュ領域を使ってEntityを管理していることが関係しているようです。
.save()
や .findById()
を使う場合
例えば、以下のようなJPARepositoryが持つ通常のメソッドを使う場合、EntityManagerは永続化コンテキストにあるEntityを優先して使用します。
userRepository.save(..)
userRepository.findById(..)
言葉では分かりにくいので図で解説します。
はじめに、.save()
を実行するとEntityManagerはRepositoryから送られてきたEntityを永続化コンテキストに一時キャッシュします。(①②③)
ここで、同じ永続化コンテキスト内で findById()
を実行すると、EntityManager はキャッシュされたエンティティを返します。(④⑤)
永続化コンテキスト内の変更は、トランザクションの終了時にDBへ反映されます。(⑥)
トランザクションとは?
複数の処理をひとまとまりとして実行・管理する仕組みです。
途中でエラーがあれば、すべて元に戻す(ロールバック)ことができます。
つまり、.save()
や .findById()
のような通常のメソッドを使用する場合はfindByIdした際に最新のEntityを取得することができるためテストも問題なく成功します。
@Modifying
+ @Query
を使うと...
一方で、今回は@Modifying
を使ってカスタムクエリを実行していました。このようなカスタムクエリは永続化コンテキストにキャッシュを残しません。
@Modifying
@Query("UPDATE users u SET u.profileImageObjectKey = :objectKey WHERE u.id = :id")
こちらも図解します。
まず、カスタムクエリ.updateProfileImage()
を実行すると、EntityManagerは永続化コンテキスト(キャッシュ)を経由せず、直接データベースを UPDATE します(①②③)。
ここで、同じ永続化コンテキスト内で findById()
を実行すると、EntityManager は既にキャッシュされている古いエンティティを返してしまいます(④⑤)。
これは、UPDATE による変更がキャッシュに反映されていないためです。
つまり、この古いキャッシュ読み込みが、テストが失敗する原因だったわけです。
flush() & clear() の役割とは?
今回使用した entityManager.flush()
と entityManager.clear()
は、どちらも永続化コンテキストに対する操作ですが、目的が異なります。
メソッド | 説明 |
---|---|
flush | 永続化コンテキストの変更を強制的にDBに反映する(SQLを即時発行) |
clear | 永続化コンテキスト内のキャッシュをすべて削除し、空の状態にする |
今回のポイントは.clear()
を実行することです。
EntityManager.clear()を使うと、永続化コンテキストが空になり、次に findById() を呼び出したとき、JPAはキャッシュに頼らず実際のデータベースから最新の情報を再取得します。
つまり、古いデータが残っているキャッシュをクリアすることで、DBの更新が反映された「正しいデータ」を取得できるようになるのです。
また、今回のケースでは.flush
はなくてもテストは成功します。なぜなら、今回起こった問題はDBの変更が永続化コンテキストに反映されていないことであり、「キャッシュの内容をDBに同期する」役割を持つ.flush()
メソッドは関係ないからです。
なぜ.flush()を一緒に使うのかについてはChatGPTに解説を求めました。
他のテストでキャッシュ内の変更がまだ反映されていない状態のまま
clear()
してしまうと、未保存のデータを失ってしまう可能性がある
そのため、安全のためにflush()
→clear()
の順で呼び出すのが一般的なパターン となっています
まとめ
今回の問題は、JPAの「永続化コンテキスト(キャッシュ)」という仕組みを理解する良いきっかけになりました。
- 通常のRepositoryメソッド(
.save()
,.findById()
)ではEntityManagerのキャッシュが使われる -
@Modifying @Query
を使うとキャッシュを通さずDBだけが更新される - その結果、キャッシュには古い値が残り、テストで意図した通りの値が取得できない
- 解決策は
entityManager.clear()
を使ってキャッシュをリセットすること
最後まで読んでいただきありがとうございました。