[Spring] 빈 생명주기와 빈 스코프

Loopy·2023년 3월 7일
0
post-thumbnail

빈 생명주기(Bean Lifecycle)

빈 생명주기 콜백

애플리케이션 시작 시점에 필요한 커넥션(DB 커넥션 풀, 네트워크 소켓)을 미리 생성하고 종료 시점에 커넥션을 종료하는 작업을 진행해야하는 경우에는, 객체의 초기화와 종료 작업이 필요하다.

객체의 생성과 초기화를 분리하지 않는 예시

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
        connect();
        call("초기화 연결 메시지");
    }
    
    public void setUrl(String url) {
        this.url = url;
    }
    ...
}

생성자 부분을 보면 url 정보 없이 connect 가 호출되는데, 당연히 객체를 생성하는 단계에서는 url이 없고 이후 수정자 주입을 통해 setUrl() 이 호출되어야 url 값이 존재하기 때문이다.

생성자 주입을 제외한 나머지 필드와 세터 주입은, 객체 생성 -> 의존관계 주입이라는 라이프사이클을 가진다. 따라서 초기화 작업은 의존관계 주입까지 완료된 이후에 호출해야 위와 같은 문제 상황이 발생하지 않을 것이다.

🔖 객체 생성과 초기화 분리
생성자 주입을 통해 생성과 주입을 한번에 완료해도 괜찮지만, 가능한 객체 생성과 초기화 작업을 분리하자. 생성자의 메모리를 할당해서 객체를 생성하는 책임과 달리 초기화 작업은 외부 커넥션에 연결하는 등 무거운 작업이 일어나기 때문이다.

그렇다면 의존관계 주입이 완료되었는지 어떻게 알 수 있을까?

☁️ 스프링 빈의 이벤트 라이프사이클

스프링은 의존관계 주입이 완료되면 콜백 메서드를 통해서 스프링 빈에게 초기화 시점을 알려주고, 컨테이너 종료 전 소멸 콜백을 알려주는 다양한 기능들을 제공한다. 인터페이스, 메소드, 어노테이션 세가지 방법이 존재한다.

스프링 빈 이벤트 라이프사이클

  1. 스프링 컨테이너 생성
  2. 스프링 빈 생성
  3. 의존관계 주입
  4. 초기화 콜백
  5. 사용
  6. 소멸 전 콜백
  7. 스프링 종료

초기화 콜백 은 빈이 생성되고 의존관계 주입 완료 후 호출되고, 소멸 전 콜백은 빈이 소멸되기 직전에 호출된다.

1. InitializingBean, DisposableBean 인터페이스

이 인터페이스는 스프링 전용 인터페이스므로, 해당 코드가 스프링 전용 인터페이스에 의존하게 된다. 또한 초기화, 소멸 메서드의 이름을 변경할 수 없으며
직접 코드를 고칠 수 없는 외부 라이브러리에 적용할 수 없다는 단점들이 존재한다.

따라서, 현재는 거의 사용하지 않는 방법이다.

public class NetworkClient implements InitializingBean, DisposableBean {
  ... 
  //의존관계 주입이 끝나면 호출
  @Override
  public void afterPropertiesSet() throws Exception {
      connect();
      call("초기화 메시지");
  }

  //빈 소멸 전에 호출 
  @Override
  public void destroy() throws Exception {
      disconnect();
  }
}

2. 빈 등록 초기화,소멸 메소드

설정 정보에 @Bean(initMethod = "init", destroyMethod = "close") 처럼 초기화, 소멸 메서드를 지정해주면 된다.

메서드 이름을 자유롭게 지을 수 있으며, 스프링 코드에 전혀 의존하지 않는다는 장점이 있다. 더불어 설정 정보만 고치기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 사용 가능하다.

public class NetworkClient{
 ...
 //의존관계 주입이 끝나면
   public void init() {
       System.out.println("NetworkClient.init");
       connect();
       call("초기화 메시지");
   }
}
@Configuration
   static class LifeCycleConfig {

       @Bean(initMethod = "init", destroyMethod = "close")
       public NetworkClient networkClient(){
           NetworkClient networkClient = new NetworkClient();  //url : null 값
           networkClient.setUrl("http://hello-spring.dev"); 
           return networkClient;
       }
   }

🔖 destoryMethod의 추론 기능
외부 라이브러리의 종료 메소드인 close, shutdown을 자동으로 호출해준다. destroyMethod 는 기본값이 (inferred), 즉 추론으로 등록되어 있기 때문에 추론 기능을 끄고 싶다면 옵션 값에 공백을 설정해주도록 하자.

3. @PostConstruct, @PreDestory(권장)

메서드 위에 어노테이션만 붙이면 되므로 매우 편리하고, 스프링에 종속적이지 않아서(javax) 다른 컨테이너에서도 동작한다는 장점이 존재한다.

하지만 코드를 고칠 수 없는 외부 라이브러리의 경우에는 빈 등록 초기화 및 소멸 메서드를 사용하자.

 @PostConstruct
 public class NetworkClient{
 
   @PostConstruct
   public void init() throws Exception {
       System.out.println("NetworkClient.init");
       connect();
       call("초기화 메시지");
   }

   @PreDestroy
   public void close() throws Exception {
       System.out.println("NetworkClient.close");
       disconnect();
   }
}

빈 스코프(Bean Scope)

빈 스코프 개념

스코프란 빈이 존재할 수 있는 범위를 의미하며, 스프링 빈은 기본적으로 싱글톤 스코프로 생성한다.

@Scope("prototype")
@Component
public class HelloBean {}

스코프의 종류

  • 싱글톤 : 기본 스코프로써, 스프링 컨테이너와 같은 생명주기를 가짐
  • 프로토타입 : 스프링 컨테이너가 빈의 생성과 의존관계 주입까지만 관여(초기화 메소드까지는 불러줌)하는 매우 짧은 범위의 스코프
  • 웹 관련 스코프
    • request : 웹 요청이 들어오고 나갈때까지 유지되는 스코프
    • session : 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
    • application : 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프

☁️ 프로토타입 스코프

싱글톤 스코프가 조회 시 항상 같은 인스턴스의 스프링 빈을 반환하는 것과 달리, 프로토타입 스코프는 항상 새로운 인스턴스를 생성해서 반환한다.

빈 요청 과정

  1. 프로토타입 스코프의 빈을 스프링 컨테이너에 요청한다.
  2. 요청 시점에 컨테이너가 프로토타입 빈 생성, 의존관계 주입(DI), 초기화 까지 처리한다. (싱글톤은 컨테이너 실행 시점에 생성)
  3. 생성한 빈을 클라이언트에 반환하고 관리하지 않고, 이후 같은 요청이 올때마다 새로운 프로토타입 빈 생성해서 반환한다. 따라서, @PreDestory 와 같은 종료 메소드 호출 불가능하기 때문에 빈을 조회한 클라이언트가 프로토타입 빈을 직접 관리해야한다.

☁️ 싱글톤 빈과 프로토타입 빈 함께 사용시 문제점

싱글톤 빈 내부에는 의존 관계 주입으로 프로토타입 빈의 참조를 가지고 있을 것이다. 다만 싱글톤 빈은 생성 시점에만 의존관계 주입을 받기 때문에, 프로토타입이 요청 시마다 새로 생성되는 것이 아니라 주입 시에만 한번 생성되어 같은 빈이 유지되기 때문에 문제가 발생한다.

문제 발생 예제 코드

 @Scope("singleton")    //싱글톤
 static class ClientBean{
     private final PrototypeBean prototypeBean;    //생성 시점에 이미 주입(계속 같은 것)

     @Autowired
     public ClientBean(PrototypeBean prototypeBean) {
          this.prototypeBean = prototypeBean;
     }
 
     public int logic(){
          prototypeBean.addCount();
          return prototypeBean.getCount();
     }
 } 

ClientBean 객체는 싱글톤이기 때문에 주입 된 PrototypeBean은 이미 과거(생성 시점)에 주입이 끝난 빈이다. 따라서 서로 다른 클라이언트가 요청해도, 같은 프로토타입 빈이 반환된다.

그렇다면 어떻게 사용할 때 마다 항상 새로운 프로토타입 빈을 생성할 수 있을까? 즉 프로토 타입을 주입 시점에만 생성하는 것이 아니라, 요청 시마다 생성하려면 어떻게 해야할까?

☁️ Provider로 문제 해결

1. 스프링 컨테이너에 요청

 @Autowired
 private ApplicationContext ac;
 
 public int logic() {
    PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);  //항상 새로운 프로토타입 빈 생성
    ...
}

외부에서 주입 받는 것이 아니라, 스프링 컨테이너로부터 직접 필요한 의존관계를 찾아서 해결할 수 있다(Dependency Lookup). 하지만, 스프링의 애플리케이션 컨텍스트 전체를 주입받게 되면 스프링 컨테이너에 종속적인 코드가 되고, 단위 테스트도 어려워진다.

2. ObjectProvider

ObjectProvider스프링 컨테이너에서 필요한 빈을 대신 조회하는 DL 서비스를 제공한다. getObject() 를 통해, 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한는데 프로토타입 스코프는 요청 시 마다 새로운 객체가 반환되니 문제가 해결이 된다.

  @Autowired
  private ObjectProvider<PrototypeBean> prototypeBeanProvider;

  public int logic() {
     PrototypeBean prototypeBean = prototypeBeanProvider.getObject();  //컨테이너에서 해당 빈 찾아서 반환
     ...
  }

웹 스코프

웹 환경에서만 동작하며, 프로토 타입과 다르게 스프링이 스코프의 종료시점까지 관리한다. 따라서, 종료 메소드 호출이 가능하다.

웹 스코프 종류

  1. request : HTTP 웹 요청 하나가 들어오고 나갈때까지 유지되는 스코프, 각각의 클라이언트 요청마다 별도의 빈 인스턴스 생성되고 관리된다. 만약 컨트롤러에서 불러진 다른 객체라 하더라도 하나의 라이프 사이클 동안에는 같은 객체가 유지되므로 서비스에서 불러도 같은 객체가 반환될 것이다.
  2. session : HTTP Session과 동일 생명주기 가지는 스코프이다.
  3. application : 서블릿 컨텍스와 같은 범위로 유지되는 스코프이다.
  4. websocket : 웹 소켓과 동일한 생명주기 가지는 스코프이다.

Request 스코프 구현

1. 웹 환경 구축하기

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

spring-boot-starter-web 를 추가하면, 스프링 부트는 내장 톰캣 서버 활용하여 웹서버와 스프링 함께 실행시킨다. 웹 라이브러리 추가 시, 웹 관련 기능이 더 추가된 AnnotationConfigServletWebServerApplicationContext를 기반으로 애플리케이션을 구동하게 된다.

2. Request 스코프

Request 스코프를 활용한다면 동시에 여러 HTTP 요청이 올때에도 정확히 어떤 요청이 남긴 로그인지 구분이 가능하다. 빈 생성 시점에, 초기화 메소드 사용하여 자동으로 uuid 생성해 저장해두면 항상 요청이 유지되는 동안은 까지는 같은 객체를 반환해주므로 다른 HTTP 요청과 구분 가능해지기 때문이다.

로그 출력 위한 클래스

@Component
@Scope(value = "request")     //http 요청 당 하나씩 생성, 요청 끝나는 시점에 소멸
public class MyLogger {

    private String uuid;
    private String requestURL;

    //빈 생성 시점에 알 수 없어서 외부에서 setter 로 입력
    public void setRequestURL(String requestURL) {
        this.requestURL = requestURL;
    }
    ...

    @PostConstruct
    public void init(){
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean create:" + this);
    }

    @PreDestroy
    public void close(){
    	...
    }
}

로거 작동 확인 테스트용 컨트롤러

@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.getRequestURI().toString();  //받은 URL값을 myLogger에 저장
        myLogger.setRequestURL(requestURL);  
		// myLogger 로그 기능 수행
       ...
    }
}

하지만, HTTP 요청은 빈이 생성되고 난 이후에 일어난다.

따라서 의존 관계 주입 과정에서 객체가 없어서 에러가 발생하는데, 이때 컨테이너에게 빈을 요청하는 단계는 의존관계 주입 단계가 아니라 뒤로 지연시켜야 빈이 초기화 되지 않았다는 오류가 발생하지 않을 것이다.

해결 방안으로는 앞에서 배운 Provider 와 프록시 두 가지 방법이 존재한다. 두 가지 방법 모두 진짜 객체 조회를 꼭 필요한 시점 까지 지연처리 한다는 점이 핵심이다.

🔖 Provider
ObjectProvider.getObject() 를 호출하는 시점까지 request scope 빈의 생성을 지연 가능하다. HTTP 요청만 유지되고 있다면 컨트롤러, 서비스 상관없이 같은 MyLogger 객체를 반환해준다. (비밀은 스레드 로컬에..)

 private final ObjectProvider<MyLogger> myLoggerProvider;

 public String logDemo(HttpServletRequest request){
       String requestURL = request.getRequestURI().toString();
       MyLogger myLogger = myLoggerProvider.getObject();  //필요한 시점에 처음 생성되고 초기화 메서드인 init이 실행된다.
       myLogger.setRequestURL(requestURL);
       ...
  }

프록시(Prpxy)

proxyMode 를 설정해주면, 스프링 컨테이너는 CGLIBMyLogger를 상속 받은 가짜 프록시 객체를 생성 후 등록해주므로, HTTP 요청과 관계 없이 미리 주입이 가능해진다. 요청이 오면 가짜 프록시 객체가 실제 myLogger 를 호출하는 방식이다.

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

출처
스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술

profile
개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂

0개의 댓글

관련 채용 정보