はじめに (対象読者・この記事でわかること)

この記事は、Springフレームワークを使用したJava開発経験がある方を対象にしています。特に、DI(依存性注入)を活用した大規模なアプリケーション開発に携わっている方にとって有益な内容です。

この記事を読むことで、Springの@AutoWiredと@Validatedアノテーションを使用した際に発生する循環参照のメカニズムを理解し、実際の開発で問題を回避・解決する具体的な方法を習得できます。また、循環参照を防ぐための設計パターンやベストプラクティスについても学ぶことができます。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Java言語の基本的な知識 - Spring Frameworkの基本的な理解(DIコンテナなど) - MavenまたはGradleの基本的な使用経験 - @AutoWiredアノテーションの基本的な知識

循環参照とはなぜ問題になるのか

循環参照とは、2つ以上のオブジェクトが互いに参照し合う状態を指します。Springフレームワークにおいて、Bean同士が相互に依存関係を持つと、アプリケーション起動時に問題が発生します。

@AutoWiredアノテーションを使用してBeanを注入する際、循環参照が存在すると、SpringコンテナはBeanの初期化を完了できずに例外をスローします。具体的には、「BeanCreationException: Error creating bean with name 'xxx': Requested bean is currently in creation: Is there an unresolvable circular reference?」のようなエラーメッセージが表示されます。

また、@Validatedアノテーションをメソッドに適用してバリデーションを行う場合でも、循環参照が存在すると実行時エラーが発生します。この問題は、大規模なアプリケーション開発時に特に顕著になり、コードの可読性や保守性にも悪影響を与えます。

Spring Boot 2.6以降では、デフォルトで循環参照が許可されなくなっているため、アプリケーションが起動しなくなります。これにより、開発者は循環参照の問題に気づきやすくなっています。

循環参照の具体的なケースと解決方法

循環参照が発生する典型的なケース

以下に、循環参照が発生する典型的なケースをいくつか紹介します。

ケース1:相互依存するサービスクラス

Java
@Service public class UserService { @Autowired private RoleService roleService; @Validated public void updateUser(UserDTO userDTO) { // ユーザー更新処理 roleService.updateRoles(userDTO.getId(), userDTO.getRoles()); } } @Service public class RoleService { @Autowired private UserService userService; @Validated public void updateRoles(Long userId, List<String> roles) { // ロール更新処理 UserDTO user = userService.getUserById(userId); // 処理の続き } }

このコードでは、UserServiceがRoleServiceを注入し、RoleServiceがUserServiceを注入しており、明確な循環参照が発生しています。

ケース2:コントローラーとサービスの相互参照

Java
@RestController @RequestMapping("/api/users") public class UserController { @Autowired private UserService userService; @Autowired private ValidationService validationService; @PostMapping public ResponseEntity<UserDTO> createUser(@Valid @RequestBody UserDTO userDTO) { validationService.validateUser(userDTO); return ResponseEntity.ok(userService.createUser(userDTO)); } } @Service public class UserService { @Autowired private UserController userController; @Validated public UserDTO createUser(UserDTO userDTO) { // ユーザー作成処理 return userController.getUserById(userDTO.getId()); } }

このケースでは、コントローラーとサービスが相互に参照し合っており、循環参照が発生しています。

循環参照の検出方法

循環参照を早期に検出するためには、Spring Bootの起動時のログを確認するのが有効です。循環参照が存在する場合、以下のようなエラーメッセージが表示されます。

BeanCreationException: Error creating bean with name 'userService': 
Requested bean is currently in creation: Is there an unresolvable circular reference?

また、JUnitとSpring Boot Testを使用したテストで確認することもできます。

Java
@SpringBootTest class UserServiceTest { @Autowired private UserService userService; @Autowired private RoleService roleService; @Test void testCircularReferenceDetection() { // 循環参照が存在する場合のテスト // このテストは失敗するはず assertThrows(BeanCreationException.class, () -> { context.refresh(); }); } }

循環参照の解決策

循環参照を解決するための主な方法を以下に紹介します。

解決策1:設計の見直し

最も基本的な解決策は、相互依存関係をなくす設計の見直しです。サービス間の責務を明確に分離し、一方方向の依存関係にすることで循環参照を回避できます。

Java
@Service public class UserService { @Autowired private RoleService roleService; @Autowired private UserRepository userRepository; @Validated public void updateUser(UserDTO userDTO) { // ユーザー更新処理 User user = userRepository.findById(userDTO.getId()) .orElseThrow(() -> new UserNotFoundException(userDTO.getId())); user.update(userDTO); userRepository.save(user); roleService.updateRoles(userDTO.getId(), userDTO.getRoles()); } } @Service public class RoleService { @Autowired private RoleRepository roleRepository; @Autowired private UserRepository userRepository; @Validated public void updateRoles(Long userId, List<String> roles) { // ロール更新処理 roles.forEach(roleName -> { Role role = roleRepository.findByName(roleName) .orElseGet(() -> new Role(roleName)); User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException(userId)); user.addRole(role); roleRepository.save(role); }); } }

このように、サービスクラスがリポジトリに直接依存するように変更することで、サービス間の相互依存をなくしています。

解決策2:イベントパターンの利用

サービス間の直接の依存関係をなくすために、イベントパターンを利用する方法があります。SpringのApplicationEvent機能を使って、サービス間の通信を実現します。

Java
@Service public class UserService { @Autowired private ApplicationEventPublisher eventPublisher; @Autowired private UserRepository userRepository; @Validated public void updateUser(UserDTO userDTO) { // ユーザー更新処理 User user = userRepository.findById(userDTO.getId()) .orElseThrow(() -> new UserNotFoundException(userDTO.getId())); user.update(userDTO); userRepository.save(user); // イベントを発行 eventPublisher.publishEvent(new UserUpdatedEvent(userDTO.getId(), userDTO.getRoles())); } } @Service public class RoleService { @EventListener @Async public void handleUserUpdatedEvent(UserUpdatedEvent event) { // ユーザー更新イベントを処理 updateRoles(event.getUserId(), event.getRoles()); } @Validated public void updateRoles(Long userId, List<String> roles) { // ロール更新処理 // 実装は省略 } } // イベントクラス public class UserUpdatedEvent { private Long userId; private List<String> roles; // コンストラクタ、getter、setterは省略 }

このように、UserServiceはRoleServiceに直接依存しなくなり、循環参照が解消されています。

解決策3:依存性の注入遅延化

Springでは、@Lazyアノテーションを使用してBeanの初期化を遅らせることで、循環参照を一時的に回避できます。

Java
@Service @Lazy public class UserService { @Autowired private RoleService roleService; @Validated public void updateUser(UserDTO userDTO) { // ユーザー更新処理 roleService.updateRoles(userDTO.getId(), userDTO.getRoles()); } } @Service @Lazy public class RoleService { @Autowired private UserService userService; @Validated public void updateRoles(Long userId, List<String> roles) { // ロール更新処理 UserDTO user = userService.getUserById(userId); // 処理の続き } }

ただし、この方法は根本的な解決策ではなく、問題の隠蔽にしかなりません。また、@Lazyアノテーションを使用すると、Beanの初期化が遅延するため、パフォーマンスにも影響を与える可能性があります。

解決策4:プロキシクラスの利用

循環参照を解消するもう一つの方法は、プロキシクラスを利用することです。具体的には、依存関係のあるクラスのインタフェースを定義し、実装クラスではなくインタフェースを注入するようにします。

Java
public interface UserService { @Validated void updateUser(UserDTO userDTO); UserDTO getUserById(Long userId); } @Service public class UserServiceImpl implements UserService { @Autowired private RoleService roleService; @Override @Validated public void updateUser(UserDTO userDTO) { // ユーザー更新処理 roleService.updateRoles(userDTO.getId(), userDTO.getRoles()); } @Override public UserDTO getUserById(Long userId) { // ユーザー取得処理 return null; } } public interface RoleService { @Validated void updateRoles(Long userId, List<String> roles); } @Service public class RoleServiceImpl implements RoleService { @Autowired private UserService userService; @Override @Validated public void updateRoles(Long userId, List<String> roles) { // ロール更新処理 UserDTO user = userService.getUserById(userId); // 処理の続き } }

このように、インタフェースと実装クラスを分離することで、Springがプロキシクラスを生成し、循環参照を回避できます。

@Validatedアノテーションを使用する際の注意点

@Validatedアノテーションを使用する際にも循環参照の問題が発生する可能性があります。特に、バリデーションを行うサービスが他のサービスを参照している場合です。

Java
@Service public class ValidationService { @Autowired private UserService userService; @Validated public void validateUser(UserDTO userDTO) { // ユーザー存在チェック if (!userService.existsUser(userDTO.getId())) { throw new UserNotFoundException(userDTO.getId()); } // その他のバリデーション } } @Service public class UserService { @Autowired private ValidationService validationService; @Validated public void updateUser(UserDTO userDTO) { validationService.validateUser(userDTO); // ユーザー更新処理 } }

このようなケースでは、ValidationServiceがUserServiceを参照し、UserServiceがValidationServiceを参照しており、循環参照が発生しています。

この問題を解決するには、先述の解決策(設計の見直し、イベントパターン、依存性の注入遅延化、プロキシクラスの利用)を適用することが有効です。特に、バリデーション専用のサービスを別途作成し、他のサービスから直接参照しないように設計することをお勧めします。

まとめ

本記事では、Springの@AutoWiredと@Validatedを使用した際に発生する循環参照問題について解説しました。

  • 循環参照とは、2つ以上のオブジェクトが互いに参照し合う状態を指し、SpringフレームワークではBeanの初期化問題を引き起こします
  • 循環参照を解決する主な方法には、設計の見直し、イベントパターンの利用、依存性の注入遅延化、プロキシクラスの利用があります
  • @Validatedアノテーションを使用する際にも循環参照の問題が発生する可能性があるため、注意が必要です
  • 単体テストや統合テストを通じて循環参照の有無を確認することで、問題を早期に発見できます

この記事を通して、循環参照問題の理解とその解決方法についての知識が深まったことと思います。今後は、より複雑な依存関係を持つアプリケーション開発でも円滑に進められるようになるでしょう。

参考資料