Spring, Apache, Java

Bean Scope

cwchoiit 2024. 5. 27. 16:03
728x90
반응형
SMALL

빈 스코프란?

"스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때 까지 유지된다"고 배웠다. 이것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문이다. 스코프는 말 그대로 빈이 존재할 수 있는 범위를 뜻한다.

 

근데 스프링은 싱글톤 스코프 외에 더 많은 범위를 제공한다.

  • 싱글톤: 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프이다.
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈때 까지 유지되는 스코프
    • session: 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
    • application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프

 

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

 

컴포넌트 스캔 자동 등록 

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

 

수동 등록

@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
    return new HelloBean();
}

 

 

조금 더 자세히 프로토타입 스코프와 싱글톤 스코프의 차이를 알아보자.

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다. 반면에 프로토타입 스코프를 스프링 컨테이너에서 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.

 

싱글톤 빈 요청

 

 

프로토타입 빈 요청

1. 클라이언트는 프로토타입 스코프 빈을 스프링 컨테이너에 요청한다.

2. 스프링 컨테이너는 이 시점에 프로토타입 빈을 생성하고 의존관계 주입도 해준다.

3. 스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환한다.

4. 이후에 스프링 컨테이너는 더이상 그 빈을 관리하지 않는다. 같은 요청이 오더라도 항상 새로운 프로토타입 빈을 생성해서 반환한다.

 

그래서, 핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입을 하고, 초기화까지만 처리한다는 것이다. 클라이언트에게 반환한 후 스프링 컨테이너는 생성된 프로토타입 빈을 관리하지 않는다. 프로토타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에게 있다. 그래서 @PreDestroy 같은 소멸 메서드는 호출되지 않는다.

 

 

테스트 해보자!

 

PrototypeTest

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class PrototypeTest {

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

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

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

실행결과

find prototypeBean1
SingletonBean.init
find prototypeBean2
SingletonBean.init
prototypeBean1 = org.example.springcore.scope.PrototypeTest$PrototypeBean@700fb871
prototypeBean2 = org.example.springcore.scope.PrototypeTest$PrototypeBean@3b35a229

 

실행결과를 보면 초기화 메서드까지는 스프링 컨테이너가 해준다는 것을 알 수 있다. 초기화 뿐 아니라 의존관계 주입도 역시 해준다. 근데 넘겨받은 프로토타입 빈의 참조값을 보면 두개가 다르다. 즉, 요청이 올때마다 새로운 빈을 생성한다는 것을 확인할 수 있다. 그리고 더 중요한 건 @PreDestroy가 호출되지 않는다. 왜냐? 생성하고 돌려준 뒤 더는 관리하지 않으니까 빈 생명주기에 소멸 생명주기는 없는것이다. 

 

 

근데, 프로토타입 빈과 싱글톤 빈을 같이 사용할 땐 어떤 문제가 생긴다. 무슨 문제인지 알아보자.

 

프로토타입 빈과 싱글톤 빈을 같이 사용할 때 생기는 문제점

일단 프로토타입 빈의 존재 의미를 먼저 생각해보자. 프로토타입 빈은 클라이언트가 요청할 때 마다 새로 생성되는 것이 바로 프로토타입 빈의 생성의 의미다. 이 점을 잘 유의하고 다음 내용을 보자.

 

만약, 싱글톤 빈에 의존관계 주입이 필요한데 그 의존관계가 프로토타입인 경우엔 어떻게 동작할까?

우선 프로토타입 빈은 count라는 필드를 가지고, 그 값을 하나씩 증가시키는 addCount()가 있다.

그리고 싱글톤 빈은 프로토타입 빈을 주입받는다. 

 

그럼 최초의 클라이언트가 이 싱글톤 빈을 요청해서 스프링 컨테이너로부터 빈을 돌려받으면 그때 싱글톤 빈은 의존관계 주입이 모두 끝난 상태로 클라이언트에게 전달된다. 그리고 이 빈은 싱글톤 빈이라 스프링 컨테이너가 닫히는 순간까지 스프링 컨테이너에 의해 관리된다.

 

그래서 클라이언트는 이 싱글톤 빈이 가진 logic()을 호출하고 그 메서드는 프로토타입 빈의 addCount()를 호출한 후 적용된 값을 반환하는 간단한 메서드이다. 그럼 최초의 count 값은 0이니까 1로 증가된다.

 

그리고 나서, 또 다른 클라이언트가 이 싱글톤 빈을 스프링한테 요청했다. 여기가 중요하다! 그럼 싱글톤 빈은 스프링 컨테이너에 의해 관리되는 빈이기 때문에 이전에 만들어진 빈 그대로를 다시 반환한다.

 

그럼, 이 또 다른 클라이언트의 의도는 프로토타입 빈이라는 요청할때마다 따끈따끈한 새로운 빈을 받길 원하는데 이미 count값이 1인 상태인 빈을 받는것이다. 왜냐? 싱글톤 빈이 이미 기존에 의존관계가 다 주입이 된 상태고 그 상태 그대로로 스프링 컨테이너에 의해 관리되는 중이기 때문이다.

 

 

그래서 코드로 직접 이 상황을 만들어보자.

 

SingletonWithPrototypeTest1

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class SingletonWithPrototypeTest1 {

    @Test
    void prototypeFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
        prototypeBean.addCount();

        assertThat(prototypeBean.getCount()).isEqualTo(1);

        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean2.addCount();
        assertThat(prototypeBean.getCount()).isEqualTo(1);
    }

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

        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);

        int count1 = singletonBean1.logic();
        assertThat(count1).isEqualTo(1);

        int count2 = singletonBean2.logic();
        assertThat(count2).isEqualTo(2);
    }

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

    @Scope("singleton")
    static class SingletonBean {
        private final PrototypeBean prototypeBean;

        public SingletonBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }
}

 

1. 싱글톤 빈과 프로토타입 빈 두개가 있다.

2. 싱글톤 빈은 프로토타입 빈을 주입받는다.

3. 싱글톤 빈의 logic()은 프로토타입 빈의 addCount()를 호출하고 프로토타입 빈의 count 값을 반환한다.

4. singletonClientUsePrototype() 테스트를 실행하면 첫번째로 싱글톤 빈을 요청한 순간에 싱글톤 빈이 필요한 모든 의존관계가 주입이 된 상태로 스프링 컨테이너에 유지된다.

5. 두번째로 요청한 싱글톤 빈은 이미 첫번째에서 요청된 싱글톤 빈과 100% 같은 객체이다.

6. logic()을 실행하면 count 값은 공유된다.

 

이런 문제가 발생한다. 프로토타입 빈의 존재 의미가 없어져버렸다. 이 문제를 해결할 수 있는 방법은 뭐가 있을까?

Provider로 프로토타입 빈을 꺼내오기

스프링 컨테이너에 등록된 빈이 필요할 때 가져오는 방법 중 `Privider`를 이용하는 방법이 있다.

이 `Provider`를 이용하는 건 의존관계 주입(DI)이 아니라 필요한 의존관계를 직접 찾는 DL(Dependency Lookup)방식이다.

그래서 다음과 같이 간단하게 사용할 수 있다.

 

SingletonWithPrototypeTest1

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class SingletonWithPrototypeTest1 {

    ...

    @Scope("singleton")
    static class SingletonBean {

        private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

        public SingletonBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
            this.prototypeBeanProvider = prototypeBeanProvider;
        }

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

스프링에서 제공하는 ObjectProvider를 사용하고 내가 사용하고자 하는 의존관계를 직접 제네릭 타입에 넣어주면 된다.

그리고 생성자 주입을 통해 이 ObjectProvider를 주입받으면 필요한 곳에서 getObject()를 호출해서 꺼내올 수 있다.

 

getObject()를 호출하면 스프링 컨테이너에 빈을 달라고 요청하는 작업이랑 같다고 생각하면 된다. 그럼 스프링 컨테이너는 프로토타입 빈이기 때문에 그때 그때 생성을 한다.

 

참고로 이 스프링이 제공하는 ObjectProvider는 프로토타입 빈 뿐 아니라 그냥 DL이 필요한 경우엔 언제든지 사용할 수 있다.

 

그럼 이런일이 빈번하게 일어날까?

애시당초에 프로토타입 빈을 사용할 일 자체가 매우 드물다. 그리고 만약 싱글톤 빈 안에 프로토타입 빈을 사용할 일이 있다면 그냥 위 방법처럼 ObjectProvider를 사용해서 프로토타입 빈 그 자체의 의미를 살려주면 된다.

 

 

웹 스코프

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

 

웹 스코프의 종류

  • request: HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
  • session: HTTP Session과 동일한 생명주기를 가지는 스코프
  • application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
  • websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프

 

HTTP Request 요청 당 각각 할당되는 request 스코프

 

웹 환경 추가

웹 스코프를 테스트해보기 위해 웹 환경을 추가해보자.

 

build.gradle

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

 

request 스코프 예제

동시에 여러 HTTP 요청이 오면 정확히 어떤 요청이 남긴 로그인지 구분하기 어려울 때 사용하기 딱 좋은게 바로 request 스코프이다.

다음과 같이 로그가 남도록 request 스코프를 활용해서 추가 기능을 개발해보자.

[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
  • 기대하는 공통 포맷: [UUID][URL]message
  • UUID를 사용해서 HTTP 요청을 구분하자.

 

MyLogger

package org.example.springcore.common;

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

import java.util.UUID;

@Component
@Scope(value = "request")
public class MyLogger {
    private String uuid;
    @Setter
    private String 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("[" + uuid + "] request scope bean close: " + this);
    }
}
  • 로그를 출력하기 위한 MyLogger 클래스
  • @Scope(value = "request")를 사용해서 request 스코프로 지정했다. 이제 이 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.
  • 이 빈이 생성되는 시점에 자동으로 @PostConstruct 초기화 메서드를 사용해서 UUID를 생성해서 저장해둔다. 이 빈은 HTTP 요청당 하나씩 생성되므로, UUID를 저장해두면 다른 HTTP 요청과 구분할 수 있다.
  • 이 빈이 소멸되는 시점에 @PreDestroy를 사용해서 종료 메시지를 남긴다.
  • requestURL은 이 빈이 생성되는 시점에는 알 수 없으므로 외부에서 setter로 입력 받는다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@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";
    }
}
  • 테스트를 위한 컨트롤러
  • HttpServletRequest를 통해서 요청 URL을 받는다.
  • requestURLmyLogger에 저장해둔다. myLogger는 HTTP 요청 당 각각 구분되므로 다른 HTTP 요청 때문에 값이 섞이는 걱정은 하지 않아도 된다.
참고로, requestURL을 MyLogger에 저장하는 부분은 컨트롤러 보다는 공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋다.

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service Id: " + id);
    }
}
  • 비즈니스 로직이 있는 서비스 계층에서도 로그를 출력한다.
  • 중요한 점은 request scope를 사용하지 않고 파라미터로 이 모든 정보를 서비스 계층에 넘긴다면, 파라미터가 많아서 지저분해진다. 더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 된다. 웹과 관련된 부분은 컨트롤러까지만 사용해야 한다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋다.
  • request scopeMyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, 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;

어떤 에러냐면, 스프링 애플리케이션을 실행하는 시점에 싱글톤 빈은 생성해서 주입이 가능하지만, request 스코프 빈은 아직 생성되지 않는다. 이 빈은 실제 고객의 요청이 들어와야 생성되는 빈이다. 그러다보니 주입할 수 없는 빈을 싱글톤 빈이 가지고 있다고 에러를 내는 것이다.  

 

해결방법 1 - ObjectProvider

첫번째 간단한 해결방법은 DL이다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        MyLogger myLogger = myLoggerProvider.getObject();

        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");

        return "OK";
    }
}

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final ObjectProvider<MyLogger> myLoggerProvider;

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

 

  • ObjectProvider 덕분에 ObjectProvider.getObject()를 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있다.
  • ObjectProvider.getObject()를 호출하는 시점에는 HTTP 요청이 진행중이므로 request scope 빈의 생성이 정상 처리된다.
  • ObjectProvider.getObject()LogDemoController, LogDemoService에서 각각 한번씩 따로 호출해도, 같은 HTTP 요청이기 때문에 동일한 스프링 빈이 반환된다.

그래서 실행 결과를 확인해보면 정상적으로 출력된다.

[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] request scope bean create: org.example.springcore.common.MyLogger@4cd4281b
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] [http://localhost:8080/log-demo] controller test
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] [http://localhost:8080/log-demo] service Id: testId
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] request scope bean close: org.example.springcore.common.MyLogger@4cd4281b

 

근데 Provider가 아니라 코드를 더 줄일 수 있는 해결 방법이 있다.

 

해결방법 1 - Proxy

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
  • @Scope에서 proxyModeTARGET_CLASS로 지정한다. 
  • 이렇게 하면 MyLogger의 프록시 클래스를 만들어서 HTTP Request 요청이 들어올 때 생성할 수 있는게 아니라 미리 생성해둔 프록시를 주입시켜둘 수 있다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

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

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final MyLogger myLogger;

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

 

실행결과

[93f232c4-f367-4cd9-a3f0-191cc1ef6290] request scope bean create: org.example.springcore.common.MyLogger@b04b639
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] [http://localhost:8080/log-demo] controller test
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] [http://localhost:8080/log-demo] service Id: testId
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] request scope bean close: org.example.springcore.common.MyLogger@b04b639

 

이제 Provider 없이도 잘 동작한다. CGLIB는 이미 다뤘던 내용이니까 어떤 원리로 돌아가는지 이해할 수 있다.

중요한 건, AOP에 대한 이해다. 클라이언트 코드에 부가적인 관점(기능)을 추가하고 싶을 때 클라이언트 코드에 전혀 손을 대지 않아도 가능하다는 것.

728x90
반응형
LIST