스프링 입문-빈 생명주기와 스코프

빈의 생명 주기 콜백

스프링 컨테이너에 올라간 빈은 기본적으로 의존관계 주입이 완료되어야 제대로 된 기능을 수행할 수 있는 상태가 된다. 하지만 객체 중 초기화나 적절한 종료 작업이 필요한 객체가 있다면 어떻게 해야 할까?
이를 위해 스프링에서는 객체의 의존관계 주입이 완료된 후, 그리고 객체의 소멸 직전에 작동하는 여러 콜백 기능을 제공한다. 이러한 콜백 기능에는 3가지가 있다.

InitializingBean, DisposableBean

public class NetworkClient implements InitializingBean, DisposableBean {
    @Override
    public void afterPropertiesSet() throws Exception {
        connect();
        call("초기화 연결 메시지");
    }
    @Override
    public void destroy() throws Exception {
        disConnect();
    }
}

위와 같이 InitializingBean, DisposableBean 인터페이스를 구현한 클래스는 객체의 의존관계 주입 이후와 소멸 직전에 발동하는 afterPropertiesSet(), destroy() 메서드를 제공한다.
이 방법은 스프링 초창기에 나온 방법으로 스프링에 코드가 종속되고 외부 라이브러리에 적용이 불가능하며 초기화, 소멸 메서드의 이름을 변경하는 것이 불가능한 등 여러 단점이 있어 지금은 거의 사용되지 않는다.

빈 초기화, 소멸 메서드 지정

설정 정보에서 파라미터를 넣어 빈의 초기화와 종료 메서드를 지정할 수도 있다.

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

위와 같이 설정 정보에서 지정하면 메서드 이름을 자유롭게 지정할 수 있고, 외부 라이브러리에서도 사용 가능하며 스프링에 종속되지 않는 등 인터페이스 구현 방식의 단점을 모두 해결한 것을 볼 수 있다.
하지만 이보다 더 편한 방법이 있는데 애너테이션을 이용하는 것이다.

@PostConstruct, @PreDestroy

@PostConstruct
public void init() {
    System.out.println("NetworkClient.init");
    connect();
    call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
    System.out.println("NetworkClient.close");
    disConnect();
}

위와 같이 초기화, 소멸 메서드로 지정하고 싶은 메서드 위에 @PostConstruct, @PreDestroy 애너테이션을 달아주면 초기화, 소멸 메서드로써 기능한다.
이 방법은 최신 스프링에서 가장 권장하는 방법이고, 편할 뿐더러 스프링이 아닌 자바 표준이기 때문에 다른 컨테이너에서도 작동한다. 콜백 기능을 사용해야 한다면 애너테이션 방식을 사용하자.

빈 스코프

우리가 스프링 빈이 싱글톤을 보장한다고 배웠던 것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문이다. 스코프는 번역 그대로 빈이 존재할 수 있는 범위를 뜻하는데, 기본적으로 싱글톤, 프로토타입 스코프가 있고 웹 관련으로는 request, session, application 스코프가 있다.

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

스코프는 위와 같이 애너테이션을 통해 지정한다. 스코프들 중 우리가 배우지 않았던 프로토타입 스코프와 웹 스코프 중 가장 기본적인 request 스코프를 알아보자.

프로토타입 스코프

프로토타입

위 그림과 같이 프로토타입 빈은 싱글톤을 보장하지 않는다. 클라이언트가 빈을 요청하면 매번 새로운 객체를 생성하여 클라이언트에게 전달한다.
프로토타입 빈은 객체의 생성, 의존관계 주입, 초기화 까지만 책임을 진다. 그 이후의 책임은 클라이언트에게 있다. 따라서 @PreDestroy와 같은 종료 메서드도 작동하지 않는다.
프로토타입 빈 자체는 기능대로 작동한다. 하지만 싱글톤 빈과 프로토타입 빈을 같이 사용한다면 예상치 못한 문제가 생길 수도 있다.

    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class,PrototypeBean.class);
        ClientBean clientBean1 = ac.getBean(ClientBean.class);
        int count1 = clientBean1.logic();
        Assertions.assertThat(count1).isEqualTo(1);
        ClientBean clientBean2 = ac.getBean(ClientBean.class);
        int count2 = clientBean2.logic();
        Assertions.assertThat(count2).isEqualTo(1);
    }
    static class ClientBean {
        private final PrototypeBean prototypeBean;
        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
        }
        public int logic() {
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }
    @Scope("prototype")
    static class PrototypeBean {
        private int count=0;
        public void addCount() {
            count++;
        }
        public int getCount() {
            return count;
        }

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

위 코드는 싱글톤 빈이 프로토타입 빈을 주입받아 작동하는 코드이다. 프로토타입 빈을 사용하였으니 매번 객체를 새로 생성받아 작동하는 것을 기대했겠지만, 실제로는 그렇게 작동하지 않는다.
싱글톤 빈이 처음 객체를 생성하여 컨테이너에 올라갈 때, 그 때 프로토타입 빈을 같이 생성하여 주입받은 뒤 해당 객체를 계속해서 사용한다. 따라서 clientBean1과 clientBean2의 프로토타입 빈은 모두 같은 객체이다.
이러한 문제를 해결하기 위해선 Provider과 DL에 대해 알아야 한다.

Dependency Lookup

DL(Dependency Lookup)이란 의존관계를 외부에서 주입받는 것이 아닌 직접 필요한 의존관계를 찾는 것을 뜻한다. 위의 문제를 해결하기 위해서는 빈 생성 때 프로토타입 빈을 주입받는 것이 아닌, 클래스 내부에서 프로토타입 빈이 필요할 때 컨테이너에서 꺼내 객체를 생성하는 방식, 즉 DL이 필요하다.
그런데 ApplicationContext를 사용하여 컨테이너를 통째로 가져오면 코드가 스프링 컨테이너에 종속되고, 순수한 자바 코드가 아니므로 unit 테스트도 어려워진다. 따라서 컨테이너에서 빈을 찾아주는, 딱 그 정도의 역할을 하는 것이 필요한데 그것이 바로 Provider이다.

Provider

Provider는 컨테이너에서 빈을 찾아주는 DL서비스를 제공하는 클래스이다. Provider에는 스프링이 제공하는 ObjectProvider와 자바 표준인 Provider 두 종류가 존재한다.

        @Autowired
        private ObjectProvider<PrototypeBean> prototypeBeanProvider;

        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }

스프링에서 제공하는 ObjectProvier를 사용하여 ClientBean 내부에 DL을 구축한 코드이다. 자바에서 제공하는 Provider를 사용하려면 라이브러리를 추가하고 ObjectProvider 대신 Provider의 get() 메서드를 사용하면 된다.
이렇게 Provider를 사용하여 DL을 구축하면 간단한 기능이므로 테스트도 편해지고, 자바 표준 Provider는 다른 컨테이너에서도 사용이 가능한 장점이 있다.

웹 스코프

웹 스코프들 중 request 스코프만을 알아볼 것이다. request 스코프를 이해하면 다른 스코프들은 범위만 다를 뿐 다 이해할 수 있기 때문이다.
웹 스코프는 웹 환경에서만 동작하며, 스프링이 처음부터 끝까지 관리하기 때문에 종료 메서드가 동작한다. 웹 스코프 중 request 스코프는 HTTP 요청이 들어올 때 생성되고, 요청이 처리되면 소멸한다. 그리고 각 요청마다 인스턴스가 따로 생성되므로 어떤 요청이 로직을 처리했는지 알아보기 쉬운 장점이 있다.

package hello.core.common;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

import java.util.UUID;

@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 + "] request scope bean create: " + this);
    }

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

request 스코프 클래스로 외부에서 URL과 메세지를 받아 unique한 id인 uuid와 함께 출력하는 MyLogger 클래스이다. uuid를 통해 각 요청을 쉽게 식별할 수 있다.
request 스코프는 위에서 말했듯이 HTTP 요청이 들어올 때 생성된다. 따라서 일반 빈처럼 의존관계를 주입해주면 요청은 들어오지 않았는데 빈이 생성될 때 의존관계 주입을 필요로 하므로 예외가 발생할 수 있다.
이러한 상황을 방지하기 위해선 요청이 들어올 때까지 생성에 지연을 주어야하는데, 그 방법으로는 두 가지가 있다.
첫 번째는 위에서 배운 Provider를 사용하는 것이다.

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

Provider를 사용하여 구현한 Controller이다. DL을 사용하여 요청이 있을 때에만 로직 내에서 request 스코프 클래스를 생성하여 받아오므로 예외가 발생하지 않고 정상 작동한다.
두 번째 방법은 proxy를 사용하는 것이다.

@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 클래스의 스코프 value에 proxyMode = ScopedProxyMode.TARGET_CLASS 가 들어가있는 것을 볼 수 있다. 해당 파라미터를 추가하면 스프링은 이전에 배웠던 바이트코드 조작 라이브러리인 CGLIB을 사용하여 진짜 MyLogger 클래스를 상속받는 가짜 MyLogger 클래스를 생성한다. 그리고 해당 클래스가 실제로 필요할 때에만 진짜 MyLogger의 로직을 실행한다.

프록시

위의 그림처럼 진짜 request 스코프가 아닌 가짜 클래스를 전면에 내세웠기 때문에 일반적인 방법으로 의존관계를 주입해도 예외가 발생하지 않고, 마치 싱글톤 빈을 사용하듯이 request 스코프 빈을 사용할 수 있게 된다.

두 방법 모두 핵심은 지연이다. 요청이 들어와서 해당 클래스가 정말 필요해 질 때까지 객체의 생성을 지연하는 것이다. request 스코프같은 경우 지금까지 배웠던 것처럼 특수한 빈이기 때문에 주의깊게 사용해야 한다.