Dev/Spring

[스프링 핵심 원리] Bean Scope

oxdjww 2023. 9. 30. 19:36
728x90
반응형

Intro

본 카테고리는 Inflearn 김영한 강사님의 스프링 핵심 원리 강의를 수강하며 이해하고 학습한 내용을 정리한 내용으로 구성되어 있다.

본 포스팅에서는 빈 스코프, 즉 빈이 존재할 수 있는 다양한 범위를 예제들과 함께 다룬다.

Bean Scope?

스프링은 빈에 대해 다음과 같은 다양한 스코프를 지원한다.

싱글톤

기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
Ref

프로토타입

스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프이다.

기본 값인 싱클톤 스코프는 항상 같은 인스턴스를 반환해주는 특징을 갖고 있다.
하지만 가벼운 기능 테스트 등에 사용하기 위해서는 매번 새로운 빈을 반환해야 하는 경우가 있다.

이 때 활용할 수 있는 것이 프로토타입 스코프이다.
프로토타입 스코프로 설정하면 같은 종류의 요청이 들어와도, 매 요청마다 새로운 스프링 빈을 반환해준다.
스프링 컨테이너는 새로운 빈을 반환하고, 이를 관리하지 않는다.

이 말은 즉슨 스프링 컨테이너가 종료되어도 @PreDestroy와 같은 메서드를 호출하지 않는다는 것이다.
프로토타입 빈을 생성하고, 의존관계 주입, 초기화까지만 처리하는 특징을 가진다.

Sample code


@Scope("prototype")
static class PrototypeBean {
    @PostConstruct
    public void init(){
        System.out.println("PrototypeBean.init");
    }

    @PreDestroy
    public void destroy() {
        System.out.println("PrototypeBean.destroy");
    }
}

@Test
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();
}

서로 다른 스프링 빈이 생성된 것을 확인할 수 있다.
또, @PreDestory 메서드가 호출되지 않은 것을 확인할 수 있다.

Trouble When Used with Singleton Bean


프로토타입 빈을 주입 받는 싱클톤 빈을 사용하는 상황을 가정하자.

clientBean 은 싱글톤이므로, 보통 스프링 컨테이너 생성 시점에 함께 생성되고, 의존관계 주입도 발생한다.

  1. clientBean 은 의존관계 자동 주입을 사용한다. 주입 시점에 스프링 컨테이너에 프로토타입 빈을 요청한다.
  2. 스프링 컨테이너는 프로토타입 빈을 생성해서 clientBean 에 반환한다. 프로토타입 빈의 count 필드 값은 0이다.

이제 clientBean 은 프로토타입 빈을 내부 필드에 보관한다. (정확히는 참조값을 보관한다.)

클라이언트 A는 clientBean 을 스프링 컨테이너에 요청해서 받는다.싱글톤이므로 항상 같은 clientBean 이 반환된다.

  1. 클라이언트 A는 clientBean.logic() 을 호출한다.
  2. clientBean 은 prototypeBean의 addCount() 를 호출해서 프로토타입 빈의 count를 증가한다. count값이 1이 된다.

클라이언트 B는 clientBean 을 스프링 컨테이너에 요청해서 받는다.싱글톤이므로 항상 같은 clientBean 이 반환된다.

이 떄, clientBean이 필드로 가지고 있는 프로토타입 빈은 과거에 생성 시에 주입이 이미 끝난 상태이다.
즉, 사용자가 clientBean에 접근할 때마다 새로 생성되는 것이 아니라 이미 필드로 갖고 있는 프로토타입 빈에 접근하게 된다.

  1. 클라이언트 B는 clientBean.logic() 을 호출한다.
  2. clientBean 은 prototypeBean의 addCount() 를 호출해서 프로토타입 빈의 count를 증가한다. 원래 count 값이 1이었으므로 2가 된다.

그래서 결론적으로, 필자는

| 스프링 빈을 사용할 때 프로토타입 스코프 빈싱글톤 스코프 빈에 주입되는 상황에서는 프로토타입 스코프 빈의 특징이 묻힐 수 있다

로 이해했다.


Solve Trouble When Used with Singleton Bean


Dependency Lookup이란?


| 의존 관계를 외부에서 주입 받는 것(DI)이 아니라 내부에서 외부로 직접 필요한 의존 관계를 찾는 것을 DL이라 한다.

그래서, 스프링 어플리케이션 컨텍스트 자체 (AnnotationConfigApplicationContext)를 주입받아 사용할 수 있지만 코드가 난해해진다.
이에, Provider를 사용하여 문제를 해결할 수 있다.

  • Spring의 ObjectProvider
  • Java Provider

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;
}

이렇게 함으로써, ObjectProvidergetObject() 를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환 한다. (DL)
하지만 이 기술은 스프링 기술이므로 스프링에 종속적이다.

Java Provider

implementation('javax.inject:javax.inject:1')build.gradle에 추가해주자.

그리고 기존의 프로토타입 빈private Provider<PrototypeBean> provider;로 바꾸어주자.

@Autowired
 private Provider<PrototypeBean> provider;
 public int logic() {
     PrototypeBean prototypeBean = provider.get();
     prototypeBean.addCount();
     int count = prototypeBean.getCount();
     return count;
}

이렇게 함으로써, providerget() 을 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다. (DL)
이 문법은 자바 표준이므로, 스프링 컨테이너의 경우가 아니더라도 사용할 수 있다는 장점이 있다.

이렇게 프로토 타입 빈의 이용 예제를 알아보았다.

웹 관련 스코프

웹 환경(http)에서 빈이 스코프를 지정할 수 있는 방법들이다.

request : 웹 요청이 들어오고 나갈때 까지 유지되는 스코프이다.
session : HTTP Session과 동일한 생명주기를 가지는 스코프이다.
application : 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프이다.
websocket : 웹 소켓과 동일한 생명주기를 가지는 스코프이다.


request scope


우선 build.gradleimplementation 'org.springframework.boot:spring-boot-starter-web'를 추가하여 웹 환경을 구축할 수 있게 한다.

[d06b992f...] request scope bean create
[d06b992f...][http://localhost:8080/log-demo] controller test
[d06b992f...][http://localhost:8080/log-demo] service id = testId
[d06b992f...] request scope bean close

http 요청이 들어오면 이런 로그가 남도록 test를 작성해볼 것이다.
다양한 사용자가 http request를 날리고, 접속해도 이를 구분할 수 있도록 하기 위함이다.

MyLogger

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS )
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 + "]" +"[" + requestURL + "] request scope bean create:" + this) ;
    }

    @PreDestroy
    public void close() {
        System.out.println();
        System.out.println("[" + uuid + "]" +"[" + requestURL + "] request scope bean close:" + this) ;
    }
}

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();

        System.out.println("myLogger = " + myLogger.getClass());

        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logdemoService.logic("testId");
        return "OK";
    }
}

LogdemoService

@Service
@RequiredArgsConstructor
public class LogdemoService {

    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service id = " + id);
    }
}

테스트 시나리오는 다음과 같다.

  1. log-demo 서브 패스로 http request 요청이 들어온다
  2. LogdemoController에서 LogdemoServic에서 로그를 남기는 로직을 호출한다.
  3. 이 때, MyLogger라는 클래스의 인스턴스를 이용하여 로그를 찍어낸다.
  4. 같은 UUID(Unique User ID)인 경우에는 http request를 구분하여 식별할 수 있도록 한다.

하지만 실제로 실행해보면,

 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;

라는 에러와 함께 실행할 수 없다.

스프링 애플리케이션을 동작하는 과정에서, MyLogger class에서 의존 주입을 받는다.
하지만 request 스코프 빈은 생성되지 않았기에 주입을 받을 수 없어 오류를 내는 것이다.

Solve Trouble with Provider


해결방법은?
스프링 빈의 주입 시점을 늦추는 것이다.
이전에 사용했던 Provider를 통해서 이를 해결할 수 있다.

@Service, @Controller를 통해서 @Component가 등록되어 클래스에 접근 시 바로 빈으로 등록하려 해도,
자료형에 Provider 특징을 줌으로서 get 메서드를 실행하기 전까지 request 스코프 빈의 생성 및 주입을 늦출 수 있다는 것이다.

@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);
} }

실행 결과 이렇게 http request를 각각 구분하여 볼 수 있다.


Solve Trouble with Proxy


Privder 옵션을 주는 것보다 더 간단한 방법도 존재한다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS )
public class MyLogger {

이렇게 MyLogger와 같은 request 스코프 클래스에 옵션을 주면 된다.
적용 대상이 인터페이스가 아닌 클래스면 TARGET_CLASS 를 선택
적용 대상이 인터페이스면 INTERFACES 를 선택
이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.

작동원리

먼저 주입된 myLogger를 확인해보자.

실행 결과를 보고 다음과 같이 해석할 수 있다.


| CGLIB라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입한다.

@ScopeproxyMode = ScopedProxyMode.TARGET_CLASS) 를 설정하면 스프링 컨테이너는 CGLIB라는 바이트코드를 조작하는 라이브러리를 사용해서, MyLogger를 상속받은 가짜 프록시 객체를 생성한다.

결과를 확인해보면 우리가 등록한 순수한 MyLogger 클래스가 아니라 MyLogger$$EnhancerBySpringCGLIB 이라는 클래스로 만들어진 객체가 대신 등록된 것을 확인할 수 있다.
그리고 스프링 컨테이너에 "myLogger"라는 이름으로 진짜 대신에 이 가짜 프록시 객체를 등록한다.
ac.getBean("myLogger", MyLogger.class) 로 조회해도 프록시 객체가 조회되는 것을 확인할 수 있다.

그래서 의존관계 주입도 이 가짜 프록시 객체가 주입된다.

가짜 프록시 객체는 요청이 오면 그때 내부에서 진짜 빈을 요청하는 위임 로직이 들어있다.

가짜 프록시 객체는 내부에 진짜 myLogger 를 찾는 방법을 알고 있다.
클라이언트가 myLogger.log() 을 호출하면 사실은 가짜 프록시 객체의 메서드를 호출한 것이다.

가짜 프록시 객체는 request 스코프의 진짜 myLogger.log() 를 호출한다.
가짜 프록시 객체는 원본 클래스를 상속 받아서 만들어졌기 때문에 이 객체를 사용하는 클라이언트 입장에서는 사 실 원본인지 아닌지도 모르게, 동일하게 사용할 수 있다(다형성)

즉, 정리하자면 다음과 같다.

Provider든, Proxy를 사용하든 스프링 객체 조회를 실제로 객체가 필요한 시점까지 지연처리를 도와준다는 점이다.



감사합니다.

728x90
반응형