백앤드(스프링)

빈 스코프 2편

유승혁 2022. 1. 28. 23:15

 빈 스코프 1편에 이어서 빈 스코프 2편으로 돌아왔습니다!!!!

 

1. 싱글톤 빈이 프로토타입 빈을 의존한다면..?

 만약에 clientBean이라는 싱글톤인 빈이 있다면, 컨테이너의 생성과 동시에 clientBean을 만들고 이것이 의존하는 프로토타입 빈이 있다면 의존관계 자동주입을 하게 되면서 컨테이너에 이 프로토타입 빈을 요청한다. 그러면 컨테이너는 이를 반환하고 clientBean은 이 프로토타입 빈을 보관한다. 포함한다고 생각하면 된다. 아래 그림처럼.

이제 이렇게 되면 싱글톤이 프로토타입 빈을 품고 있기에 이 프로토타입 빈은 싱글톤과 함께 컨테이너 종료 시까지 계속 살아있는 것이다. 

 즉, 첫번째 손님이 와서 count++를 하여 1을 늘리고 튀어 버리면 두번째 손님이 count++를 또 하게 되면 저 값은 2가 되어 버린다. 

 괜찮은거 아냐? 당연히 아니지! 그럴거면 뭐하러 프로토타입을 쓰니 그냥 싱글톤으로 만들어 버리지.

 

 

위 같은 상황이 나오게 되는 이유를 조금 더 뒷받침 해주자면 clientBean 내부의 프로토타입 빈은 이미 주입이 끝났고, 주입 시점에 요청해서 컨테이너가 프로토타입 빈이 생성 된 것이지, 두 손님이 count값을 변경하는 것은 컨테이너에게 프로토타입 빈을 생성하는 요청으로 여겨지지 않는 다는 것이다.

 아마 본래의 의도는 clientBean에서 count 값에 접근 할 때마다 새로운 프로토타입 빈을 받아오는 것을 원하는 것일 것이다. 그럼 이제 그렇게 하는 방법을 알려주도록 하지!

 참고로, 여러 빈에서 같은 프로토 타입 빈을 주입 받으면 각 프로토타입은 컨테이너로부터 새로 생겨나 결국 다른 빈 값들을 주입할 것이다.

 

2. 해결 방법

  물론 clientBean에서 count값 접근 하는 함수를 호출 할 때마다 getBean(prototypeBean.class) 해서 컨테이너에게 새로 만들어줘! 라고 때를 쓸 수 는 있지만, 이렇게 되면 스프링 컨테이너에게 너무 많이 의존하게 되고 코드들을 유연하지 않게 된다.

 그래서 DL이라는 의존관계 조회(탐색)이라는 개념이 있다. DI는 의존관계를 외부에서 주입을 하였지만, 직접 필요한 의존관계를 찾는 방식을 말하는 것이 DL이다. 물론 이런 개념을 도와주는 도구는 이미 스프링에게 있다.

 

2.1 ObjectProvider

 지정한 빈을 컨테이너에서 대신 찾아주는 띠엘 싸비~쓰가 바로 ObjectProvider 이다. 과거에는 ObjectFactory라고 있었는데 여기에 편의 기능들이 붙어 ObjectProvider만 사용한다. 

@Scope("singleton")
static class ClientBean{
   @Autowired
   private ObjectProvider<PrototypeBean> prototypeBeanProvider;

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

 위 코드에서 logic()함수가 바로 아까 그 ClientBean 이 품을 프로토타입의 필드에 있는 count 값을 1 증가시키는 함수를 부르는 함수이다. addCont()가 그 역할을 하고. (오타 났었넹 ㅎㅎ) getCount()로 그 값을 반환한다. 또한 여기서는 필드 DL을 하였는데 생성자나 setter를 통해서도 가능하다.

 아무튼 중요한 것은 PrototypeBean prototypeBean = prototypeBeanProvider.getObject(); 이 부분인데 getObject()를 하게 되면 <>안에 지정되어 있던 PrototypeBean이라는 빈을 컨테이너를 통해 해당 빈을 찾는다. 물론 이 찾을 때 생성이 되겠지.

 이후 사용이 완료되면 DL에 맞게 의존관계가 성립되는게 아니라 사용 후에 밖으로 드러내진다. 아까 그림에서 프로토타입 빈이 쓰윽 하고 컨테이너 위로 들린다고 생각하면 된다. 당연히 프로토타입 빈은 컨테이너가 관리하지 않기에 자연스럽게 사라진다.. 총총총...

 참고로 여기서 놓치면 안되는 것이 DL은 프로토타입 전용이 아니라는 것이다. 만약 싱글톤을 DL을 하게 되면 쓰이고 쓱 들어내져서 스프링 컨테이너 어딘가에 존재해 있는 것이다!

 

2.2 JSR-330 Provider

 Provider 방식은 앞에 JSR-330이 붙어있듯이 자바 표준이라는 뜻이다. 저번 글에서 콜백 함수들 내용에 보면 @Bean 어노테이션에 설정정보를 지정하는 방식은 스프링 프레임워크를 사용하지 않는 자바코드에서는 유효하지 않으니 @PostConstruct - @PreDestroy 어노테이션을 사용 한다고 했다. 여기서도 마찬가지이다.

2.1의 ObjectProvider는 정말 많은 기능들이 있지만, 결국 스프링에 의존한다. Provider는 자바 표준이므로 gradle에 javax.inject::javax.inject:1 이라는 라이브러리만 추가해서 쓰면 된다.

 코드는 아래와 같다.

@Scope("singleton")
static class ClientBean{
    @Autowired
    private Provider<PrototypeBean> prototypeBeanProvider;
    public int logic(){
        PrototypeBean prototypeBean = prototypeBeanProvider.get();
        prototypeBean.addCont();
        return prototypeBean.getCount();
    }
}

  위 아래 코드가 거의 비슷해서 틀린 그림 찾기 수준이다 ㅋㅋㅋ. 정답을 공개하자면 첫번째 줄의 ObjectProvider에서 Object가 빠졌고 DL을 만들어내는 prototypeBeanProvider.getObject()에서 Object가 빠졌다.

 Porvider의 경우 DL 기능만 해낸다. 즉, get() 함수 말고는 아아아무우우 것도 없다. 하지만 자바에 의존적이라는 것이 큰 장점.

 

2.3 프로토타입 스코프 정리

 음,,, 근데 언제 써? 사실 저번 글에서 클라이언트가 count값에 접근 하는 것 같은 경우를 최대한 안 만드는게 좋은 개발의 덕목이라고 했다. 하지만 가끔 접근을 하게 만드는 것이 필요 할 때가 있다면 멋지게 등장해 주면 고마울 것 같다. 그리고 한가지 눈에 띄는 것이 A가 B를 의존하고 B가 A를 의존하게 되면 두 A,B 모두 영영 생성하지 못한다. 의존관계가 성립하지 않으니, 당연하다. 이런 상황에서는 DL을 이용해서 A에서 B를 사용할 때는 DL을 이용해서 필요할 때만 부르게 하는 것도 방법일 거 같다.

 

3. 웹 스코프

 저번에 소개만 다루었던 웹 스코프에 대해 이야기 해보겠다!

 웹 스코프의 생존 능력은 싱글톤과 프로토타입의 그 중간인 것 같다.  웹 스코프는 웹 환경에서만 동작한다. 그리고 프로토타입과는 다르게 컨테이너가 해당 빈의 종료 시점까지 관리 해준다. 하지만 싱글톤처럼 컨테이너의 생성과 동시에 빈으로 등록 되는 것은 아니다. 대신 프로토 타입 처럼 조회 같은 요청이 들어 오면 그제서야 만들어 진다. 그래서 웹 스코프는 두 스코프의 중간의 느낌이 강하다.

 웹 스코프의 종류는 request/session/application/websocket 이 있는데 각각은 생존 능력을 뜻한다. 예를 들어 request일 경우 하나의 HTTP 요청이 들어오고 나갈 때 까지 해당 빈은 살아있다. 또한 각 HTTP 요청마다 별개의 빈 인스턴스들이 생성된다. 아래 그림처럼!

HTTP요청 당 빈이 생성

그 외의 웹 스코프는 범위만 다를 뿐 request와 같다고 생각하면 된다~~

 

3.1 코드로서 보여주기

 MyLogger라는 클래스가 있다. 여기에는 uuid라고 unique-userId라고 생각하면 된다. 각 HTTP request마다 글로벌하게 고유의 uid를 설정 하는 것이다. 즉, 각 request의 고유 식별 번호라고 생각하자! 또한 requestURL이라고 있는데 이는 어떤 웹 사이트에서 HTTP request 요청이 들어 왔는지 그 시작 주소를 찍어주는 것이다. 암튼 아래 코드로 보여주겠다.

@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 bean create: "+this);
    }
    @PreDestroy
    public void close(){
        System.out.println("["+uuid+"] request scope bean close: "+this);
    }
}

 requestURL은 당연히 빈 생성 시점에는 모르니 setter로 받기로 하고 uuid의 경우 init()을 보면 PostConstruct이니 초기화 콜백이라서 빈 생성이 완료 됨과 동시에 자신 고유의 id 값을 할당 받게 된다. 참고로 UUID.randimUUID().toString()은 자바에 종속된 클래스이다.

 아이고 내 정신 좀 봐. 가장 중요한 것은 클래스 위에 Scope(value = "request")라고 함으로 써 스프링에게 이 녀석은 request 스코프야~ 라고 말을 하는 것이다. 물론 <value => 생략 가능

 그래서 이제 이 myLogger를 의존하여 가지고 노는 Controller와 Service 클래스를 소개할 예정이다. Controller의 경우 첫 스프링 글에서 다루었듯이 웹 mvc에 해당한다. 즉, 웹을 보여주는 기능을 담당하는 곳이다. Service는 비즈니스 로직을 담당하는 부분. Service 클래스는 말로만 설명해도 충분 할 거 같아서 Controller 클래스만 보여주겠다. 쨔잔~

@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");
        /*try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }*/
        logDemoService.logic("testId");
        return "OK";
    }
}

 Contoller는 @Controller 어노테이션을 붙이면 되고 그 아래에 보이는 @RequierdArgsConstructor 어노테이션은 룸복이다. 필요하면 찾아보시오!

 @RequestMapping("log-demo")는 만약 viewer 주소가 되는데, 만약 웹 페이지의 기본 주소가 그 유명한 localhost8080이라면 localhost8080/log-demo 를 하게 되면 logDemo함수가 반환하는 String 값에 해당하는 이름을 가진 html 페이지를 보여주게 한다. 근데 @ResponseBody를 붙이면 html 페이지 까지 갈거 없고~! 걍 웹 페이지 상에 리턴 값만 띄워 주세요! 라고 하는 것이다. 어렵다면 밑 사진의 자료화면 보시죠~

역시 직접 보는게 좋다 ㅎㅎ. 이 블로그 배경도 하얗고 사진도 하얘서 잘 보일런지 모르겠지만 logDemo 함수의 리턴 값인 "OK"가 좌측 상단에 자그마하게 보이는 것을 볼 수 있다!

 

이제 여기에  LogDemoService라는 클래스가 있고 logDemoService로서 필드에 존재하는 것을 볼 수 있는 데, 이 클래스의 함수인 logic("XXX")는 인자 값인 XXX를 MyLogger 클래스가 가지고 있는 log 라는 함수에다가 ("service id = XXX") 라고 전달해주는 별거 아닌 함수이다.

 아무튼 이렇게 해서 딱 실행을 하게되면!!!! 뜨악,, 의존관계 주입 에러가 발생한다. 왜지? 왜?.... 아하!

 

3.2 아하!

 웹 스코프는 고객의 요청이 들어와야 그제서야 생성 된다고 했다. 지금 LogDemoController 클래스의 필드 중  myLogger가 있고 룸복 어노테이션인 @RequieredArgsConstructor가 생성자 주입을 완성해주는데, 이런 젠장 myLogger가 컨테이너에 없잖아! 도대체,, 왜,, 왜냐,,왜냐,,면,,!!(울먹울먹) 프로토타입의 조회처럼 요청이 들어와야지 그제서야 컨테이너가 만든다면서!!!!

 그렇다면 우리는 이 난관을 어찌 해결해야 하는 가..!! 바로 프로토타입 스코프에서도 유용했던 Provider를 이용하면 된다. 그래 바로 DI가 아닌 DL을 사용 하는 것이다. 아마 DL에 대해 더 감이 잘 오게 될텐데, 의존관계가 필요한 것을 컨테이너에서 찾는 DI가 아닌 DL은 컨테이너에 요청해서 만들어주세요~! 라는 느낌이 왔다면 굳굳.

 다음과 같이 코드를 수정하면 된다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final ObjectProvider<MyLogger> myLoggerProvider;
	...
    
    public String logDemo(HttpServletRequest request){
        String requestURL = request.getRequestURL().toString();
        MyLogger myLogger = myLoggerProvider.getObject();
        ...
        return "OK";
    }
}

 이제 이렇게 되면 코드의 흐름이 어떻게 되냐묜~~ logDemo의 실행되고 나면 localhost8080/log-demo가 함수의 인자로 들어와서 그 값을 requestURL에 저장한다. 이후 myLoggerProvider.getObject()를 호출하게 되면, 컨테이너에게 MyLogger 타입의 빈을 내놔! 라고 하면 그제서야 컨테이너가 끼적끼적 대더니 만들어서 반환해준다. 그 후엔 위 코드와 똑같이 myLogger.log() 를 호출하고 logDemoService.logic()이 호출 되는 것이다. 그래서 출력된 값들을 자알 보면 아래 사진과 같다.

 요청이 들어오니깐 컨테이너가 끼적 대면서 request 스코프의 빈을 만들고 이 생성과 동시에 초기화 콜백 함수가 불려져 첫번째 문장이 출력 된다. 이후 myLogger.log(), logDemoService.logic(), 소멸 전 콜백 순으로 출력 되는 것을 볼 수 있다. 게다가 지금 4 단계 모두 []안에는 아까 말했듯이 함수 출력마다 UUID를 넣게 끔 하였고 이를 통해 지금 하나의 request 스코프 빈에 대해 다루는 것을 볼 수있다. 추가로 소멸 전 콜백 함수도 부르는 것으로 보아 HTTP request의 응답이 만들어져 나가게 되어 request 스코프 빈이 소멸할 때까지 컨테이너가 들고 있는 모습을 볼 수 있다.

 

3.3 호들갑 떨기

 "OK"만 보여주는 웹 페이지(localhost8080/log-demo)를 누군가 접속 할 때마다 위 사진 처럼 로그가 찍히는 것이다. 접속이 곧 logDemo함수를 불러내는 것이며 여기서 request 스코프 빈을 생성하는 흐름을 잘 이해하였는 가!

 그렇다면 아까 말했던 HTTP요청마다 request 스코프 빈이 생성 된다고 말했는데 그렇다면 저 페이지를 방문할 때마다 새로운 request 스코프 빈이 생겨나겠네? 그럼 새로고침 연타하면 각기 다른 request 스코프 빈이 생성 될 것이야! 그리고 이 녀석들이 다르다는 것은 UUID를 보면 되는 거야?! 와우 박수 짝짝. 이걸 자연스럽게 깨달았다면 오늘 자기 전에 발 닦고 자도 된다. 아래 사진으로 보여주겠다. 새로고침 연타하면 아래와 같다.

호들갑 지렸다. 진짜 여러번 눌러보았다. 재밌었다 ㅎㅎ. 

아무튼 보면 지금 4줄을 한 세트로 UUID가 계속 다른 것을 볼 수 있다. 

 이렇게 의심 할 수도 있다. '하나의 request 스코프 빈이 들어오고 처리가 끝나야지만이 그제서야 다른 request 스코프 빈이 생겨나는 거 아냐? 마치 싱글톤 하나인데 UUID 필드 값만 바꾸면서 다른 녀석인척 하는거 아니냐고?! 지금 장난하냐?'

 그런 분들을 위해 맨 처음 LogDemoController의 주석 부분을 보면  Thread.Sleep()을 한다. 만약 의심 시나리오가 맞다면, 두 번의 요청이 들어오게 되면 아래 순서와 같을 것이다.

 생성1->첫번째 함수1->(1초 잠자기)->두번째 함수-> 소멸 -> 생성2 -> 첫번째 함수2 -> (1초 잠자기) -> 두번째 함수2 -> 소멸

 

 쫄리면 직접 해보시던가 ㅎㅎ. 귀찮을 당신들을 위해 내가 직접 돌리고 캡쳐를 해놓았다.

 위 사진에서 보이듯이,  UUID가 ae8로 보이는 것이 생성되고 첫번째 함수 호출 후 잠자는 동안 a8이 생성되고 자기 하고 싶은 대로 하는 모습을 보인다. 즉, 이게 무슨 뜻이냐면 정말 정확하게 두 요청이 들어온다고 한들 컨테이너에 두 개의 request 스코프 빈이 생성되어 각 로직들이 두 빈을 서로 별개의 것으로 취급한다는 것을 알 수 있다.

 

4. 스코프와 프록시 

 짧다. 금방 설명하겠다.

 Provider 방식으로 DL 을 이용하여 의존관계 문제를 해결했는데 이런 방법이 있다면 어떨까? request 스코프 빈의 복제품을 만들고 이 복제품은 싱글톤이라서 의존관계 주입이 얼마든지 가능하다. 이 상황에서 원본 request 스코프 빈의 로직을 요청할 때 그제서야 복제품이 아닌 원본을 만들고 그 요청을 해결해 준다. 이 복제품 이름이 프록시. 어마맛? 썩 괜찮은데? 근데 코드가 귀찮다면 안할꺼야. 보아라!

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
		...
}

 

 아까 그 MyLogger 클래스에 proxyMode = ScopedProxyMode.TARGET_CLASS를 @Scope 어노테이션의 옵션으로 넣어주면 된다. 이때 주의할 것은 위에선 'value = '을 생략해도 된다고 했지만 이 옵션을 추가할 것이면 생략하면 안된다. 추가적으로 클래스가 아닌 인터페이스일 경우 .INTERFACES로 수정하기만 하면 된다.

 이렇데 되면 LogDemoController 에서 보면 Provider를 이용해야 했지만 이제 그런거 필요 없이 마치 싱글톤 처럼 사용 할 수 있다. 아래 코드 처럼

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

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

	myLogger.setRequestURL(requestURL);
        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}

 코드에서 보이듯이 Provider가 사라진 것을 볼 수 있다.

 이 프록시가 MyLogger 클래스의 싱글톤 버전 복제품이 되어 DI가 가능해지고 myLogger.set~~ 을 할 때 비로소 진짜 request 스코프 빈인 myLogger를 만들어 낸다. 이걸 좀 더 정확하게 확인해 보기 위해 myLogger.getClass()를 통해 프록시가 어떠한 모습을 지니고 있는지 보여주겠다.

myLogger = 부분을 보면 CGLIB라는 부분이 보인다. 스프링 컨테이너는 CGLIB라는 바이트 코드 조작 라이브러리를 사용해서, MyLogger 클래스를 상속받은 가짜 프록시 객체를 생성하는 것이다.

 잠시만, 그렇다면 아까처럼 새로고침 하면 어떻게 돼? 당연히 myLoger = ~~ 는 같은 값을 호출 하고 그 아래 UUID 값은 변경되어서 출력 된다!

 이처럼 프록시는 상당히 코드도 간결해지고 싱글톤 처럼 사용 할 수 있어서 편리하지만,, 이것이 양날의 검이다. 싱글톤 처럼 사용하다보니 전세가 역전되어 싱글톤으로 사용해버리다가 오류가 날 수 도 있겠다. 그래서 싱글톤이 아닌 다루었던 프로토타입이나 request 스코프는 꼭 필요한 곳에서만! 사용하는 것이 좋아 보인다아아아아~~

 

마무리 

 지금껏 기본적이나 꽤 많은 이야기들을 다루었으니, 복습의 시간이 필요해 보인다. 그래서 지금 껏 한 내용들을 다시 정리하면서 코딩도 해보고 머리속에 집어넣고 또 부가적인 경험들도 쌓는 시간을 가져야 겠다. 그래서 아마 다음 글은 이런 과정에서 얻은 교훈이나 새로운 지식을 다룰 수도 있고~ 아니면 안할 수도 있고~! ㅎㅎ 내 맘대로 할 거지롱~

'백앤드(스프링)' 카테고리의 다른 글

HTTP 총 정리 1편  (0) 2022.02.23
지금까지의 스픠릥 몰랐던 것들 정리  (2) 2022.02.08
빈 생명주기, 빈 스코프 1편  (0) 2022.01.27
의존관계 자동주입 2편  (0) 2022.01.25
의존관계 자동 주입 1편  (0) 2022.01.22