Spring Bootを書いていて、「@Scope("prototype") を付けたのに毎回同じインスタンスが返ってくる」と困ったことはありませんか。Beanスコープはなんとなくsingletonのまま使われがちですが、ちゃんと理解しておくと設計の幅が広がります。この記事では5つの標準スコープの動作と、singletonにprototypeを注入するときの落とし穴を整理していきます。

Beanスコープとは何か

Beanスコープは、Springコンテナが管理するBeanインスタンスの 生存範囲と共有単位 のことです。

デフォルトはsingletonで、コンテナ全体で1つだけインスタンスが作られます。なぜsingletonが標準なのかというと、メモリ効率が良く、ステートレスなサービス層であれば共有しても問題がないからです。逆に言えば、状態を持たせるなら別のスコープを検討する必要があります。

スコープは @Scope アノテーションで指定します。Beanの基本については What is @Component も参考にしてください。

5つの標準スコープ

Springが標準で提供するスコープは次の5つです。

  • singleton: コンテナ全体で1つ。ステートレスなサービス層に最適
  • prototype: 取得のたびに新規インスタンス。短命でステートを持つオブジェクト向け
  • request: HTTPリクエスト単位(Web環境のみ)
  • session: HTTPセッション単位。ログインユーザー情報など
  • application: ServletContext単位。全リクエストで共有する構成情報など

宣言は次のように書きます。

@Component
@Scope("prototype")
public class OrderProcessor {
    // 取得のたびに新しいインスタンス
}

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String traceId;
    // リクエスト単位の情報を保持
}

文字列リテラルではなく ConfigurableBeanFactory.SCOPE_PROTOTYPE などの定数を使うとタイポを防げます。

proxyModeの使い分け

requestやprototypeなど 短命なスコープのBeanをsingletonに注入する とき、proxyModeの指定が必要になります。なぜなら、singletonの初期化時に注入された参照が固定されてしまい、リクエストごとに切り替わってくれないからです。

proxyModeにはふたつの選択肢があります。

  • TARGET_CLASS: CGLIBによるサブクラスプロキシ。インタフェースがなくても使える
  • INTERFACES: JDK動的プロキシ。インタフェース実装が前提

基本的には TARGET_CLASS で問題ありませんが、final クラスにはサブクラスを作れないので注意してください。

singleton内にprototypeを注入する落とし穴

ここが一番つまずきやすいポイントです。次のコードを見てください。

@Component
@Scope("prototype")
public class Task {
    private final long id = System.nanoTime();
    public long getId() { return id; }
}

@Service
public class TaskRunner {
    private final Task task;

    public TaskRunner(Task task) {
        this.task = task;
    }

    public void run() {
        System.out.println("task id = " + task.getId());
    }
}

TaskRunner を2回呼んでも、task.getId() の値は同じです。TaskRunner 自体がsingletonなので、コンストラクタで受け取った Task の参照が固定されてしまうのですね。「prototypeだから毎回新しくなる」のは 取得時 の話で、注入されて保持されたものは1つのままなのです。

回避策1: ObjectProvider(推奨)

Spring 4.3以降のおすすめは ObjectProvider です。必要なタイミングで getObject() を呼ぶことで、その都度新しいインスタンスを取得できます。

@Service
public class TaskRunner {
    private final ObjectProvider<Task> taskProvider;

    public TaskRunner(ObjectProvider<Task> taskProvider) {
        this.taskProvider = taskProvider;
    }

    public void run() {
        Task task = taskProvider.getObject();
        System.out.println("task id = " + task.getId());
    }
}

getIfAvailable()getIfUnique() といったnull安全なAPIも用意されており、扱いやすいです。

回避策2: jakarta.inject.Provider

JSR-330標準の Provider でも同じことができます。他のDIコンテナへの移植性を意識する場合に有効です。

import jakarta.inject.Provider;

@Service
public class TaskRunner {
    private final Provider<Task> taskProvider;

    public TaskRunner(Provider<Task> taskProvider) {
        this.taskProvider = taskProvider;
    }

    public void run() {
        System.out.println("task id = " + taskProvider.get().getId());
    }
}

利用するには jakarta.inject:jakarta.inject-api への依存追加が必要です。

回避策3: @Lookupメソッドインジェクション

抽象メソッドや具象メソッドに @Lookup を付けると、Springがサブクラスを生成してメソッドの実装を差し込んでくれます。

@Service
public abstract class TaskRunner {
    @Lookup
    protected abstract Task createTask();

    public void run() {
        Task task = createTask();
        System.out.println("task id = " + task.getId());
    }
}

CGLIBによるサブクラス生成が前提なので final クラスでは使えません。ObjectProviderが使えない事情があるときの選択肢として覚えておくと良いでしょう。

回避策4: ApplicationContextから直接取得

最終手段として ApplicationContext.getBean を使う方法もあります。

@Service
public class TaskRunner {
    private final ApplicationContext context;

    public TaskRunner(ApplicationContext context) {
        this.context = context;
    }

    public void run() {
        Task task = context.getBean(Task.class);
        System.out.println("task id = " + task.getId());
    }
}

ロジックがSpring APIに強く結合するためテストが書きにくくなります。他の手段が使えないときだけ選びましょう。

request / sessionスコープを使うときの前提

requestとsessionはWeb環境(Spring MVC)でのみ有効です。singletonなコントローラやサービスに注入する場合は、先述の通り proxyMode = ScopedProxyMode.TARGET_CLASS が必須です。

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class LoginUser implements Serializable {
    private String userId;
    // セッション内で保持する情報
}

sessionスコープのBeanは Serializable にしておくと、セッションのレプリケーションやファイルパーシステンスに備えられます。テストで使う場合は RequestContextHolder の設定が必要なので、@WebMvcTest などのスライステストを活用すると楽です。

アンチパターンに注意

スコープを意識し始めると、つい凝った設計をしたくなりますが、以下は避けたいパターンです。

  • 状態を持つsingleton: スレッドセーフ性が崩壊しやすい。フィールドに可変状態を持たせない
  • なんでもprototype: 過剰設計。引数で状態を渡す方がシンプルなことが多い
  • request/sessionにビジネスロジックを大量に詰める: テストが難しくなる。情報の保持役に留める

迷ったら まずsingleton + 引数で状態を渡す 設計を検討するのが安全です。Beanのライフサイクルそのものについては Bean lifecycle で詳しく扱っています。

まとめ

Beanスコープの基本はsingletonです。状態管理が必要になったときに、prototypeやrequest、sessionを使い分けます。singletonに短命なBeanを注入する場合は ObjectProvider を第一候補に考えると素直に書けます。Web依存スコープを使うときは proxyMode の指定と、テスト時の文脈設定を忘れずに。

スコープを理解すると「なぜこの設計なのか」を説明できるようになります。普段singletonで困っていなくても、選択肢として知っておくと設計の引き出しが増えるはずです。