[Spring] 빈 스코프

윤여송·2026년 9월 27일

Spring

목록 보기
12/12

빈 스코프

1. 빈 스코프란?

빈 스코프(scope) 는 스프링 빈이 생성되고 유지되며 소멸되는 범위다.

지금까지 주로 사용한 스프링 빈은 기본값인 싱글톤 스코프였다.

싱글톤 빈
스프링 컨테이너 시작 → 빈 생성 → 컨테이너 종료 → 빈 소멸

스프링은 다음과 같은 대표 스코프를 지원한다.

스코프유지 범위주 사용 상황
singleton스프링 컨테이너 시작부터 종료까지서비스, 리포지토리, 컨트롤러 등 기본 빈
prototype컨테이너가 요청받아 생성·주입·초기화하는 시점까지매번 새 객체가 꼭 필요한 드문 경우
requestHTTP 요청 시작부터 종료까지요청 단위 로그, 요청 컨텍스트
sessionHTTP 세션 생성부터 종료까지사용자 세션 단위 상태
applicationServletContext 생명주기 동안웹 애플리케이션 전역 상태
websocketWebSocket 연결 동안WebSocket 연결별 상태

스코프는 컴포넌트 스캔과 @Bean 등록 모두에 지정할 수 있다.

@Component
@Scope("prototype")
public class PrototypeBean {
}
@Bean
@Scope("prototype")
public PrototypeBean prototypeBean() {
    return new PrototypeBean();
}

2. 싱글톤 스코프: 기본값

스프링 컨테이너는 싱글톤 빈을 하나 생성해 관리하고, 같은 빈을 요청할 때마다 같은 인스턴스를 반환한다.

SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);

assertThat(singletonBean1).isSameAs(singletonBean2);
컨테이너 생성 시
→ SingletonBean 생성
→ @PostConstruct 실행

빈을 여러 번 조회
→ 항상 같은 SingletonBean 반환

컨테이너 종료 시
→ @PreDestroy 실행

싱글톤 빈은 여러 요청이 공유하므로, 요청별 가변 상태를 필드에 저장하면 안 된다. 서비스와 리포지토리는 보통 상태 없이(stateless) 설계한다.

3. 프로토타입 스코프

프로토타입 빈은 컨테이너가 요청을 받을 때마다 새 인스턴스를 생성해 반환한다.

@Scope("prototype")
static class PrototypeBean {

    @PostConstruct
    public void init() {
        System.out.println("PrototypeBean.init");
    }

    @PreDestroy
    public void destroy() {
        System.out.println("PrototypeBean.destroy");
    }
}
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);

assertThat(prototypeBean1).isNotSameAs(prototypeBean2);

싱글톤과 프로토타입의 차이

sequenceDiagram
    participant C as Client
    participant S as Spring Container
    C->>S: singletonBean 요청
    S-->>C: 기존 싱글톤 인스턴스 반환
    C->>S: singletonBean 다시 요청
    S-->>C: 같은 인스턴스 반환

    C->>S: prototypeBean 요청
    S->>S: 새 객체 생성·주입·초기화
    S-->>C: 새 인스턴스 #1 반환
    C->>S: prototypeBean 다시 요청
    S->>S: 새 객체 생성·주입·초기화
    S-->>C: 새 인스턴스 #2 반환
구분싱글톤프로토타입
생성 시점보통 컨테이너 시작 시getBean() 등으로 요청할 때
같은 빈 재조회같은 객체 반환새 객체 반환
@PostConstruct빈당 한 번생성될 때마다 한 번
@PreDestroy컨테이너 종료 시 실행컨테이너가 자동 실행하지 않음

프로토타입의 중요한 특징: 소멸은 클라이언트 책임

스프링 컨테이너는 프로토타입 빈의:

객체 생성 → 의존관계 주입 → 초기화

까지만 처리하고, 생성한 객체를 클라이언트에게 넘긴 뒤에는 더 이상 관리하지 않는다. 따라서 컨테이너를 닫아도 프로토타입 빈의 @PreDestroy는 자동 호출되지 않는다.

필요하다면 프로토타입 빈을 받은 클라이언트가 종료 메서드를 직접 호출해 자원을 정리해야 한다.

4. 싱글톤이 프로토타입을 주입받을 때 생기는 문제

다음 구조를 생각해 보자.

@Component
public class ClientBean {

    private final PrototypeBean prototypeBean;

    public ClientBean(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
    }

    public int logic() {
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

ClientBean은 기본 싱글톤이다. 싱글톤 빈은 컨테이너 시작 시 한 번 생성되고, 그때 PrototypeBean도 한 번 주입받는다.

컨테이너 시작
→ ClientBean(싱글톤) 생성
→ PrototypeBean #1 생성 후 ClientBean 필드에 주입

첫 번째 logic()
→ PrototypeBean #1의 count: 0 → 1

두 번째 logic()
→ 같은 PrototypeBean #1의 count: 1 → 2

즉, PrototypeBean 자체는 프로토타입이지만 싱글톤에 한 번 주입된 뒤에는 그 싱글톤이 같은 참조를 계속 사용한다. 사용할 때마다 새 객체가 생기지 않는다.

assertThat(clientBean1.logic()).isEqualTo(1);
assertThat(clientBean2.logic()).isEqualTo(2); // 기대와 달라질 수 있음

여러 싱글톤 빈이 각각 프로토타입 빈을 주입받으면, 주입 시점에는 각자 다른 프로토타입 객체를 받는다. 하지만 각 싱글톤 내부에서는 그 객체 하나를 계속 사용한다.

5. 해결: 필요할 때 조회(DL)하기

매번 새 프로토타입 객체가 필요하다면, 사용 시점에 컨테이너에 다시 요청해야 한다. 이를 DL(Dependency Lookup, 의존관계 조회/탐색) 이라고 한다.

ApplicationContext를 직접 주입하는 방식

public class ClientBean {

    @Autowired
    private ApplicationContext ac;

    public int logic() {
        PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

매번 getBean()을 호출하므로 새 프로토타입 객체를 받을 수 있다. 하지만 서비스 코드가 컨테이너 전체에 직접 의존하게 되어, 필요한 기능에 비해 의존 범위가 너무 크다.

ObjectProvider: 스프링 방식의 가벼운 DL

@Component
public class ClientBean {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public ClientBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

getObject()를 호출하는 순간 컨테이너에서 PrototypeBean을 조회한다. 프로토타입 스코프이므로 매 호출마다 새 객체가 반환된다.

provider.getObject()
→ 필요한 빈을 그 시점에 조회
→ prototype이면 새 객체
→ request이면 현재 HTTP 요청의 같은 객체

ObjectProvider는 ApplicationContext 전체보다 작은 범위의 DL 기능만 제공하며, getIfAvailable(), 스트림 처리 등 편의 기능도 제공한다.

jakarta.inject.Provider: 표준 방식

import jakarta.inject.Provider;

public class ClientBean {

    private final Provider<PrototypeBean> prototypeBeanProvider;

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.get();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}
방식호출 메서드특징
ObjectProvider<T>getObject()스프링 기능, 편의 기능이 많음
Provider<T>get()Jakarta 표준, 기능이 단순함

일반적인 스프링 애플리케이션이라면 별도 의존성 없이 쓸 수 있고 기능도 많은 ObjectProvider가 편리하다. 다른 DI 컨테이너에서도 돌아가야 하는 코드라면 표준 Provider를 고려할 수 있다.

프로토타입 스코프를 직접 써야 하는 상황은 실무에서 많지 않다. 대부분의 경우 상태 없는 싱글톤 빈만으로 해결된다. ObjectProvider와 Provider는 프로토타입뿐 아니라, 필요한 시점까지 빈 조회를 지연해야 하는 모든 경우에 사용할 수 있다.

6. 웹 스코프

웹 스코프는 웹 애플리케이션에서만 동작하며, 프로토타입과 달리 스프링이 스코프 종료 시점까지 빈을 관리한다. 따라서 종료 콜백도 호출된다.

웹 스코프유지 범위
requestHTTP 요청 하나가 시작해서 응답이 끝날 때까지
sessionHTTP 세션이 생성되어 종료될 때까지
applicationServletContext가 유지되는 동안
websocketWebSocket 연결이 유지되는 동안

웹 스코프를 사용하려면 웹 환경이 필요하다. Spring Boot에서는 spring-boot-starter-web을 추가하면 내장 Tomcat과 웹 애플리케이션 컨텍스트가 함께 구성된다.

7. request 스코프와 MyLogger

현재 프로젝트의 MyLogger는 요청마다 로그를 묶기 위한 request 스코프 빈이다.

@Component
@Scope("request")
public class MyLogger {

    private String uuid;
    private String requestURL;

    @PostConstruct
    public void init() {
        this.uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean create: " + this);
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean close: " + this);
    }
}

요청 하나에서 UUID가 같은 이유

request 스코프는 HTTP 요청 하나마다 MyLogger를 하나만 만든다.

요청 A 시작
→ MyLogger A 생성
→ @PostConstruct에서 UUID A 생성
→ controller와 service가 MyLogger A를 함께 사용
→ @PreDestroy 실행
→ 요청 A 종료

요청 B 시작
→ MyLogger B 생성
→ UUID B 생성

따라서 요청 A에서 로그가 네 번 출력돼도 UUID는 모두 같다.

[uuid-A] request scope bean create
[uuid-A][http://localhost:8080/log-demo][controller test]
[uuid-A][http://localhost:8080/log-demo][service id = testID]
[uuid-A] request scope bean close

중요한 코드는 지역 변수가 아니라 필드에 UUID를 저장하는 것이다.

// 잘못된 코드: 지역 변수만 생성됨
String uuid = UUID.randomUUID().toString();

// 올바른 코드: 요청 범위 빈의 필드에 저장됨
this.uuid = UUID.randomUUID().toString();

request URL은 왜 setter로 넣나?

MyLogger가 생성되는 시점에는 아직 어떤 URL로 들어온 요청인지 알 수 없다. 컨트롤러가 요청을 받은 뒤 URL을 알고 있으므로, 그때 setRequestURL()로 넣는다.

String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);

실무에서는 컨트롤러마다 직접 넣기보다 서블릿 필터나 스프링 인터셉터에서 공통으로 처리하는 편이 낫다.

8. request 빈을 싱글톤에 바로 주입하면 왜 실패할까?

LogDemoController, LogDemoService는 싱글톤이다. 이들은 애플리케이션 시작 시 생성된다.

반면 MyLogger는 실제 HTTP 요청이 들어와야 만들어지는 request 빈이다.

애플리케이션 시작 시
→ Controller, Service는 생성 가능
→ 아직 HTTP 요청 없음
→ MyLogger(request)는 생성 불가
→ 싱글톤에 MyLogger를 직접 주입하려 하면 오류

대표 오류:

Scope 'request' is not active for the current thread

핵심은 진짜 request 빈 조회를 HTTP 요청이 진행되는 시점까지 늦추는 것이다.

9. 해결 1: ObjectProvider로 지연 조회

현재 프로젝트는 ObjectProvider<MyLogger>를 controller와 service에 주입해 사용한다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.setRequestURL(request.getRequestURL().toString());
        myLogger.log("controller test");

        logDemoService.logic("testID");
        return "OK";
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}

getObject() 호출 시점은 이미 HTTP 요청 처리 중이므로 MyLogger를 정상적으로 조회할 수 있다. controller와 service가 각각 getObject()를 호출해도 같은 HTTP 요청이면 같은 request 빈을 받는다.

10. 해결 2: 스코프 프록시

Provider 호출 코드까지 없애고 일반 빈처럼 주입하고 싶다면 스코프 프록시를 사용한다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}

이후 controller와 service는 일반적인 생성자 주입처럼 작성할 수 있다.

@RequiredArgsConstructor
@Service
public class LogDemoService {

    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service id = " + id);
    }
}

프록시는 어떻게 동작할까?

스프링은 애플리케이션 시작 시 진짜 request 빈 대신 MyLogger를 상속한 프록시 객체를 만들어 싱글톤 빈에 주입한다.

애플리케이션 시작
→ MyLogger 프록시 생성·주입 가능

HTTP 요청 A에서 myLogger.log() 호출
→ 프록시가 요청 A의 진짜 MyLogger 조회
→ 진짜 MyLogger.log()에 위임

HTTP 요청 B에서 myLogger.log() 호출
→ 같은 프록시가 요청 B의 다른 진짜 MyLogger 조회
→ 진짜 MyLogger.log()에 위임
flowchart LR
    C[Controller / Service] --> P[MyLogger 프록시]
    P -->|요청 A| A[MyLogger A - request scope]
    P -->|요청 B| B[MyLogger B - request scope]

클래스 타입을 프록시할 때는 ScopedProxyMode.TARGET_CLASS를 사용한다. 인터페이스 타입이라면 ScopedProxyMode.INTERFACES를 선택할 수 있다.

프록시가 주입된 실제 클래스명은 MyLogger$$SpringCGLIB$$...처럼 보일 수 있다. 이는 원본 MyLogger를 상속한 CGLIB 프록시다.

Provider와 프록시의 공통점

둘 다 진짜 스코프 빈의 조회를 필요한 시점까지 미룬다.

ObjectProvider
→ 코드에서 getObject()로 지연 조회를 명시

Scoped proxy
→ 프록시가 메서드 호출 시 내부적으로 지연 조회

프록시는 코드는 간결하지만, 객체가 프록시라는 사실과 실제 생명주기가 다르다는 점을 이해하고 꼭 필요한 곳에만 사용한다.

11. 스코프 선택 기준

일반 서비스·리포지토리·컨트롤러
→ singleton (기본값)

매번 독립된 새 객체가 꼭 필요
→ prototype + Provider로 사용 시점 조회 검토

HTTP 요청 하나 동안 공유할 정보
→ request

사용자 로그인 세션 동안 공유할 정보
→ session

특별한 스코프는 유용하지만 싱글톤과 생명주기가 다르고 프록시·지연 조회를 이해해야 한다. 따라서 꼭 필요한 범위에만 최소한으로 사용한다.

핵심 요약

  1. 스코프는 빈이 존재하고 관리되는 범위다.
  2. 기본값은 싱글톤이며, 서비스·리포지토리 같은 대부분의 빈에 적합하다.
  3. 프로토타입 빈은 요청할 때마다 새로 생성되지만, 컨테이너는 소멸 콜백을 자동 관리하지 않는다.
  4. 싱글톤이 프로토타입을 일반 주입받으면 새 객체를 매 사용 시 받지 못한다.
  5. 이때 ObjectProvider 또는 Provider로 필요한 순간에 DL을 수행한다.
  6. request 스코프는 HTTP 요청당 객체 하나를 만들어, 같은 요청 안에서 controller와 service가 공유한다.
  7. request 빈을 싱글톤에 쓰려면 ObjectProvider 또는 scoped proxy로 실제 조회를 요청 시점까지 늦춘다.
  8. scoped proxy는 프록시가 실제 스코프 빈에 위임하므로, 일반 주입처럼 코드를 유지할 수 있다.
profile
y_some__velog

0개의 댓글