Singleton Scope 빈에 Request Scope 빈을 주입받고 싶다면… (Scoped Proxy Bean)
문제 상황
프로젝트 도중 singleton scope 빈에 request scope을 주입해서 사용해야 할 상황이 생겼다.
아래와 같이 인가 처리를 위한 커스텀 인터셉터에서 인증 정보를 담아두는 객체를 주입받아 사용해야 했기 때문이다.
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
private final AuthenticationContext authenticationContext;
...
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String token = extractToken(header);
String subject = jwtTokenProvider.extractSubject(token);
authenticationContext.setPrincipal(subject);
return true;
}
}
@Component
@RequestScope
public class AuthenticationContext {
private String principal;
public String getPrincipal() {
return principal;
}
public void setPrincipal(String principal) {
this.principal = principal;
}
}
AuthenticationContext는 principal이라는 상태를 가지기 때문에 싱글톤으로 사용할 경우 멀티 쓰레드 환경에서 버그를 일으킬 수 있다. 하나의 요청에서는 같은 인증 정보를 사용하기 때문에 빈의 스코프를 request로 제한하여 사용하면 된다.
애플리케이션은 문제없이 잘 작동했다. 그런데 다음과 같은 의문이 들었다. 커스텀 인터셉터는 싱글톤 스코프니까 애플리케이션에서 단 한 번만 초기화될 텐데, 어째서 AuthenticationContext는 매번 새로 생성될까?
실제로 로그를 찍어보면 AuthenticationInterceptor의 참조값은 변하지 않지만, AuthenticationContext의 참조값은 매 요청마다 변한다.
Bean Scope
일반적으로 싱글톤 스코프 빈에 범위가 더 좁은 스코프를 가진 빈을 주입하면 주입받은 빈은 단 한 번밖에 초기화되지 않는다.
아래처럼 빈 스코프를 프로토타입으로 설정하면 컨테이너에 빈을 요청할 때마다 새로운 객체를 생성해서 반환한다.
class PrototypeTest {
@Test
void prototypeBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
}
@Configuration
static class Config {
@Bean
@Scope("prototype")
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
}
static class PrototypeBean {
}
}
하지만 프로토타입 빈을 싱글톤 빈에 주입한다면, 프로토타입 빈 또한 한 번만 생성된다.
class PrototypeTest {
@Test
void prototypeBeanInSingletonBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
assertThat(singletonBean1.getPrototypeBean())
.isSameAs(singletonBean2.getPrototypeBean());
}
@Configuration
static class Config {
@Bean
@Scope(value = "prototype")
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
@Bean
public SingletonBean singletonBean() {
return new SingletonBean(prototypeBean());
}
}
static class PrototypeBean {
}
static class SingletonBean {
private PrototypeBean prototypeBean;
public SingletonBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public PrototypeBean getPrototypeBean() {
return prototypeBean;
}
}
}
Scoped Proxy Bean
하지만 싱글톤 빈 안에 더 좁은 스코프의 빈을 주입할 때 해당 빈을 새로 생성해서 사용하고 싶은 경우가 있다.
이때는 두 가지 방법이 있는데 조금 차이가 있다.
- ObjectProvider
- getObject를 호출할 때 객체를 생성한다.
- Scoped Proxy Bean
- 프록시를 넣어놨다가 메서드 호출 시점에 실제 객체를 생성하고 요청을 위임한다
- @Scope 애너테이션의 proxyMode를 TARGET_CLASS로 설정하면 된다.
주입할 빈을 직접 사용하지 않고 ObjectProvider를 주입한다. 해당 빈을 사용할 때는 getObject()를 호출해서 빈을 가져와서 사용한다. getObject를 호출할 때까지 빈 생성을 지연할 수 있다.
class PrototypeTest {
@Test
void prototypeBeanInSingletonBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
assertThat(singletonBean1.getPrototypeBean())
.isNotSameAs(singletonBean2.getPrototypeBean());
}
@Configuration
static class Config {
@Bean
@Scope(value = "prototype")
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
@Bean
public SingletonBean singletonBean() {
return new SingletonBean();
}
}
static class PrototypeBean {
}
static class SingletonBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanObjectProvider;
public PrototypeBean getPrototypeBean() {
return prototypeBeanObjectProvider.getObject();
}
}
}
다른 방법으로는 proxyMode 값을 설정해 Scoped Proxy Bean 사용하는 것이 있다.
주의할 점은 프로토타입 스코프임에도 불구하고 컨테이너에서 여러 번 요청해도 같은 객체가 나온다는 점이다. 그리고 이 객체는 실제 객체가 아닌 프록시이다.
class PrototypeBeanProxyTest {
@Test
void prototypeBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
assertThat(prototypeBean1).isSameAs(prototypeBean2); // 두 객체가 같다
}
@Test
void prototypeBeanInSingletonBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
assertThat(singletonBean1.getPrototypeBean())
.isSameAs(singletonBean2.getPrototypeBean()); // 마찬가지
}
@Configuration
static class Config {
@Bean
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
@Bean
public SingletonBean singletonBean() {
return new SingletonBean(prototypeBean());
}
}
static class PrototypeBean {
}
static class SingletonBean {
private PrototypeBean prototypeBean;
public SingletonBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public PrototypeBean getPrototypeBean() {
return prototypeBean;
}
}
}
proxyMode를 TARGET_CLASS로 설정하면 메서드를 호출하는 시점에 실제 객체를 생성한다. 그래서 로그를 찍어보면, 메서드를 호출하기 전과 호출할 때의 객체의 주소 값이 다르다.
class PrototypeBeanProxyTest {
@Test
void test() {
ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
SingletonBean singletonBean = ac.getBean(SingletonBean.class);
//increaseAndGetCount를 호출할 때 프로토타입 빈의 주소를 콘솔에 출력하도록 설정
assertThat(singletonBean.increaseAndGetCount()).isEqualTo(1);
//In Singleton = PrototypeBeanProxyTest$PrototypeBean@53499d85
//this = PrototypeBeanProxyTest$PrototypeBean@1133ec6e
}
@Configuration
static class Config {
@Bean
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public PrototypeBean prototypeBean() {
return new PrototypeBean();
}
@Bean
public SingletonBean singletonBean() {
return new SingletonBean(prototypeBean());
}
}
static class PrototypeBean {
private int count = 0;
public int increaseAndGetCount() {
System.out.println("this = " + this);
count++;
return count;
}
}
static class SingletonBean {
private PrototypeBean prototypeBean;
public SingletonBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int increaseAndGetCount() {
System.out.println("In Singleton = " + prototypeBean);
return prototypeBean.increaseAndGetCount();
}
public PrototypeBean getPrototypeBean() {
return prototypeBean;
}
}
}
@RequestScope
프로젝트에서 @RequestScope를 사용하면 아무 문제가 없었던 이유는 @RequestScope가 @Scope(value = “request”, proxyMode = ScopedProxyMode.TARGET_CLASS)의 숏컷이기 때문이다.
또한 Request 스코프의 경우 빈의 생명 주기가 웹 요청과 같기 때문에 Application을 처음 구동할 때는 생성되지 않는다. 만약 Request 스코프의 빈을 다른 생명 주기의 빈에 주입해서 사용할 때는 반드시 프록시 모드로 사용해야 한다.
package org.springframework.web.context.annotation;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Scope(WebApplicationContext.SCOPE_REQUEST)
public @interface RequestScope {
/**
* Alias for {@link Scope#proxyMode}.
* <p>Defaults to {@link ScopedProxyMode#TARGET_CLASS}.
*/
@AliasFor(annotation = Scope.class)
ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}
따라서 제일 처음 가졌던 의문
커스텀 인터셉터는 싱글톤 스코프니까 애플리케이션에서 단 한 번만 초기화될 텐데, 어째서 AuthenticationContext는 매번 새로 생성될까?
이에 대한 답은 다음과 같다. AuthenticationInterceptor가 초기화될 때, AuthenticationContext의 프록시 객체가 주입된다, AuthenticationContext의 메서드가 호출될 때마다 실제 빈을 생성하고 요청을 위임한다.