지금까지 우리는 스프링 빈이 스프링 컨테이너 시작 시 생성되어 컨테이너 종료 시까지 유지된다는 것을 학습했다. 이는 스프링 빈이 기본적으로 싱글톤(Singleton) 스코프로 생성되기 때문이다. 스코프(scope)란, 말 그대로 빈이 존재할 수 있는 범위를 뜻한다.
실제로 스프링은 @Scope 애노테이션을 통해 다양한 스코프를 지원한다. 기본적으로 사용하던 싱글톤(Singleton) 스코프를 포함하여, 프로토타입(Prototype), 웹 관련 스코프(Request, Session, Application) 등이 이에 해당한다. 각 스코프는 빈이 생성되고 유지되는 범위를 설정하여 다양한 요구사항에 맞게 활용할 수 있다.
싱글톤의 필요성을 이해하기 위해, 우리는 이전에 컨테이너 내에 많은 객체가 생성되는 상황을 가정하며 메모리 비용 문제를 직접 체감한 적이 있다. 이는 곧 싱글톤이 사용되는 주요 이유가 되었다.
반면, Prototype 스코프의 빈은 호출될 때마다 새로운 객체를 생성하여 호출한 곳에 전달한다. 특징적으로, 생성된 객체는 스프링 컨테이너 내에서 관리되지 않는다. 즉, 호출한 곳에 전달된 이후 컨테이너에서는 해당 객체를 더 이상 참조하지 않는다. 이것이 단순히 마구잡이로 생성되는 객체들과는 차별화되는 지점이다.
Prototype 빈은 호출 시 컨테이너가 프로토타입 스코프임을 인지하고, 객체를 생성 → 전달 → 삭제 과정을 즉시 진행한다. 이 과정에서 생성된 객체는 호출한 곳으로 책임이 넘어가며, 컨테이너는 더 이상 해당 객체를 관리하지 않는다.
또한, Prototype 빈의 경우 컨테이너는 객체 생성, 의존관계 주입, 초기화까지만 담당하며, Destroy와 같은 종료 작업은 책임지지 않는다. 이는 생성된 객체가 호출한 곳에 전달된 후 컨테이너 내에서 삭제되기 때문에 종료 콜백을 관리할 수 없기 때문이다. 결국, 종료 콜백을 컨테이너에서 관리하려는 시도 자체가 프로토타입 빈의 특성상 어울리지 않는다는 것을 의미한다.
결론적으로 섞어 쓰는 방법은 권장되는 방식이 아니다 다음의 코드를 확인해보자.
@Test
void singletonClientUsePrototype() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
ClientBean clientBean1 = ac.getBean(ClientBean.class);
int count1 = clientBean1.logic();
assertThat(count1).isEqualTo(1);
ClientBean clientBean2 = ac.getBean(ClientBean.class);
int count2 = clientBean2.logic();
assertThat(count2).isEqualTo(1);
}
@Scope("singleton") <----- 싱글 톤 빈
static class ClientBean {
@Autowired
private PrototypeBean prototypeBean; <----- 프로토 타입 빈 주입받기(섞임)
public int logic() {
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
@Scope("prototype") <----- 프로토 타입 빈
static class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init " + this);
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
위 테스트 코드는 컨테이너 ac에서 싱글톤 빈인 ClientBean을 호출한다. ClientBean의 메서드 logic()은 프로토타입 빈인 PrototypeBean을 사용한다. PrototypeBean은 생성자에서 주입되어야 하며, logic() 메서드에서 필드인 count를 증가시키고 조회하는 기능을 수행한다. 프로토타입 빈을 사용하는 목적은 매 호출마다 새로운 객체를 생성받기 위함이다. 그러나 실제로 동작을 살펴보면, PrototypeBean은 싱글톤 빈인 ClientBean에 의해 묶여 사용되므로 프로토타입 빈의 본래 목적을 잃게 된다. 자세히 알아보자.
ClientBean은 싱글톤 빈으로, 애플리케이션 시작 시 한 번만 생성되고 계속해서 재사용된다. PrototypeBean은 요청 시마다 새로운 인스턴스를 생성해야 하지만, ClientBean에서 주입된 프로토타입 빈은 처음 생성될 때만 인스턴스가 생성되고, 이후 logic() 메서드를 호출해도 동일한 객체가 계속 사용된다. 따라서 PrototypeBean을 사용하는 목적이 사라지고, 새로운 객체를 생성하는 효과가 나타나지 않게 된다.
PrototypeBean은 매번 새로운 객체를 가져야 하므로, 싱글톤 빈인 ClientBean에서 PrototypeBean을 주입받는 방식은 원래 목적에 부합하지 않는다. 이 문제를 해결하려면, 싱글톤 빈인 ClientBean에서 PrototypeBean을 매번 새로 생성하도록 해야 한다. 이를 위해 ObjectProvider 또는 @Scope 어노테이션을 활용하여 프로토타입 빈을 생성할 수 있다.
실제로 스프링 컨테이너에는 Provider라는 것이 존재한다. 사용법은 다음과 같다. 위의 코드를 약간만 수정하도록 한다.
@Test
void singletonClientUsePrototype() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
ClientBean clientBean1 = ac.getBean(ClientBean.class);
int count1 = clientBean1.logic();
assertThat(count1).isEqualTo(1);
ClientBean clientBean2 = ac.getBean(ClientBean.class);
int count2 = clientBean2.logic();
assertThat(count2).isEqualTo(1);
}
@Scope("singleton")
static class ClientBean {
@Autowired
private Provider<PrototypeBean> prototypeBeansProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeansProvider.get();
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
@Scope("prototype")
static class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init " + this);
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
jakarta.inject.Provider는 자바 표준에서 제공하는 Provider 인터페이스로, 이 매개체를 사용하면 PrototypeBean을 직접 주입받지 않고, 호출 시점에 .get() 메서드를 통해 인스턴스를 생성할 수 있다. 이 방법은 자바 표준으로 스프링 의존성을 줄일 수 있는 해결책이다. 그러나 표준임에도 불구하고 gradle.build 파일에 해당 의존성을 추가해야 하는 번거로움이 있다.
ObjectProviderSpring에서는 ObjectProvider를 제공하여, PrototypeBean을 관리할 수 있다. ObjectProvider는 프로토타입 빈을 요청할 때마다 매번 새로운 인스턴스를 반환하며, Provider.getObject()와 유사하게 동작한다. 이 방식은 Spring의 의존성 주입에 대한 유연성을 제공하며, 자바 표준이 아닌 Spring 전용의 해결책이다.
Provider와 ObjectProvider 방식은 모두 의존 관계를 즉시 주입받는 것이 아니라, 프로그래머가 의도한 시점에 의존성 객체를 탐색하고 생성하는 방식이다. 이를 Dependency Lookup(DL)이라고 한다. DL은 런타임에서 의존성을 검색하여 필요한 객체를 주입받는 방식으로, 의존성을 컴파일 타임에 주입받는 기존의 의존성 주입 방식(DI)과는 구별된다.
웹 스코프의 종류는 다음과 같다.
ServletContext )와 동일한 생명주기를 가지는 스코프