본문 바로가기
카테고리 없음

(9)빈 스코프

by 샤샤샤샤 2023. 6. 5.

스코프란?

빈의 존재 방식을 말한다. 스코프 종류에 따라 빈은 컨테이너에게 관리받는 시점을 조절하거나 객체가 유지되는 기간을 설정할수 있다.

 

스코프의 종류

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

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

웹 관련 스코프

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

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

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

 

 

스코프의 등록법

@Scope("[스코프 종류]") 를 사용하면 된다. 자동 등록 빈이건 수동 등록 빈이건 똑같다.

 

자동

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

수동

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

 

프로토 타입

사용자의 요청시 빈을 생성해 의존관계 주입, 초기화까지 끝낸 객체를 반환해주고, 더이상 관리하지 않는다.

즉, 사용자 요청이 들어올때마다 완전한 객체를 새롭게 생성한뒤, 스프링으로부터 독립된다.

따라서 스프링에서 지원하는 각종 메서드나 @PreDestroy와 같이 스프링이 감시하다가 실행시켜주는 어노테이션 등을 사용할수 없다.

코드로 확인해보자.

 

# PrototypeBean

@Scope("prototype")
@Component
public class ProtoTypeBean {

    public ProtoTypeBean() {
        System.out.println("Prototype 빈, 메모리 주소값:" + this);
    }
    
    @PreDestroy
    public void close(){
        System.out.println("빈 소멸");
    }
}

 

# 테스트 코드

	@Test
	void prototypeScopeTest(){
	    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);
		ProtoTypeBean bean1 = ac.getBean(ProtoTypeBean.class);
		ProtoTypeBean bean2 = ac.getBean(ProtoTypeBean.class);
		ProtoTypeBean bean3 = ac.getBean(ProtoTypeBean.class);
		ac.close();
	}

 

#AutoAppConfig

@Configuration
@ComponentScan
public class AutoAppConfig {

}

 

결과:

주소값이 다 다르다.

사용자가 요청할때마다 새로운 객체가 생성되어 반환되는 것을 확인할수 있다.

@PreDestroy 메서드가 동작하지 않는데, 이는 스프링이 실시간으로 감시하면서 빈이 소멸하는 것을 확인해야 작동하는데, 프로토타입 스코프는 스프링이 관리하지 않기 때문이다.

 따라서 프로토타입을 사용할때는 사용자가 직접 관리해줘야 하는 번거로움이 존재한다.

 

 

프로토타입과 싱글톤 혼합 사용

프로토타입과 싱글톤을 혼합 사용할시, 개발자의 의도대로 코드가 진행되지 않을수도 있다. 아래 그림과 같은 클래스가 존재한다고 가정해보자.

 

A 클래스는 싱글톤 빈이고, B는 프로토타입일때, 사용자 1, 2의 요청에 반환되는 B는 서로 다른 객체일까, 같은 객체일까? 답은 같다는 것이다. 프로토타입을 사용하는 목적은 요청할때마다 새로운 객체를 반환받는 것인데 싱글톤처럼 같은 객체가 반환되는 것이다.

 

Provider 사용법

위의 문제는 A 빈이 호출될때마다 B 객체가 새롭게 주입받으면 해결된다. 따라서 개발자는 의존관계에 있는 B만 빈 컨테이너에서 따로 찾아와 주입하면 문제를 해결할수 있다.

스프링에서는 이를 위해 ObjectFactory와 ObjectProvider를 제공한다. ObjectProvider가 더 기능이 많다.

 

#SingletonBean

@Component
@RequiredArgsConstructor
public class SingletonBean {
    private final ObjectProvider<ProtoTypeBean> protoTypeBeanProvider;

    public void test(){
        ProtoTypeBean object = protoTypeBeanProvider.getObject();
        // getObject 메서드는 컨테이너에서 무조건 새로운 객체를 가져온다.
    }
}

# 테스트 코드

    @Test
    void singleTonPrototypeTest(){
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);
        SingletonBean bean = ac.getBean(SingletonBean.class);
        SingletonBean bean2 = ac.getBean(SingletonBean.class);
        bean.test();
        bean2.test();
    }

ObjectProvider<> 의 getBean을 사용해서 필요할때마다 새롭게 객체를 가져오도록 코드를 작성했다. 덕분에 싱글톤 객체를 불러오더라도 그 내부의 프로토타입 객체는 매번 새롭게 생긴 객체가 반환된다.

Provider의 장점은 하나 더 있는데, 테스트코드의 마지막 두줄을 주석처리 해보자.

PrototypeBean 객체가 주입되지 않아(즉, 객체가 생성되지 않아), 생성자 함수가 실행되지 않는 것을 볼수 있다.

본래 의존주입은 객체가 생성될때 이뤄지지만, Provider을 사용하면 직접 사용되기 전까지 의존주입이 늦춰지기 때문에 발생하는 현상이다.

 

* 자바 표준 Provider 사용할수도 있다. 사용법은 ObjectProvider와 똑같다.

 

 

웹 스코프

싱글톤과 프로토타입은 웹 환경이 아니더라도 작동하지만 웹 스코프는 오직 웹환경에서만 동작한다.  그 종류는 다음과 같다.

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

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

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

websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프

 

범위만 다를뿐 동작 방식은 모두 똑같다. request를 가지고 이해해보자.

 

# RequestLog

@Component
@Scope(value = "request")
public class RequestLog {
    private String uuid;
    private String requestURL;

    public void setUuid(String uuid) {
        this.uuid = uuid;
    }

    public void setRequestURL(String requestURL) {
        this.requestURL = requestURL;
    }
    
    public void log(){
        System.out.println("uuid = " + uuid +"////" + "requestURL = " +requestURL);
    }
    
    @PostConstruct
    public void init(){
        uuid = UUID.randomUUID().toString();
        System.out.println("uuid = " + uuid +"\n 호출된 객체 : " + this);
    }
    
    @PreDestroy
    public void close(){
        System.out.println("uuid = " + uuid +"\n 소멸하는 객체 : " + this);
    }
}

위 클래스는 빈으로 등록되어 로그를 찍게 된다.

request 스코프는 HTTP요청 하나당 하나씩 생성되며, 해당 요청이 끝나는 순간 소멸된다. 이를 구분하기 위해 UUID를 통해 고유값을 부여했다.

 

#Service

@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final ObjectProvider<RequestLog> requestLogProvider;
    public void logic(String id) {
        RequestLog requestLog = requestLogProvider.getObject();
        requestLog.log(id);
    }
}

# Controller

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final ObjectProvider<RequestLog> requestLogProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        RequestLog requestLog = requestLogProvider.getObject();
        requestLog.setRequestURL(requestURL);
        requestLog.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}

만약 Provider 를 사용하지 않고 실행하면 오류가 발생한다. request 스코프 빈은 아직 생성되지 않았는데 생성자 주입을 받아야 어플이 돌아가기 때문에 생기는 문제다. 사용되기 전까지 빈의 생성을 미루는 Provider를 사용하여 문제를 해결하자.

 

결과는 다음과 같다.

uuid와 빈 주소값이 같은것을 확인할수 있다. 그런데 한번 더 접속하면 이 값이 바뀐다.

이는 request 스코프의 빈이 Controller의 logDemo 메서드를 들어온 순간 생성되고, 코드가 끝나는 순간 소멸되기 때문에 발생하는 일이다. 더 명확히 알아보기 위해 컨트롤러에 다음과 같은 코드를 추가해보자.

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        String requestURL = request.getRequestURL().toString();
        RequestLog requestLog = requestLogProvider.getObject();
        requestLog.setRequestURL(requestURL);
        requestLog.log("controller test");
        // 소멸까지 3초 지연하는 코드 추가
        Thread.sleep(3000);
        //
        logDemoService.logic("testId");
        return "OK";
    }

결과:

보다시피 두 요청을 동시에 처리하고 있지만 명확히 빈을 구분하여 관리하고 있는 것을 확인할수 있다.

 

스코프와 프록시

프록시 패턴이라는 코드 패턴이 존재한다. 구현 코드가 복잡하기에 간단히 설명하자면, 어떤 클래스나 인터페이스를 호출했을때 그 클래스를 상속받는 대리 클래스가 호출되는 패턴이다. 원래라면 상속받는 자식 클래스를 개발자가 구현해야 했겠지만, 스프링에서는 CGLIB를 이용해 가짜 프록시 객체(대리 클래스)를 만들어준다.

이를 사용하면 스프링에서 알아서 컴파일 과정에서 코드를 조작해주기 때문에 Provider를 사용하지 않고 빈 생성을 늦출수 있다.

@Component
// 추가된 Scope 설정
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
//
public class RequestLog {
    private String uuid;
    private String requestURL;

    public void setUuid(String uuid) {
        this.uuid = uuid;
    }

    public void setRequestURL(String requestURL) {
        this.requestURL = requestURL;
    }

    public void log( String message){
        System.out.println("uuid = " + uuid +"////" + "requestURL = " +requestURL + " ////// " + message);
    }

    @PostConstruct
    public void init(){
        uuid = UUID.randomUUID().toString();
        System.out.println("uuid = " + uuid +" 호출된 객체 : " + this);
    }

    @PreDestroy
    public void close(){
        System.out.println("uuid = " + uuid +" 소멸하는 객체 : " + this);
    }
}

# service

@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final RequestLog requestLog;
    public void logic(String id) {
        requestLog.log(id);
    }
}

# controller

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final RequestLog requestLog;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        String requestURL = request.getRequestURL().toString();
        requestLog.setRequestURL(requestURL);
        requestLog.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}

즉, 사용자가 RequestLog 빈을 주입받을때, 실제로는 CGLIB가 등록한 RequestLog$$EnhancerBySpringCGLIB$$b27b624c

라는 객체가 주입되는 것이다.

이 가짜 프록시에는 진짜 빈을 찾는 로직이 들어있는데, 사용자가 진짜 빈을 호출할때 먼저 RequestLog$$EnhancerBySpringCGLIB$$b27b624c를 거치고, 다시 진짜 빈 RequestLog 을 찾아가는 방식으로 동작한다.

굳이 이렇게 복잡한 방식을 사용하는 이유는, 가짜 빈이 싱글톤처럼 동작하기 때문에 편리하게 request scope를 사용할수 있기 때문이다. Provider건 프록시건 주된 아이디어는 필요 시점까지 주입을 지연한다는데 있다.