5 분 소요

💡Refferecne [외부 링크]’스프링 핵심원리 - 기본편’

  1. 스프링 기본 1 - DIP, OCP 원칙과 스프링 컨테이너
  2. 스프링 기본 2 - 싱글톤 패턴과 싱글톤 컨테이너
  3. 스프링 기본 3 - 빈 스코프와 생명주기

1. 개요

앞서 스프링이 많은 객체들을 싱글톤으로 관리하기 위해 스프링 컨테이너에 등록하여 필요한 시점에 제공한다고 학습했다.

하지만 싱글톤으로 빈을 가지고 있다는 것은, 리소스를 점유하고 있다는 의미이기도 하다. 그렇다면 모든 객체를 싱글톤으로 유지해야 할 필요가 있을까?

그리고 객체가 생성되고 소멸되는 순간에 필요한 동작들(예: 커넥션 연결 등)은 어떻게 다루어야 할까?

만약 서비스를 개발하던 도중, 서버로 오는 특정 API에 대해 UUID와 요청 URL을 로깅하는 기능을 개발한다고 가정하고 앞선 내용을 공부해보도록 하자.

2. 빈 스코프

Hello 문자열을 반환하는 가상의 아주아주 중요한(…!) API 가 있다고 가정해보자. 해당 API 의 요청 컨트롤러는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/hello")
public class HelloController {

    @GetMapping
    public ResponseEntity<String> hello() {
                //아주 중요한 로직
                //...
        return ResponseEntity.ok("Hello");
    }
}

그리고 실제 로깅이 실행 될 빈을 생성한 후 스프링 컨테이너에 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class MyLogger {

    private String uuid;
    private HttpServletRequest request;

    public void setRequest(HttpServletRequest request) {
        this.request = request;
    }

    public void setUuid() {
        this.uuid = UUID.randomUUID().toString();
    }

    public void log() {
        System.out.printf("[%s] %s\\n", uuid, request.getRequestURL().toString());
    }

}

해당 로거를 등록할 인터셉터를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class HelloInterceptor implements HandlerInterceptor {

    private final MyLogger myLogger;

    public HelloInterceptor(MyLogger myLogger) {
        this.myLogger = myLogger;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        myLogger.setRequest(request);
                myLogger.setUuid();
        myLogger.log();
        return true;
    }
}

마지막으로 해당 인터셉터를 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class HelloConfigurer implements WebMvcConfigurer {

    private final HelloInterceptor helloInterceptor;

    public HelloConfigurer(HelloInterceptorhelloInterceptor) {
        this.helloInterceptor = helloInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistryregistry) {
                registry.addInterceptor(helloInterceptor)
                .addPathPatterns("/hello");
    }
}

여기까지 작성한 후 애플리케이션을 실행하면 내가 작성한 MyLogger 가 훌륭하게 작동하고 있는 듯 보인다..!!!

Hello API 에 대한 로깅 테스트

하지만 이 로거는 싱글톤으로 관리되고 있기 때문에 치명적인 문제점이 몇가지 존재한다.

  1. 사용하지 않을때도 메모리를 점유중이다.
  2. Thread Safe 하지 않다.

잘 보면 로거에 request 정보를 전달하는 과정과 실제 로그를 출력하는 과정 사이에 서로 다른 유저가 전역 변수(request, uuid) 에 간섭을 일으킬 여지가 충분하다.

그렇다면 해당 로거가 하나의 Request 에 대해 하나의 인스턴스를 생성하고, 해당 요청이 끝난 후 사라진다면 위의 두 가지 문제가 동시에 해결 된다.

이를 위한 기능이 바로 Bean Scope 이다.

Bean Scope 는 스프링 빈이 스프링 컨텍스트 내에서 생성되고 사라지는 일종의 생명주기인데 다음과 옵션들이 있다.

스코프

  • 싱글톤 : 스프링 기본 스코프. 스프링 컨테이너의 시작과 종료까지 함께하는 가장 넓은 범위이다.
  • 프로토타입 : 빈의 생성과 의존관계 주입까지만 관여하고 그 후에는 더이상 관리하지 않는다.

  • 웹 관련 스코프들
    • request : 웹 요청과 생명주기를 같이 하는 스코프이다.
    • session : 웹 세션과 생명주기를 같이 하는 스코프이다.
    • application : 웹 서블릿 컨텍스트와 생명주기를 같이 하는 스코프이다.

그렇다면 이제 MyLogger 클래스에 request 스코프를 지정하여 요청과 생명주기를 동일하게 맞추어 보자.

스코프는 다음과 같이 지정할 수 있다.

1
2
3
4
@Component
@Scope(value = "request")
public class MyLogger {
        // ...

스코프를 지정한 후 애플리케이션을 기동하면 예상과 다르게 에러메시지가 출력되면서 기동조차 되지 않는 것을 볼 수 있다. 이 때 출력되는 에러 메시지는 다음과 같다.

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 빈에 대한 의존성을 필요로 하는 HelloIntercepter 를 초기화 하면서 발생한다.

HelloIntercepter객체는 싱글톤 스코프이기 때문에 스프링 컨테이너가 생성되는 시점 (애플리케이션 기동 시점) 에 생성되고 의존성을 주입받아야 하는데 MyLogger 클래스는 request 스코프이기 때문에 요청이 올때까지 생성이 될 수 없다.

즉 두 협력객체의 생명주기의 불일치로 인해 발생하는 문제인데 이 때 필요한 것이 바로 앞에서도 다루었던 프록시 객체이다.

MyLogger객체를 상속받은 가짜 객체를 생성하는 프록시 객체를 HelloIntercepter 객체에 주입하면 된다.

그리고 해당 과정은 다음과 같이 옵션 하나로 간편하게 지정 가능하다.

1
2
3
4
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
        //...

그리고 추가적으로 HelloIntercepter클래스의 생성자에도 MyLogger 의 클래스를 출력 해보자.

1
2
3
4
public HelloInterceptor(MyLogger myLogger) {
        this.myLogger = myLogger;
        System.out.println(myLogger.getClass());
}
HelloIntercepter 의 의존성 주입 로그

결과를 보면 다른 프록시 객체들과 마찬가지로 프록시 객체 생성 라이브러리 CGLIB 를 통해 생성된 것을 확인할 수 있다.

이를 통해 우리가 요구사항대로 만든 신규 로깅 기능이 잘 동작하게 되었다.

3. 빈 생명주기 콜백

앞서 만든 로깅 기능은 현재 만족스럽게 작동하고 있는 듯 보인다. 하지만 아직 마음에 들지 않는 부분이 있다.

바로 개별 요청에 대한 UUID 를 생성하는 처리를 별도로 해주어야 한다는 점이다.

로거 클래스가 모든 Request 에 대해 생성되고 사라진다면, 클래스가 생성되는 시점에 UUID 를 주입할 수 있지 않을까?

그리고 객체가 컨테이너에서 제거되는 시점에 특정 동작(예: 파일 기록 등)을 추가하고 싶다면 어떻게 해야 할까?

빈이 생성되고 제거될 때 @PostConstruct@PreDestroy 애노테이션을 사용할 수 있다.

해당 애노테이션을 적용하여 Logger 클래스를 개선하면 다음과 같다.

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
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {

    private String uuid;
    private String log;
    private HttpServletRequest request;

    public void setRequest(HttpServletRequest request) {
        this.request = request;
    }

    public void log() {
        log = String.format("[%s] %s\\n", uuid, request.getRequestURL().toString());
        System.out.print(log);
    }

    @PostConstruct
    private void init() {
        uuid = UUID.randomUUID().toString();
    }

    @PreDestroy
    private void close() {
        try {
            BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("log.txt", true));
            bos.write(log.getBytes());
            bos.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    }

위의 예제에서 빈 생성시점 (@PostConstruct 사용 시점) 에 일어나는 로직은 사실 생성자에서 대체 되어도 무관하다.

하지만 스프링이 주입해야 하는 의존성이 필요하다면 생성자에서 처리할 수가 없다.

스프링은 다음과 같은 순서로 빈의 이벤트를 처리한다

  1. 스프링 컨테이너 생성
  2. 스프링 빈 생성 : 생성자 동작
  3. 의존관계 주입
  4. 초기화 콜백 : PostConstruct 동작
  5. 사용 : 위 예제에선 request 발생 시점
  6. 소멸전 콜백 : PreDestory 동작
  7. 종료

그리고 주입 된 의존성을 활용하여 외부 커넥션 연결과 같은 무거운 동작을 처리 하는 것은 생성자에서 처리하기 보다 4번의 초기화 시점에 실행하는 것이 유지보수 측면에서 유리하다.

초기화와 소멸 콜백을 이용하기 위해서는 위 애노테이션 외에도 다음 방법도 활용 가능하다.

  1. InitializingBean , DisposableBean 인터페이스 상속 (스프링 전용)
  2. 빈 등록 초기화 소멸 메서드 지정 @Bean(initMethod = "init", destroyMethod = "close") 외부 라이브러리 빈 사용시 활용

4. 정리

앞서 스프링 컨테이너가 지원하는 특수한 생명주기에 대해 알아보았다.

하지만 대부분의 객체들은 스프링 컨테이너와 동일한 생명주기를 가지는게 유지보수 측면에서 좋다.

명확하게 생명주기가 정해져있고, 자주 호출되지 않을 만한 로직에만 사용하는 것이 좋을 듯 하다.