@Service로 등록한 Bean을 여러 곳에서 주입받으면, 같은 객체일까요 다른 객체일까요? 당연히 같은 객체입니다. 그런데 왜 같은 객체인지, 다른 객체로 만들 수는 없는지 생각해본 적 있으신가요?
이 질문의 답이 바로 스코프(Scope)입니다. 스프링이 Bean을 어떻게 생성하고 관리할지 결정하는 설정인데, 이걸 모르고 사용하면 예상치 못한 버그를 만날 수 있습니다.
스코프(Scope)란?
좀 더 정확히 말하면, 스코프는 Bean이 존재할 수 있는 범위입니다. 스프링 컨테이너가 Bean 객체를 언제 생성하고, 얼마나 오래 유지할지를 결정합니다.
스프링은 다양한 스코프를 지원하지만, 가장 기본이 되는 두 가지가 있습니다:
- 싱글톤(Singleton): 컨테이너에 딱 하나만 존재
- 프로토타입(Prototype): 요청할 때마다 새로 생성
이 외에도 웹 환경에서 사용하는 request, session, application 스코프가 있지만, 이들도 결국 싱글톤과 프로토타입의 변형이라고 볼 수 있습니다. 오늘은 핵심인 싱글톤과 프로토타입에 집중해보겠습니다.
싱글톤(Singleton) 스코프
기본 동작
싱글톤은 Spring의 기본 스코프입니다. 별도로 설정하지 않으면 모든 Bean은 싱글톤으로 관리됩니다.
@Component
public class SingletonBean {
public SingletonBean() {
System.out.println("SingletonBean 생성됨: " + this);
}
}
이 Bean을 여러 곳에서 주입받아 사용해도 항상 같은 인스턴스입니다:
@Service
public class ServiceA {
private final SingletonBean singletonBean;
public ServiceA(SingletonBean singletonBean) {
this.singletonBean = singletonBean;
System.out.println("ServiceA가 받은 Bean: " + singletonBean);
}
}
@Service
public class ServiceB {
private final SingletonBean singletonBean;
public ServiceB(SingletonBean singletonBean) {
this.singletonBean = singletonBean;
System.out.println("ServiceB가 받은 Bean: " + singletonBean);
}
}
실행 결과:
SingletonBean 생성됨: com.example.SingletonBean@1a2b3c4d
ServiceA가 받은 Bean: com.example.SingletonBean@1a2b3c4d
ServiceB가 받은 Bean: com.example.SingletonBean@1a2b3c4d
SingletonBean은 딱 한 번만 생성되고, ServiceA와 ServiceB는 같은 인스턴스를 공유합니다.
싱글톤의 특징
- 스프링 컨테이너가 시작될 때 Bean이 생성됩니다 (
@Lazy를 사용하면 지연 초기화도 가능) - 컨테이너가 종료될 때까지 유지됩니다
- 모든 요청에서 같은 인스턴스를 반환합니다
- 메모리 효율적이고 성능이 좋습니다
프로토타입(Prototype) 스코프
기본 동작
프로토타입 스코프는 Bean을 요청할 때마다 새로운 인스턴스를 생성합니다. @Scope 어노테이션으로 지정할 수 있습니다:
@Component
@Scope("prototype")
public class PrototypeBean {
public PrototypeBean() {
System.out.println("PrototypeBean 생성됨: " + this);
}
}
또는 ConfigurableBeanFactory의 상수를 사용할 수도 있습니다:
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class PrototypeBean {
// ...
}
이 Bean을 여러 곳에서 주입받으면:
@Service
public class ServiceA {
private final PrototypeBean prototypeBean;
public ServiceA(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
System.out.println("ServiceA가 받은 Bean: " + prototypeBean);
}
}
@Service
public class ServiceB {
private final PrototypeBean prototypeBean;
public ServiceB(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
System.out.println("ServiceB가 받은 Bean: " + prototypeBean);
}
}
실행 결과:
PrototypeBean 생성됨: com.example.PrototypeBean@1a2b3c4d
ServiceA가 받은 Bean: com.example.PrototypeBean@1a2b3c4d
PrototypeBean 생성됨: com.example.PrototypeBean@5e6f7g8h
ServiceB가 받은 Bean: com.example.PrototypeBean@5e6f7g8h
PrototypeBean이 두 번 생성되었고, ServiceA와 ServiceB는 서로 다른 인스턴스를 가지고 있습니다.
프로토타입의 특징
- Bean을 요청할 때마다 새로운 인스턴스를 생성합니다
- 스프링 컨테이너는 생성과 초기화까지만 관여합니다
- 이후 관리 책임은 클라이언트에게 있습니다
@PreDestroy같은 소멸 콜백이 호출되지 않습니다 (따라서 리소스 정리는 클라이언트가 직접 해야 합니다)
결정적 차이: 상태 관리
두 스코프의 가장 중요한 차이는 상태를 가지는 객체를 다룰 때 드러납니다.
싱글톤에서 상태를 가지면 생기는 문제
@Component
public class Counter {
private int count = 0;
public void increase() {
count++;
}
public int getCount() {
return count;
}
}
이 Bean은 싱글톤이므로, 모든 곳에서 같은 인스턴스를 공유합니다:
@RestController
public class CounterController {
private final Counter counter;
public CounterController(Counter counter) {
this.counter = counter;
}
@GetMapping("/increase")
public int increase() {
counter.increase();
return counter.getCount();
}
}
여러 사용자가 동시에 /increase를 호출하면 어떻게 될까요?
사용자A 호출 -> count: 1
사용자B 호출 -> count: 2
사용자C 호출 -> count: 3
사용자A 다시 호출 -> count: 4
모든 사용자가 하나의 count 값을 공유하게 됩니다. 만약 사용자별로 독립적인 카운터가 필요했다면 이는 심각한 버그입니다. 더 나아가 멀티스레드 환경에서는 동시성 문제까지 발생할 수 있습니다.
싱글톤은 무상태(Stateless)로 설계해야 한다
이런 이유로 싱글톤 Bean은 무상태로 설계해야 합니다:
@Component
public class Calculator {
// 필드에 상태를 저장하지 않는다
public int add(int a, int b) {
return a + b;
}
public int multiply(int a, int b) {
return a * b;
}
}
상태가 필요하다면 메서드의 파라미터나 로컬 변수를 활용해야 합니다. 로컬 변수는 스레드마다 독립적인 스택 메모리에 저장되므로 공유 문제가 없습니다.
프로토타입은 상태를 가져도 된다
프로토타입 Bean은 요청마다 새 인스턴스가 생성되므로, 상태를 가져도 괜찮습니다:
@Component
@Scope("prototype")
public class UserSession {
private String username;
private List<String> cart = new ArrayList<>();
public void setUsername(String username) {
this.username = username;
}
public void addToCart(String item) {
cart.add(item);
}
public List<String> getCart() {
return cart;
}
}
각 요청마다 새로운 UserSession이 생성되므로, 사용자 간에 데이터가 섞이지 않습니다.
싱글톤과 프로토타입을 함께 사용할 때 주의점
여기서 한 가지 함정이 있습니다. 싱글톤 Bean이 프로토타입 Bean을 주입받으면 예상과 다르게 동작합니다.
문제 상황
@Component
@Scope("prototype")
public class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
}
@Component
public class SingletonClient {
private final PrototypeBean prototypeBean;
public SingletonClient(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
SingletonClient는 싱글톤이고, 생성자를 통해 PrototypeBean을 주입받습니다. 이 상황에서 logic() 메서드를 여러 번 호출하면:
@Test
void 프로토타입_주입_테스트() {
SingletonClient client = context.getBean(SingletonClient.class);
int count1 = client.logic(); // 1
int count2 = client.logic(); // 2 (예상: 1)
int count3 = client.logic(); // 3 (예상: 1)
}
프로토타입인데 왜 count가 계속 증가할까요?
싱글톤 Bean은 생성 시점에 한 번만 의존성을 주입받습니다. 그래서 SingletonClient가 처음 생성될 때 PrototypeBean도 함께 생성되어 주입되고, 이후로는 계속 같은 인스턴스를 사용하게 됩니다. 프로토타입의 의미가 없어지는 것입니다.
해결 방법 1: ObjectProvider 사용
@Component
public class SingletonClient {
private final ObjectProvider<PrototypeBean> prototypeBeanProvider;
public SingletonClient(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
this.prototypeBeanProvider = prototypeBeanProvider;
}
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
ObjectProvider의 getObject()를 호출할 때마다 스프링 컨테이너에서 새로운 프로토타입 Bean을 조회합니다. 의존성을 주입(Injection)받는 것이 아니라, 필요한 시점에 직접 찾아오는(Lookup) 방식이라서 Dependency Lookup (DL)이라고 부릅니다.
해결 방법 2: @Lookup 사용
@Component
public abstract class SingletonClient {
public int logic() {
PrototypeBean prototypeBean = getPrototypeBean();
prototypeBean.addCount();
return prototypeBean.getCount();
}
@Lookup
protected abstract PrototypeBean getPrototypeBean();
}
@Lookup 어노테이션을 사용하면 스프링이 해당 메서드를 오버라이드해서 매번 새로운 프로토타입 Bean을 반환하도록 만들어줍니다.
해결 방법 3: 프록시 모드 사용
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PrototypeBean {
// ...
}
프록시 모드를 사용하면, 싱글톤 Bean에는 실제 프로토타입 Bean 대신 프록시(가짜) 객체가 주입됩니다. 이 프록시 객체의 메서드를 호출할 때마다 내부적으로 진짜 프로토타입 Bean을 새로 생성해서 요청을 위임합니다.
언제 어떤 스코프를 사용해야 할까?
싱글톤을 사용하는 경우 (대부분의 경우)
- 상태가 없는 서비스 객체 (Service, Repository 등)
- 공유해도 문제없는 읽기 전용 데이터
- 설정 객체나 유틸리티 객체
@Service
public class OrderService {
private final OrderRepository orderRepository;
// 상태 없이 로직만 수행
public Order createOrder(OrderRequest request) {
// ...
}
}
프로토타입을 사용하는 경우
- 각 사용 시점마다 독립적인 상태가 필요한 경우
- 복잡한 초기화가 필요한 객체를 매번 새로 만들어야 하는 경우
- 스레드 안전성이 보장되어야 하는 상태 객체
@Component
@Scope("prototype")
public class ReportGenerator {
private List<String> data = new ArrayList<>();
private StringBuilder result = new StringBuilder();
public void addData(String item) {
data.add(item);
}
public String generate() {
// data를 기반으로 리포트 생성
return result.toString();
}
}
정리
핵심을 요약하면 다음과 같습니다:
싱글톤(Singleton)
- 스프링의 기본 스코프
- 컨테이너에 Bean이 딱 하나만 존재
- 모든 곳에서 같은 인스턴스를 공유
- 무상태(Stateless)로 설계해야 함
- 메모리 효율적이고 성능이 좋음
프로토타입(Prototype)
@Scope("prototype")으로 지정- 요청할 때마다 새 인스턴스 생성
- 생성과 초기화만 컨테이너가 관리
- 상태를 가져도 안전함
- 소멸 콜백이 호출되지 않음
주의할 점
- 싱글톤에서 프로토타입을 주입받으면, 프로토타입도 사실상 싱글톤처럼 동작
ObjectProvider,@Lookup, 프록시 모드로 해결 가능
실무에서는 대부분 싱글톤만 사용하게 됩니다. 싱글톤의 무상태 원칙만 잘 지키면 문제없이 사용할 수 있기 때문입니다. 프로토타입은 정말 필요한 경우에만 제한적으로 사용하는 것이 좋습니다.
'프레임워크 > 스프링' 카테고리의 다른 글
| AOP(관점 지향 프로그래밍): 어떻게 메소드 실행 전후에 로그를 남길까? (0) | 2025.12.24 |
|---|---|
| Filter vs Interceptor: 둘 다 거름망인데 도대체 뭐가 다를까? (0) | 2025.12.22 |
| DispatcherServlet의 역할: 요청이 들어와서 응답이 나갈 때까지의 여정 (1) | 2025.12.19 |
| Spring Bean의 생명주기(Lifecycle) - 객체 생성부터 소멸까지 (0) | 2025.12.18 |
| DI(의존성 주입)과 IoC(제어의 역전): 왜 우리가 직접 new를 안 쓸까? (0) | 2025.12.18 |