스프링 핵심 원리 09] 빈 스코프

컴업·2022년 2월 4일
0

본 포스트는 Inflearn 김영한 선생님 강의를 정리한 것 입니다!

빈 스코프란?

스코프를 영한사전에서 찾아보면 "범위", "영역"이라고 나옵니다.

빈 스코프는 말 그대로 빈이 생존(?)해 있는 범위를 의미하는 것 입니다.
지금까지 우리는 스프링 빈이 스프링 컨테이너가 생성될 때 생성되고, 종료될 때 제거된다고 배웠습니다.
이것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문입니다.

스프링은 다음과 같은 다양한 스코프를 지원합니다.

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

빈 스코프 지정

빈 스코프는 다음과 같은 방법으로 지정할 수 있습니다.

컴포넌트 스캔 자동 등록

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

수동 등록

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

프로토타입 스코프

프로토타입 스코프를 스프링 컨테이너에서 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환합니다.


1. 프로토타입 스코프의 빈을 요청
2. 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계를 주입


3. 스프링 컨테이너는 생성한 빈을 반환
4. 요청마다 새로운 빈을 생성해서 반환

스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주이브 초기화까지만 처리하고 이후에는 반환한 빈을 관리하지는 않습니다. 따라서 @PreDestroy같은 종료 메서드가 호출되지 않아 빈을 조회한 클라이언트에서 관리해야합니다.

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

프로토타입 빈을 싱글톤 빈과 함께 사용하면 의도한 대로 잘 동작하지 않으므로 사용에 유의해야합니다.

먼저 문제가 없는 프로토타입 빈을 직접 요청하는 경우를 살펴보겠습니다.

  1. 클리어언트A가 프로토타입 빈을 요청하고 빈의 addCount()메서드를 실행해 count필드를 1로 증가시킵니다.
  2. 이후 클라이언트B가 프로토타입 빈을 요청하면 새로운 프로토타입 빈이 생성되거 반환되고 이 빈의 addCount()를 실행해 count필드를 1로 증가시킵니다.

이럴경우 클라이언트는 늘 새로운 프로토타입 빈을 받으므로 아무런 문제가 없습니다!

그러나 clientBean이라는 싱글톤 빈이 의존관계 주입을 통해 프로토타입 빈을 주입받아서 사용하는 예를 살펴보겠습니다.

  1. clientBean은 싱글톤이므로 스프링 컨테이너 생성시 만들어지고 필요한 prototypeBean도 의존관계 주입받습니다.

  2. 클라이언트A가 clientBean의 logic()을 호출해 prototypeBean의 addCount()를 호출해 count를 1로 증가시킵니다.

  3. 이후 클라이언트B가 마찬가지로 logic()을 호출하면 clientBean이 생성시 주입받았던 같은 protorypeBean의 addCount()가 호출되며 count필드가 2로 증가됩니다.

프로토타입 빈은 요청시 항상 새로운 빈을 받아야하는데, 이 경우 이미 과거에 주입이 끝난 prototypeBean이 계속 호출되게 됩니다.

사용 할 때마다 새로 생성되는 것이 아니지요.

테스트 코드

package hello.core.scope;

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 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);
    }
    
    static class ClientBean {
    
        private final PrototypeBean prototypeBean;
        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }
        public int logic() {
            prototypeBean.addCount();
            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");
        }
    }
}

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

이런 문제를 어떻게 해결할 수 있을까요?
어떻게 하면 사용할 때 마다 항상 새로운 프로토타입 빈을 생성할 수 있을까요?

스프링 컨테이너에 요청

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

public class PrototypeProviderTest {
    
    @Test
    void providerTest() {
        
        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);
    }
    
    static class ClientBean {
        
        @Autowired
        private ApplicationContext ac;
        
        public int logic() {
            PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
            prototypeBean.addCount();
            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");
        }
    }
}

의존관계를 외부에서 주입 받는 것이 아니라, 이렇게 직접 필요한 의존관계를 찾는 것을 Dependency Lookup(DL) 의존관계 조회(탐색)이라고 합니다.

그러나 이렇게 applicationContext를 주입받게되면, 스프링 컨테이너에 종속적인 코드가되고, 단위 테스트도 어려워지게됩니다.

스프링에서는 이러한 문제를 해결하기 위해 필요한 프로토타입 빈을 컨테이너에서 대신 찾아주는, 다시말해 DL만 제공하는 기능을 가지고있습니다.

ObjectFactory, ObjectProvider

지정한 빈을 컨테이너에서 대신 찾아주는 DL 을 제공하는 것이 바로 ObjectFactory이고 이것에 편의 기능을 추가해서 ObjectProvider가 만들어졌습니다.

static class ClientBean {

    @Autowired
    private ObjectProvider<PrototypeBean> prototypeBeanProvider;
    
    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        int count = prototypeBean.getCount();
        return count;
    }
}

prototypeBeanProvider.getObject()를 통해 항상 새로운 프로토타입 빈이 생성됩니다.

ObjectProvider의 getObject()를 호출하면 내부에서 스프링 컨테이너를 통해 해당 빈을 찾아 반환합니다 (DL)

스프링이 제공하는 기능을 사용하지만, 기능이 단순해 단위테스트를 만들거나 mock 코드를 만들기 쉽습니다.

스프링 의존적이긴 합니다!

JSR-330 Provider

스프링에 완전히 독립적인 방법도 존재합니다.

javax.inject.Provider라는 JSR-330 자바 표준을 사용하는 것입니다.
단 이방법을 사용하려면 javax.inject:javax.inject:1라이브러리를 gradle에 추가해야 합니다.

package javax.inject;
public interface Provider<T> {
	T get();
}
static class ClientBean {

    @Autowired
    private Provider<PrototypeBean> provider;
    
    public int logic() {
        PrototypeBean prototypeBean = provider.get();
        prototypeBean.addCount();
        int count = prototypeBean.getCount();
        return count;
    }
}

provider.get() 을 통해 항상 새로운 프로토타입 빈이 생성됩니다.



정리

늘 복잡한 기술에 대해 마지막에 하는 말이지만 프로토타입빈을 사용하는 경우는 매우 드뭅니다. 보통 싱글톤 빈으로 대부분의 문제를 해결할 수 있기 때문입니다.



웹 스코프

웹 스코프는 웹 환경에서만 동작합니다.

프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리합니다. 따라서 종료 메서드가 호출됩니다.

웹 스코프 종류

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

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

@Scope(value = "request") 를 사용해 request 스코프로 지정합니다.
이제 이 빈은 HTTP 요청당 하나씩 생성되고 요청이 끝나면 소멸됩니다.

@PostConstruct로 이 빈이 생성될 때마다 새로운 UUID를 생성해 저장해둡니다.

requestURL은 이 빈이 생성되는 시점에는 알 수 없으므로, 외부에서 setter로 입력 받습니다.

package hello.core.web;

import hello.core.common.MyLogger;
import hello.core.logdemo.LogDemoService;
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 scope를 사용하지 않고 파라미터로 이 모든 정보를 서비스 게층에 넘긴다면, 파라미터가 많아서 지저분해질 뿐만 아니라 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 됩니다. 웹과 관련된 부분은 컨트롤러까지만 사용해아합니다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋습니다.

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

package hello.core.logdemo;

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

실행해보면 기대와 다르게 애플리케이션 실행 시점에 오류가 발생합니다.

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 스코프 빈은 실제 고객의 요청이 와야 생성할 수 있기 때문에 스프링 애플리케이션을 실행하는 시점에는 없기 때문!

스코프와 Provider

앞서배운 Provider를 이용해 이 문제를 해결할 수 있습니다.

package hello.core.web;

import hello.core.common.MyLogger;
import hello.core.logdemo.LogDemoService;
import lombok.RequiredArgsConstructor;
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;

import javax.servlet.http.HttpServletRequest;

@Controller
@RequiredArgsConstructor
public class LogDemoController {
    
    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;
    
    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        
        String requestURL = request.getRequestURL().toString();
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.setRequestURL(requestURL);
        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}
package hello.core.logdemo;

import hello.core.common.MyLogger;
import lombok.RequiredArgsConstructor;
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);
    }
}

서버를 켜고 http://localhost:8080/log-demo로 들어가면 정상 로그를 확인할 수 있습니다.

[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

ObjectProvider 덕분에 request scope 빈의 생성을 지연할 수 있습니다.
getObject()를 호출하는 시점에는 HTTP 요청이 진행중이므로 request scope빈의 생성이 정상 처리됩니다.

ObjectProvider.getObject()를 LogDemoController, LogDemoService에서 각각 한번씩 따로 호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환됩니다!

스코프와 프록시

이번에는 프록시 방식을 사용해보겠습니다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
  • 적용 대상이 클래스이면 TARGET_CLASS, 인터페이스면 INTERFACES를 선택

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

이제

private final ObjectProvider<MyLogger> myLoggerProvider;

로 바꾸었던 코드를 원래대로 바꾸어 둡니다.


웹 스코프와 프록시 동작 원리

먼저 주입된 myLogger를 확인해보겠습니다.

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

출력결과

myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d

CGLIB라는 라이브러리로 내 클래스를 상속받은 가짜 프록시 객체를 만들어 스프링 컨테이너에 등록합니다. 따라서 의존관계 주입도 이 가짜 프록시 객체가 주입됩니다.
(실제 내 코드를 등록하는 것이 아니라 그것의 카피를 만들어 등록하는 방식)

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

  • 가짜 프록시 객체는 내부에 진짜 myLogger를 찾는 방법을 알고 있습니다.
  • 클라이언트가 myLogger.logic()을 호출하면 사실은 가짜 객체의 메서드를 호출한 것입니다.
  • 가짜 객체는 request 스코프의 진짜 myLogger.logic()을 호출합니다.
profile
좋은 사람, 좋은 개발자 (되는중.. :D)

0개의 댓글