Spring Bean Scope: 싱글톤부터 Request·Session까지

Spring 빈 스코프 5가지(singleton·prototype·request·session·application)의 동작 원리를 이해하고, 싱글톤에 프로토타입을 안전하게 주입하는 ObjectProvider·scoped-proxy 패턴을 실전 예제로 설명합니다.

· 8 min read · PALDYN Team

지난 글에서는 여러 구현체 중 하나를 선택하는 @Qualifier@Primary 전략을 살펴봤습니다. 이번에는 한 걸음 더 나아가, 빈이 몇 개나 만들어지는가를 결정하는 스코프(Scope) 개념을 파고듭니다.

Spring은 기본적으로 모든 빈을 싱글톤으로 관리합니다. 하지만 상황에 따라 요청마다, 혹은 세션마다 새 인스턴스가 필요한 경우가 있습니다. 스코프를 이해하면 메모리 사용, 스레드 안전성, 상태 격리 문제를 올바르게 설계할 수 있습니다.

스코프 종류 한눈에 보기

Spring Bean Scope 한눈에 보기

Spring은 크게 다섯 가지 스코프를 제공합니다.

스코프범위비고
singletonApplicationContext당 1개기본값
prototypegetBean 또는 주입 요청마다 새 인스턴스소멸 콜백 없음
requestHTTP 요청 1건웹 전용
sessionHTTP 세션웹 전용
applicationServletContext 생애사실상 싱글톤

Singleton — 기본값, 상태 없는 서비스에 최적

@Service
// @Scope("singleton") — 기본값이므로 생략 가능
public class OrderService {
    // 상태 없음 → 스레드 안전
}

싱글톤 빈은 컨테이너 시작 시 한 번 생성되고 컨테이너가 종료될 때까지 유지됩니다. 모든 요청 스레드가 동일 인스턴스를 공유하기 때문에 인스턴스 변수에 요청별 상태를 저장하면 레이스 컨디션이 발생합니다.

@Service
public class BadService {
    private String currentUser; // ← 절대 금지: 요청 간 공유됨

    public void setUser(String user) { this.currentUser = user; }
    public void doWork() { System.out.println(currentUser + " 처리"); }
}

싱글톤은 의존성 주입된 참조를 보관하는 것이지 요청 데이터를 보관해서는 안 됩니다.

Prototype — 매 요청마다 새 인스턴스

@Component
@Scope("prototype")
public class ReportBuilder {
    private final List<String> lines = new ArrayList<>();

    public void addLine(String line) { lines.add(line); }
    public String build() { return String.join("\n", lines); }
}

getBean(ReportBuilder.class) 또는 주입이 일어날 때마다 새 인스턴스가 만들어집니다. 상태가 있는(stateful) 빈에 적합합니다.

중요한 차이가 하나 있습니다. Spring은 프로토타입 빈의 생성과 의존성 주입까지만 관여하고, 이후 소멸 관리는 하지 않습니다. @PreDestroy가 호출되지 않으므로, 리소스 해제가 필요하다면 직접 처리해야 합니다.

싱글톤에 Prototype 주입 — 함정과 해결

Singleton에 Prototype 주입 함정과 해결

싱글톤 빈이 프로토타입 빈을 일반 생성자 주입으로 받으면 문제가 생깁니다.

@Service                          // singleton
public class SingletonService {
    private final PrototypeBean pb;

    public SingletonService(PrototypeBean pb) {
        this.pb = pb;             // 컨테이너 시작 시 1회 주입, 이후 고정
    }

    public void doWork() {
        pb.reset();               // 항상 같은 pb → prototype의 의미 없음
    }
}

SingletonService는 한 번 생성될 때 PrototypeBean 인스턴스를 주입받고 그걸 계속 씁니다. 매 호출마다 새 인스턴스를 원하는 목적이 달성되지 않습니다.

해결 1: ObjectProvider

Spring이 제공하는 ObjectProvider<T>를 주입받아 메서드 호출 시점마다 새 인스턴스를 요청합니다.

@Service
public class SingletonService {

    private final ObjectProvider<PrototypeBean> pbProvider;

    public SingletonService(ObjectProvider<PrototypeBean> pbProvider) {
        this.pbProvider = pbProvider;
    }

    public void doWork() {
        PrototypeBean pb = pbProvider.getObject(); // 매번 새 인스턴스
        pb.reset();
        pb.process();
    }
}

해결 2: @ScopeproxyMode

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PrototypeBean { /* ... */ }

ScopedProxyMode.TARGET_CLASS를 설정하면 Spring은 PrototypeBean을 감싸는 CGLIB 프록시를 생성합니다. 싱글톤이 프록시를 보유하고, 프록시에 메서드가 호출될 때마다 실제 프로토타입 인스턴스를 컨테이너에서 새로 꺼냅니다.

@Service
public class SingletonService {
    private final PrototypeBean pb; // 실제론 프록시

    public SingletonService(PrototypeBean pb) { this.pb = pb; }

    public void doWork() {
        pb.reset();   // 내부적으로 새 인스턴스에 위임
    }
}

코드가 더 깔끔하지만, 프록시 생성을 위해 CGLIB 의존성이 필요하며 인터페이스 기반이면 ScopedProxyMode.INTERFACES를 씁니다.

웹 스코프 — request·session·application

웹 스코프는 WebApplicationContext에서만 동작합니다. Spring MVC 환경에서 자동 활성화되며, 스프링 부트는 별도 설정 없이 사용 가능합니다.

Request Scope

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {

    private String traceId = UUID.randomUUID().toString();
    private long   startTime = System.currentTimeMillis();

    public String getTraceId() { return traceId; }
    public long   elapsed()    { return System.currentTimeMillis() - startTime; }
}

HTTP 요청마다 새 RequestContext 인스턴스가 생성됩니다. proxyMode를 설정하면 싱글톤 서비스에 주입해도 요청별 인스턴스를 사용합니다. 분산 트레이싱 ID, 요청 로깅에 유용합니다.

Session Scope

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart implements Serializable {

    private final List<CartItem> items = new ArrayList<>();

    public void add(CartItem item) { items.add(item); }
    public List<CartItem> getItems() { return Collections.unmodifiableList(items); }
    public int size() { return items.size(); }
}

HTTP 세션 하나당 ShoppingCart 인스턴스 하나가 유지됩니다. Serializable을 구현하면 세션 클러스터링(Redis 등)에서도 직렬화가 가능합니다.

@RequestScope / @SessionScope 단축 어노테이션

Spring 4.3부터 웹 스코프 선언을 단축할 수 있습니다.

@Component
@RequestScope                   // proxyMode=TARGET_CLASS 자동 포함
public class RequestLogger { /* ... */ }

@Component
@SessionScope
public class UserPreference { /* ... */ }

proxyModeTARGET_CLASS로 자동 설정해 주기 때문에 별도 지정이 필요 없습니다.

실전 선택 기준

상태 없는 서비스·리포지터리    → singleton (기본)
호출마다 새 상태가 필요한 빌더  → prototype + ObjectProvider
요청별 로깅·트레이싱 컨텍스트  → @RequestScope
로그인 사용자 정보·장바구니     → @SessionScope

싱글톤이 압도적으로 많이 쓰이고, 나머지 스코프는 상태 격리가 명확히 필요한 곳에만 사용합니다. 잘못 사용하면 메모리 낭비(prototype 리소스 미해제)나 스레드 안전성 문제로 이어집니다.

정리

  • singleton: 기본값, 상태 없는 서비스에 적합
  • prototype: 상태 있는 빈, ObjectProvider로 매번 새 인스턴스 획득
  • 웹 스코프(request·session): 싱글톤에 주입 시 proxyMode 필수
  • @RequestScope·@SessionScope: 단축 어노테이션, proxyMode 자동 포함

다음 글에서는 빈의 **생명주기(Lifecycle)**를 살펴봅니다. 빈이 생성되고 초기화 콜백이 호출되며 소멸하는 전 과정을 추적하고, @PostConstruct·@PreDestroy·InitializingBean 등의 차이를 설명합니다.


지난 글: @Autowired와 @Qualifier: 빈 선택 전략 완전 정리

다음 글: Spring Bean 생명주기: 초기화부터 소멸까지 완전 분석


읽어주셔서 감사합니다. 😊