5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Springの@Transactionalはthis.XXXでは機能しない

Last updated at Posted at 2023-12-13

記事の概要

この記事は「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について

@TransactionalSpring AOPの上に構築されている機能です。

Spring AOP は、JDK 動的プロキシまたは CGLIB を使用して、指定されたターゲットオブジェクトのプロキシを作成します

Springドキュメント(プロキシメカニズム)参照
  
私はこれを以下のように理解しています。

  1. 実装しているclassを継承したオブジェクトが生成される(これがproxy)
  2. 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 プロキシはプライベートメソッドをインターセプトしません。このようなプロキシ上でプライベートメソッドを呼び出そうとしても、実際のスコープ付きターゲットオブジェクトには委譲されません。

Springドキュメント(Beanスコープ)参照


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を学び始めた中で書いた記事ですので細部など間違った記述や至らない点など多いと思いますが、少しでも同じような問題に遭遇した方の一助になると幸いです。
私も今後もっと理解を深めていきたいと思います。

この記事を読んでくださった方、ありがとうございました!

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?