우리는 지금까지 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때까지 유지된다고 배웠습니다. 이는 스프링 빈들이 기본적으로 싱글톤 객체로 형성되어 관리되기 때문입니다. 여기서 Bean Scope 의 개념에 대해서 살펴볼 필요가 있습니다. Bean Scope 란 말 그대로 빈이 존재할 수 있는 범위를 말합니다.
우선 지정해줄 수 있는 Bean 들의 Scope 종류에 대해서 살펴보겠습니다. (물론 기본은 Singleton 이며, 특수한 경우를 제외하고는 모두 싱글톤으로 제어해주는게 맞습니다)
Singleton : 가장 긴 생명주기를 가진 기본 스코프. IOC 컨테이너에서 관리하며, 컨테이너의 시작과 함께 종료까지 유지된다
Prototype : IOC 컨테이너에서는 Bean 의 생성과 의존관계 주입까지만 처리 후 Bean을 요청한 Client에 반환만 해준다. 더 이상은 IOC 컨테이너에서 관리하지 않는다
Request : Requset가 들어오고 Response가 나갈 때까지 IOC 컨테이너에서 관리해준다
Session : Session 이 형성되고 종료될 때까지 유지된다.
Application : Servlet Context 의 범위에 있어서 유지된다.
Request, Session, Application 은 웹 관련된 Scope로 구분지어줍니다. Singleton 외로 Prototype, Request Scope 들이 그나마 사용된다고 할 수 있으며, Session, Application Scope 들은 크게 사용할 일이 없다고 보셔되 됩니다. 하지만 물론 알아두는게 좋겠죠? (특히 여기서 등장하는 용어들 중 Session, Servlet Context 등이 뭐지 싶으시다면, 반드시 이해하고 넘어가셔야 합니다.)
Prototype Scope
[빈 스코프 - 프로토타입 스코프 강의 그림들 참고]
> 기억 : Singleton 빈은 항상 같은 인스턴스ㅇ의 Spring Bean 을 반환하게 되어 있다. (반면에 Prototype 은 항상 새로운 객체를 만들어서 반환을 해주게 된다. Container 에서 관리를 안하기 때문)
**** Prototype
1:25 그림
앱이 처음 시작될 때, Spring Container 는 PrototypeBean01 에 대해서 객체를 생성 및 싱글톤 등록을 진행하지 않습니다. 그리고 추후 A의 요청이 진행되는 중 해당 Bean 을 요청하게 되면, 그 때 PrototypeBean 을 생성하고 DI를 진행합니다. 그리고 그 요청이 종료되면 해당 Bean 을 없애고, 더 이상 관리를 하지 않습니다. 동일하게, B가 요청을 하면, C가 요청을 하면 각각 작업을 해주게 됩니다. 즉, 어떠한 요청이든 항상 새로운 객체를 사용하게 됩니다.
가장 중요한 점은, Spring Container 는 여기서 빈을 생성하고, 의존관계 주입, 초기화 까지만 담당한다는 것입니다. 그 이후의 책임은 받은 클라이언트에게 있기 때문에, 그 클라이언트가 종료를 담당해야 합니다. 따라서, 서버 입장에서 @PreDestroy 같은 것도 실행시키지 않습니다. 종료를 컨테이너에서 관리하지 않기 때문입니다.
************ 싱글톤 빈, 프로토 ㅏ입 빈과 함께 사용시 문제점.
그렇다면 이 Prototype Bean 을 같이 사용하게 될 경우, 어떤 점들을 주의해야 할까요? 다음과 같은 Prototype Bean 이 있다고 가정해봅시다.
[시작시 그림]
@Scope("prototype")
static class PrototypeBean{
private int cnt = 0;
public void addCnt(){
this.cnt ++;
}
}
각각 프로토 타입 빈을 객체 한명씩 요청하고, addCnt 를 수행하면 각자 반환값은 1이 됩니다. 해당 빈은 한 요청당 한번 쓰고 버리기 때문입니다. (참고로, 그렇다면 싱글톤 빈도 뭐가 요청당 따로 수행하지 않나? 하시는 분들은 싱글톤 빈 생성시 주의사항을 한번 다시 읽고 오시면 좋을 것 같습니다)
[5분 17초 대역 그림]
위 그림처럼, client Bean 이라는 싱글톤 빈이 있는데, 여기서 의존관계 주입을 통해 Prototype Bean 을 주입받을 것입니다. 일단요청대로 Prototype Bean 은 빈을 생성해서 Client Bean 에게 반환을 해주게 됩니다. 자, 말씀드렸듯이 Spring Container 에서는 이를 절대 관리하지 않고, 이제 관리 대상은 ClientBean 이게 되고, Client Bean 은 클래스로서 내부 필드에 프로토타입 빈을 보관합니다.
static class ClientBean{
private final PrototypeBean prototypeBean;
@Autowired
public ClientBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic(){
prototypeBean.addCnt();
return prototypeBean.getCnt();
}
}
자 이제 ClientBean 을 통한 로직을 수행하라는 요청이 날라옵니다. Request 는 Client Bean 이 대상이므로, 관리중인 Prototype Bean 의 addCnt 를 수행하라고 하고, getCnt 를 통해서 Cnt 값을 반환받습니다. 당연히 Client A 는 1을 받게 되겠죠?
문제는 다음입니다. 동일한 Request 가 또 날라옵니다. 똑같이 로직을 수행해서 addCnt, getCnt 를 하게 되는데, 이 때의 Cnt 반환값은 2가 됩니다. 읽으시면서 왜 그런지 당연히 이해가 되셨을 겁니다.
Clien Bean 이 내부에 가지고 있는 Prototype Bean 은 옛날에 이미 주입이 끝난 빈으로, 그 빈을 계속 쓰게 됩니다. 일반 Prototype Bean 처럼 사용 할 때마다 새로 생성되는게 아니기 때문에 1을 반환하지 않습니다. 어디서 본 듯한 패턴이죠? 즉, 싱글톤 빈과 아무런 차이가 없게 됩니다. 관리 중인 대상이 Spring Container 가 아니라 Client Bean 일 뿐입니다.
@Test
@DisplayName("Client Bean이 Prototype Bean 을 주입받으면, 상태를 유지하며 관리한다")
void test2(){
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
ClientBean bean1 = ac.getBean(ClientBean.class);
int cnt1 = bean1.logic();
Assertions.assertThat(cnt1).isSameAs(1);
ClientBean bean2 = ac.getBean(ClientBean.class);
int cnt2 = bean2.logic();
Assertions.assertThat(cnt2).isEqualTo(2);
}
ClientBean bean2 가 Bean 을 가지고 왔을 때 Prototype Bean 을 사용하는 이유는 아마 Prototype의 객체, 즉 매번 새로 생성되는 객체를 반환받길 원했을 것입니다. 즉, logic() 을 수행 후 1의 cnt 값을 반환하길 원했을 것입니다. 하지만, ClientBean 이 관리 대상이 되어버렸기 때문에 상태를 유지시키게 되어 2를 반환받게 되는 문제가 발생하게 됩니다.
static class ClientBean{
@Autowired private ApplicationContext ac;
public int logic(){
PrototypeBean pb = ac.getBean(PrototypeBean.class);
prototypeBean.addCnt();
return prototypeBean.getCnt();
}
}
해당 문제를 해결하려면 단순하게 위처럼 ApplicationContext 를 Bean 화해서 주입시켜도 됩니다. 사용할 때마다 새로 가져오는 것이지요. 하지만 그건 너무 스프링 종속적인 코드 (Container 를 직접 요청) 가 되고, 단위 테스트도 어려워지게 됩니다 (다양한 Bean Test 가 어려워집니다). 참고로, 위 코드와 같이 DI 를 받는 것이 아니라, 소스 코드 내에서 직접 컨테이너에서 관리중인 Bean 을 찾는 것을 Dependency Lookup (DL) 이라고 합니다.
그렇다면 이렇게 싱글톤, 프로토타입을 병행해서 사용할 경우, 어떻게 해야 할까요?
ObjectProvider
특수한 케이스가 아니라면 실질적으로 위와 같이 제어할 일은 거의 없겠지만, Prototype Bean 을 활용해야하고 싱글톤 빈에서 이를 활용해야하는 케이스가 있다면, Provider 들을 통해서 해결할 수 있습니다.
static class ClientBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeansProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeansProvider.getObject();
prototypeBean.addCnt();
return prototypeBean.getCnt();
}
}
ObjectProvider 는 Spring 에서 제공해주는 Provider 로, 일종의 의존성을 대신해서 수행해주는 역할을 한다고 볼 수 있습니다. 싱글톤 Bean 인 ClientBean 에서 위와 같이 사용한다면, ClientBean 내에서도 필요 logic 을 수행할 때 PrototypeBean 을 그 필요한 때에 해당 객체를 생성해서 반환해주는 역할을 합니다. ObjectProvider 는 실용성이 있으나, Spring 의존성이 상당히 크다고 볼 수 있기 때문에, Java 표준 Provider 를 사용하는 것을 권장하기도 합니다.
JSR-330 Provider
gradle 파일에 다음과 같은 Library 를 추가해준 후, 기존 클래스를 다음과 같이 수정해줍니다
implementation 'javax.inject:javax.inject:1'
static class ClientBean {
@Autowired
private Provider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.get();
prototypeBean.addCnt();
return prototypeBean.getCnt();
}
}
ObjectProvider 와 똑같이, JSR-Provider 의 get() 함수를 호출하면 내부적으로 Spring Container 를 통해서 해당 빈을 찾아서 반환해 줌으로써 항상 새로운 프로토 타입 빈이 생성되는 것을 확인할 수 있습니다 (수행 결과 자체는 동일합니다). Java 표준 Provider 는 문서에 들어가보면 Injector 라는 표현을 사용합니다. 즉, 스프링에 종속되지 않고 어떤 컨테이너를 사용하든지, 동일한 수행을 할 수 있다는 점에서 차이가 있다고 보시면 될 것 같습니다 (그렇게 중요한 부분 아니니, 그냥 이렇게 하는구나 하면 되는 것 같습니다..)
실무에서는 사실 Prototype 을 직접적으로 사용하는 경우는 매우 드물다고 합니다. 왜냐하면 보통 필요한 객체를 그 때 그 때 그냥 생성해서 사용해도 되기 때문입니다. 혹시 남의 코드를 보게 될 때 이게 뭐였지 할 때를 위해 Prototype Scope에 대해서 알아만 두면 될 것 같습니다.
저 같은 경우 회사에서 사내 SSO 자동 로그인 시스템을 구축해야 할 일이 있었는데, 정확하진 않았지만 다음과 같은 근거들을 토대로 Prototype Bean 을 사용해서 구현을 하였습니다.
1. 사내 SSO API 제공 하는 쪽에서 로그인을 처리하는 객체의 초기화 등을 통해서 제공해주는 방식이였음 (SSO Controller 에서 DIP 를 지키기 위해서 SSO 처리 빈을 만들어야 겠다고 판단)
2. 내부적으로는 SSO 를 통해서 받은 DB 를 사용하지 않고, SSO 를 통해서 받은 사내 ID 를 통해서 웹 시스템이 돌아갔기 때문에, SSO 를 위해 사용되었던 것들이 다 관리되고 있을 필요가 없었음
3. 초기화 및 파기에 용이, 요청별로 다른 URL 생성 (요청 유저의 사내 정보에 따른) 이 필요했어서, 통합적으로 보았을 때 Prototype Bean 을 사용해봐도 되겠다고 판단함.
Web Scope (Request Scope)
- (Web Scope 는 Spring Boot Web 패키지가 Gradle Import 되어야 활용 가능합니다)
웹 스코프도 Spring 에서 제공하는 Bean Type 중 하나로, 싱글톤과 마찬가지로 앱 종료시점까지 컨테이너가 관리를 해주긴 합니다 (다만, 일단 싱글톤은 아닙니다). 하지만 Prototype 과 마찬가지로 각각ㄱ의 요청마다 별도의 Bean Instance 가 생성되어서 사용되는데, 바로 Http 요청 하나가 들어오고 나갈때까지 유지되는 Scope 입니다. 즉, Singleton 적인 측면과 Prototype 적인 측면이 모두 있는 Scope 를 말합니다.
[Web Scope 그림]
위 그림처럼 Clien A 가 요청을 전송한다고 합시다. 해당 요청은 필터들을 통과하여 Controller 에 들어올 것이고, Controller 는 싱글톤으로 관리되고 있는 Bean Instance 일 것입니다. 이 때, 해당 Controller 에서 자신 요청 전용으로 Request Scope Bean A를 형성할 수 있습니다. 동일한 Controller 로 다른 종류의 요청이 발생하면 같은 Bean 클래스로부터 다른 Bean B를 따로 발급받게 되는 것입니다. (한 요청 내에서는 또 요청하면 같은 Bean 을 가져옵니다)
여기서 Prototype Bean 과의 차이점을 확인할 수 있는데, 바로 같은 요청 내에서 또 해당 Bean 을 요청하게 되면, Controller 때 주입시켜줬던 똑같은 Bean 을 반환해준다는 점입니다. Prototype 은 요청할 때마다 새 Bean 을 반환하지만, Web Scope Bean 은 해당 요청 내에서는 동일한 객체를 유지시키고, 그 이후에는 파기됩니다.
Web Scope 에 대해 더 자세히 살펴보기 위해 다음과 같은 예제를 살펴보겠습니다.
@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 msg) {
System.out.println("[" + uuid + "]" + "[" + requestUrl + "] " + msg);
}
@PostConstruct
public void postConstruct() { // 고객 요청이 들어올 때 bean 생성하기에 앞서 uuid 를 박아둠
uuid = UUID.randomUUID().toString();
System.out.println("["+ uuid + "] request scope created: " + this);
}
@PreDestroy
public void close(){
System.out.println("[" + uuid + "] has been destroyed: " + this);
}
}
특수 요청에 따른 요청을 분석하기 위한 로그를 서버에 남기기 위해 출력한다고 가정해보고, 다음과 같은 Sample Bean 을 만들었습니다. Bean 이 생성될 시점에 UUID 를 랜덤으로 생성해 주입을 시키고, 매 요청마다 별개로 관리될 수 있도록 @Scope("request") 로 지정해주었습니다. 참고로 requestUrl 은 논리적으로 Bean 생성 시점에 알 수 있는 정보가 아니므로, 요청이 오면 그 때 해당 Url 에 맞춰 주입할 수 있도록 setter injection 방식을 선택하였습니다.
해당 Logger 를 통해서 요청을 분석할 Service 와 Controller 를 다음과 같이 생성해주어서, Request Scope 에 대한 Test를 해보려 합니다.
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logService;
private final MyLogger logger;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
logger.setRequestUrl(request.getRequestURL().toString());
logger.log("Received In Controller");
logService.logic("TESTING");
return "OK";
}
}
@Service
public class LogDemoService {
private final MyLogger myLogger;
public LogDemoService(MyLogger myLogger) {
this.myLogger = myLogger;
}
public void logic(String id) {
myLogger.log("Service received: " + id);
}
}
실행을 해보면, 다음과 같은 에러가 발생해야 정상입니다.
...
Caused by: java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? ...
...
위아래로 쭉 읽어보면, Controller 와 Service 모두 Bean 생성을 실패했다고 합니다. 여기서 포스트를 내리시기 전에 왜 이런 오류가 발생했을까를 생각해본다면 정말 좋은 공부입니다. 지금까지 배운 내용으로 알 수 있는 내용입니다. 실무에서는 별의 별 에러를 다 겪고 발생하자마자 보통 구글 검색 조지기 때문에, 공부할 때라도 이런 에러가 왜 났을까를 생각하는게 진짜 좋은 공부인 것 같습니다. (저는 조금 생각하다가 "아, 더 들어야 알 수 있는 건가보다" 하면서 들었다가 후회했습니다)
..... 생각해봅시다
바로 MyLogger 가 아직 Bean 생성이 되지 않았기 때문입니다. 지금은 앱 생성 시점으로, Controller 와 Service 모두 생성자 주입을 받고 Bean 등록이 되어야 하는데, 현재 MyLogger.class 를 구현 객체로 의존하고 있습니다. 하지만 MyLogger는 Bean 등록이 App 생성 시점에 등록되는 것이 아닙니다. Request 가 들어와야 Container 에서 Bean 등록을 하게되며, Request 가 유지되는 동안 관리를 해주고, Request 가 끝나면 Container 가 해당 Bean 을 폐기시키는 과정을 거칩니다. 현재 Request 가 들어오지도 않은 상태에서 Request Scope 빈을 주입시키려 하니까 Spring Container 에 없는 Bean 을 주입시키려 했기 때문에, 당연히 Fail 날 수 밖에 없습니다.
그렇다면 어떻게 사용해야 할까요?
1. 위에서 배운 ObjectProvider 사용
ObjectProvider Library 를 사용하면 해당 Bean 이 필요할 때 등록을 해주게 됩니다.
2. Proxy 사용
위에 까지 해봤을 때, 저는 의문점이 들었습니다. Provider 를 사용하면 "getObject() 를 하는 시점에 소스코드들을 훑어서 해당 Bean Class에 대한 bean 을 생성시켜 Container 에 등록시킨다" 라고 이해를 했습니다. 하지만 Object Provider 가 필요 시점에 소스코드들을 쭉 훑어서 해당 Bean Class 를 반환해준다고 생각하니, 이미 Component Scan 으로 패키지를 뒤졌는데 필요 시점에 또 훑지 말고, "그냥 싱글톤 빈에 Web Scope 를 주입할 때는 얘는 Request Scope 니까 나중에 주입된다"라고 등록해 놓을 수는 없는걸까? 라는 의문점이 들었습니다.
바로 다음 내용에서 의문점이 해결이 되었는데, Scope Proxy 를 사용하는 것이였습니다. 기존 MyLogger.class 에 다음과 같이 추가하면 해당 클래스는 Request Scope로, Proxy 관리가 필요하다고 등록이 됩니다.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
private String uuid;
private String requestUrl;
...
}
그렇게 되면 기존 클래스를 ObjectProvider 없는 원래상태로 원복시킬 수 있습니다.
@Controller
@RequiredArgsConstructor
public class LogDemoController {
private final LogDemoService logService;
private final MyLogger logger;
// private final ObjectProvider<MyLogger> myLoggerProvider;
...
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
//private final ObjectProvider<MyLogger> myLoggerProvider;
....
}
Proxy 를 통해서 작동시킬 수 있는 이유는 다음과 같습니다.
[프록시 강의 그림]
Proxy 클래스로 등록이 된다면, 해당 클래스는 CGLIB라는 바이트 코드 라이브러리를 통해서 가짜 프록시 객체를 만들어서 필요한 Bean 들에게 주입을 시켜놓게 됩니다. 즉, 가짜 Web Scope Bean 객체를 만들어서 이 객체를 Client 에 주입시켜 두는 원리로 보면 될 것 같습니다 (사실 프록시의 원리가 다 이런 것 같지 않나요?)
이 가짜 프록시는 요청이 들어오면 그 때 내부에서 진짜 Bean 을 요청하는 위임 로직이 들어있습니다. 따라서 Controller 클래스와 Service 클래스가 둘다 MyLogger.class 를 주입 받는 시점에는 가짜 객체를, 그리고 각자 자기만 사용할 전용 MyLogger.class 가짜 객체를 주입받게 됩니다. 그리고 각 클래스에서 logger.logic() 을 호출한다면, 이 역시 가짜 메소드를 호출하는 것입니다.
그리고 실제 Request 가 들어오면 등록되어 있던 프록시 MyLogger 가 진짜 MyLogger.class Bean 을 생성하고, 이를 Bean Container 에 등록하게 됩니다. 클라이언트 입장에서는 이 My Logger 가 진짜인지 가짜인지 모르고 관리를 해주고 있는 상태입니다. Proxy 객체는 실 객체를 어떻게 가져올 수 있는지 알고 있고, 사용시점에 실 객체를 만들어줘서 사용하는 클라이언트에 주입시켜주므로, 다형성의 원리도 잘 지킨다고 볼 수 있습니다.
지금까지 Bean 의 Scope 종류에 대해서 살펴보았습니다. 사실 Provider 든, Proxy 든 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점입니다. 이런 것이 DI 컨테이너의 큰 장점입니다.
이런 다양한 빈들은 백그라운드에서 앱의 기능들을 보조해주기 위한 기능들로 많이 사용되나, 비즈니스 코드 구성에는 잘 사용되지 않습니다. (제가 사용했던 SSO 통신 담당 Bean, 실제로 쓴 예제 Logger 같은 Bean 들도 실제로는 Scope 범위를 사용하기도 합니다)

아우.. 어려워 .. 저도 또 읽고 찾아보고 하면서 더 공부해야겠습니다 ...
포스트 요약
- 빈은 관리되는 범위에 따라 Scope 범위가 나뉘는데, 그 종류로는 Singleton, Prototype, Request (+Session, Application) Scope 가 있다
- Prototype 은 매 요청마다 다른 Bean 을 반환하며, IOC Container 에서 생성, 의존관계 주입, 초기화까지만 담당하고, 이후는 (종료) 사용하는 Client 객체에게 위임한다
- Singleton Bean 내에서 Prototype Bean 을 사용할 때는 Provider 등을 사용하여 Prototype Bean 의 본 역할을 수행할 수 있도록 보조해줘야 한다
- Web Scope 중 Request Scope 는 요청을 수행하는 과정에서만 IOC에서 관리해주는 반반의 성격을 가진 Scope이다
- 일반 Bean 들과는 다르게 앱 실행 시점에 IOC Container 에 등록되는 것이 아니고 요청이 들어와야 Bean 생성, 의존성 주입 및 초기화가 이루어지기 때문에, Provider 혹은 Proxy 지정을 통해 앱 실행을 보조해줘야 한다
- 비즈니스 로직 구성에서는 많이 쓰이진 않지만, 실제로 여러 부가 기능들을 보조하는 Bean 들은 사용해주기도 한다. IOC Container 의 부하를 줄이기 위해서이다
출처
[스프링 기본]으로 엮인 모든 포스트들은 교육 사이트 인프런의 지식공유자이신 김영한님의 [스프링 핵심 원리] 강의를 기반으로 작성되었습니다. 열심히 정리하고 스스로 공부하기 위해 만든 포스트이지만, 제대로 공부하고 싶으시면 해당 강의를 꼭 들으시는 것을 추천드립니다.
스프링 핵심 원리 - 기본편 - 인프런 | 강의
스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., - 강의 소개 | 인프런...
www.inflearn.com
'Spring > Spring 기본' 카테고리의 다른 글
[Spring과 DB] 5-2 Spring 에서의 예외 추상화 (1) | 2023.08.16 |
---|---|
[Spring과 DB] 5-1 Spring 에서의 예외처리 지원 (0) | 2023.08.15 |
[Spring 기본] Bean 생명주기 콜백 (0) | 2022.10.18 |
[Spring 기본] 의존 관계 주입 전략 (DI Strategy) (0) | 2022.10.17 |
[Spring 기본] Component Scan을 통한 Bean 자동화 관리 (0) | 2022.10.17 |