🎃

ひやりハットを防ぐ!ArchUnitでスレッドセーフをチェックする方法をご紹介

に公開

こんにちは、WealthNaviでバックエンドエンジニアを担当している星原です。
今回は「真夏の怪談!ひやりハット特集」というお題に沿って、Spring Bootのデフォルトスコープであるシングルトンについての注意事項とArchUnit[1]を使ったCIパイプラインでの実装チェック方法をご紹介します。

SpringBootにおけるスレッドセーフな実装パターン

SpringBootアプリケーションはマルチスレッド環境で動作します。マルチスレッド環境では、複数のHTTPリクエストが同時に処理されるため、スレッドセーフでないコードは予期しない動作やデータ破損を引き起こす可能性があります。

前提環境

本記事では以下のソフトウェアスタックを前提としています。

項目 バージョン
Java 21
Spring Boot 3.5.x
Maven 3.9.x
JUnit 5
ArchUnit 1.4.1
CI/CD GitHub Actions

また、REST APIアプリケーションを題材として、Webアプリケーション開発で遭遇する問題を扱います。

Springのシングルトンスコープとスレッドセーフ

Springには6つのスコープ[2]がありますが、今回はREST APIアプリケーションでよく使われる主要な3つのスコープを紹介します。

// シングルトン(デフォルト):アプリケーション全体で1つ
@Component
@Scope("singleton")
public class SingletonService {
    // 全てのリクエストで同一インスタンス
}

// プロトタイプ:毎回新しいインスタンス
@Component
@Scope("prototype")
public class PrototypeService {
    // Bean取得の度に新しいインスタンス作成
}

// リクエストスコープ:HTTPリクエスト毎に新しいインスタンス
@Component
@Scope("request")
public class RequestScopedService {
    // HTTPリクエスト毎に新しいインスタンス
}

この中でも特にシングルトンスコープは、デフォルト設定であり、すべてのHTTPリクエストで同一インスタンスが共有されるため、インスタンス変数で状態を保持しない実装、言い換えるとスレッドセーフな実装が必須です。

スレッドセーフ問題の分類

SpringBootアプリケーションでよく発生するスレッドセーフ問題を3つの観点で分類しました。

問題分類 典型的な症状
状態管理 他ユーザーのデータが混在する
依存性注入 初期化後の依存性が変更される
コレクション 共有されたMapで競合が発生する

スレッドセーフの考慮漏れについては、IDEが明示的に指摘するケースを除き、単体テストでは発見困難で、本番環境の負荷が高い状況で初めて問題として露見します。

今回は、SpringBootアプリケーションで注意すべき危険な実装パターンを3つ挙げ、その修正方法を解説します。

危険な実装パターン3選(NGケース)

以下、ユーザー管理REST API を例にとり、3つの危険なパターンをコメントで紹介します。
※簡易的にController内にロジックを記載しています。

@RestController
public class UserController {
    
    // NG-1: 【状態管理】インスタンス変数で状態を保持
    // → 状態はローカル変数で管理すべき
    private User currentUser;
    
    // NG-2: 【依存性注入】@Autowiredフィールドが非final
    @Autowired
    private UserService userService;
    
    // NG-3: 【コレクション】非スレッドセーフなMapの共有
    // → 専用キャッシュライブラリ(Redis・Caffeine等)を使用すべき
    // ※HashMapに限らず、ArrayListなど他の非スレッドセーフなコレクションでも同様の問題が発生します

    private Map<String, User> cache = new HashMap<>();
    
    @GetMapping("/user/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        // NG-1の問題:他のリクエストで上書きされる
        this.currentUser = userService.findById(id);
        
        if (currentUser == null) {
            return ResponseEntity.notFound().build();
        }
        
        // NG-3の問題:HashMapは並行アクセスで破損する可能性
        cache.put(id.toString(), currentUser);
        
        return ResponseEntity.ok(currentUser);
    }
    
    // NG-2の問題:後から依存性を変更される危険性
    public void setUserService(UserService newService) {
        this.userService = newService;
    }
}

3つの問題の分類

  1. NG-1 状態管理: インスタンス変数での状態保持
  2. NG-2 依存性注入: 後から変更可能な依存性
  3. NG-3 コレクション: マルチスレッド環境での可変Mapの共有

正しい実装(OKケース)

NGケースを修正した実装例を以下に示します。

@RestController
public class UserController {
    
    // OK-2: 【依存性注入】finalフィールドで不変性保証
    private final UserService userService;
    private final CacheService cacheService;
    
    // OK-2: 【依存性注入】コンストラクタインジェクション
    public UserController(UserService userService, CacheService cacheService) {
        this.userService = userService;
        this.cacheService = cacheService;
    }
    
    @GetMapping("/user/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        // OK-1: 【状態管理】ローカル変数で状態管理
        User user = userService.findById(id);
        
        if (user == null) {
            return ResponseEntity.notFound().build();
        }
        
        // OK-3: 【コレクション】スレッドセーフなMapまたは専用サービス
        // CacheServiceでは、ConcurrentHashMapや外部キャッシュ(Redis等)の使用を想定
        cacheService.putUser(id.toString(), user);
        
        // OK-1: 新しいレスポンスオブジェクトを作成
        UserResponse response = UserResponse.builder()
            .id(user.getId())
            .name(user.getName())
            .processedAt(LocalDateTime.now())
            .build();
            
        return ResponseEntity.ok(response);
    }
    
    // OK-2: setterは存在しない(不変性が保証される)
}

ArchUnitによる自動チェック

ArchUnitは通常、レイヤー間の依存関係やパッケージ構造の検証に使われますが、カスタムルールを作成することでスレッドセーフなど特定の品質要件もチェックできます。
前述のNG例はIDEでも警告されますが、ArchUnitを使えばこれらのルールをテストコードとして明文化し、バージョン管理できます。以下に、3つのNGパターンを自動検出する実装例を示します。

依存関係設定

Mavenを使った導入例を記載します。

<!-- pom.xml -->
<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.4.1</version>
    <scope>test</scope>
</dependency>

スレッドセーフチェック

3つのNGパターンを自動で検出するArchUnitテストを実装します。
※今回はNGケース毎に対応するテストを記載しています。
RestControllerに絞って記載しています。

class ThreadSafetyArchTest {

    @Test
    @DisplayName("NG-1対策: Springコンポーネントのフィールドはfinalにすべき")
    void springComponentFieldsShouldBeFinal() {
        fields()
            .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
            .and().areNotFinal()
            .and().areNotStatic()
            .should().beFinal()
            .because("状態はローカル変数で管理し、フィールドは不変にすべき")
            .check(new ClassFileImporter().importPackages("com.example.archunit"));
    }

    @Test
    @DisplayName("NG-2対策: @Autowiredアノテーションは使用禁止")
    void shouldNotUseAutowiredAnnotation() {
        noFields()
            .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
            // ★:必要に応じて例外処理を追加する場合は以下のコメントアウトを外す
            // .and().areDeclaredInClassesThat().areNotAnnotatedWith(AllowAutowired.class)
            .should().beAnnotatedWith(Autowired.class)
            .because("依存性の不変性を保証するため、コンストラクタインジェクションを使用してください")
            .check(new ClassFileImporter().importPackages("com.example.archunit"));
    }

    @Test
    @DisplayName("NG-3対策: Springコンポーネントで非スレッドセーフなMapを使用してはいけない")
    void shouldAvoidMutableMapsInComponents() {
        // マルチスレッド環境では、非finalなMapフィールドは禁止
        fields()
                .that().haveRawType(Map.class)
                .or().haveRawType(HashMap.class)
                .and().areNotFinal()
                .and().areDeclaredInClassesThat()
                    .areAnnotatedWith(RestController.class)
                .should().beFinal()
                .allowEmptyShould(true)
                .because("ConcurrentHashMapや外部キャッシュ(Redis等)を使用してください")
                .check(new ClassFileImporter().importPackages("com.example.archunit"));
    }
 }

補足:@Autowired禁止ルールについて
NG-2の「@Autowired禁止」は、Spring 4.3以降の仕様変更[3]を踏まえた設計ルールです。Spring 4.3以降では、コンストラクタが1つしかない場合は@Autowiredが不要になりました。また、複数コンストラクタは依存関係を複雑にするため、1つのコンストラクタに留めることを推奨します。
実際の開発で@Autowiredが必要となるケースが発生した場合は、以下のようなカスタムアノテーションを当該クラスに付与し、上記NG-2の「★」部分のコメントアウトを外すことで、特定のクラスでのみ@Autowiredを許可することを検討してください。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AllowAutowired {
    String reason() default "";
}

CIパイプライン統合

GitHub Actionsを使った導入例を記載します。

# .github/workflows/thread-safety.yml
name: Thread Safety Check
on: [push, pull_request]

jobs:
  thread-safety:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
      - name: Run Thread Safety Tests
        run: mvn test -Dtest=ThreadSafetyArchTest

まとめ

最後にシングルトンの3つのNGパターンとArchUnitから得られる効果を記載します。

3つのNGパターンの対策まとめ

NGパターン 問題 対策
状態管理 インスタンス変数での状態保持 - ローカル変数で状態管理
- レスポンス用の新しいオブジェクトを作成して返却
依存性注入 @Autowiredフィールドの変更可能性 @Autowiredフィールドインジェクションを使わず、コンストラクタインジェクションとfinal修飾子で依存性の不変性を保証
コレクション マルチスレッド環境で
非スレッドセーフなMapの使用
HashMapなどの非スレッドセーフなMapの使用を避け、ConcurrentHashMapや外部キャッシュ(Redis等)の使用を検討する

ArchUnitの効果

  • 自動検出: 今回挙げたようなNGパターンを機械的に検出可能
  • 継続的品質保証: CIパイプラインを通してコード品質を保証
  • コード品質を標準化: (結果的に)コードレビューの負荷を軽減

ArchUnitを活用することで、マルチスレッド環境でも安全で持続可能なSpringBootアプリケーションを効率的に構築できます。ぜひお試しください。
ここまで読んでいただき、ありがとうございます。

プロフィール

Koki Hoshihara
サービス基盤 ソフトウェアエンジニアリングチーム所属

脚注
  1. https://www.archunit.org/ ↩︎

  2. https://spring.pleiades.io/spring-framework/reference/core/beans/factory-scopes.html ↩︎

  3. https://docs.spring.io/spring-framework/reference/core/beans/annotation-config/autowired.html ↩︎

WealthNavi Engineering Blog

Discussion