1. 서론
지금까지 우리는 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어 컨테이너가 종료될 때까지 유지된다고 배웠습니다. 그 이유는 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문입니다. 하지만 스프링은 싱글톤 외에도 다양한 스코프를 지원하며, 이를 통해 빈의 생명주기와 접근 방식을 더 유연하게 제어할 수 있습니다.
이번 포스팅에서는 스프링이 제공하는 다양한 빈 스코프의 종류와 특징, 그리고 실제 사용 방법에 대해 알아보겠습니다. 특히 프로토타입 스코프와 웹 스코프를 중심으로 실제 코드 예제와 함께 자세히 살펴보겠습니다.
2. 빈 스코프의 종류
스코프(Scope)는 번역 그대로 빈이 존재할 수 있는 범위를 의미합니다. 스프링은 다음과 같은 다양한 스코프를 지원합니다.
2.1. 싱글톤 스코프 (Singleton)
•
기본 스코프
•
스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프
•
하나의 빈 정의에 대해 스프링 IoC 컨테이너 내에 단 하나의 객체만 존재
2.2. 프로토타입 스코프 (Prototype)
•
스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프
•
요청할 때마다 새로운 객체 인스턴스 생성
2.3. 웹 관련 스코프
•
request: 웹 요청이 들어오고 나갈 때까지 유지되는 스코프
•
session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
•
application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프
•
websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프
빈 스코프는 다음과 같이 지정할 수 있습니다.
// 컴포넌트 스캔 자동 등록
@Scope("prototype")
@Component
public class HelloBean {}
// 수동 등록
@Scope("prototype")
@Bean
PrototypeBean helloBean() {
return new HelloBean();
}
Java
복사
3. 프로토타입 스코프
싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환합니다. 반면에 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환합니다.
3.1. 싱글톤 빈 vs 프로토타입 빈
싱글톤 빈 요청 과정
1.
싱글톤 스코프의 빈을 스프링 컨테이너에 요청
2.
스프링 컨테이너는 본인이 관리하는 스프링 빈을 반환
3.
이후에 같은 요청이 와도 같은 객체 인스턴스의 스프링 빈을 반환
프로토타입 빈 요청 과정
1.
프로토타입 스코프의 빈을 스프링 컨테이너에 요청
2.
스프링 컨테이너는 이 시점에 프로토타입 빈을 생성하고, 의존관계를 주입
3.
스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환
4.
이후에 같은 요청이 오면 항상 새로운 프로토타입 빈을 생성해서 반환
핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입, 초기화까지만 처리한다는 것입니다. 클라이언트에 빈을 반환한 이후에는 스프링 컨테이너가 생성된 프로토타입 빈을 관리하지 않습니다. 따라서 @PreDestroy 같은 종료 메서드가 호출되지 않습니다.
3.2. 프로토타입 스코프 테스트
코드로 싱글톤 스코프와 프로토타입 스코프의 차이를 확인해보겠습니다.
싱글톤 스코프 빈 테스트
public class SingletonTest {
@Test
public void singletonBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
System.out.println("singletonBean1 = " + singletonBean1);
System.out.println("singletonBean2 = " + singletonBean2);
assertThat(singletonBean1).isSameAs(singletonBean2);
ac.close();
}
@Scope("singleton")
static class SingletonBean {
@PostConstruct
public void init() {
System.out.println("SingletonBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("SingletonBean.destroy");
}
}
}
Java
복사
실행 결과
SingletonBean.init
singletonBean1 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd
singletonBean2 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd
org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing
SingletonBean.destroy
Plain Text
복사
프로토타입 스코프 빈 테스트
public class PrototypeTest {
@Test
public void prototypeBeanFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
System.out.println("find prototypeBean1");
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
System.out.println("find prototypeBean2");
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
System.out.println("prototypeBean1 = " + prototypeBean1);
System.out.println("prototypeBean2 = " + prototypeBean2);
assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
ac.close();
}
@Scope("prototype")
static class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
}
Java
복사
실행 결과
find prototypeBean1
PrototypeBean.init
find prototypeBean2
PrototypeBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@13d4992d
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@302f7971
org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing
Plain Text
복사
결과를 보면 다음과 같은 차이점을 확인할 수 있습니다.
1.
싱글톤 빈은 스프링 컨테이너 생성 시점에 초기화 메서드가 실행되지만, 프로토타입 스코프의 빈은 스프링 컨테이너에서 빈을 조회할 때 생성되고 초기화됩니다.
2.
프로토타입 빈을 2번 조회했을 때 서로 다른 스프링 빈이 생성되고, 초기화도 2번 실행됩니다.
3.
싱글톤 빈은 스프링 컨테이너 종료 시 종료 메서드가 실행되지만, 프로토타입 빈은 종료 메서드가 실행되지 않습니다.
3.3 프로토타입 빈의 특징 정리
•
스프링 컨테이너에 요청할 때마다 새로 생성됩니다.
•
스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입, 초기화까지만 관여합니다.
•
종료 메서드가 호출되지 않습니다.
•
프로토타입 빈은 프로토타입 빈을 조회한 클라이언트가 관리해야 하며, 종료 메서드에 대한 호출도 클라이언트가 직접 해야 합니다.
4. 프로토타입 스코프와 싱글톤 빈 함께 사용 시 문제점
스프링 컨테이너에 프로토타입 스코프의 빈을 요청하면 항상 새로운 객체 인스턴스를 생성해서 반환합니다. 하지만 싱글톤 빈과 함께 사용할 때는 의도한 대로 동작하지 않을 수 있습니다.
4.1 문제 상황 이해
다음 예제를 통해 싱글톤 빈이 프로토타입 빈을 의존관계 주입으로 사용하는 경우를 살펴보겠습니다:
public class SingletonWithPrototypeTest1 {
@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(2); // 기대하는 값은 1이지만, 실제로는 2가 된다
}
static class ClientBean {
private final PrototypeBean prototypeBean;
@Autowired
public ClientBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
}
@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");
}
}
}
Java
복사
위 테스트를 실행하면 예상과 다르게 clientBean2.logic()의 결과값이 2가 됩니다. 이유는 다음과 같습니다.
1.
clientBean은 싱글톤이므로 스프링 컨테이너 생성 시점에 함께 생성되고, 의존관계 주입도 발생합니다.
2.
주입 시점에 프로토타입 빈이 생성되어 clientBean에 주입됩니다.
3.
클라이언트A가 clientBean.logic()을 호출하면 주입된 프로토타입 빈의 addCount()가 호출되어 count가 1이 됩니다.
4.
클라이언트B도 같은 clientBean을 받아와 logic()을 호출하면, 이미 주입되어 있는 동일한 프로토타입 빈의 addCount()가 호출되어 count가 2가 됩니다.
문제의 핵심
•
프로토타입 빈은 주입 시점에 생성되어 싱글톤 빈과 함께 계속 유지됩니다.
•
사용할 때마다 새로운 프로토타입 빈이 생성되지 않는 것이 문제입니다.
5. Provider를 통한 문제 해결
싱글톤 빈과 프로토타입 빈을 함께 사용할 때, 사용할 때마다 항상 새로운 프로토타입 빈을 생성하려면 어떻게 해야 할까요? 이 문제를 해결하는 방법으로 Provider를 사용할 수 있습니다.
5.1 스프링 컨테이너에 직접 요청하기
가장 간단한 방법은, 싱글톤 빈이 프로토타입을 사용할 때마다 스프링 컨테이너에 새로 요청하는 것입니다.
@Autowired
private ApplicationContext ac;
public int logic() {
PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
Java
복사
이렇게 하면 ac.getBean()을 통해 항상 새로운 프로토타입 빈이 생성됩니다. 하지만 이 방법은 스프링 컨테이너에 종속적인 코드가 되고, 단위 테스트도 어려워지는 단점이 있습니다.
5.2 ObjectFactory, ObjectProvider 사용하기
지정한 빈을 컨테이너에서 대신 찾아주는 DL(Dependency Lookup) 서비스를 제공하는 것이 ObjectProvider입니다.
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
Java
복사
prototypeBeanProvider.getObject()를 호출하면 내부에서 스프링 컨테이너를 통해 해당 빈을 찾아서 반환합니다. 이 방법은 스프링에 의존적이지만, 기능이 단순하여 단위 테스트나 mock 코드 작성이 쉬워집니다.
5.3 JSR-330 Provider 사용하기
마지막 방법은 javax.inject.Provider 라는 JSR-330 자바 표준을 사용하는 것입니다(스프링 부트 3.0은 jakarta.inject.Provider 사용)
// 의존성 추가 필요
// 스프링부트 3.0 미만: javax.inject:javax.inject:1
// 스프링부트 3.0 이상: jakarta.inject:jakarta.inject-api:2.0.1
@Autowired
private Provider<PrototypeBean> provider;
public int logic() {
PrototypeBean prototypeBean = provider.get();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
Java
복사
provider.get()을 호출할 때마다 항상 새로운 프로토타입 빈이 생성됩니다. 이 방법은 자바 표준이므로 스프링이 아닌 다른 컨테이너에서도 사용할 수 있습니다.
5.4 Provider 방식의 특징 비교
1.
ObjectFactory/ObjectProvider
•
기능이 단순하고 별도의 라이브러리가 필요 없음
•
스프링에 의존적임
•
ObjectProvider는 ObjectFactory를 상속하며 옵션, 스트림 처리 등 편의 기능 제공
2.
JSR-330 Provider
•
기능이 매우 단순함 (get() 메서드 하나)
•
별도의 라이브러리 필요
•
자바 표준이므로 스프링이 아닌 다른 컨테이너에서도 사용 가능
참고: 프로토타입 빈은 매번 사용할 때마다 의존관계 주입이 완료된 새로운 객체가 필요한 경우에 사용합니다. 하지만 실무에서는 싱글톤 빈으로 대부분의 문제를 해결할 수 있어 프로토타입 빈을 직접 사용하는 일은 매우 드뭅니다. Provider는 프로토타입 빈뿐만 아니라 DL이 필요한 모든 경우에 사용할 수 있습니다.
6. 웹 스코프
이제 웹 환경에서만 동작하는 웹 스코프에 대해 알아보겠습니다. 웹 스코프는 웹 환경에서만 동작하며, 프로토타입과 다르게 스프링이 해당 스코프의 종료 시점까지 관리하므로 종료 메서드가 호출됩니다.
6.1 웹 스코프의 종류
•
request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성됩니다.
•
session: HTTP Session과 동일한 생명주기를 가지는 스코프
•
application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
•
websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프
7. request 스코프 예제 만들기
웹 스코프를 사용하려면 웹 환경이 필요하므로, 웹 라이브러리를 추가해야 합니다.
//build.gradle에 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
Plain Text
복사
만약 기본 포트인 8080 포트를 다른곳에서 사용중이어서 오류가 발생하면 포트를 변경하려면 다음 설정을 추가합니다:
# application.properties
server.port=9090
Plain Text
복사
7.1 request 스코프 예제: 로그 추적기
여러 HTTP 요청이 동시에 들어올 때 어떤 요청이 남긴 로그인지 구분하기 어려운 문제가 있습니다. 이런 경우 request 스코프를 활용하면 각 요청별로 고유한 빈을 생성하여 로그를 구분할 수 있습니다.
MyLogger 클래스 (request 스코프 빈)
@Component
@Scope(value = "request")
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();
System.out.println("[" + uuid + "] request scope bean create:" + this);
}
@PreDestroy
public void close() {
System.out.println("[" + uuid + "] request scope bean close:" + this);
}
}
Java
복사
LogDemoController
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logDemoService;
private final MyLogger myLogger;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
Java
복사
LogDemoService
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
myLogger.log("service id = " + id);
}
}
Java
복사
이 코드를 실행하면 다음과 같은 오류가 발생합니다.
Error creating bean with name 'myLogger': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton;
Plain Text
복사
이는 스프링 애플리케이션을 실행하는 시점에는 HTTP 요청이 없어서 request 스코프 빈이 생성되지 않기 때문입니다. 이 문제를 해결하는 방법으로 Provider나 프록시를 사용할 수 있습니다.
8. 스코프와 Provider
Provider를 사용하여 request 스코프 빈 문제를 해결해 봅시다.
LogDemoController 수정
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logDemoService;
private final ObjectProvider<MyLogger> myLoggerProvider;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
String requestURL = request.getRequestURL().toString();
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.setRequestURL(requestURL);
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
Java
복사
LogDemoService 수정
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final ObjectProvider<MyLogger> myLoggerProvider;
public void logic(String id) {
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.log("service id = " + id);
}
}
Java
복사
이제 ObjectProvider.getObject()를 호출하는 시점에 HTTP 요청이 진행 중이므로 request 스코프 빈의 생성이 가능해집니다. 같은 HTTP 요청이면 같은 스프링 빈이 반환됩니다.
9. 스코프와 프록시
Provider 대신 프록시 방식을 사용해서도 문제를 해결할 수 있습니다.
MyLogger 수정
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
// 기존 코드와 동일
}
Java
복사
핵심은 proxyMode = ScopedProxyMode.TARGET_CLASS 설정입니다. 이렇게 하면 CGLIB라는 라이브러리로 내 클래스를 상속받은 가짜 프록시 객체를 생성하고, 이 프록시 객체를 다른 빈에 미리 주입해 둘 수 있습니다.
이제 다시 원래 코드로 돌아갈 수 있습니다.
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logDemoService;
private final MyLogger myLogger;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
myLogger.log("service id = " + id);
}
}
Java
복사
9.1 프록시 동작 원리
프록시 객체가 어떻게 동작하는지 확인해볼 수 있습니다:
System.out.println("myLogger = " + myLogger.getClass());
// 출력: myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d
Java
복사
CGLIB 라이브러리가 내 클래스를 상속받은 가짜 프록시 객체를 만들어 주입합니다. 이 가짜 프록시 객체는 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직이 들어있습니다.
•
동작 과정
1.
@Scope의 proxyMode = ScopedProxyMode.TARGET_CLASS를 설정하면 스프링 컨테이너는 CGLIB를 사용해 프록시 객체를 생성합니다.
2.
이 프록시 객체는 실제 요청이 오면 내부에서 실제 빈을 요청하는 위임 로직을 포함합니다.
3.
클라이언트는 실제 빈을 사용하는 것처럼 프록시 객체를 사용하면 됩니다(다형성 활용).
•
특징
◦
프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request 스코프를 사용할 수 있습니다.
◦
Provider를 사용하든 프록시를 사용하든 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리한다는 점입니다.
◦
단지 애노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있는 것이 다형성과 DI 컨테이너가 가진 큰 강점입니다.
•
주의점
◦
마치 싱글톤을 사용하는 것 같지만 다르게 동작하기 때문에 주의해서 사용해야 합니다.
◦
이런 특별한 스코프는 꼭 필요한 곳에만 최소화해서 사용하는 것이 좋습니다. 무분별하게 사용하면 유지보수가 어려워질 수 있습니다.
10. 결론
이번 포스팅에서는 스프링의 다양한 빈 스코프에 대해 알아보았습니다. 기본적인 싱글톤 스코프부터 프로토타입 스코프, 웹 관련 스코프까지 각각의 특징과 사용법을 살펴봤습니다.
특히 프로토타입 스코프와 싱글톤 빈을 함께 사용할 때 발생할 수 있는 문제점과 이를 해결하는 Provider 및 프록시 방식에 대해 자세히 알아보았습니다. 또한 request 스코프를 활용한 웹 요청 로그 추적 예제를 통해 실제 적용 방법도 확인했습니다.
스프링의 빈 스코프를 적절히 활용하면 다양한 상황에 맞는 유연한 애플리케이션 설계가 가능합니다. 하지만 특별한 스코프는 복잡성을 증가시킬 수 있으므로, 꼭 필요한 경우에만 사용하는 것이 좋습니다.