스프링 빈 스코프에 대해서

7340 단어 SpringSpring

빈 스코프란

빈이 관리되는 범위로 별다른 설정을 하지 않으면 스프링에서는 기본적으로 싱글톤 스코프로 생성된다. 스프링에서는 특정 타입의 빈을 하나만 생성하고 시작과 종료까지 1개의 객체를 유지하고 공유하면서 사용하는데 빈에 상태를 저장하는 코드를 작성하면 동시성 문제가 생길 수 있다. 하지만, 필요에 따라 비싱글톤이 필요할 때도 있어서 스코프라는 개념을 사용해서 명시적으로 구분한다.

  • 싱글톤
    빈을 하나만 생성하고 시작과 종료까지 1개의 객체를 유지하기 때문에 스프링 컨테이너에서는 항상 같은 인스턴스의 스프링 빈을 반환한다.

  • 프로토타입
    스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다. 프로토타입 빈의 생성과 의존관계를 주입까지만 관여하고 그 이후는 스프링 컨테이너가 관여하지 않는다. 스프링 컨테이너가 종료될 때 @PreDestroy 같은 종료 메서드가 실행되지 않는다.
    즉, 스프링 컨테이너는 프로토타입 빈을 생성하고 의존관계 주입, 초기화까지만 처리한다. 프로토타입을 받은 클라이언트가 객체를 관리해야 한다.


  • 웹 환경에서만 동작하고, 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다. 그렇기 때문에 종료 메서드가 호출된다.
    -request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프이다. 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.
    -session: HTTP session과 동일한 생명주기를 가지는 스코프이다.
    -application: 서블릿 컨텍스트와 동일한 생명주기를 가지는 스코프이다.
    -websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프이다.

프로토타입 스코프를 싱글톤 빈과 함께 사용했을 때의 문제점

스프링 컨테이너는 싱글톤이므로 항상 같은 빈이 반환되는데 그 후 의존관계 자동 주입 시점에 스프링 컨테이너에 프로토타입 빈을 요청하고 생성해서 클라이언트에게 프로토타입 빈을 반환한다. 다른 클라이언트가 스프링 컨테이너에게 빈을 요청하면 싱글톤이므로 같은 빈이 반환되고 빈 내부에 있는 프로토타입 빈은 이미 과거에 주입이 끝난 빈이고 주입 시점에 새로 생성된 것이지 사용할 때마다 새로 생기는 것이 아니다. 즉, 싱글톤 빈은 생성 시점에만 의존관계를 주입 받기 때문에 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지되는 것이 문제이다.

class SingletonWithPrototypeTest1 {

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

        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        prototypeBean1.getCount();
        assertThat(prototypeBean1.getCount()).isEqualTo(1);

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

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

    @Scope("singleton")
    static class ClientBean {
        private final PrototypeBean prototypeBean;  // 생성 시점에 주입

        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();   // 생성 시점에 주입된 prototypeBean을 사용, clientBean2 호출 시에도 같은 prototypeBean을 사용
            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");
        }
    }
}

Provider를 통한 문제 해결

싱글톤 빈과 프로토타입 빈을 함께 사용할 때마다 항상 새로운 프로토타입 빈을 생성하기 위해서는 싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하는 것이다.
의존관계를 외부에서 주입(DI) 받는 것이 아니라 직접 필요한 의존관계를 찾는 것을 DL(Dependency Lookup) 의존관계라고 한다. 하지만, 스프링 애플리케이션 컨텍스트 전체를 주입받게 되면 스프링 컨테이너에 종속적이고 단위테스트가 어려워지므로 지정한 프로토타입 빈을 컨테이너에서 찾아주는 것이 필요하다.

스프링에서 제공하는 기능인 ObjectFactory, ObjectProvider를 사용하면 되는데 과거에 만들어진 것이 ObjectFactory이고, ObjectFactory 상속하고, 옵셔널, 스트림 처리 등 편의 기능이 추가된 것이 ObjectProvider 이다. 별도의 라이브러리가 필요 없지만 스프링에 의존적이다.

스프링에 의존적이지 않는 방법은 javax.inject.Provider라는 JSR-330 자바 표준을 사용하는 것이다. javax.inject:javax.inject:1 라이브러리를 gradle에 추가해야한다.
위 2가지 방법 중에서 편한 것을 선택해서 사용하면 된다.

    @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 Provider<PrototypeBean> prototypeBeanProvider;  // JSR-330 Provider 사용
        // private ObjectProvider<PrototypeBean> prototypeBeanProvider; // ObjectProvider 사용

        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.get();    // 항상 새로운 프로토타입 빈이 생성, 내부에서는 스프링 컨테이를 통해 해당 빈을 찾아서 반환한다.
            // PrototypeBean prototypeBean = prototypeBeanProvider.getObject(); // ObjectProvider 사용
            prototypeBean.addCount();   // 생성 시점에 주입된 prototypeBean을 사용, clientBean2 호출 시에도 같은 prototypeBean을 사용
            int count = prototypeBean.getCount();
            return count;
        }
    }

스코프에 프록시 추가하기

@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)를 추가하면 (적용 대상이 인터페이스면 INTERFACES로..) 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입할 수 있다.
스프링 컨테이너는 CGLIB라는 바이트 코드를 조작하는 라이브러리를 사용해서 MyLogger를 상속받은 가짜 프록시 객체를 생성한다. 실제 요청이 오면 그 때 내부에서 실제 빈을 요청하는 위임 로직이 들어있다.

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

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final MyLogger myLogger;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        String requestURL = request.getRequestURL().toString();

        System.out.println("myLogger = " + myLogger.getClass());
        myLogger.setRequestURL(requestURL);

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

        return "OK";
    }
}

스프링 컨테이너에 myLogger 라는 이름으로 진짜 대신에 이 가짜 프록시 객체를 등록한다. getBean("myLogger", MyLogger.class)로 조회해도 프록시 객체가 조회된다. 따라서 의존관계 주입도 가짜 프록시 객체가 주입된다.

*참고 자료
스프링 핵심 원리 - 기본편 (김영한님)

좋은 웹페이지 즐겨찾기