빈 스코프(scope) 는 스프링 빈이 생성되고 유지되며 소멸되는 범위다.
지금까지 주로 사용한 스프링 빈은 기본값인 싱글톤 스코프였다.
싱글톤 빈
스프링 컨테이너 시작 → 빈 생성 → 컨테이너 종료 → 빈 소멸
스프링은 다음과 같은 대표 스코프를 지원한다.
| 스코프 | 유지 범위 | 주 사용 상황 |
|---|---|---|
singleton | 스프링 컨테이너 시작부터 종료까지 | 서비스, 리포지토리, 컨트롤러 등 기본 빈 |
prototype | 컨테이너가 요청받아 생성·주입·초기화하는 시점까지 | 매번 새 객체가 꼭 필요한 드문 경우 |
request | HTTP 요청 시작부터 종료까지 | 요청 단위 로그, 요청 컨텍스트 |
session | HTTP 세션 생성부터 종료까지 | 사용자 세션 단위 상태 |
application | ServletContext 생명주기 동안 | 웹 애플리케이션 전역 상태 |
websocket | WebSocket 연결 동안 | WebSocket 연결별 상태 |
스코프는 컴포넌트 스캔과 @Bean 등록 모두에 지정할 수 있다.
@Component
@Scope("prototype")
public class PrototypeBean {
}
@Bean
@Scope("prototype")
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
스프링 컨테이너는 싱글톤 빈을 하나 생성해 관리하고, 같은 빈을 요청할 때마다 같은 인스턴스를 반환한다.
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
assertThat(singletonBean1).isSameAs(singletonBean2);
컨테이너 생성 시
→ SingletonBean 생성
→ @PostConstruct 실행
빈을 여러 번 조회
→ 항상 같은 SingletonBean 반환
컨테이너 종료 시
→ @PreDestroy 실행
싱글톤 빈은 여러 요청이 공유하므로, 요청별 가변 상태를 필드에 저장하면 안 된다. 서비스와 리포지토리는 보통 상태 없이(stateless) 설계한다.
프로토타입 빈은 컨테이너가 요청을 받을 때마다 새 인스턴스를 생성해 반환한다.
@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는 자동 호출되지 않는다.
필요하다면 프로토타입 빈을 받은 클라이언트가 종료 메서드를 직접 호출해 자원을 정리해야 한다.
다음 구조를 생각해 보자.
@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); // 기대와 달라질 수 있음
여러 싱글톤 빈이 각각 프로토타입 빈을 주입받으면, 주입 시점에는 각자 다른 프로토타입 객체를 받는다. 하지만 각 싱글톤 내부에서는 그 객체 하나를 계속 사용한다.
매번 새 프로토타입 객체가 필요하다면, 사용 시점에 컨테이너에 다시 요청해야 한다. 이를 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는 프로토타입뿐 아니라, 필요한 시점까지 빈 조회를 지연해야 하는 모든 경우에 사용할 수 있다.
웹 스코프는 웹 애플리케이션에서만 동작하며, 프로토타입과 달리 스프링이 스코프 종료 시점까지 빈을 관리한다. 따라서 종료 콜백도 호출된다.
| 웹 스코프 | 유지 범위 |
|---|---|
request | HTTP 요청 하나가 시작해서 응답이 끝날 때까지 |
session | HTTP 세션이 생성되어 종료될 때까지 |
application | ServletContext가 유지되는 동안 |
websocket | WebSocket 연결이 유지되는 동안 |
웹 스코프를 사용하려면 웹 환경이 필요하다. Spring Boot에서는 spring-boot-starter-web을 추가하면 내장 Tomcat과 웹 애플리케이션 컨텍스트가 함께 구성된다.
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);
}
}
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();
MyLogger가 생성되는 시점에는 아직 어떤 URL로 들어온 요청인지 알 수 없다. 컨트롤러가 요청을 받은 뒤 URL을 알고 있으므로, 그때 setRequestURL()로 넣는다.
String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);
실무에서는 컨트롤러마다 직접 넣기보다 서블릿 필터나 스프링 인터셉터에서 공통으로 처리하는 편이 낫다.
LogDemoController, LogDemoService는 싱글톤이다. 이들은 애플리케이션 시작 시 생성된다.
반면 MyLogger는 실제 HTTP 요청이 들어와야 만들어지는 request 빈이다.
애플리케이션 시작 시
→ Controller, Service는 생성 가능
→ 아직 HTTP 요청 없음
→ MyLogger(request)는 생성 불가
→ 싱글톤에 MyLogger를 직접 주입하려 하면 오류
대표 오류:
Scope 'request' is not active for the current thread
핵심은 진짜 request 빈 조회를 HTTP 요청이 진행되는 시점까지 늦추는 것이다.
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 빈을 받는다.
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 프록시다.
둘 다 진짜 스코프 빈의 조회를 필요한 시점까지 미룬다.
ObjectProvider
→ 코드에서 getObject()로 지연 조회를 명시
Scoped proxy
→ 프록시가 메서드 호출 시 내부적으로 지연 조회
프록시는 코드는 간결하지만, 객체가 프록시라는 사실과 실제 생명주기가 다르다는 점을 이해하고 꼭 필요한 곳에만 사용한다.
일반 서비스·리포지토리·컨트롤러
→ singleton (기본값)
매번 독립된 새 객체가 꼭 필요
→ prototype + Provider로 사용 시점 조회 검토
HTTP 요청 하나 동안 공유할 정보
→ request
사용자 로그인 세션 동안 공유할 정보
→ session
특별한 스코프는 유용하지만 싱글톤과 생명주기가 다르고 프록시·지연 조회를 이해해야 한다. 따라서 꼭 필요한 범위에만 최소한으로 사용한다.
ObjectProvider 또는 Provider로 필요한 순간에 DL을 수행한다.ObjectProvider 또는 scoped proxy로 실제 조회를 요청 시점까지 늦춘다.