빈 스코프 (Bean Scope)
왜 필요한가
스프링 빈은 기본적으로 싱글톤이다. 컨테이너당 하나의 인스턴스만 생성하고 공유한다.
하지만 경우에 따라 다른 방식이 필요하다.
- "이 빈은 요청할 때마다 새 인스턴스를 줘야 해" → 프로토타입
- "HTTP 요청 하나 동안만 같은 인스턴스를 써야 해" → request 스코프
- "로그인한 세션 동안 같은 인스턴스를 써야 해" → session 스코프
스코프는 빈이 존재할 수 있는 범위(생명주기)를 정의한다.
스코프 종류
| 스코프 | 설명 | 사용 환경 |
|---|---|---|
singleton |
기본값. 컨테이너당 1개 | 모든 환경 |
prototype |
요청마다 새 인스턴스 생성 | 모든 환경 |
request |
HTTP 요청 1개당 1개 | 웹 |
session |
HTTP 세션 1개당 1개 | 웹 |
application |
서블릿 컨텍스트 1개당 1개 (싱글톤과 유사) | 웹 |
websocket |
웹 소켓 1개당 1개 | 웹 소켓 |
싱글톤 vs 프로토타입
싱글톤
@Component
// @Scope("singleton") — 기본값이므로 생략 가능
public class SingletonBean {
private int count = 0;
public void addCount() { count++; }
public int getCount() { return count; }
}
클라이언트 A → getBean() → SingletonBean@x01 반환
클라이언트 B → getBean() → SingletonBean@x01 반환 ← 같은 인스턴스!
스프링 컨테이너: 생성 → 관리 → 소멸 콜백 → 소멸 (전 과정 관리)
프로토타입
@Component
@Scope("prototype")
public class PrototypeBean {
private int count = 0;
public void addCount() { count++; }
public int getCount() { return count; }
}
클라이언트 A → getBean() → 새 인스턴스 생성 → PrototypeBean@x01 반환
클라이언트 B → getBean() → 새 인스턴스 생성 → PrototypeBean@x02 반환 ← 다른 인스턴스!
스프링 컨테이너: 생성 → DI → @PostConstruct 초기화 콜백까지만 관여
이후(소멸 포함)는 클라이언트 책임!
중요: 프로토타입 빈은 @PreDestroy가 호출되지 않는다. 스프링이 생성·DI·초기화까지만 관여하고 이후 소멸은 클라이언트 책임이다.
비교
| 구분 | 싱글톤 | 프로토타입 |
|---|---|---|
| 인스턴스 수 | 1개 | 요청마다 새로 생성 |
| 생성 시점 | 컨테이너 시작 시 (기본) | getBean() 호출 시마다 |
| 관리 주체 | 스프링 컨테이너 | 클라이언트 |
| 초기화 콜백 (@PostConstruct) | O | O |
| 소멸 콜백 (@PreDestroy) | O | X |
| 사용 시기 | 대부분의 빈 | 매번 독립적인 상태가 필요한 빈 |
// 테스트로 확인
AnnotationConfigApplicationContext ac =
new AnnotationConfigApplicationContext(PrototypeBean.class);
PrototypeBean bean1 = ac.getBean(PrototypeBean.class);
PrototypeBean bean2 = ac.getBean(PrototypeBean.class);
assertThat(bean1).isNotSameAs(bean2); // true — 다른 인스턴스
ac.close(); // 프로토타입 빈의 @PreDestroy는 호출되지 않음!
싱글톤 빈에서 프로토타입 빈을 사용할 때 문제
문제 발생 원리
프로토타입 빈의 의도는 "요청할 때마다 새 인스턴스"다.
그런데 싱글톤 빈이 프로토타입 빈을 @Autowired로 주입받으면 문제가 생긴다.
주입은 싱글톤 빈이 생성될 때 딱 한 번만 일어난다. 이후 싱글톤 빈이 살아있는 동안 계속 같은 프로토타입 빈 인스턴스를 재사용한다.
@Component // 싱글톤
public class ClientBean {
@Autowired
private PrototypeBean prototypeBean; // 생성 시 딱 한 번 주입됨 — 이후 계속 재사용!
public int logic() {
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
@Component
@Scope("prototype")
public class PrototypeBean {
private int count = 0;
public void addCount() { count++; }
public int getCount() { return count; }
}
[기대]
logic() 1회 호출 → 새 PrototypeBean 생성 → count = 1
logic() 2회 호출 → 새 PrototypeBean 생성 → count = 1
[실제]
ClientBean 생성 시 PrototypeBean@x01 딱 한 번 주입됨
logic() 1회 호출 → 기존 @x01 재사용 → count = 1
logic() 2회 호출 → 기존 @x01 재사용 → count = 2 ← 의도와 다름!
프로토타입 빈을 쓰는 의미가 사라진다.
해결 방법 1: ObjectProvider
빈을 직접 주입받는 대신, 빈을 조회하는 기능(Provider)을 주입받아서 필요할 때마다 새로 꺼낸다.
@Component
public class ClientBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
// PrototypeBean 자체가 아니라, PrototypeBean을 조회하는 기능을 주입받음
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
// getObject() 호출 시마다 스프링 컨테이너에서 새 인스턴스를 생성해서 반환
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
logic() 1회 호출 → getObject() → 새 PrototypeBean@x01 생성 → count = 1
logic() 2회 호출 → getObject() → 새 PrototypeBean@x02 생성 → count = 1 ✓
ObjectProvider가 제공하는 다른 기능들:
// 빈이 없어도 예외 없이 null 반환
PrototypeBean bean = provider.getIfAvailable();
// 빈이 없으면 기본값 사용
PrototypeBean bean = provider.getIfAvailable(() -> new PrototypeBean());
// 스트림으로 여러 빈 처리
provider.stream().forEach(bean -> System.out.println(bean));
ObjectProvider는 스프링 전용이다. 스프링 프로젝트에서 가장 많이 쓰는 방식.
해결 방법 2: JSR-330 Provider (표준)
스프링에 의존하지 않는 자바 표준 인터페이스다. 다른 DI 컨테이너에서도 동작하는 이식성이 필요할 때 사용한다.
import jakarta.inject.Provider;
@Component
public class ClientBean {
@Autowired
private Provider<PrototypeBean> provider; // 자바 표준 Provider
public int logic() {
PrototypeBean prototypeBean = provider.get(); // 매번 새 인스턴스
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
| 구분 | ObjectProvider | Provider (JSR-330) |
|---|---|---|
| 표준 여부 | 스프링 전용 | 자바 표준 |
| 기능 | 다양 (getIfAvailable, stream 등) | 단순 (get() 하나) |
| 의존성 추가 | 불필요 | 필요 |
| 권장 상황 | 스프링 프로젝트 | 스프링 외 이식성 필요할 때 |
해결 방법 3: 스코프 프록시 (가장 편리)
프록시 객체가 싱글톤 빈에 주입되고, 메서드 호출마다 실제 빈을 새로 조회해서 위임한다. 클라이언트 코드 변경 없이 가장 편리하게 해결할 수 있다.
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
// TARGET_CLASS = CGLIB 프록시 생성 (클래스 기반 — 인터페이스 없어도 됨)
public class PrototypeBean {
private int count = 0;
public void addCount() { count++; }
public int getCount() { return count; }
}
@Component
public class ClientBean {
@Autowired
private PrototypeBean prototypeBean;
// 실제로 주입되는 건 PrototypeBean이 아니라 CGLIB이 만든 프록시 객체!
// 프록시는 PrototypeBean을 상속하므로 타입은 호환됨
public int logic() {
prototypeBean.addCount(); // 프록시의 addCount() 호출
return prototypeBean.getCount(); // 프록시의 getCount() 호출
// 프록시는 메서드 호출이 올 때마다 스프링 컨테이너에서 새 PrototypeBean을 조회하고
// 실제 인스턴스에 위임
}
}
[ClientBean]
prototypeBean = PrototypeBeanCGLIB@proxy (프록시 — 싱글톤 라이프사이클)
↓ addCount() 호출마다
스프링 컨테이너에서 새 PrototypeBean 인스턴스 조회 후 위임
ScopedProxyMode 옵션:
| 옵션 | 설명 | 사용 시기 |
|---|---|---|
TARGET_CLASS |
CGLIB 프록시 (클래스 상속 기반) | 인터페이스 없는 경우도 가능 |
INTERFACES |
JDK 동적 프록시 (인터페이스 기반) | 인터페이스가 있는 경우 |
NO |
프록시 없음 (기본값) | — |
세 가지 해결 방법 비교
| 구분 | ObjectProvider | JSR-330 Provider | 스코프 프록시 |
|---|---|---|---|
| 코드 변경 | 주입 타입 변경 | 주입 타입 변경 | 없음 (가장 편리) |
| 스프링 의존 | 있음 | 없음 (표준) | 있음 |
| 추가 의존성 | 없음 | 필요 | 없음 |
| 가독성 | 중간 (getObject() 명시) |
중간 (get() 명시) |
가장 자연스러움 |
| 권장 상황 | 일반적 | 이식성 필요 시 | 가장 편리, 웹 스코프에 특히 유용 |
웹 스코프
웹 환경에서만 동작한다. 싱글톤과 달리 스프링이 해당 스코프의 종료 시점까지 직접 관리하므로 소멸 콜백(@PreDestroy)도 호출된다.
request 스코프
HTTP 요청 하나당 하나의 빈이 생성되고, 그 요청이 끝나면 소멸된다. 같은 요청 안에서는 어디서 꺼내든 같은 인스턴스를 반환한다.
언제 쓰는가: 로그 추적, 요청 ID, 트랜잭션 컨텍스트 등 요청 단위 데이터 관리.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
private String uuid;
private String requestURL;
public void setRequestURL(String requestURL) {
this.requestURL = requestURL;
}
public void log(String message) {
System.out.println("[" + uuid + "][" + requestURL + "] " + message);
}
@PostConstruct
public void init() {
uuid = UUID.randomUUID().toString(); // 요청마다 다른 UUID 생성
System.out.println("[" + uuid + "] request 스코프 빈 생성");
}
@PreDestroy
public void close() {
System.out.println("[" + uuid + "] request 스코프 빈 소멸");
}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger; // 프록시 주입 (실제 빈은 요청 시 생성)
public void logic(String id) {
myLogger.log("service id=" + id);
}
}
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logDemoService;
private final MyLogger myLogger; // 같은 요청이면 서비스와 동일한 인스턴스
@GetMapping("/log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
myLogger.setRequestURL(request.getRequestURL().toString());
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
요청 처리 흐름
HTTP 요청 A (사용자 김철수)
→ 스프링이 MyLogger 빈 생성 (UUID: aaaa-bbbb)
→ LogDemoController: myLogger.log("controller") → [aaaa-bbbb][/log-demo] controller test
→ LogDemoService: myLogger.log("service") → [aaaa-bbbb][/log-demo] service id=testId
→ HTTP 응답 완료 → MyLogger@aaaa-bbbb 소멸 (@PreDestroy 호출)
HTTP 요청 B (사용자 이영희, 동시)
→ 스프링이 MyLogger 빈 생성 (UUID: cccc-dddd) ← 완전히 다른 인스턴스
→ LogDemoController: myLogger.log("controller") → [cccc-dddd][/log-demo] controller test
→ ...
request 스코프가 없다면 uuid, requestURL을 모든 메서드 파라미터로 계속 전달해야 한다.
request 스코프를 쓰면 파라미터 없이 메서드 어디서든 myLogger.log()만 호출하면 된다.
proxyMode가 필요한 이유
@Scope("request")만 붙이면 문제가 생긴다.
문제:
LogDemoController는 스프링 시작 시점에 생성됨 (싱글톤)
이 때 MyLogger가 주입되어야 하는데...
MyLogger는 HTTP 요청이 와야 생성되는 빈
→ 스프링 시작 시점에는 MyLogger를 만들 수 없음 → 오류!
proxyMode = ScopedProxyMode.TARGET_CLASS를 붙이면:
- 스프링 시작 시점에 가짜 프록시 객체를 만들어서 LogDemoController에 주입
- HTTP 요청이 오면 프록시가 실제 MyLogger를 조회해서 메서드 위임
스프링 시작: MyLoggerCGLIB@proxy (프록시) → LogDemoController에 주입
HTTP 요청: 프록시.log() 호출 → 스프링이 이 요청에 해당하는 실제 MyLogger 조회 → 위임
스코프 선택 기준
| 상황 | 스코프 |
|---|---|
| 상태 없는 서비스, 레포지토리 (대부분) | singleton |
| 매 요청마다 독립적인 상태가 필요한 빈 | prototype |
| HTTP 요청 단위 상태 관리 (로그 추적, 요청 컨텍스트) | request |
| 로그인 사용자 정보 등 세션 단위 관리 | session |
실무에서는 singleton이 거의 전부고, request 스코프가 간헐적으로 쓰인다.
prototype은 매우 드물게 사용된다.
내부 동작 원리
Request 스코프 — RequestContextHolder 내부 메커니즘
request 스코프 빈이 어떻게 HTTP 요청마다 독립적인 인스턴스를 유지하는가?
핵심: RequestContextHolder가 내부적으로 ThreadLocal을 사용한다.
톰캣은 스레드풀을 사용하고, HTTP 요청 하나가 스레드 하나를 독점한다.
따라서 ThreadLocal(스레드별 독립 저장소)에 요청 정보를 저장하면 = 요청별로 독립된다.
HTTP 요청 도착
↓
DispatcherServlet.service() 실행
↓
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request))
→ ThreadLocal<RequestAttributes>에 현재 스레드의 요청 정보 저장
비즈니스 로직 처리 (Controller → Service → Repository)
같은 스레드이므로 ThreadLocal에서 항상 같은 요청 정보 조회 가능
HTTP 응답 완료
↓
RequestContextHolder.resetRequestAttributes()
→ ThreadLocal에서 요청 정보 제거 (스레드풀 반환 전 정리)
request 스코프 빈이 요청 정보를 조회하는 실제 흐름:
myLogger.log("message") 호출 (프록시를 통해)
↓
CGLIB 프록시가 가로챔
↓
RequestScope.get(beanName, objectFactory)
→ RequestContextHolder.currentRequestAttributes() 호출
→ ThreadLocal에서 현재 스레드의 RequestAttributes 조회
→ request.getAttribute("scopedTarget.myLogger") 확인
→ 없으면: objectFactory.getObject() → 새 MyLogger 인스턴스 생성
request.setAttribute("scopedTarget.myLogger", newBean) → 요청 속성에 저장
→ 있으면: 기존 인스턴스 반환 (같은 요청 내에서는 항상 동일 인스턴스)
↓
실제 MyLogger 인스턴스에 log("message") 위임
왜 ThreadLocal인가? 톰캣의 스레드풀 모델: 요청마다 풀에서 스레드를 꺼내 처리하고 반환한다. 한 요청은 처음부터 끝까지 같은 스레드가 처리한다. ThreadLocal에 저장된 값은 해당 스레드 내에서만 접근되므로, 요청 격리가 자연스럽게 달성된다.
CGLIB 스코프 프록시 내부 동작
proxyMode = ScopedProxyMode.TARGET_CLASS 설정 시 스프링이 생성하는 프록시의 실제 동작:
// 스프링이 CGLIB으로 생성하는 프록시 (의사코드)
public class MyLogger$$EnhancerBySpringCGLIB extends MyLogger {
// 실제 스코프 관리자 (request, prototype 등에 따라 다름)
private final Scope scope;
private final String targetBeanName;
@Override
public void log(String message) {
// 1. 현재 스코프에서 실제 빈 조회
MyLogger target = (MyLogger) scope.get(targetBeanName, () -> {
// 없으면 새로 생성 (prototype이면 항상 새로, request면 요청당 한 번)
return beanFactory.createBean(MyLogger.class);
});
// 2. 실제 빈에 위임
target.log(message);
}
@Override
public void setRequestURL(String url) {
MyLogger target = (MyLogger) scope.get(targetBeanName, ...);
target.setRequestURL(url);
}
}
LogDemoController가 보는 것:
myLogger = MyLogger$$EnhancerBySpringCGLIB@proxy (항상 같은 프록시 객체)
실제 동작:
myLogger.log("test") 호출
→ 프록시: RequestScope.get() → ThreadLocal로 현재 요청의 MyLogger 조회
→ 실제 MyLogger@{요청별_인스턴스}.log("test") 실행
요청 A: MyLogger@x01 사용
요청 B: MyLogger@x02 사용 (완전히 다른 인스턴스, 다른 스레드의 ThreadLocal)
| 프록시 관점 | 설명 |
|---|---|
| 컨트롤러/서비스가 주입받는 것 | 항상 동일한 CGLIB 프록시 객체 (싱글톤처럼 보임) |
| 실제 메서드 호출 시 | 프록시가 현재 스코프에서 실제 빈을 조회해서 위임 |
| request 스코프의 격리 | ThreadLocal + HttpServletRequest.getAttribute()로 요청별 인스턴스 구분 |