[spring boot 기본 원리] 빈 스코프와 스코프 종류
1. 빈 스코프
- 스프링 컨테이너는 기본적으로 스프링 빈을 싱글톤으로 관리한다.
- 이는 스프링 빈이 싱글톤 스코프로 생성됨을 의미한다.
- 여기서 스코프란 빈이 존재할 수 있는 범위를 뜻한다.
* 스프링이 지원하는 스코프 종류
1) 싱글톤 : 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프, 생명주기가 스프링 컨테이너와 같다.
2) 프로토타입 : 요청 시 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리 하지 않는 매우 짧은 범위의 스코프
3) 웹 관련 스코프
(1) request : 웹 요청이 들어오고 나갈때 까지 유지되는 스코프
(2) session : 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
(3) application : 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프
- 아래 코드는 빈 스코프를 지정하는 코드이다.
* 컴포넌트 스캔 자동 등록
@Scope("prototype")
@Component
public class HelloBean {}
* 수동 등록
@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
return new HelloBean();
}
2. 프로토 타입 스코프
- 우선 싱글톤 스코프로 관리되는 스프링 컨테이너의 로직을 아래 그림을 통해 알아보자
- 싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.
- 클라이언트가 누구던, 어떤 요청이던 상관없이 그 빈을 조회한다면 싱글톤이기에 항상 같은 인스턴스를 반환하는 것이 싱글톤의 특징이다.
- 그러나 프로토타입 스코프는 스프링 컨테이너에 프로토타입 스코프를 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성하여 반환한다.
- 클라이언트가 prototypeBean을 요청할 때마다 새로운 인스턴스를 생성하여 반환하고 관리하지는 않는다.
- 여기서 핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입, 초기화까지만 처리하고 관리를 하지 않는다는 점이다.
- 프로토 타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에게 있다. 그래서 @PreDestroy 같은 종료 메서드가 호출되지 않는다.
* 아래코드는 프로토타입 빈을 생성하는 코드이다.
@Scope("prototype")
static class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
- 기본적으로 @Bean을 붙이면 싱글톤으로 생성된다. (@Scope("singleton)으로도 생성 가능)
- @Scope("prototype")을 적용하면 프로토타입 스코프로 생성된다.
* 프로토타입 빈의 특징 정리
1) 스프링 컨테이너에 요청할 때마다 새로 생성된다.
2) 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입 그리고 초기화까지만 관여한다.
3) 종료 메서드가 호출되지 않는다.
4) 프로토타입 빈을 조회한 클라이언트가 관리해야한다.(종료메서드 호출 등등...)
3. 프로토타입 빈과 싱글톤 빈을 함께 사용할 때 문제점
- 먼저 아래의 그림을 살펴보자
- 클라이언트 A가 스프링 컨테이너에 프로토타입 빈을 요청하면
- 스프링 컨테이너는 프로토타입 빈을 새로 생성하여 반환(x01)한다. 이때 필드의 count 값은 0이다.
- 클라이언트는 조회한 프로토타입 빈에 addCount()를 호출하여 count 필드의 값을 +1 시킨다.
- 결과적으로 x01의 count는 1이 된다.
- 그러나 싱글톤빈에서 프로토타입 빈을 사용하면 문제가 발생한다.
- clientBean은 싱글톤이므로, 스프링 컨테이너 생성 시점에 함께 생성되며 의존관계 주입도 발생한다.
- 프로토타입 빈이 싱글톤 빈 안에 있으므로 클라이언트가 프로토타입 빈을 요청한다면 스프링 컨테이너는 프로토타입빈을 생성하여 clientBean에 반환한다.
- 이때 프로토타입 빈은 clientBean 내부 필드에 참조 값이 보관된다.
- 클라이언트 A가 clientBean을 요청하여 받고 logic()을 호출하면
- clientBean은 프로토타입 빈의 addCount()를 호출하여 count를 증가시킨다. 이때 count의 값은 1이 된다.
- 그런데 클라이언트 B가 스프링 컨테이너에 clientBean을 요청한다고 하자.
- clientBean이 내부에 가지고 있는 프로토타입 빈은 이미 의존관계 주입이 끝난 빈이다. 주입 시점에 스프링 컨테이너에 요청해서 프로토타입 빈이 생성된 것이지, 사용할 때마다 생성되는 것은 아니다.
- 클라이언트 B가 logic()를 호출하면 clientBean은 프로토타입 빈의 addCount()를 호출하여 count를 증가시킨다. 이때 count의 값은 원래 1이었으므로 2가 된다.
- 이렇게 동작하는 이유는 프로토타입 빈이 싱글톤 빈 안에 필드로 들어가 있기 때문이다.
- 싱글톤 빈 안에 필드로 관리 되기 때문에 PrototypeBean 또한 싱글톤으로 관리 된다.
4. Provider
- 우선 DL이 무엇인지 알아보자
- DL(Dependency Lookup) : 의존관계를 외부에서 주입(DI)받는 것이 아니라 직접 필요한 의존관계를 찾는 것
- 스프링은 이러한 DL 기능을 통해 앞서 언급한 싱글톤 안에 있는 프로토타입 빈을 요청할 때 마다 새로 생성할 수 있다.
4-1. ObjectFactory, ObjectProvider
- 지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공하는 것이 ObjectProvider이다.
- ObjectFactory가 예전에 있었는데, 편의 기능을 추가하여 만든것이 ObjectProvider이다.
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
- ObjectProvider의 getObject()를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다.(DL)
- ObjectProvider는 ObjectFactory 상속, 옵션, 스트림 처리등 편의 기능이 많고 별도의 라이브러리가 필요 없다.
- 대신 스프링에 의존적이다.
* 실무에서는 매번 사용할 때 마다 의존관계 주입이 완료된 새로운 객체가 필요하면 프로토타입 빈을 사용한다.
그러나 실제로 싱글톤 빈으로 대부분의 문제를 해결할 수 있기 때문에 자주 사용하지는 않는다.
5. 웹 스코프
* 웹 스코프의 특징
1) 웹 스코프는 웹 환경에서만 동작한다.
2) 웹 스코프는 프로토타입 빈과는 다르게 해당 스코프의 종료시점까지 관리한다. 따라서 종료 메서드가 호출된다.
3) 무분별하게 사용하면 유지보수하기 어렵다.
아래 그림은 HTTP request 요청 당 할당되는 request 스코프의 동작 방식을 보여주는 그림이다.
- 우선 웹 스코프를 사용하려면 build.gradle에 의존성을 추가해주어야 한다.
//web 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
* 스프링 부트는 웹 라이브러리가 없으면 AnnotationConfigApplicationContext를 기반으로 애플리케이션을 구동하지만, 웹 라이브러리가 추가되면 웹과 관련된 추가 설정과 환경이 필요하므로 AnnotationConfigServletWebServerApplicationContext를 기반으로 애플리케이션을 구동한다.
5-1. requset 스코프 예제
- requset 스코프는 동시에 여러 HTTP 요청이 오면 정확히 어떤 요청이 남긴 로그인지 구분하기가 쉽다.
- [UUID][requestURL]{message}의 형식으로 로그를 남겨보자
MyLogger.class
@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);
}
}
- 로그를 출력하기 위한 클래스이다.
- @Scope(value = "request")를 사용하여 request 스코프로 지정한다. 이제 이 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.
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";
}
}
- MyLogger가 잘 동작하는지 테스트하는 컨트롤러이다.
- 이때 setRequestURL로 넘겨지는 URL은 "http://localhost:8080/log-demo"이다.
LogDemoService
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
myLogger.log("service id = " + id);
}
}
- 서비스 계층을 만들어 로그를 출력해보자.
- 여기서 중요한 점은 request 스코프를 사용하지 않고 파라미터로 이 모든 정보를 서비스 계층으로 넘긴다면 파라미터가 많아서 지저분해진다.
- 더 중요한 문제는 웹과 관련된 정보가 연관도 없는 서비스 계층으로 넘어간다면 유지보수 하기에도 좋지 않기 때문에 웹과 관련된 기능이 있다면 request 스코프를 사용해보자.
- request 스코프의 MyLogger 덕분에 서비스 계층에 필요 없는 정보를 파라미터로 넘기지 않고 MyLogger의 멤버변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있다.
- 그런데 이대로 실행하면 오류가 발생한다.
- 이는 스프링 애플리케이션을 실행하는 시점에 싱글톤 빈은 생성해서 주입이 가능하지만 request 스코프 빈은 실제 클라이언트의 요청이 있어야 생성할 수 있다.
5-2. request 스코프와 Provider
- ObjectProvider를 사용한다면 문제를 해결할 수 있다.
- ObjectProvider는 지정한 빈을 스프링 컨테이너에서 대신 찾아주는 DL 기능을 제공한다.
- 그러나 DL 말고도 다른 기능도 제공하는데 바로 빈의 생성을 지연해준다는 것이다.
@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";
}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final ObjectProvider<MyLogger> myLoggerProvider;
public void logic(String id) {
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.log("service id = " + id);
}
}
- 이렇게 코드를 수정하여 실행하면 정상적으로 실행된다.
- ObjectProvider 덕분에 ObjectProvider.getObject()를 호출하는 시점까지 request 스코프 빈의 생성을 지연할 수 있다.
* 결과
5-3. request 스코프와 프록시
- 이번엔 프록시 방식으로 바꿔보자.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
* LogDemoController와 LogDemoService를 Provider를 사용하기 이전으로 돌려야 한다.
@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);
}
}
- proxyMode = ScopedProxyMode.TARGET_CLASS : 적용되어 있는 클래스의 가짜 버전을 만들어주는 것
- 적용 대상이 인터페이스이면 INTERFACES를 선택하고 적용 대상이 클래스이면 TARGET_CLASS를 선택하면 된다.
5-4. 프록시 동작원리
- 주입 받은 MyLogger의 Class 정보를 확인해보자.
System.out.println("myLogger = " + myLogger.getClass());
* 결과
- CGLIB라는 바이트 코드 조작 라이브러리를 이용하여 클래스를 상속받은 가짜 프록시 객체를 만들어서 주입한다.
- 그래서 ac.getBean("myLogger", MyLogger.class)로 조회하면 프록시 객체가 조회된다.
- 가짜 프록시 객체는 요청이 오면 그때 내부에서 진짜 빈을 요청하는 위임 로직이 있다.
- 클라이언트가 mylogger.logic()을 호출하면 가짜 프록시 객체를 호출하게 되고 이 객체는 request 스코프의 진짜 myLogger.logic()을 호출한다.
- 가짜 프록시 객체는 실제 request 스코프와는 관계가 없다. 단순히 가짜이며 내부에 위임 로직만 있고, 싱글톤 처럼 동작하는 것이 특징이다.
* 프록시 특징 정리
1) 프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 request 스코프를 사용할 수 있다.
2) Provier를 이용하던 프록시를 이용하던 핵심은 진짜 객체를 조회를 꼭 필요한 시점까지 지연처리 한다는 것이다.
3) 꼭 웹 스코프가 아니더라도 프록시는 사용할 수 있다.
4) 싱글톤 처럼 동작하지만 다르게 동작하는 것이기에 사용에 주의해야한다.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
본 포스팅은 인프런의 '스프링 핵심 원리 - 기본편 : 김영한 강사님'의 강의와 자료를 참고하였습니다.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------