빈 스코프: singleton이 전부가 아니다
들어가며
질문 하나로 시작하자. UserService 빈은 지금
JVM에 몇 개 떠 있을까? "1개"라고 답하면 절반만 맞다. 정확한
답은 **"ApplicationContext당 1개"**다. 컨테이너가 두 개면 인스턴스도 두
개고, GoF 디자인 패턴의 싱글톤과는 경계가 다르다. 빈의 "개수"와 "공유
범위"를 결정하는 것이 바로 **스코프(Scope)**다. 기본값이 워낙 무난해서
스코프를 의식할 일이 별로 없지만, 그 기본값이 깔고 있는 전제를 모르면
멀티스레드 환경이나 웹 요청 경계에서 조용히 깨진다.
이 주제를 공부할 때 특히 막히기 쉬운 지점 다섯 가지가 있다.
- 싱글톤은 JVM 전체 하나가 아니다 — 정확히는
ApplicationContext하나당 하나다 - 싱글톤 빈에 가변 필드를 넣고 "빈이니까 안전하다"고 쓰면 동시성 버그가 된다
- 싱글톤에 프로토타입을 주입하면 매번 새 인스턴스가 올 것 같지만 실제로는 한 번만 주입된다
@Scope("request")만 붙이고 싱글톤에 주입하면ScopeNotActiveException이 터진다- 프로토타입 빈의
@PreDestroy는 호출되지 않는다 — 이유를 모르면 리소스 누수가 생긴다
이 글은 왜 → 종류 → 싱글톤 → 프로토타입 → 주입 함정 → 웹 스코프 → Provider → 프록시 → 실무 순으로, Spring Framework 6.x(Java 17+) 기준으로 정리한다. 3편에서 라이프사이클 콜백을 봤다면, 4편은 **"빈이 몇 개 존재하고 누구와 공유되는가"**의 문제다.
목차
- 1) 스코프란 무엇인가: 개수와 공유 범위 두 축
- 2) Singleton: 기본값이자 함정
- 3) Prototype: 매번 새로 만드는 빈
- 4) 싱글톤 안에 프로토타입 주입: 가장 유명한 함정
- 5) Web Scopes: HTTP 경계를 빈으로 쓰는 법
- 6) Provider와 ObjectProvider: JSR-330 표준과 Spring 전용
- 7) Scoped Proxy: 수명이 다른 빈을 함께 쓰는 법
- 8) 커스텀 스코프: 있다는 것만 알자
- 9) 실무에서 이렇게 고른다
- 10) 한 줄 정리
1) 스코프란 무엇인가: 개수와 공유 범위 두 축
1-1) 두 축으로 보면 이해가 빠르다
스코프를 처음 볼 때 흔히 "싱글톤이냐 프로토타입이냐"라는 이분법으로 접근한다. 그런데 스프링이 제공하는 6종 스코프는 개수와 공유 범위라는 두 축을 따로 결정하는 쪽이 구조가 훨씬 잘 보인다. 스코프를 고른다는 것은 결국 이 두 질문에 답하는 일이다.
- 개수 축 — 컨테이너 안에 이 빈이 몇 개 존재하는가. 1개인가, N개인가.
- 공유 축 — 그 인스턴스를 누가 공유하는가. 애플리케이션 전체인가, HTTP 요청인가, 세션인가, 스레드인가.
싱글톤은 "1개 + 컨테이너 전체 공유"다. 프로토타입은 "요청 때마다 N개 + 공유 없음"이다. 웹 스코프는 "요청마다 1개 + 요청 경계 안에서만 공유"다. 두 축이 독립적이라는 걸 먼저 잡아두면, 뒤에 나올 함정들이 전부 이 구조 안에서 해석된다.
1-2) 6종 스코프 한눈에
| 스코프 | 개수 | 공유 범위 | 기본 등록 |
|---|---|---|---|
| singleton | 1개 | ApplicationContext당 |
모든 컨테이너 (기본값) |
| prototype | 요청 때마다 N개 | 공유 없음 | 모든 컨테이너 |
| request | 1개 / HTTP 요청 | 한 HTTP 요청 내부 | WebApplicationContext |
| session | 1개 / HTTP 세션 | 한 HTTP 세션 내부 | WebApplicationContext |
| application | 1개 / ServletContext |
전체 ServletContext |
WebApplicationContext |
| websocket | 1개 / WebSocket 세션 | 한 WebSocket 세션 내부 | WebApplicationContext |
표의 오른쪽 열이 중요하다. 위 둘(singleton,
prototype)은 일반 ApplicationContext에서 기본
등록되지만, 아래 넷은 WebApplicationContext가
있어야 등록된다. 웹이 아닌 배치 애플리케이션에서는
@RequestScope를 써도 동작하지 않는다는 뜻이다.
1-3) 핵심: "per Spring IoC container"이지 per JVM이 아니다
Spring Reference가 싱글톤 스코프를 설명할 때 쓰는 정확한 표현이 있다.
"Only one shared instance of a singleton bean is managed, and all requests for beans with an ID or IDs that match that bean definition result in that one specific bean instance being returned by the Spring container."
"shared instance ... managed by the Spring
container" — 여기서 말하는 "하나"는 JVM이 아니라 스프링
컨테이너 안에서 하나라는 뜻이다. 같은 JVM 프로세스 안에서
ApplicationContext를 두 번 띄우면 같은 클래스의 싱글톤 빈도
인스턴스가 두 개 존재한다. 테스트에서 여러 컨텍스트를 띄울 때 이 차이가
체감된다.
다이어그램의 포인트는 모든 스코프 박스가
ApplicationContext 박스 안에 있다는 것이다. JVM
안에 싱글톤이 떠 있는 게 아니라, 컨테이너 안에
스코프별로 인스턴스가 관리된다. 컨테이너가 둘이면 박스 전체가 두 번
그려진다고 생각하면 된다.
2) Singleton: 기본값이자 함정
2-1) 왜 기본값인가
스프링이 싱글톤을 기본 스코프로 삼은 이유는 단순하다. **대부분의 빈이
무상태(stateless)**이기 때문이다. OrderService,
UserRepository, PaymentGateway 같은 서비스
계층 빈들은 자기 상태를 들고 있지 않고, 메서드 파라미터로 받은 값을
처리해서 다른 빈에게 넘긴다. 같은 인스턴스를 수천 스레드가 공유해도
문제가 없다. 싱글톤은 이런 빈에 가장 저렴한 선택이다. 생성 비용이 한
번만 들고, GC 대상에서 빠지고, 의존성 그래프가 단순해진다.
Spring Reference가 이 원칙을 직접 말한다.
"As a rule, you should use the prototype scope for all stateful beans and the singleton scope for stateless beans."
stateless면 singleton, stateful이면 prototype이라는 명시적 가이드라인이다. 거꾸로 말하면, 싱글톤을 쓰는 순간 "이 빈은 상태가 없어야 한다"는 암묵적 계약을 받아들인 셈이다.
2-2) 함정: 가변 필드 = 동시성 버그
가장 흔한 안티패턴은 이것이다. 싱글톤 빈 안에 int나
Map 같은 가변 필드를 넣고, 메서드에서 그 필드를 수정하는
코드다.
@Service
public class CounterService {
private int count = 0; // 공유 상태 (stateful)
public int increment() {
return ++count; // race condition
}
}필드 하나 추가했을 뿐이지만, 이 빈은 모든 스레드가
공유하므로 ++count가 원자적이지 않다. 두 스레드가
동시에 increment()를 호출하면 증가가 한 번만 반영될 수
있다. 여기서 틀리기 쉽다 — "@Service를 붙였으니 스프링이
알아서 해주겠지"라는 착각이다. 스프링은 빈을 만들고 주입할 뿐,
동시성 보장은 해주지 않는다.
해결은 세 가지다. 첫째, 필드를 지운다. 카운트는
Redis나 DB로 옮긴다. 둘째, 불변으로 만든다.
final 필드로 초기값만 받고 이후 변경하지 않는다. 셋째,
스코프를 바꾼다. 정말 요청마다 상태가 달라야 한다면
@RequestScope를 쓴다. 필드에 synchronized를
덕지덕지 바르는 건 네 번째 선택지지만, 대부분의 경우 설계 자체가 잘못된
신호다.
2-3) Singleton = per ApplicationContext
싱글톤을 JVM 싱글톤(GoF 패턴)으로 이해하면 두 가지 지점에서 깨진다.
첫째, 테스트에서 여러 컨텍스트를 띄울 때.
@SpringBootTest로 컨텍스트 A를, @DataJpaTest로
컨텍스트 B를 띄우면 같은 UserService 클래스여도 인스턴스가
두 개 존재한다. 둘째, 부모-자식 컨텍스트 구조.
WebApplicationContext는 부모
ApplicationContext를 가질 수 있는데, 각각에 싱글톤 빈이
있으면 둘은 서로 다른 인스턴스다.
따라서 "싱글톤이니까 static처럼 전역 상태를 저장해도
되겠지"라는 판단은 두 컨텍스트가 만나는 순간 깨진다.
JVM 전역 상태가 필요하면 static 필드나 외부 저장소(Redis
등)를 써야 하고, 그런 요구 자체가 이미 설계 재검토 신호인 경우가
많다.
3) Prototype: 매번 새로 만드는 빈
3-1) 정의
프로토타입은 이름 그대로다. 컨테이너에 빈 요청이 들어올 때마다 새 인스턴스를 만든다. Spring Reference의 설명은 이렇다.
"The non-singleton prototype scope of bean deployment results in the creation of a new bean instance every time a request for that specific bean is made."
"every time a request ... is made" — 요청 시점마다 새로 만든다는 뜻이다. 코드로 확인해보면 차이가 명확하다.
@Component
@Scope("prototype")
public class Cart {
private final List<Item> items = new ArrayList<>();
// ...
}
// 호출부
Cart c1 = context.getBean(Cart.class);
Cart c2 = context.getBean(Cart.class);
System.out.println(c1 == c2); // false — 서로 다른 인스턴스같은 클래스의 빈을 두 번 요청했지만, c1과
c2는 서로 다른 객체다. 싱글톤이면 이
비교가 true였을 것이다.
3-2) 적합한 경우는 매우 좁다
프로토타입이 "상태가 있는 빈"에 쓰라고 되어 있지만, 실무에서 진짜로
적합한 경우는 생각보다 드물다. 대부분의 "매번 새 객체가 필요한" 상황은
메서드 파라미터로 값을 넘기거나, 그냥 new로
만들면 충분하다. 굳이 컨테이너를 거치는 이유는 그
객체가 다른 빈을 주입받아야 할 때에 한정된다.
실제로 프로토타입이 쓰이는 전형적인 자리는 이런 식이다.
- Command 패턴의 명령 객체 — 실행 상태를 들고 있고, 내부에서 서비스 빈을 호출해야 하는 경우
- Builder/Assembler — 조립 중간 상태를 들고 있어야 하는 경우
- Spring Batch의 Step 컴포넌트 — 실행마다 독립된 상태가 필요한 경우
이 외의 대부분 상황은 new로 만들고 값 객체(DTO,
VO)로 취급하는 쪽이 단순하다. 프로토타입을 남용하면 "왜 이건
빈인데 저건 아니지?"라는 혼란만 생긴다.
3-3)
@PreDestroy가 호출되지 않는다
3편에서 @PreDestroy가 소멸 콜백으로 호출된다고
정리했지만, 프로토타입 빈은 예외다. Spring Reference가
이 사실을 명시적으로 박아두고 있다.
"In contrast to the other scopes, Spring does not manage the complete lifecycle of a prototype bean. The container instantiates, configures, and otherwise assembles a prototype object and hands it to the client, with no further record of that prototype instance."
"no further record" — 컨테이너는 프로토타입을
만들어서 건네주는 순간, 그 인스턴스를 더 이상 추적하지 않는다. 싱글톤은
컨테이너가 종료될 때 소멸 콜백을 불러주지만, 프로토타입은 GC가 수거할
뿐이다. 따라서 @PreDestroy에 파일 닫기나 커넥션 반환 같은
리소스 정리 로직을 넣어두면 영영 실행되지 않는다.
리소스 정리가 필요한 객체는 try-with-resources로 직접
관리하거나, 해제 책임을 호출하는 쪽에 명시적으로 위임해야 한다. 그러면
"호출할 때마다 새로 가져오는" 패턴 자체는 어떻게 풀어야 할까. §4 싱글톤 안에
프로토타입 주입: 가장 유명한 함정에서 ObjectProvider로
호출자가 명시적으로 관리하는 패턴을 본다.
4) 싱글톤 안에 프로토타입 주입: 가장 유명한 함정
4-1) 함정 재현
스프링 스코프를 처음 공부할 때 대부분이 밟는 지뢰가 이것이다. 싱글톤 빈이 프로토타입 빈을 필드로 주입받으면, 주입 시점에 생성된 프로토타입 인스턴스 하나가 이후 내내 재사용된다. Spring Reference의 경고를 직접 보자.
"Thus, if you dependency-inject a prototype-scoped bean into a singleton-scoped bean, a new prototype bean is instantiated and then dependency-injected into the singleton bean. The prototype instance is the sole instance that is ever supplied to the singleton-scoped bean."
"the sole instance that is ever supplied" — 싱글톤이 만들어질 때 프로토타입 빈 1개가 새로 생성돼 주입되고, 바로 그 한 개가 싱글톤에게 영원히 공급되는 유일한 인스턴스다. "프로토타입이니까 호출할 때마다 새로 오겠지"라는 기대는 여기서 무너진다.
@Service
public class OrderService {
@Autowired
private Cart cart; // @Scope("prototype")
public void run() {
System.out.println(cart); // 같은 인스턴스
}
}
// 두 번 호출해도 cart의 identityHashCode는 동일
orderService.run();
orderService.run();OrderService가 싱글톤이므로 컨테이너는
OrderService를 한 번 만들 때 Cart도 한 번
만들어서 주입한다. 이후로 orderService.run()을 몇 번 부르든
cart는 항상 같은 인스턴스다. 프로토타입의 의미가
사라지는 지점이 여기다.
4-2) 해결 1:
ObjectProvider (1순위)
가장 권장되는 해결법이다. 프로토타입 빈을 직접 주입받는 대신, **빈을 꺼내올 수 있는 공급자(provider)**를 주입받는다.
@Service
public class OrderService {
private final ObjectProvider<Cart> cartProvider;
public OrderService(ObjectProvider<Cart> cartProvider) {
this.cartProvider = cartProvider;
}
public void run() {
Cart fresh = cartProvider.getObject(); // 호출할 때마다 새 인스턴스
// ...
}
}ObjectProvider.getObject()를 호출하는 시점마다
컨테이너가 새로운 프로토타입 인스턴스를 만들어
돌려준다. 주입은 한 번만 일어나지만, 그 주입된
객체(ObjectProvider)가 빈 생성을 위임하는
핸들이기 때문에 프로토타입의 의미가 살아난다.
ObjectProvider는 스프링 4.3에서 도입됐다. 그 이전에는
javax.inject.Provider나 @Lookup을 썼는데,
ObjectProvider가 더 간결하고 Spring 전용
기능(getIfAvailable, 스트림 접근 등)을 추가로 제공한다.
테스트에서 ObjectProvider를 직접 목킹하기도 쉽다.
jakarta.inject.Provider<T>와의 차이는 §6
Provider와 ObjectProvider에서 따로 정리한다.
4-3) 해결 2: @Lookup
두 번째 선택지는 메서드 주입(Method Injection) 방식이다.
@Service
public abstract class OrderService {
public void run() {
Cart fresh = createCart(); // 호출할 때마다 새 인스턴스
}
@Lookup
protected abstract Cart createCart();
}@Lookup이 붙은 메서드는 스프링이 런타임에
서브클래스를 만들어 구현해준다. 개발자가 추상 메서드로
선언만 하면, 호출 시점에 컨테이너가 해당 타입의 빈을 꺼내서 돌려준다.
내부적으로는 CGLIB로 서브클래싱을 하기 때문에 클래스나 메서드가
final이면 동작하지 않는다는 제약이 있다.
@Lookup은 레거시 코드나 특수한 경우에만 쓴다. 새로
쓴다면 ObjectProvider가 거의 항상 더 낫다.
4-4) 해결 비교표
| 방법 | 코드량 | 테스트 용이성 | final 제약 | 권장도 |
|---|---|---|---|---|
ObjectProvider<T> |
적음 | 높음 (직접 목킹 가능) | 없음 | 1순위 |
@Lookup |
보통 | 보통 (서브클래싱 필요) | final 메서드·클래스 불가 |
2순위 |
ApplicationContext.getBean() |
적음 | 낮음 (컨텍스트 의존) | 없음 | 비권장 |
세 번째 행의 ApplicationContext를 직접 주입받아
getBean()을 호출하는 방식은 서비스 로케이터(Service
Locator) 안티패턴에 가깝다. 동작은 하지만 테스트에서
ApplicationContext를 구성해야 하고, 클래스가 스프링에
강하게 묶인다. 웬만하면 ObjectProvider를 쓴다.
5) Web Scopes: HTTP 경계를 빈으로 쓰는 법
웹 스코프는 WebApplicationContext가 있어야 동작한다.
Spring Boot의 spring-boot-starter-web을 쓰면 자동으로
잡힌다. 네 종류가 있지만 실무 비중은 request 50%, session 30%,
application과 websocket이 나머지 정도다.
5-1) request scope
HTTP 요청 하나가 들어와서 응답이 나갈 때까지, 그 요청 경계 안에서만 유효한 빈이다.
@Component
@RequestScope
public class RequestContext {
private String correlationId;
private String userAgent;
// getter/setter
}전형적인 쓸모는 **요청별 상관 ID(correlation ID)**나 요청별
사용자 컨텍스트를 담는 것이다. 필터나 인터셉터에서 값을 채우고,
서비스 계층에서 꺼내 쓴다. 같은 요청 안에서는 어디서
RequestContext를 주입받든 같은 인스턴스가
오고, 요청이 끝나면 소멸된다.
5-2) session scope
HTTP 세션 하나 안에서 유효한 빈이다.
@Component
@SessionScope
public class UserPreferences {
private String theme = "light";
private String locale = "ko-KR";
}로그인 사용자의 개인화 설정이나 쇼핑 카트 같은 세션 단위
상태를 담는다. 단, 여기서 함정이 하나 있다. 로드밸런싱
환경에서 sticky session이 없으면 요청이 다른 인스턴스로 가는
순간 세션 빈의 상태가 사라진다. HTTP 세션 자체는 서블릿 컨테이너가
관리하지만, @SessionScope 빈은 기본적으로 JVM
메모리에 있기 때문이다.
해결책은 Spring Session을 쓰는 것이다. Redis나 JDBC를
세션 저장소로 삼으면 여러 인스턴스가 세션을 공유할 수 있고,
@SessionScope 빈도 자연스럽게 따라간다. 세션 빈을 쓸 때는
"이 상태가 인스턴스 하나에 묶여도 괜찮은가"를 먼저 따져야 한다.
5-3) application scope
싱글톤과 혼동하기 쉬운 스코프다. @ApplicationScope는
ServletContext당 하나를 의미한다. Spring Reference가 이
차이를 직접 설명한다.
"It is a singleton per ServletContext, not per Spring ApplicationContext (for which there may be several in any given web application)"
중요한 차이는 기준이 ServletContext냐
ApplicationContext냐다. 일반적인 웹 애플리케이션
하나라면 둘이 1:1이라 차이가 없지만, **한 서블릿 컨테이너에 여러
ApplicationContext**가 뜨는 구조(전통적인 서블릿 + 스프링
설정)에서는 application 스코프 빈이 그 컨테이너들
전체에 걸쳐 공유된다. 한 서블릿 컨테이너 안에 여러 Spring
ApplicationContext가 뜨는 구조라면 @ApplicationScope 빈은
그들 전부에 걸쳐 공유되지만, 각 ApplicationContext의 싱글톤은 서로
다르다. Spring Boot 단일 애플리케이션에서는 사실상 싱글톤과 동일하게
동작한다.
5-4) websocket scope
한 WebSocket 세션이 열려서 닫힐 때까지 유효한 빈이다. STOMP 기반 WebSocket에서 세션별 구독 상태나 인증 컨텍스트를 담는 데 쓴다. 비중이 낮아서 이 글에서는 깊게 다루지 않지만, 쓸 때는 WebSocket 세션의 수명이 HTTP 세션과 다르다는 점만 유의하면 된다.
6) Provider와 ObjectProvider: JSR-330 표준과 Spring 전용
§4에서 프로토타입 함정을 피하는 1순위로 ObjectProvider를
꼽았다. 그런데 비슷한 이름의 Provider<T>가 또 하나
있다. jakarta.inject.Provider<T>다. 둘 다 "빈을
필요할 때 꺼내온다"는 같은 역할을 하지만, 출신과 기능 범위가 다르다.
스코프 주입을 다루는 글에서 한 번은 구분해두는 쪽이 좋다.
6-1) 두 가지 Provider의 출신
jakarta.inject.Provider<T>는 **JSR-330
표준(Dependency Injection for Java)**의 일부다. 메서드가
get() 하나뿐인 극히 단순한 인터페이스로, 다른 DI
프레임워크(Guice, CDI 등)로 옮겨도 그대로 쓸 수 있다. 이식성이 필요한
라이브러리 코드에 잘 어울린다.
org.springframework.beans.factory.ObjectProvider<T>는
Spring 4.3에서 도입된 Spring 전용 확장이다.
Provider<T>를 상속하면서
getIfAvailable(), getIfUnique(),
ifAvailable(Consumer), stream(),
orderedStream() 같은 부가 메서드를 더 얹었다. 빈이 없을
수도 있거나 여러 개일 때의 분기를 간결하게 표현할 수 있다.
6-2) 비교표
| 항목 | jakarta.inject.Provider<T> |
ObjectProvider<T> |
|---|---|---|
| 출신 | JSR-330 표준 | Spring 4.3+ 전용 |
| 필요 의존성 | jakarta.inject-api 추가 |
스프링 코어에 포함 |
| 메서드 | get() 하나 |
get() + getIfAvailable /
getIfUnique / stream /
orderedStream |
| 부재·중복 처리 | 직접 try/catch |
getIfAvailable() 한 줄 |
| 이식성 | 다른 DI 프레임워크 호환 | Spring 전용 |
| 권장 기본값 | 라이브러리 코드 | 애플리케이션 코드 |
6-3) 짧은 예시
@Service
public class NotificationService {
private final ObjectProvider<PushSender> pushSender;
public NotificationService(ObjectProvider<PushSender> pushSender) {
this.pushSender = pushSender;
}
public void notify(String message) {
pushSender.ifAvailable(sender -> sender.send(message));
// 빈이 없으면 그냥 스킵, 있으면 호출
}
}같은 로직을 jakarta.inject.Provider<T>로 짜려면
try { provider.get(); } catch (...)로 직접 분기해야 한다.
애플리케이션 코드는 ObjectProvider로 쓰고,
라이브러리를 만든다면 이식성을 위해
jakarta.inject.Provider를 고려하는 정도가 합리적인
기본값이다.
7) Scoped Proxy: 수명이 다른 빈을 함께 쓰는 법
7-1) 왜 필요한가
싱글톤 컨트롤러가 @RequestScope 빈을 주입받는다고
해보자. 컨트롤러는 애플리케이션 시작 시점에 만들어지는데, 그때는 아직
HTTP 요청이 없다. 요청이 없으면 request scope 빈도 존재하지
않는다. 주입을 시도하는 순간
ScopeNotActiveException이 터진다.
이 문제를 푸는 것이 **스코프드 프록시(Scoped Proxy)**다. 컨트롤러에 진짜 빈 대신 프록시 객체를 주입해두고, 컨트롤러 메서드가 실제로 호출되는 순간(= 요청이 진행 중인 순간) 프록시가 진짜 빈을 찾아서 호출을 위임한다. 2편에서 본 AOP 프록시와 같은 메커니즘이지만, 여기서는 횡단 관심사가 아니라 수명 경계를 프록시로 감싼다는 점이 다르다.
7-2)
@Scope("request") vs @RequestScope
여기서 틀리기 쉬운 지점이 있다. 선언 방식에 따라 프록시가 기본으로 붙느냐 안 붙느냐가 다르다.
| 선언 | proxyMode 기본 |
싱글톤에 주입 시 |
|---|---|---|
@Scope("request") |
NO (프록시 없음) |
시작 시 또는 호출 시 ScopeNotActiveException |
@Scope(value="request", proxyMode=TARGET_CLASS) |
TARGET_CLASS |
정상 동작 |
@RequestScope |
TARGET_CLASS (내장) |
정상 동작 |
@Scope("request")는 **프록시 모드가 없음(NO)**이
기본이다. 그냥 붙이면 안 된다. 싱글톤 빈에 주입되는 순간 "지금은 request
scope가 활성화되지 않았다"는 예외가 난다. 해결책은 두 가지다.
// 방법 1: @Scope에 proxyMode 명시
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext { /* ... */ }
// 방법 2: @RequestScope (스프링 4.3+ 권장)
@Component
@RequestScope
public class RequestContext { /* ... */ }두 번째 방식이 권장된다. @RequestScope는 내부적으로
@Scope(value = "request", proxyMode = TARGET_CLASS)를
조합한 메타 애노테이션이라, 빠뜨릴 수 있는
proxyMode를 내장하고 있다.
@SessionScope, @ApplicationScope,
@WebSocketScope도 동일한 패턴이다. 실무에서는
@Scope를 직접 쓰는 대신 이 네 개의 전용 애노테이션을 쓰는
게 기본이라고 봐도 된다.
7-3) CGLIB 제약
TARGET_CLASS 프록시는 내부적으로 CGLIB
서브클래싱으로 만들어진다. 즉, 대상 클래스를 상속해서 메서드
호출을 가로채는 방식이다. 여기서 두 가지 제약이 따라온다.
final클래스나final메서드는 프록시가 안 된다 — CGLIB가 서브클래싱을 못 하기 때문이다.- **Kotlin 클래스는 기본이
final**이므로 그대로 쓰면 프록시 생성이 실패한다.open키워드를 붙이거나kotlin-allopen/kotlin-spring플러그인을 써서 스프링 대상 클래스를 자동으로open으로 바꿔줘야 한다.
Java라면 대부분 문제없지만, final class FooService를
가끔 쓰는 패턴이 있다면 @RequestScope와 조합할 때 조용히
실패할 수 있다. 에러 메시지에 "Cannot subclass final class"라는 문구가
뜨면 이 문제다.
8) 커스텀 스코프: 있다는 것만 알자
8-1) Scope 인터페이스
스프링은 사용자가 직접 스코프를 정의할 수 있는 확장점을 열어두고
있다. org.springframework.beans.factory.config.Scope
인터페이스를 구현하고,
ConfigurableBeanFactory.registerScope("myScope", new MyScope())로
등록하면 @Scope("myScope")로 쓸 수 있다. 구현해야 할
메서드는 get()(인스턴스 제공), remove()(제거),
registerDestructionCallback()(소멸 콜백 등록) 정도다.
실무에서 커스텀 스코프를 직접 만들 일은 거의 없다. 대부분의 요구는 기존 스코프로 해결된다. 존재만 알아두면 된다.
8-2) SimpleThreadScope
경고
스프링이 참고용으로 제공하는 SimpleThreadScope가 있는데,
이름에 "Simple"이 붙어 있는 이유가 있다. 기본으로 등록되어 있지
않다. Spring Reference의 원문은 이렇다.
"A thread scope is available but is not registered by default. For more information, see the documentation for SimpleThreadScope."
참고로 SimpleThreadScope의 Javadoc을 보면, 이 스코프는
파괴 콜백(destruction callback)을 호출하지 않는다는
점이 명시되어 있다. 그래서 프로덕션에서 쓰기엔 부족하다. 스레드 스코프가
진짜 필요하다면 직접 Scope를 구현하거나, 더 나은 방법으로
스레드에 묶인 상태를 빈이 아니라 ThreadLocal로
관리하는 편이 단순하다.
9) 실무에서 이렇게 고른다
- 상태가 없고 전역에서 하나면 된다 → singleton (기본값) — 대부분의 서비스·리포지토리·컴포넌트는 여기 해당한다. 괜히 스코프를 바꾸지 말 것.
- 호출할 때마다 진짜로 새 객체가 필요하면
new부터 고민한다 — 값 객체(DTO, VO)는 빈이 아니다. 그래도 DI가 필요한 경우에만 prototype +ObjectProvider로 간다. - HTTP 요청 단위의 컨텍스트가 필요하면
@RequestScope— 상관 ID, 사용자 정보, 요청 메타데이터.@Scope("request")는 쓰지 말고@RequestScope로. - 로그인 사용자 상태를 담으면
@SessionScope+ sticky session 또는 Spring Session — 단일 인스턴스 가정을 넘는 순간 터진다. - 싱글톤에 다른 스코프 빈을 주입해야 하면 Scoped Proxy 또는
ObjectProvider—proxyMode = TARGET_CLASS를 빠뜨리지 않는다. 전용 애노테이션(@RequestScope등)이 안전하다. @Async메서드에서@RequestScope빈을 쓰지 않는다 — 비동기 스레드로 넘어가는 순간 요청 컨텍스트가 없다.ScopeNotActiveException이 터진다. 꼭 필요하면 요청 스레드에서 값을 꺼내 파라미터로 넘기거나,RequestContextHolder를 명시적으로 복사해야 한다.- 싱글톤 빈에 가변 필드를 넣지 않는다 — 이게 이 글의 한 문장 결론이기도 하다. 상태가 필요하면 스코프부터 다시 고른다.
- 테스트에서 컨텍스트를 여러 개 띄우면 싱글톤도 여러 개 존재한다 — "전역에서 하나"라는 가정으로 코드를 쓰면 테스트가 먼저 깨진다.
10) 한 줄 정리
스코프는 "개수"와 "공유 범위"를 동시에 고르는 일이고, 기본값
싱글톤은 "ApplicationContext당 하나, stateless면 가장
저렴"이란 뜻이다. 싱글톤에 프로토타입을 그냥 주입하면 한 번만
생성되므로, ObjectProvider나
@Lookup으로 "필요할 때 가져오는" 방식을 써야 한다.
HTTP 경계를 빈으로 쓰려면
@RequestScope/@SessionScope를 쓰고,
@Scope("request")를 쓸 땐
proxyMode = TARGET_CLASS를 빼먹지 말 것.
태그: Spring Framework, 빈 스코프, singleton, prototype, @RequestScope, Scoped Proxy, ObjectProvider, @Lookup, ScopeNotActiveException, Spring 6
'CS > Spring' 카테고리의 다른 글
| 빈 초기화 순서: @DependsOn·@Lazy·@Order로 기동을 통제하기 (0) | 2026.04.12 |
|---|---|
| 조건부 빈 등록: @Conditional과 Auto-configuration의 뿌리 (1) | 2026.04.12 |
| 빈 라이프사이클: 태어나서 사라지기까지 (1) | 2026.04.12 |
| 빈이란 무엇인가: 객체에서 빈으로 (0) | 2026.04.12 |
| 스프링 컨테이너와 IoC/DI/AOP — 빈이 태어나는 자리 (1) | 2026.04.11 |