Spring

[Spring] 스프링 빈 스코프(Bean Scope)

cloud-grace 2024. 6. 9. 01:07

빈 스코프(Bean Scope)란?

스프링 빈(Spring Bean)이 스프링 컨테이너에 시작과 동시에 만들어지고, 컨테이너가 종료될 때까지 유지된다고 지금까지 배웠으며, 이는 스프링 빈이 기본적으로 싱글톤 스코프(Singleton Scope)로 생성되기 때문이였다. 즉, 빈 스코프(Bean Scope)는 빈이 존재할 수 있는 범위를 말한다.
 
하지만, 요구사항과 여러 구현할 기능에 의해 싱글톤이 아닌 스코프도 필요한 경우가 많다. 이를 명시적으로 구분하려고 Scope라는 키워드가 존재한다.
 

빈 스코프 종류

싱글톤 스코프

스프링 프레임워크의 기본 스코프이며, 스프링 컨테이너 시작과 종료 사이에 유지되는 가장 넓은 범위의 스코프이다.
 

프로토타입 스코프

프로토타입 빈의 생성과 의존 관계 주입까지만 관여하고 그 외에는 관리하지 않는 매우 짧은 범위의 스코프이다.
요청이 오게 되면 새로운 인스턴스 생성 후 반환하며, 그 이후 관여하지 않는다.
프로토타입을 받은 클라이언트가 객체를 관리해야 한다.
 

웹 관련 스코프

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

 

빈 스코프 사용 방법

  • 컴포넌트 스캔 자동 등록
@Scope("prototype")
@Component
public class PrototypeBean {
}
  • 수동 등록
@Scope("prototype")
@Bean
PrototypeBean prototypeBean() {
	return new PrototypeBean();
}

 
 

싱글톤 스코프

  • 싱글톤 빈은 기본값이며, @Scope 또는 @Scope("singleton")이라고 붙여 사용할 수 있다.
  • 스프링 컨테이너에서 한 번만 생성되며, 컨테이너 종료 시 소멸된다.
  • 싱글톤 스코프의 스프링 빈은 하나의 공유 인스턴스만 관리하며, 동일 참조를 보장한다.
  • private 생성자를 통해 외부에서 new를 못하도록 해야 한다.

 

싱글톤으로 다루기에 알맞은 객체

  • 읽기 전용 상태의 객체
  • 쓰기 가능한 상태를 가져도 사용 빈도가 매우 높은 객체(동기화 필요)
  • 상태가 없는 공유 객체

 

싱글톤 스코프 빈 테스트

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 destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

 

출력

  • 같은 인스턴스의 빈을 조회한다.
  • 컨테이너 생성 시점에 초기화 메서드 실행이 된다.
  • 컨테이너 종료 시점에 종료 메서드 실행이 된다.
SingletonBean.init
singletonBean1 = hello.core.scope.SingletonTest$SingletonBean@24561357
singletonBean2 = hello.core.scope.SingletonTest$SingletonBean@24561357
16:45:26.527 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext ..........
SingletonBean.destroy

 

프로토타입 스코프

  • 프로토타입 빈 대상 클래스에 @Scope("prototype")을 붙인다.
  • 의존 관계 주입이 될 때마다 새로운 객체가 생성과 의존 관계 주입까지만 관여한다.
  • 컨테이너에서 조회하면 컨테이너는 항상 새로운 인스턴스를 생성하여 반환한다.

 

프로토타입으로 다루기에 알맞은 객체

  • 쓰기가 가능한 상태가 있는 객체
  • 사용할 때마다 상태가 달라져야 되는 객체

 

프로토타입 스코프 빈 테스트

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);
        
        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);

        ac.close();
    }

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

        @PreDestroy
        public void destroy() {
            System.out.println("ProtoTypeBean.destroy");
        }
    }
}

 

출력

  • 컨테이너에서 빈을 조회할 때 생성되면서 초기화 메서드도 실행된다.
  • 2번째 조회일 때 서로 다른 빈이 생성되며, 초기화도 2번 수행된다.
  • 객체 생성과 의존 관계 주입까지만 관여하기 때문에 종료 메서드가 수행되지 않는다.
find prototypeBean1
ProtoTypeBean.init
find prototypeBean2
ProtoTypeBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$ProtoTypeBean@24561357
prototypeBean2 = hello.core.scope.PrototypeTest$ProtoTypeBean@21s6d2a5
16:55:22.251 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext ..........

 

싱글톤 빈과 프로토타입 빈을 같이 사용하면 발생하는 문제점

프로토타입 빈에서 싱글톤 빈을 사용하면 문제가 생기지 않는다.
그러나, 싱글톤 빈에서 프로토타입 빈을 사용하면 문제가 발생할 수 있다.
 
 

예제 코드

  • 프로토타입 빈 호출 시 count를 1씩 늘리는 테스트 코드
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 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();
            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" + this);
        }
    }
}

 

테스트 결과

  • 프로토타입 빈은 항상 새로운 인스턴스를 반환하므로 계속 1이 되어야 한다.
prototypeFind()
  • 싱글톤 빈에서 프로토타입 빈을 사용할 경우, 싱글톤이 생성되는 시점에 의존 관계 주입을 받으며 프로토타입 빈이 생성되지만 싱글톤 빈과 함께 계속 유지가 된다.
singletonClientUsePrototype()

 

DL(Dependency Lookup) 의존 관계 탐색

@Scope("singleton")
static class ClientBean {

    @Autowired private ApplicationContext ac;

    public int logic() {
        PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}
  • DL은 싱글톤 빈이 프로토타입 빈을 사용할 때마다 컨테이너에게 새로운 요청을 한다.
  • login() 안의 ac.getBean()을 통해 항상 새로운 프로토타입 빈을 생성한다.
  • 외부에서 의존 관계 주입을 받는 것이 아니라, 직접 필요에 따라 의존 관계를 탐색하는 방법이다.
  • 하지만, ApplicationContext를 주입받게 되면 컨테이너에 종속적이며 단위 테스트도 하기 힘들다.

 

Provider

ObjectProvider

  • 위의 DL의 문제점을 해결하기 위해 지정한 프로토타입 빈을 컨테이너에서 대신 찾아주는 DL 기능을 제공하는 것이다.
  • getObject() 메서드를 통해 항상 새로운 프로토타입 빈을 만들 수 있으며, 이 메서드는 컨테이너를 통해 해당 빈을 찾아 반환하는 DL 기능을 한다.
  • Spring이 제공하는 기능이며, 단순하여 단위 테스트 활용과 mock 코드 만들기에 쉽다.
  • ObjectFactory : 기능이 단순하고 라이브러리가 필요 없으며 스프링에 의존한다.
  • ObjectProvider : 상속, 옵션, 스트림 처리 등의 편한 기능이 많으면서 라이브러리 역시 필요 없고 스프링에 의존한다.

 

1) 필드 주입 ObjectProvider

@Scope("singleton")
static class ClientBean {

    @Autowired
    private ObjectProvider<PrototypeBean> prototypeBeanProvider;

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

 

2) 생성자 주입 ObjectProvider

@Scope("singleton")
static class ClientBean {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    @Autowired
    public ClientBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

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

 

JSR-330 Provider

  • 위의 ObjectProvider는 스프링에 의존한다는 단점이 있기에, Java 표준으로 스프링이 아니여도 사용할 수 있는 JSR-330 Provider이 있다.
  • 이것을 사용하려면 javax.inject:javax.inject:1 라이브러리를 추가하자.
  • get() 메서드를 통해 새로운 프로토타입 빈을 생성할 수 있으며, 이 메서드는 컨테이너를 통해 해당 빈을 찾아 반환하는 DL 기능을 한다.
  • Java 표준이며 기능이 단순하고 단위 테스트 및 mock 코드 작성에 용이하다.
// javax.inject:javax.inject:1 라이브러리 추가 필수
@Scope("singleton")
static class ClientBean {

    @Autowired
    private Provider<PrototypeBean> prototypeBeanProvider;

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

 

ObjectProvider VS JSR-330 Provider

ObjectProvider과 JSR-330 Provider는 프로토타입 뿐만이 아닌, DL 기능이 필요할 때도 사용이 가능하다.
이 둘 이외에는 스프링이 제공하는 @Lookup 어노테이션을 활용하는 방법이 있다.
이 둘은 DL을 위해 편의 기능이 많으며, 별도의 의존 관계 추가가 필요 없다.
스프링에서는 ObjectProvider, 이외에는 JSR-330 Provider를 사용해야 한다.
그래도 타 컨테이너를 사용하는 특별한 이유가 없다면 스프링이 제공하는 ObjectProvider를 사용하는 것이 좋다고 한다.
 

웹 스코프

  • 웹 환경에서만 동작한다.
  • 프로토타입과 달리 해당 스코프 종료 시점까지 스프링이 관여한다. 즉, 종료 메서드가 호출된다.

 

웹 스코프 종류

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

 

request

  • request 스코프는 클라이언트 요청에 따라, 해당 클라이언트 전용 빈 인스턴스를 생성해서 요청이 끝날 때까지 관리하는 스코프이다.
  • 만약, 클라이언트 A & B가 동시에 요청을 보내더라도, 각각의 A & B 각각의 전용 빈 인스턴스를 생성하여 Service 로직을 통해 각각의 결과를 반환한다. 이때 각각의 전용 빈 객체는 소멸된다.

 

request 스코프 예제 코드

  • MyLogger : 로그를 출력하기 위한 클래스
  • request 스코프는 @Scope(value = “request”)로 지정
  • HTTP 요청 당 하나씩 빈 인스턴스가 생성되며, HTTP 요청이 끝나는 시점에 빈 객체는 소멸된다.
  • 빈이 생성되는 시점에 @PostConstruct 초기화 메서드를 통해 uuid를 생성하며, 다른 HTTP 요청과 구분할 수 있도록 저장한다.
  • 빈이 소멸되는 시점에 @PreDestroy를 사용해서 종료 메시지를 출력한다.
  • requestURL은 외부에서 빈이 setter로 입력 받는다.
@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);
    }
}
  • LogDemoController는 MyLogger 클래스가 잘 작동하는지 확인하기 위한 테스트용 컨트롤러
  • HttpServletRequest를 통해 요청 URL을 받는다.
  • requestURL : http://localhost:8080/log-demo
  • requestURL 값을 myLogger에 저장하며, controller test라는 로그를 출력한다.
  • requestURL을 MyLogger에 저장하는 곳은 컨트롤러보다 공통 처리가 가능한 스프링 인터셉터(Spring Interceptor)나 서블릿 필터(Servlet Filter) 같은 곳을 활용하는 것이 좋다.
@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";
    }
}
  • LogDemoService : 비즈니스 로직이 존재하는 서비스 계층에서도 로그를 출력하기 위한 코드
  • 서비스 계층은 웹과 관련 없는 계층이기 때문에, 웹과 관련된 부분은 컨트롤러까지만 사용해야 한다.
  • 서비스 계층은 웹 기술에 종속되지 않게 순수한 상태로 유지하는 것이 유지보수성에 좋다.
  • request scope의 MyLogger로 인해 MyLogger의 멤버 변수에 저장해서 코드와 계층을 깔끔히 유지할 수 있다.
  • 하지만, 이 상태로 스프링 애플리케이션을 실행시키면 오류가 발생한다.
  • 스프링 실행 시점에서, 싱글톤 빈은 생성해서 주입이 가능하지만, request 스코프 빈은 생성되지 않는다.
  • request 스코프 빈은 실제 HTTP 요청이 올 때 생성되기 때문이다.
@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final MyLogger myLogger;

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

 
 

웹 스코프와 Provider

ObjectProvider를 통해 getObject() 메서드를 호출하는 시점까지 request scope 빈의 생성을 늦출 수 있다.
getObject() 메서드 호출 시점은 HTTP 요청이 진행 중인 때이므로, request scope 빈 생성이 정상적으로 작동한다.
따라서 스프링 실행 후, 브라우저 접속할 때마다 서로 다른 request 빈 생성 시점과 소멸의 로그를 출력할 수 있다.
  • LogDemoController
@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    
    // ObjectProvider 적용
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        
        // getObject() 메서드 : 빈 인스턴스 생성
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}
  • LogDemoService
@Service
@RequiredArgsConstructor
public class LogDemoService {

    // ObjectProvider 적용
    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(String id) {
    
        // getObject() 메서드 : 빈 인스턴스 생성
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}

 

웹 스코프와 프록시

프록시 방식은 @Scope 어노테이션에서 proxyMode = ScopedProxyMode.TARGET_CLASS로 사용할 수 있다.


ScopedProxyMode에서

  • 적용 대상이 클래스이면 TARGET_CLASS
  • 적용 대상이 인터페이스이면 INTERFACES

 
proxyMode를 설정하면 가짜 프록시 클래스를 만들어서 HTTP request와 상관없이 가짜 프록시 클래스를 다른 빈에 request scope를 미리 주입해 둘 수 있다.
 

웹 스코프와 프록시 특징

  • 무조건 웹 스코프가 아니어도 프록시는 사용할 수 있다.
  • Provider 방식 & 프록시 방식의 핵심은 객체 조회를 필요한 시점까지 지연 처리를 하는 것이다.
  • 프록시 객체를 통해 클라이언트는 싱글톤 빈을 사용하듯이 편리하게 request scope를 사용할 수 있다.
  • 어노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있다.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
  • Controller와 Service를 Provider를 적용하기 이전의 코드로 되돌려 놓는다.
  • 그리고 MyLogger 클래스의 @Scope에 proxyMode를 추가한다.
  • 단순한 설정으로 Provider를 적용한 것과 동일하게 수행한다.
  • 따라서 위의 Provider 적용하였을 때처럼, 스프링 실행 후, 브라우저 접속할 때마다 서로 다른 request 빈 생성 시점과 소멸의 로그를 출력할 수 있다.

 

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

  • proxyMode로 가짜 프록시 클래스 만들어서 다른 빈에 request 스코프 빈을 주입한다.
System.out.println("myLogger.getClass() = " + myLogger.getClass());
  • 출력하여 확인하면 CGLIB 라이브러리로 작성한 클래스를 상속받은 가짜 프록시 객체를 만들어 주입한다.
myLogger.getClass() = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$35920143
  • MyLogger를 상속받은 가짜 프록시 객체를 생성하고 있다.
  • 순수 MyLogger 클래스가 아닌 MyLogger$$EnhancerBySpringCGLIB 클래스로 만들어진 객체가 대신하여 등록되어 있다.
  • ac.getBean() 메서드를 통해 조회해도 같은 결과를 확인할 수 있다.
  • 의존관계 주입 역시 가짜 프록시 객체가 주입된다.

 

가짜 프록시 객체에는 HTTP 요청이 오게 되면 이때 내부에서 진짜 빈을 요청하는 로직이 포함되어 있다.
  • 가짜 프록시 객체는 내부에 진짜 myLogger 클래스를 찾는 방법을 알고 있기 때문이다.
  • 클라이언트가 myLogger.logic()을 호출하면 가짜 프록시 객체 메서드를 호출한 것이다.
  • 이후, 가짜 프록시 객체가 request 스코프의 myLogger.logic()을 호출한다.
  • 클라이언트 입장에서는 이것이 가짜 or 진짜인지 알 수 없는 상태에서 동일하게 사용할 수 있다.

 

주의점

  • 마치 싱글톤을 사용하는 것 같지만 다르게 동작하므로 주의해서 사용해야 한다.
  • 특별한 scope는 꼭 필요한 곳에서만 최소화해서 사용해야 하며, 무분별하게 사용할 경우 유지보수성이 낮아진다.

 
 
 

참고 자료
내용 참고 : 인프런 김영한 님의 강의 "스프링 핵심 원리 - 기본편"
https://catsbi.oopy.io/b2de2693-fd8c-46e3-908a-188b3dd961f3
https://daegwonkim.tistory.com/286?category=1026270
https://ittrue.tistory.com/225