Post

[Spring] 빈을 생명주기 콜백을 지원하는 스프링 프레임워크와 빈의 유효범위 '빈 스코프' 및 '웹 스코프'

빈 생명주기 콜백

🐀 스프링 빈이 생성되거나 죽기 직전에 스프링 프레임워크가 빈 안에 있는 메서드를 호출해 줄 수 있는 기능을 말한다

즉, 스프링 빈🥔이 생성되고, 초기화 될 때 또는 빈이 사라지기 직전에 안전하게 종료할 수 있는 메서드를 스프링 프레임워크가 호출해 줄 수 있다.

Database Connection Pool🏖️와 콜백
웹 애플리케이션 시작 시점에 필요한 연결을 미리 해두고,
종료 시점에 연결을 모두 종료하려는 작업을 진행할 때 쓰인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Setter
public class NetworkClient {
    private String url;

    public NetworkClient() {
        System.out.println("call the constructor: url=" + url);
        connnect();
        call("initialization connect message");
    }

    public void connect() { // 빈 생성 직후 호출
        System.out.println("connect=" + url);
    }

    public void call(String message) {
        System.out.println("call=" + url + " message=" + message);
    }

    public void disconnect() { // 빈 소멸 직전 호출
        System.out.println("disconnect=" + url);
    }
}

🦕 BeanLifeCycleTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class BeanLifeCycleTest {

    @Test
    public void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);

        NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();

        // 결과
        // call the constructor: url=null
        // connect=null
        // call=null message=initialization connect message
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://example-test.dev"); // setter주입
            return networkClient;
        }
    }
}

다음과 같이 객체를 생성한 다음에 의존관계 주입이 되기도 전에 초기화 작업이 수행되었기 때문에, null을 출력하는 결과를 냈다.

즉, 초기화 작업은 객체가 생성되고, 의존관계 주입이 모두 완료되고 난 다음에 호출되어야 한다.

보통은 객체 생성단계를 거치고, 의존관계 주입단계를 거친다.
(⚠️ 생성자 주입은 객체 생성시, 스프링 빈이 같이 들어와야하기 때문에 예외)
초기화 작업은 이 의존관계 주입단계가 완료되고 일어나야하는데,
개발자가 의존관계 주입이 완료된 시점을 어떻게 알 수 있을까?

스프링 프레임워크는 의존관계 주입이 완료되면 스프링 빈🥔콜백 메서드를 통해 초기화 시점을 알려준다.
또한, 스프링은 스프링 컨테이너🥥 또는 스프링 빈🥔이 종료되기 직전에 소멸 콜백 메서드를 통해, 안전하게 종료작업을 진행할 수도 있다.

☄️ 스프링 빈의 LifeCycle
스프링 컨테이너🥥 생성 -> 스프링 빈🥔 생성 -> 의존관계 주입 -> 초기화 콜백 -> 사용 -> 소멸 콜백 -> 스프링 종료

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

🍪 객체의 생성과 초기화는 분리하는게 좋다.
초기화 작업에는 외부 연결을 맺는 무거운 작업이 포함될 수 있기 때문에,
따로 빼놓는 것이 🏆유지보수성에 좋다.
또한, 초기화 작업을 분리하면 동작을 지연할 수 있다.
즉, 객체를 생성하는 것까지만 하고, 실제 초기화 작업(외부 커넥션을 맺거나…)은 최초의 액션이 일어날때까지 미룰 수 있는 장점이 있다.
⚠️ 객체 생성은 딱 메모리를 할당하는 것까지를 말한다.

빈 생명주기 콜백을 지원하는 스프링 프레임워크의 3가지 방법

🐁 초기화/소멸 인터페이스, 초기화/소멸 메서드, 초기화/소멸 어노테이션
  • 초기화/소멸 인터페이스🗿 (InitializingBean, DisposableBean)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Setter
public class NetworkClient implements InitializingBean, DisposableBean {
    private String url;

    public NetworkClient() {
        System.out.println("call the constructor: url=" + url);
    }

    public void connect() { // 빈 생성 직후 호출
        System.out.println("connect=" + url);
    }

    public void call(String message) {
        System.out.println("call=" + url + " message=" + message);
    }

    public void disconnect() { // 빈 소멸 직전 호출
        System.out.println("disconnect=" + url);
    }

    @Override
    public void afterPropertiesSet() throws Exception { // 의존관계 주입 직후 호출
        connect();
        call("initialization connect message");
    }

    @Override
    public void destroy() throws Exception {
        disconnect();
    }
}
☄️ 초기화, 소멸 인터페이스의 단점
스프링 전용 인터페이스이다. 즉, 해당 코드가 스트링 전용 인터페이스에 의존한다.
(코드를 고칠수 없는) 외부 라이브러리에 적용할 수 없다.

  • 설정정보 빈 등록 초기화/소멸 메서드
    @Bean(initMethod="init", destroyMethod="close")
    라이브러리는 대부분 close, shutdown과 같은 이름의 소멸 메서드를 사용한다.
    destroyMethod는 기본값이 (inferred)로 등록되어 있다. 이 기본값은 close, shutdown이라는 이름의 메서드를 자동으로 호출해준다.
    즉, 종료 메서드를 추론해서 호출해준다. 따라서 직접 스프링 빈으로 등록하면, 종료 메서드는 따로 적어주지 않아도 잘 동작한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Setter
public class NetworkClient {
    private String url;

    public NetworkClient() {
        System.out.println("call the constructor: url=" + url);
    }

    public void connect() {
        System.out.println("connect=" + url);
    }

    public void call(String message) {
        System.out.println("call=" + url + " message=" + message);
    }

    public void disconnect() {
        System.out.println("disclose=" + url);
    }

    public void init() throws Exception {
        connect();
        call("initialization connect message");
    }

    public void close() throws Exception {
        disconnect();
    }
}

🦕 BeanLifeCycleTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class BeanLifeCycleTest {

    @Test
    public void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);

        NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configurataion
    static class LifeCycleConfig {

        @Bean(initMethod="init", destroyMethod="close")
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://example-test.dev");
            return networkClient;
        }
    }
}
♻️ 설정정보 초기화/소멸 메서드의 장점
설정정보를 사용하기 때문에, 외부 라이브러리에도 초기화/종료 메서드를 적용할 수 있다.
⚠️ 또한, 정해진 메서드 이름이 아닌 자유롭게 지정할 수 있다.

  • @PostConstruct @PreDestroy 어노테이션 지원 자바 표준 권고
    java.annotation 패키지로 스프링에 종속적인 기술이 아닌 자바 표준이다.
    ⚠️ (코드를 고칠 수 없는) 외부 라이브러리에 적용할 수 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Setter
public class NetworkClient {
    private String url;

    public NetworkClient() {
        System.out.println("call the constructor: url=" + url);
    }

    public void connect() {
        System.out.println("connect=" + url);
    }

    public void call(String message) {
        System.out.println("call=" + url + " message=" + message);
    }

    public void disconnect() {
        System.out.println("disconnect=" + url);
    }

    @PostConstruct
    public void init() throws Exception {
        connect();
        call("initialization connect message");
    }

    @PreDestroy
    public void close() throws Exception {
        disconnect();
    }
}

빈이 존재할 수 있는 범위, 빈 스코프

🐁 스프링 빈은 기본적으로 싱글톤 스코프로 생성된다
  • Singleton Scope
    스프링 컨테이너🥥의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • Prototype Scope
    사용자가 요청을 할 때 스프링 컨테이너🥥스프링 빈🥔의 생성과 의존관계 주입 및 초기화까지만 불러주고, 이후 클라이언트에 반환하고 더이상 관리하지 않는다.
    ⚠️ 때문에, 소멸 메서드 호출이 안된다.
    🎯 요청할 때마다 의존관계 주입이 완료된 새로운 객체가 필요할 때
    서로 필요한 시점이 다르니까 📛순환참조 문제가 발생하지 않는다.

싱글톤 스코프 빈을 조회하면 스프링 컨테이너🥥는 항상 같은 인스턴스의 스프링 빈🥔을 반환한다.
하지만 프로토타입 스코프 빈을 조회하면, 스프링 컨테이너🥥는 항상 새로운 인스턴스의 스프링 빈🥔을 생성해서 반환하고, 그 이후부터는 프로토타입 빈을 조회한 클라이언트가 직접 관리해야 한다.

즉, 프로토타입 스코프는 스프링 컨테이너🥥에서 스프링 빈🥔을 조회할 때 생성이 되고,
초기화 메서드가 이때 실행된다.

📕 웹 관련 스코프
Spring-WEB이 들어가야 쓸 수 있는 스코프
request: 웹 요청이 들어오고 나갈때까지 생존범위를 가지는 스코프
session: 웹 세션이 생성되고 종료될때까지만 생존범위를 가지는 스코프
application: 웹 서블릿 컨텍스트와 같은 생존범위를 가지는 스코프

🦕 PrototypeBeanTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PrototypeBeanTest {

    @Test
    void prototypeBeanFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);

        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);

        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
        ac.close();
    }

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

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

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

🏈 프로토타입 스코프 빈이 정상적으로 동작하지 않을 수 있다

만약 싱글톤 빈이 스프링 컨테이너 생성 직후, 의존관계 주입을 통해 프로토타입 스코프 빈을 주입받는다고 가정했을 때,
싱글톤 스코프 빈이 내부에 가지고 있는 프로토타입 스코프 빈이미 주입이 끝난 빈이다.
즉, 주입 시점에 요청되어 이미 스프링 컨테이너🥥에 의해 생성되고 초기화까지 마친 빈이다.
따라서 이후, 해당 프로토타입 스코프 빈의 관리는 호출한 클라이언트 객체가 맡게되고,
때문에 싱글톤 스코프 빈의 생존범위와 동일하게 동작하게 된다.

☄️ 프로토타입 스코프 빈을 주입 시점에서만 새로 생성하는게 아닌, 요청할 때마다 새로 생성해서 사용하려면??
Provider를 이용하면 된다.
Provider는 의존관계를 외부에서 주입받는 것이 아니라,
직접 필요한 의존관계를 조회한다. Dependency Lookup
즉, 지정한 빈을 Provider가 스프링 컨테이너🥥대신 요청하여 찾아주는 DL 작업을 한다.
스프링이 제공하는 기능을 사용하지만, 기능이 단순해서 단위테스트를 만들거나 mock 코드를 만들기 쉬워진다.

ObjectProvider: getObject() 하나만 제공
ObjectFactory: 편의 기능 확장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class SingletonWithPrototypeTest {

    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);

        ClientBean clientBean1 = ac.getBean(ClientBean.class);
        int count1 = clientBean1.logic();
        assertThat(count1).isEqualTo(1);

        ClientBean clientBean2 = ac.getBean(ClientBean.class);
        int count2 = clientBean2.logic();
        assertThat(count2).isEqualTo(1);

        @Scope("singleton")
        static class ClientBean {

            @Autowired
            private ObjectProvider<PrototypeBean> prototypeBeanProvider;

            public int logic() {
                PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
                // 호출하면 그때서야 스프링 컨테이너에서 프로토타입 스코프 빈을 찾아 반환.
                // 필요할 때마다 스프링 컨테이너에 대신 요청하는 작업을 할 수 있다.

                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");
            }
        }
    }
}
📘 스프링에 의존하지 않는 자바표준 JSR-330 Provider
javax.inject.Provider
위의 코드에서 Provider<PrototypeBean> prototypeBeanProvider;로 변경해도 의도한 결과가 나온다.
자바 표준이고 기능이 단순하므로, 단위테스트를 만들거나 mock 코드를 만들기가 훨씬 쉽다.
⚠️ implementation 'javax.inject:1' 라이브러리를 gradle🐘에 추가.

웹 환경에서만 동작하는, 웹 스코프

🐀 웹 스코프는 스프링이 스코프의 종료 시점까지 관리한다

🌵 SPRING-WEB 라이브러리 추가 in SpringBoot
🐘 implementation 'org.springframework.boot:spring-boot-starter-web'

📕 스프링 웹 기술
스프링부트는 내장 톰캣 서버🐈를 활용해서 웹 서버와 스프링을 함께 실행시키는데,
해당 웹 관련 라이브러리가 스프링에 포함되면 웹 기술이 들어가면서 애플리케이션이 서버에 띄워진다.
스프링부트는 웹 라이브러리가 없으면 스프링 컨테이너를 AnnotationConfigApplicationContext를 기반으로 애플리케이션을 구동하는데
다음과 같은 웹 라이브러리가 있으면 AnnotationConfigServletWebServerApplicationContext를 기반으로 애플리케이션을 구동한다.

Tomcat started on port(s): 8080 (http) with context path ' '
Started CoreApplication in 0.914 seconds (JVM running for 1.528)
  • request Scope
    request HTTP 요청 하나가 들어오고 나갈 때까지 생존범위를 가지는 스코프
    각 HTTP 요청마다 각각의 스코프를 가진다.
    즉 각 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.
    ⚠️ HTTP 요청이 같으면 같은 객체 인스턴스를 바라보게 된다.
  • session Scope
    HTTP 세션과 동일한 생명주기를 가지는 스코프
  • application Scope
    서블릿 컨텍스트와 동일한 생명주기를 가지는 스코프
  • websocket Scope
    웹 소켓과 동일한 생명주기를 가지는 스코프

각 HTTP 요청마다 요청정보 로그찍기

⚔️ 예제: 각 HTTP 요청마다 요청정보 로그찍기

동시에 많은 HTTP요청이 들어올 때, 정확히 어떤 요청이 남긴 로그인지 확인하고 싶을 때
request Scope🦚를 사용하면 딱 좋다.

🍪 HTTP 요청당 스코프가 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.

☕ MyLogger.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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() {
        String uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean created=" + this);
    }

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

☕ LogDemoController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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";
    }
}

MyLogger는 고객 요청이 올 때, 객체가 주입되는 request Scope🦚를 가진다.
따라서, 해당 객체의 주입은 처음 빈 생성 후, 의존 관계 주입 단계가 아닌
실제 고객 요청이 왔을 때로 지연시켜야 한다.

ObjectProvider를 사용하여 요청이 올 때마다, Provider가 필요한 빈을 스프링 컨테이너에서 조회할 수 있도록 만든다.
⚠️ 사실 이러한 Logger는 컨트롤러보다는 공통처리가 가능한 스프링 인터셉터서블릿 필터같은 곳에서 활용하는 것이 좋다.

🍍 인터셉터
컨트롤러 호출 직전에 공통화한 로직을 처리할 수 있다.

☕ LogDemoService.java

1
2
3
4
5
6
7
8
9
10
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final ObjectProvider<MyLogger> myLoggerProvider;

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

마찬가지로 requestURL과 같은 웹과 관련된 정보는 서비스 계층까지 넘어가지 않는 게 좋다.
즉, 서비스 계층은 웹기술에 종속되지 않은 채, 비즈니스 로직을 처리하고
왠만하면 웹 관련 정보는 컨트롤러 단에서 처리하도록 한다. 🏆유지보수성

☕ MyLogger의 멤버변수에 requestURL과 같은 정보를 저장하여 코드와 계층을 깔끔하게 유지할 수 있다.

스코프와 프록시

🍝 CGI 라이브러리로 특정 클래스를 상속받은 가짜 프록시 객체를 만들어 주입할 수도 있다

☕MyLogger.java

1
2
3
4
5
6
7
8
@Component
@Scope(value="request", proxyMode="ScopeProxyMode.TARGET_CLASS")
public class MyLogger {
    private String uuid;
    private String requestURL;

    // ...생략
}

다음과 같이 어노테이션 설정을 변경해주면 원본객체를 프록시 객체로 대체할 수 있다.

☕ LogDemoController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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가짜 프록시 클래스를 만들어두고 주입해준다.
MyLogger\(EnhancerBySpringCGLIB\)…

가짜 프록시 객체는 실제 요청이 올때 그제서야 내부에서 진짜 빈을 요청하는 위임로직을 호출한다.
호출하는 시점에 진짜 객체를 찾아 동작한다.
이 때, 가짜 프록시 객체는 request Scope🦚와는 관계가 없다.
내부에 단순히 위임로직만 있고, 싱글톤처럼 동작한다.

프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯 request Scope🦚를 사용할 수 있다.
이 프록시 객체를 사용하는 클라이언트는 사실 주입받은 객체가 원본인지 아닌지도 모르게 사용한다.

핵심은 진짜 객체 조회를 꼭 필요한 시점까지 지연처리한다는 점이다.

☕ LogDemoService.java

1
2
3
4
5
6
7
8
9
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service id=" + id);
    }
}
This post is licensed under CC BY 4.0 by the author.