Bean 스코프 — 하나의 객체를 모두가 공유해도 괜찮을까
스프링은 기본적으로 빈을 하나만 만들어서 모든 곳에서 공유합니다. 그런데 여러 요청이 동시에 들어오면, 하나의 객체를 공유해도 정말 괜찮은 걸까요?
개념 정의
Bean 스코프 는 스프링 컨테이너가 빈 인스턴스를 얼마나 오래, 몇 개나 유지할지를 결정하는 설정입니다. 기본값은 singleton 으로, 컨테이너 전체에서 단 하나의 인스턴스만 존재합니다.
왜 필요한가
모든 객체가 싱글톤이면 안 되는 상황이 있습니다.
- **상태를 가진 객체 **: 사용자별로 다른 데이터를 담아야 하는 경우
- **HTTP 요청별 데이터 **: 요청 컨텍스트 정보를 빈에 담아야 하는 경우
- ** 매번 새 인스턴스가 필요한 경우 **: 사용할 때마다 초기 상태여야 하는 객체
스코프를 이해하지 못하면 "왜 다른 사용자의 데이터가 보이지?" 같은 심각한 버그를 만들 수 있습니다.
내부 동작
스코프 종류
| 스코프 | 설명 | 인스턴스 수 |
|---|---|---|
singleton | 컨테이너당 하나 (기본값) | 1개 |
prototype | 요청할 때마다 새로 생성 | N개 |
request | HTTP 요청당 하나 | 요청 수만큼 |
session | HTTP 세션당 하나 | 세션 수만큼 |
application | ServletContext당 하나 | 1개 |
websocket | WebSocket 세션당 하나 | 세션 수만큼 |
싱글톤이 기본인 이유
왜 매번 새 객체를 만들지 않고 하나만 만들어서 공유할까요?
- 대부분의 스프링 빈은 ** 상태를 갖지 않는 서비스 객체 **입니다.
- 상태가 없으면 여러 스레드가 동시에 사용해도 서로 영향을 주지 않습니다.
- 하나의 인스턴스를 재사용하면 객체 생성 비용과 GC 부담이 사라집니다.
- 따라서 기본 스코프를 singleton으로 설정하는 것이 합리적입니다.
@Service
public class OrderService {
private final OrderRepository orderRepository; // 상태 없음, 의존성만 보유
public Order createOrder(OrderRequest request) {
// 매개변수로 데이터를 받아서 처리 → 인스턴스 변수에 저장하지 않음
return orderRepository.save(new Order(request));
}
}
이런 서비스는 100개의 요청이 동시에 들어와도, 각자 자기 매개변수와 로컬 변수를 사용하기 때문에 하나의 인스턴스를 공유해도 안전합니다. 매번 새 객체를 만들면 메모리 낭비이고, GC 부담만 늘어납니다.
싱글톤에서 주의할 점
@Service
public class BadService {
private int count = 0; // 인스턴스 변수에 상태 저장 → 위험!
public int incrementAndGet() {
return ++count; // 여러 스레드가 동시에 접근하면 문제 발생
}
}
싱글톤 빈에 가변 상태를 두면 동시성 문제가 발생합니다. 싱글톤 빈은 ** 무상태(stateless)**로 설계해야 합니다.
Prototype 스코프
@Component
@Scope("prototype")
public class PrototypeBean {
public PrototypeBean() {
System.out.println("PrototypeBean 생성: " + this);
}
}
@Component
public class Client {
@Autowired
private ApplicationContext context;
public void doSomething() {
// 매번 새 인스턴스
PrototypeBean bean1 = context.getBean(PrototypeBean.class);
PrototypeBean bean2 = context.getBean(PrototypeBean.class);
System.out.println(bean1 == bean2); // false
}
}
중요한 점은 prototype 빈의 소멸 콜백(@PreDestroy)은 스프링이 호출하지 않는다 는 것입니다. 생성과 주입까지만 관리하고, 그 이후는 클라이언트 코드의 책임입니다.
싱글톤 + Prototype 조합 문제
이 부분이 실무에서 가장 많이 실수하는 지점입니다.
@Component
@Scope("prototype")
public class PrototypeBean {
private int count = 0;
public int addAndGet() {
return ++count;
}
}
이어서 @Component을 적용한 나머지 구현부입니다.
@Component
public class SingletonBean {
private final PrototypeBean prototypeBean; // 주입 시점에 딱 한 번 생성됨
public SingletonBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
return prototypeBean.addAndGet();
// 매번 같은 인스턴스! count가 계속 증가함
}
}
SingletonBean은 한 번만 생성되므로, 생성자에서 주입받은 PrototypeBean도 하나뿐입니다. prototype 스코프의 의미가 사라져버립니다.
코드 예제
ObjectProvider로 해결하기
@Component
public class SingletonBean {
private final ObjectProvider<PrototypeBean> prototypeBeanProvider;
public SingletonBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
this.prototypeBeanProvider = prototypeBeanProvider;
}
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
// 매번 새 인스턴스 생성
return prototypeBean.addAndGet(); // 항상 1 반환
}
}
ObjectProvider는 빈 조회를 지연시켜서, getObject()를 호출할 때마다 컨테이너에 빈을 요청합니다. prototype 스코프이므로 매번 새 인스턴스를 받습니다.
JSR-330 Provider
@Component
public class SingletonBean {
private final Provider<PrototypeBean> provider; // jakarta.inject.Provider
public SingletonBean(Provider<PrototypeBean> provider) {
this.provider = provider;
}
public int logic() {
PrototypeBean prototypeBean = provider.get(); // 매번 새 인스턴스
return prototypeBean.addAndGet();
}
}
Provider는 자바 표준이라 스프링에 덜 의존적이지만, 기능은 ObjectProvider가 더 풍부합니다(스트림 처리, Optional 지원 등).
Scoped Proxy로 해결하기
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PrototypeBean {
private int count = 0;
public int addAndGet() {
return ++count;
}
}
이렇게 하면 싱글톤 빈에 주입되는 것은 CGLIB 프록시이고, 메서드를 호출할 때마다 프록시가 컨테이너에서 새 prototype 인스턴스를 가져와 위임합니다.
Request 스코프
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
private String requestId;
private LocalDateTime requestTime;
@PostConstruct
public void init() {
this.requestId = UUID.randomUUID().toString();
this.requestTime = LocalDateTime.now();
}
// getter, setter
}
@RestController
public class OrderController {
private final RequestContext requestContext; // 프록시 주입
public OrderController(RequestContext requestContext) {
this.requestContext = requestContext;
}
@GetMapping("/order")
public String order() {
// 각 HTTP 요청마다 다른 RequestContext 인스턴스 사용
return "Request ID: " + requestContext.getRequestId();
}
}
request 스코프 빈은 proxyMode를 지정해야 합니다. 싱글톤인 컨트롤러가 생성될 때는 아직 HTTP 요청이 없으므로, 프록시를 넣어두고 실제 요청이 올 때 진짜 빈을 연결합니다.
스코프 선택 기준
빈에 상태가 있는가?
├── 없다 → singleton (기본값)
└── 있다
├── 요청마다 다른 상태? → request
├── 세션마다 다른 상태? → session
└── 매번 새 인스턴스 필요? → prototype
주의할 점
1. 싱글톤 빈에 가변 인스턴스 변수를 두면 동시성 버그가 발생한다
싱글톤 빈은 모든 스레드가 공유합니다. private int count = 0 같은 가변 필드를 두면 여러 스레드가 동시에 접근하여 레이스 컨디션이 발생합니다. "다른 사용자의 데이터가 보인다"는 류의 버그가 이 원인인 경우가 많습니다. 싱글톤 빈은 반드시 무상태(stateless)로 설계하고, 요청별 데이터는 매개변수나 로컬 변수로 처리해야 합니다.
2. 싱글톤에 prototype을 주입하면 prototype이 싱글톤처럼 동작한다
싱글톤 빈은 한 번만 생성되므로, 생성자에서 주입받은 prototype 빈도 단 한 번만 생성됩니다. prototype의 "매번 새 인스턴스" 의미가 완전히 사라집니다. ObjectProvider<T>를 사용하여 getObject() 호출 시점마다 새 인스턴스를 받거나, proxyMode = ScopedProxyMode.TARGET_CLASS를 지정해야 합니다.
3. request 스코프 빈을 proxyMode 없이 싱글톤에 주입하면 시작 시 에러가 난다
request 스코프 빈을 싱글톤 컨트롤러에 직접 주입하면, 애플리케이션 시작 시 아직 HTTP 요청이 없어 BeanCreationException이 발생합니다. @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)로 프록시를 통해 주입해야 실제 요청 시점에 올바른 빈이 연결됩니다.
정리
| 항목 | 설명 |
|---|---|
| 기본 스코프 | singleton — 컨테이너당 하나. 대부분 이것으로 충분 |
| 싱글톤 핵심 규칙 | 가변 인스턴스 변수 금지 (동시성 버그) |
| prototype 함정 | 싱글톤에 주입하면 사실상 싱글톤으로 동작 |
| 해결법 | ObjectProvider<T> 또는 proxyMode = TARGET_CLASS |
| request/session | 반드시 proxyMode 설정 필요 (싱글톤 빈에서 사용 시) |