記事の概要
この記事は「AlphaDrive Advent Calendar 2023」の12/14のエントリーです。
Javaを触り始めて半年の私が、実務の中でデグレを起こしてしまった失敗経験から学んだSpringでのトランザクション処理における注意すべきことをまとめました。
JavaやSpringでのWebアプリケーション開発を始めたばかりの私のような開発者の一助になれば幸いです。
※Springのversionによる違いやProxyの違い(JDK、CGLIBなど)による詳細などの詳細は記載しておりません。
※この記事にはサンプルコードが含まれていますが、今回のテーマとは直接関連しない部分については、文法や細部に関しては大まかに記述しています。
自己紹介
2023年6月よりAlphaDriveで開発を行っているエンジニアです。
元々Web制作よりの技術スタックを扱っていたこともあり、サーバーサイドでの開発歴は短いため暖かい目で見てくださればと思います。
デグレを起こした経緯
あるWebサービスにてAPIエンドポイントに新たなプロパティを追加する実装を行いました。
実装した内容は想定通りの挙動を示し無事に本番環境へリリース、そのAPIエンドポイントを使用したフロント側の処理も問題ありませんでした。
しかし、リリースした数日後にメンバーから「APIのエンドポイントでエラーが発生している」という連絡を受けました。私が実装したエンドポイントではなく別のエンドポイントからのエラーでしたが、私が実装した内容を起因としているものでした。
事象の詳細
プロパティを追加したエンドポイントとは別のエンドポイントで以下のようなエラーログ出ていました。
org.hibernate.HibernateException: Could not obtain transaction-synchronized Session for current thread
DB処理にHibernateを使用しているのですが、このエラー内容からHibernateに必要なトランザクション処理が機能していないことが疑われました。
実装していた内容
以下サンプルコードです(実際のコードではありません)
元々ユーザー情報を作成しているUserServiceImplクラス
public class UserServiceImpl {
private final UserRepository userRepository;
public UserDto getUserById(UserId userId) {
// userRepositoryからuserデータを取得
User user = userRepository.getUserById(userId);
UserDto dto = new UserDto();
dto.setId(userId);
dto.setName(user.getName());
return dto;
}
}
このコードに対し、該当ユーザーがユーザー名を非表示にするべきか判定するための情報を持っているUserPropertyRepository(特殊なユーザー情報管理のrepository)をインポートし、UserDtoにhideUserNameプロパティを追加しました。
public class UserServiceImpl {
private final UserRepository userRepository;
private final UserPropertyRepository userPropertyRepository;
private boolean isHideUserName(UserId userId){
// userPropertyRepositoryから特殊なユーザーのIDリストを取得
List<UserId> specialUserIds = userPropertyRepository.getSpecialUserIds();
// 指定したuserIdが特殊なユーザーに含まれているかチェック
return specialUserIds.contains(userId);
}
public UserDto getUserById(UserId userId) {
User user = userRepository.getUserById(userId);
UserDto dto = new UserDto();
dto.setId(userId);
dto.setName(user.getName());
// ユーザーが名前非表示のユーザーかをbooleanでセット
dto.setHideUserName(this.isHideUserName(user.getId()));
return dto;
}
}
HibernateのSessionFactoryおよびSessionUserを使用しているUserPropertyRepositoryを新たにインポートしたことでUserServiceImpl::getUserById呼び出し時にトランザクション処理が必須な状態となっています。
UserServiceImpl::getUserById呼び出し先のメソッド(特に修正なし)
@Transactional(readOnly = true)
@Override
public List<UserDto> getUsers(List<Long> ids) {
// userのリストを生成し返却処理
return ids.stream()
.map(userService::getUserById)
.collect(Collectors.toList());
}
これで私の目的だったユーザー情報を返すAPIのエンドポイントにユーザー名非表示フラグのプロパティを追加することができていました。
念のため他のUserServiceImpl::getUserById
を呼び出しているメソッドでもトランザクション処理が行われているか確認し必要箇所に@Transactional
アノテーションが付与されていることも確認しました。
これで大丈夫!と思っていたが・・・
以下のコードの呼び出しでエラーが起きていました。
public class CompanyServiceImpl{
private final UserServiceImpl userService
@Transactional(readOnly = true)
@Override
public List<CompanyMember> getCompanyMembers() {
// userService::getUserByIdを呼び出し社員リストを生成返却
// memberIds取得については省略
return memberIds.stream()
.map(userService::getUserById)
.collect(Collectors.toList());
}
public CompanyDto getCompany() {
List<CompanyMember> members = this.getCompanyMembers();
~ ~ ~
// company(企業情報を整形)
~ ~ ~
return company;
}
}
このgetCompanyメソッドで記事冒頭でも紹介した以下のトランザクション処理が機能していないことによるエラーが発生していました。
org.hibernate.HibernateException: Could not obtain transaction-synchronized Session for current thread
ここで思いました。トランザクション処理が必要な箇所に入っているか確認したはずなのに。。
念のため他の
UserServiceImpl::getUserById
を呼び出しているメソッドでもトランザクション処理が行われているか確認し必要箇所に@Transactional
アノテーションが付与されていることも確認しました。
Springの@Transactionalの挙動
原因を探るべく@Transactional
アノテーションをつけることでトランザクション処理がどのように動作するのか?
またアノテーションの適切な使い方や注意点について調べてみました。
Spring AOPについて
@Transactional
はSpring AOPの上に構築されている機能です。
Spring AOP は、JDK 動的プロキシまたは CGLIB を使用して、指定されたターゲットオブジェクトのプロキシを作成します
Springドキュメント(プロキシメカニズム)参照
私はこれを以下のように理解しています。
- 実装しているclassを継承したオブジェクトが生成される(これがproxy)
- Spring AOPのアノテーションを指定しているpublicメソッドを外部から呼び出した場合、そのメソッドに処理が追加される(proxyのメソッドを呼び出している)
コードとして書くとSearvice1に対してSearvice1を継承したSearvice1Proxyが生成されているイメージです。
Searvice1
@Service
public class Service1 {
@Transactional
public void method1() {
// トランザクションが必要なビジネスロジック
}
}
Searvice1Proxy
@Service
public class Service1Proxy extends Service1 {
// interceptor定義は省略
@Override
public void method1() {
// ここでトランザクション処理を挟んでいる
interceptor.begin();
try {
super.method1(); // 親クラスのメソッドを呼び出す
interceptor.commit();
} catch (RuntimeException e) {
interceptor.rollback();
throw e;
}
}
}
this.メソッドに注意
@Transactional
を含めたproxyでのインターセプト処理はpublicメソッドを外部から呼び出した時にしか機能しません。
CGLIB プロキシはプライベートメソッドをインターセプトしません。このようなプロキシ上でプライベートメソッドを呼び出そうとしても、実際のスコープ付きターゲットオブジェクトには委譲されません。
privateメソッドにアノテーションを付与しても機能しないのは当然として、publicメソッドであってもthis.methodとして同クラスから呼び出された場合も機能しない点に注意が必要です。
私がエラーを起こしてしまったコードを再度掲載して確認してみます。
public class CompanyServiceImpl{ private final UserServiceImpl userService @Transactional(readOnly = true) @Override public List<CompanyMember> getCompanyMembers() { // userService::getUserByIdを呼び出し社員リストを生成返却 // memberIds取得については省略 return memberIds.stream() .map(userService::getUserById) .collect(Collectors.toList()); } public CompanyDto getCompany() { List<CompanyMember> members = this.getCompanyMembers(); ~ ~ ~ // company(企業情報を整形) ~ ~ ~ return company; } }
このgetCompanyメソッドで記事冒頭でも紹介した以下のトランザクション処理が機能していないことによるエラーが発生していました。
getCompanyMembersメソッドには@Transactional
アノテーションを付与していますしpublicメソッドですが、getCompanyメソッドからthis.getCompanyMembers()として呼び出されているためトランザクション処理は機能しません。
なぜならこのthis
は継承後のProxyではなく継承前のオリジナルのBeanを指しているため、何も処理がインターセプトするはずがないgetCompanyMembersを呼び出しています。
Searvice1とSearvice1Proxyで例えるとthisは常にSearvice1を指しているためthis.XXXではインターセプト処理は発生し得ない。アノテーションがついたpublicメソッドを外部から呼び出すことでSearvice1Proxyのメソッドとして呼ばれることになります。
今回のエラーの原因は@Transactional
アノテーションが付与されているメソッドがthis.XXXと内部から呼び出されていることでトランザクション処理が機能していなかったことでした。
対応
対応としてはとてもシンプルでgetCompanyメソッドに@Transactional
アノテーションを付与することで解決しました。
getCompanyメソッドは外部から呼び出されており想定通りトランザクション処理も機能しています。
@Transactional(readOnly = true) // <-ここにアノテーション追加
public CompanyDto getCompany() {
List<CompanyMember> members = this.getCompanyMembers();
~ ~ ~
// company(企業情報を整形)
~ ~ ~
return company;
}
まとめ
繰り返しになりますが今回の経験を経て学んだことをまとめます。
- Springのトランザクション処理はSpring AOPという仕組みで動いている
- Spring AOPはbeanのproxyを作り、proxy上で対象のメソッドの前後に処理を挟んでいる
- this.XXXの呼び出しでは
@Transactional
は動かない
まだまだJavaやSpringを学び始めた中で書いた記事ですので細部など間違った記述や至らない点など多いと思いますが、少しでも同じような問題に遭遇した方の一助になると幸いです。
私も今後もっと理解を深めていきたいと思います。
この記事を読んでくださった方、ありがとうございました!