Spring 핵심 - 프로토타입 스코프

1. 빈 스코프


  • 싱글톤 : 기본 스코프, 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프
  • 프로토타입 : 스프링 컨테이너는 프로토타입 빈의 생성, 의존관계 주입과 초기화 메서드 호출까지만 관여하는 매우 짧은 범위의 스코프
  • 웹관련 스코프
    • request : 웹 요청이 들어오고 나갈 때까지 유지되는 스코프
    • session : 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
    • application : 웹의 서블릿 컨텍스와 같은 범위로 유지되는 스코프
// 자동 등록의 경우 사용법
@Component
@Scope("prototype")

// 수동 등록의 경우 사용법
@Bean
@Scope("prototype")


2. 프로토타입 스코프


그림1

싱글톤 스코프의 빈은 스프링 컨테이너의 시작부터 끝까지 유지되기 때문에 지속적인 요청에서 같은 빈을 반환해줬다. 프로토타입의 빈의 경우는 빈의 생성, 의존관계 주입, 초기화 메서드 호출까지만 관여하고 이후에는 컨테이너에서 관리하지 않는다. 초기화 메서드까지만 관리하고 이후에는 버린다고 생각해도 좋다. 따라서 스프링 컨테이너에 프로토타입 스코프의 빈을 요청하면 항상 새로운 객체 인스턴스를 생성해서 반환한다. 이후 해당 빈에 대한 관리책임은 스프링 컨테이너가 아니라 클라이언트에게 있다. 그러므로 스프링 컨테이너가 종료될 때 빈의 종료 메서드도 실행되지 않고 종료 메서드에 대한 호출이 필요하다면 클라이언트가 직접 호출해야한다. 또한, 프로토타입 빈은 싱글톤 빈과 다르게 스프링이 뜰 때 빈이 생성되는 것이 아니라, 스프링 컨테이너에서 빈을 조회할 때 그때서야 빈을 생성하고 초기화 메서드를 실행한다.

싱글톤 빈과 함께 사용시 문제점

그림2 프로토타입 빈을 사용한다는 것은 목적 자체가 사용할 때마다 새로 생성해서 사용하는 것이다. 스프링은 일반적으로 싱글톤 빈을 사용하므로, 싱글톤 빈이 프로토타입 빈을 사용하게 된다. 여기서 문제가 발생한다. 싱글톤 빈에 필드로 프로토타입 빈인 클래스를 사용하게 되면 스프링이 뜨면서 싱글톤 빈 클래스의 생성자가 호출되고 필드값을 주입받기 위해 프로토타입 빈을 요청하고 이때의 요청으로 프로토타입 빈이 생성되고 이를 받아서 주입받은 뒤에 싱글톤 빈이 컨테이너에 등록된다. 이게 문제인 것이다. 프로토타입을 사용하는 것의 목적은 요청할 때마다 새로 만들어서 사용하는 것인데 싱글톤 빈이 등록되는 과정에서 프로토타입 필드를 주입받은 그대로 등록되어 버렸다. 이제 이 싱글톤 빈을 사용하면 프로토타입 필드는 싱글톤 빈이 저장될 때 주입받았던 그 프로토타입 필드를 계속 사용하게 된다. 따라서 다수의 클라이언트가 해당 싱글톤 빈을 받아쓰면 프로토타입 빈인 필드도 싱글톤마냥 계속 같은 것을 공유하게 되는 것이다.
cf) 여기서 오해하면 안되는 것이 서로 다른 싱글톤에서 프로토타입 빈을 주입받으면 주입 받는 시점에 각각 새로운 프로토타입 빈을 받는다.

해결법

싱글톤 빈의 필드로 프로토타입 빈을 쓰면서 스프링이 뜰 때 주입받아서 저장하기 때문에 계속 같은 프로토타입 빈을 사용하는게 문제가 되었다. 해결책은 프로토타입 빈이 필드로 필요하다면, 프로토타입 빈을 필드로 받는 것이 아니라, 필요할 때 프로토타입 빈을 불러와주는 기능을 싱글톤 빈의 필드로 추가해주면 된다. 지정한 빈을 컨테이너에서 찾아서 반환하는 기능을 DL(dependecy lookup)이라고 하고 스프링에서는 ObjectProvider가 이런 기능을 제공한다.

@bean
@Scope("singleton") // 안써도 되긴 하는데 그냥 써줬음
public class ProviderTest{
  @Autowired // 생성자 주입으로 해야되는데 그냥 간단하게 필드주입으로 했다.
  // ObjectProvider<빈에서 찾아올 클래스>
  // ObjectProvider는 스프링이 자동으로 등록해주므로 그냥 autowired로 받아서 사용하면 된다.
  // 지정한 빈을 컨테이너에서 반환하는 기능을 한다.
  private ObjectProvider<PrototypeBean> prototypeBeanProvider;

  // 프로토타입 빈의 필드를 가져와 수행해야 하는 로직
  public int logic() {
  // 지정한 빈을 컨테이너에서 가져온다.
  // 꺼내온 빈이 프로토타입 빈이면 지금 가져온 것은 이제 컨테이너에서 관리하지 않음
  PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
  prototypeBean.addCount();
  int count = prototypeBean.getCount();
  return count;
}

싱글톤 빈 안에 ObjectProvider<프로토타입스코프빈의클래스명> 변수명 으로 선언해두고 autowired로 해두면 나중에 프로토타입이 필요한 곳에서 .getObject()하면 이때 프로토타입 빈을 받아온다. 프로토타입이므로 당연히 이때 요청이되어 이때 빈이 만들어지고 이를 받아온다. 여기서 하나 알아두어야 할 점이 ObjectProvider는 프로토타입을 빈에서 찾아주는 전용 용도가 아니다. 그냥 지정한 빈을 컨테이너에서 반환하는 기능을 가졌을 뿐이다. 그런데 autowired로 ObjectProvider를 주입받았는데 사실 빈에 등록한 적이 없는데도 잘 동작한다. 이유는 스프링 빈이 objectprovider는 자동으로 빈으로 등록해서 넣어주기 때문이다. 이 기능은 스프링이 제공해주는 것이기 때문에 스프링에 의존적이다.
자바 표준에서도 해결책으로 제공하는 기능이 있다. build.gradle에 다음을 추가하고 코끼리를 클릭해서 적용해주자.

implementation 'javax.inject:javax.inject:1'

사용법은 ObjectProvider와 똑같다. 이름을 ObjectProvider 대신 Provider로 바꿔주고, getObject라는 메서드 대신 get()이라는 메서드를 사용한다.

이제 둘 중 어느 것을 사용해야할지가 고민인데, 대부분 스프링이 더 다양하고 편리한 기능을 제공하기에 특별히 다른 컨테이너를 사용할 일이 없다면 스프링의 것을 사용하는 것이 좋은 선택이다.

3. 웹 스코프


웹스코프는 웹 환경에서만 동작하고, 프로토타입과 다르게 스프링이 스코프의 종료시점까지 관리한다. 웹 스코프의 종류는 다음과 같다.

  • request : http 요청 하나가 들어오고 나갈 때까지 유지되는 스코프
  • session : http session과 동일한 생명주기를 가지는 스코프
  • apllication : ServletContext와 동일한 생명주기를 가지즈 스코프
  • websoket : 웹 소켓과 동일한 생명주기를 가지는 스코프

그림3 웹 스코프 request의 경우, 각각의 요청마다 별도의 빈 인스턴스가 생성되고 관리된다. 즉, 클라이언트 A가 http 요청을 하면 클라이언트 A에 해당하는 빈이 하나 만들어지고 요청이 나갈 때까지 클라이언트 A에 대한 전용 빈으로 관리된다. 클라이언트 B가 http 요청을 하면 마찬가지로 클라이언트 B의 전용빈이 만들어지고 나갈 때까지 컨테이너에서 관리된다.
웹 스코프는 웹 환경에서만 동작하므로 build.gradle에서 다음 라이브러리를 추가해준다. 이 라이브러리를 추가하면 스프링 부트는 내장 톰켓 서버를 활용해 웹서버와 스프링을 함께 실행시킨다. 웹 관련 추가 설정과 환경들이 필요하므로 AnnotationConfigApplicationContext 대신 AnnotationConfigServletWebServerApplicationContext기반으로 애플리케이션이 구동된다.

implementation 'org.springframework.boot:spring-boot-starter-web'

// 웹 스코프 reqeust 사용법
@Component
@Scope("request")

request 스코프를 사용하는 예시로, 쇼핑몰을 운영하는데 한정판이라 동시에 여러 클라이언트에서 http 요청이 들어오면 어떤 요청이 남긴 로그인지 구분하기 어렵다. 이럴 때, 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);
 }
}

// 컨트롤러
@Controller
@RequiredArgsConstructor
public class LogDemoController { 
  // 웹스코프 빈을 찾아올 장치 DL
 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"); 
 return "OK";
 }
}

웹 스코프 빈 클래스에 필드로 id와 url이 있고 빈 생성후 의존관계가 주입되는 단계를 거쳐(여기서는 의존관계가 없음) @PostConstruct를 이용해 초기화 메서드에서는 고유 id값을 생성해서 넣어줬다. 사용자가 어떤 url로 요청을 했는지는 웹 스코프 빈이 생성되는 시점에서는 알 수 없으니 setter를 사용해서 Controller에서 받은 url값으로 세팅해줘야한다. Controller에서는 RequestMapping으로 받은 url을 웹 스코프 빈 클래스의 setter메서드를 통해 url필드값을 세팅해줘야 하기 때문에 컨트롤러 클래스는 필드로 웹스코프 빈 클래스를 가지고 있어야 한다. 그런데 필드에 웹 스코프빈을 선언하고 실행해보면 오류가 발생한다. 스프링이 뜰 때, 컨트롤러가 스프링 빈으로 등록하는 과정에서 필드로 웹스코프 빈을 주입받아야 하는데 스프링이 뜨는 시점에서는 컨테이너에 웹 스코프 빈이 존재하지 않는다. 웹 스코프 빈은 요청이 와야 그때서야 생성되는 것 인데 스프링이 뜨는 시점에서는 요청이 없기 때문이다. 해결책으로 좀 전에 공부했던 DL을 사용해주면 된다. 필드로 웹스코프 빈을 사용하는게 아니라 빈을 찾아주는 장치를 넣어주고 http 요청이 들어왔을 때 그 장치를 이용해서 웹 스코프 빈을 생성하고 찾아오는 것이다. ObjectProvider<>을 사용하면 컨트롤러에 필드로 ObjectProvider< 웹 스코프 빈 클래스 명 > 변수를 선언해두었다고 하면, 해당 url로 request가 들어오면 .getObject()를 처음 하는 시점에 웹 스코프 빈이 생성 되어 컨테이너에 등록되고 이를 받아오는 것이고 해당 빈은 해당 클라이언트에 대해서 전용으로 사용된다.

위의 해결책의 경우 getObject로 꺼내야하는 코드를 작성해야하는데 개발자들은 이것조차 귀찮아서 다른 방법이 있다. 프록시를 이용하는 것이다. 스코프에 옵션으로 proxyMode 값을 준다.

@Component
// 값이 하나면 request만 써도 되지만, 2가지 이상 파라미터면 value="request"라고 줘야한다
// 적용 대상이 인터페이스면 TARGET_INTERFACES, 클래스면 TARGET_CLASS
// MyLogger은 클래스이므로 클래스를 붙여준다.
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
  // 생략
}

// 컨트롤러
@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";
 }
}

프록시를 이용하면 컨트롤러에서 필드로 ObjectProvider가 아니라 그냥 웹 스코프 빈을 필드로 주입받아서 사용할 수 있다. 마치 싱글톤처럼 말이다. 지금까지 스프링이 뜰 때, 요청이 없으므로 웹 스코프 빈도 없다고 했는데 프록시를 사용하면 뜨는 시점에 스프링 컨테이너는 CGLIB라는 바이트코드를 조작하는 라이브러리를 이용해 해당 웹 스코프 빈 클래스를 상속받은 가짜 프록시 객체를 생성해서 넣어준다. 가짜 프록시 객체는 요청이 오면 그때 내부에서 진짜 빈을 요청하는 위임 로직이 들어있다. 따라서 실제 사용자가 호출하는 웹 스코프 빈의 메서드는 가짜 프록시 메서드를 호출한 것이고 가짜 프록시 객체의 메서드가 진짜를 호출하게 되는 것이다. 정리하자면, 프록시를 사용하면 마치 싱글톤처럼 추가코드없이 사용할 수 있다.(싱글톤처럼 하나를 공유하는게 아니지만 작성하는 코드가 싱글톤 같다는 뜻)

request 웹 스코프를 사용하는 방법으로 provider과 프록시를 살펴봤고 프록시의 사용이 코드를 최적화할 수 있었다. 여기서 핵심은 Provider을 사용하든 프록시를 사용하든 결국 진짜 객체 조회를 필요한 시점까지 지연처리 한다는 점이다.(정말 필요한 시점이 와야 객체 생성하여 빈으로 등록한다는 것)



본 포스팅은 인프런 김영한님의 ‘스프링 핵심 원리 - 기본편’ 강의를 듣고 정리한 내용을 바탕으로 복습을 위해 작성하였습니다. [강의 링크]


© 2021. By Backtony