WEB/Spring

김영한 (스프링 핵심원리 9) 프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점

Tony Lim 2021. 2. 15. 20:10
728x90

빈 스코프란?

스프링 빈은 기본적으로 singleton scope로 생성 되기 때문이다. scope는 번역 그대로 빈이 존재할 수 있는 범위를 뜻한다. 

싱글톤 = 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.

프로토타입 = 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지않는 매우 짧은 범위의 스코프이다.

request = 웹요청이 들어오고 나갈때까지 유지되는 스코프 이다.

session = 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프이다.

application = 웹의 서블릿 컨텍스 와 같은 범위로 유지되는 스코프이다. 

 

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.

반면에 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다. 즉 생성, 의존관계 주입, 초기화까지만 처리한다는것이다. @PreDestory 같은 종료 메서드가 호출되지 않는다.

package hello.core.scope;

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

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

public class SingletonTest
{
    @Test
    void singletonBeanFind()
    {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);

        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
        System.out.println("singletonBean1 = " + singletonBean1);
        System.out.println("singletonBean2 = " + singletonBean2);
        Assertions.assertThat(singletonBean1).isSameAs(singletonBean2);
        ac.close();
    }

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

        @PreDestroy
        public void destory()
        {
            System.out.println("SingletonBean.destory");
        }
    }
}
SingletonBean.init
singletonBean1 = hello.core.scope.SingletonTest$SingletonBean@f107c50
singletonBean2 = hello.core.scope.SingletonTest$SingletonBean@f107c50
00:07:42.040 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@15aab8c6, started on Mon Feb 15 00:07:41 KST 2021
SingletonBean.destory

한번의 init호출이 있고 마지막에 destory 도 호출 된다. 객체 또한 같다. 즉 싱글톤을 의미한다. 그와 반대로 프로토타입은 스코프는

package hello.core.scope;

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

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

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);

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


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

        @PreDestroy
        public void destory()
        {
            System.out.println("SingletonBean.destory");
        }
    }
}
find prototypeBean1
SingletonBean.init
find prototypeBean2
SingletonBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@f107c50
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@51133c06

스프링 빈을 요청할떄마다 새로운 빈을 생성해서 준다. 또한 생성및 초기화한후 스프링 컨테이너에서 관리를 안하기 때문에 destory 메서드가 호출 되지 않는다. 또한 두개의 빈이 서로 다른 객체임을 알 수 있다.

 

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

package hello.core.scope;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

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

public class SingletonWithPrototypeTest1
{
    @Test
    void prototypeFind()
    {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        prototypeBean1.addCount();
        assertThat(prototypeBean1.getCount()).isEqualTo(1);

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

    }

    @Test
    void singletonClientUserPrototype()
    {
        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();
            return prototypeBean.getCount();
        }
    }

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

로직을 호출할떄마다 새로운 prototype bean이 생성이 되지않는다. 왜냐하면 ClientBean.class 로 등록된 시점에 이미 주입이 다 끝났기 때문이다. 그래서 test가 통과하게 된다. 

 

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provider로 문제 해결

싱글톤 빈과 프로토타입 빈을 함께 사용할 때, 어떻게 하면 사용할 때마다 항상 새로운 프로토타입 빈을 생성할 수 있을까?

가장 간단한 방법은 싱글톤 빈이 프로토타입을 사용할 때 마다 스프링 컨테이너에 새로 요청하는 것이다. 

    @Test
    void singletonClientUserPrototype()
    {
        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();
            return prototypeBean.getCount();
        }
    }

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

ObjectProvider 를 이용해서 내부의 getObject를 호출하면 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다. 핵심은 Dependency Lookup 을 스프링 컨테이너를 이용해서 비교적 간단하게 해주는 대리자이다. 

또한 기능이 단순함으로 단위테스트를 만들거나 mock 코드를 만들기는 훨씬 쉬워진다. 하지만 스프링 의존적이다.

 

JSR-330 Provider

    @Scope("singleton")
    static class ClientBean
    {

        @Autowired
        private Provider<PrototypeBean> prototypeBeanProvider;


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

위와 똑같이 작동하지만 별도의 라이브러리가 필요하다. 자바 표준이므로 스프링이 아닌 다른 컨테이너에서도 사용할 수 있다.

순환참조가 일어나고있을때 Provider를 사용하면된다.

그럼 둘중 무엇을 사용해야하나? 앵간하면 ObjectProvider를 사용 하고 스프링이 아닌 다른 컨테이너에서도 사용해야하면 JSR-330 Provider를 사용해야한다. 

 

웹 스코프

request = HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프 , 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 ,관리된다.

session = HTTP Session 과 동일한 생명주기를 가지는 스코프

application = 서블릿 컨텍스트('ServletContext') 와 동일한 생명주기를 가지는 스코프

websocket = 웹 소캣 동일한 생명주기를 가지는 스코프 

request scope이기에 클라이언트 가 request를 보낼떄마다 클라이언트 전용 빈을 생성을 해준다.

 

request 스코프 예제 만들기

package hello.core.common;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.UUID;

@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()
    {
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope been create: " + this);

    }

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

위에서 request 마다 생성되는 빈을 구현한 것이다.

package hello.core.web;

import hello.core.common.MyLogger;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;

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

Request 가 들어올때 받아주는 Controller 단이다. HttpServletRequest 를 통해서 요청 URL 을받아 set 해준다.

myLogger 는 HTTP 요청 당 각각 구분되므로 다른 HTTP 요청 때문에 값이 섞이는 걱정을 하지 않아도된다. scope가 request이기에.

myLogger 원래 는 스프링 인터셉터나 서블릿 필터 같은곳 을 활용하는 것이 좋다.

package hello.core.web;

import hello.core.common.MyLogger;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService
{
    private final MyLogger myLogger;

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

비즈니스 로직이 있는 서비스계층에서도 로그를 출력해본다.

reqeust scope를 상요하지 않고 파라미터로 이 모든 정보를 서비스 계층에 넘긴다면, 파라미터가 많아서 지저분해진다.

더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 된다. 웹과관련된 부분은 컨트롤러 까지만 사용해야한다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는것이 유지보수 관점에서 좋다.

request scope 의 MyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, MyLogger의 멤버변수에 저장해서 코드와 계층을 깔쌈하게 유지할 수 있다.

앱을 실행시키면 오류가 난다. 왜냐하면 Controller 단에서 MyLogger myLogger 를 @RequiredArgsConstrutor 를 통한 생성자 주입을 받을려하지만 스프링 컨테이너에 그러한 반이 존재하지 않기 때문이다. 

존재하지않는 이유는 scope 가 request 인 빈은 생명주기가 request요청 후에 빈이 생성 되고 끝나면 빈이 죽기 때문에 아직 생성도 안되어서 스프링이 에러를 띄우는 것이다.

 

스코프와 Provider

@Controller
@RequiredArgsConstructor
public class LogDemoController
{
    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerObjectProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request)
    {
        String requestURL = request.getRequestURL().toString();
        MyLogger myLogger = myLoggerObjectProvider.getObject();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService
{
    private final ObjectProvider<MyLogger> myLoggerObjectProvider;

    public void logic(String id)
    {
        MyLogger myLogger = myLoggerObjectProvider.getObject();
        myLogger.log("service id = " + id);
    }
}
[be4890e9-a5f6-4e0b-826c-009b6eb9faa2] request scope been create: hello.core.common.MyLogger@50ddf80a
[be4890e9-a5f6-4e0b-826c-009b6eb9faa2][http://localhost:8083/log-demo] controller test
[be4890e9-a5f6-4e0b-826c-009b6eb9faa2][http://localhost:8083/log-demo] service id = testId
[be4890e9-a5f6-4e0b-826c-009b6eb9faa2] request scope been close: hello.core.common.MyLogger@50ddf80a

ObjectProvider 덕분에 ObjectProvider.getObject() 를 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있다. 정확한 표현은 스프링컨테이너에게 요청을 하는 시점을 지연할 수 있는 것이다. 

이떄 init() 이 호출이 된다.  

앞의 UUID가 같다는것이 하나의 request를 의미하는 것이다. ObjectProvider.getObject 를 LogDemoController, LogeDemoService 에서 각각 한번씩 따로호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환된다. 

하지만 좀 더 짧게 쓰고싶다...

 

스코프와 프록시

@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 been create: " + this);

    }

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

적용 대상이 인터페이스가 아닌 클래스라면 TARGET_CASS를 선택하고 인터페이스라면 INTERFACE를 선택하자.

이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request 와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.

myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$65eb95e8
[f18f3024-2d1c-4c7f-ada5-4e3f99fdef65] request scope been create: hello.core.common.MyLogger@34d9cc85
[f18f3024-2d1c-4c7f-ada5-4e3f99fdef65][http://localhost:8083/log-demo] controller test
[f18f3024-2d1c-4c7f-ada5-4e3f99fdef65][http://localhost:8083/log-demo] service id = testId
[f18f3024-2d1c-4c7f-ada5-4e3f99fdef65] request scope been close: hello.core.common.MyLogger@34d9cc85

우리가 만든 MyLogger와 다르다 또 바이트코드를 조작한 프록시 클래스가 나타난다. 실제로 myLogger.method() 를 호출하는 시점에서 Provider처럼 동작을 하는 것이다.

Proxy myLogger는 진짜 myLogger를 찾는 방법을 알고있다. 

클라이언트가 myLogger.logic() 을 호출하면 사실은 가짜 프록시 객체의 메서드를 호출한 것이다.

가짜 프록시 객체는 request 스코프의 진짜 myLogger.logic() 을 호출한다.

가짜 프록시 객체는 원본클래스를 상속받아서 만들어졌기 때문에 이 객체를 사용하는 클라이언트 입장에서는 사실 원본인지 아닌지도 모르게, 동일하게 사용할 수 있다.(다형성)

 

동작 정리

CGLIB 라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입한다.

이 가짜 프록시 객체는 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직이 들어있다.

가짜 프록시 객체는 실제 request scope 와는 관계가 없다. 그냥 가짜이고, 내부에 단순한 위임 로직만 있고, 싱글톤 처럼 동작한다.

 

특징정리

프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request scope를 사용할 수 있다.

사실 Provider를 사용하든, 프록시를 사용하든 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다.

단지 애노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있다. 이것이 바로 다형성과 DI 컨테이너가 가진 큰 강점이다. 

꼭 웹 스코프 가 아니어도 프록시는 사용할 수 있다.

 

728x90