스프링 컨테이너의 시작과 함께 스프링 빈이 생성되어서 스프링 컨테이너가 종료될 때까지 유지된다고 배웠다. 이유는 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문이다. 스코프는 말 그대로 빈이 존재할 수 있는 범위를 뜻한다.
스프링은 아래와 같은 다양한 스코프를 지원한다.
싱글톤(기본 스코프): 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프다.
프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입, 초기화 메서드까지 불러주는 것까지만 해주고 더는 관리하지 않는 매우 짧은 범위의 스코프다.
웹 관련 스코프
request: HTTP 고객 요청이 와서 애플리케이션에서 동작하다가 고객 요청이 response로 빠져나갈 것이다. 바로 이 들어오고 나갈 때까지의 생존 범위를 가지는 굉장히 특별한 스코프다.
session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프다. 세션은 보통 로그인 같은 곳에서 많이 쓰인다.
application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프다.
여기서 알아야 할 스코프는 싱글톤, 프로토타입, request 정도가 있다. 빈 스코프는 아래와 같이 지정할 수 있다.
<컴포넌트 스캔 자동 등록의 경우>
@Scope("prototype")
@Component
public class HelloBean {}
...
<수동 등록의 경우>
@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
return new HelloBean();
}
...
지금까지 싱글톤 스코프를 주구장창 사용했으니, 이제 프로토타입 스코프부터 확인해보자.
기존 싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환했다. 반면, 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.
이제 그림을 비교해보자.

위의 그림에서 클라이언트 A, 클라이언트 B, 클라이언트 C가 스코프가 싱글톤인 memberService 스프링 빈을 요청하고 있다. 그렇다면 3번 요청을 해도 항상 같은 인스턴스의 스프링 빈을 반환할 것이다.

마찬가지로, 클라이언트 A, 클라이언트 B, 클라이언트 C가 동시든, 순차적으로 하든 스코프가 프로토타입인 prototypeBean 빈을 스프링 컨테이너에게 내놓으라고 하면 요청하면 스프링 컨테이너는 딱 그 시점에 빈 객체를 생성한다. 그리고 필요하다면 DI까지 다 하고, 초기화 메서드까지 호출한 다음에 완성이 되면 클라이언트에게 던져준다. 그걸로 끝이다. 더 이상 스프링 컨테이너가 관리하지 않는다.

위의 그림을 보면 알겠지만, 클라이언트 B가 또 요청을 했을 때, 새로운 걸 다시 만들어서 던져준다. 요청할 때마다 이런 과정이 반복되는 것이다.
그럼 이제 빈을 누가 관리하지? 바로 프로토타입 빈을 호출한 클라이언트에게 책임이 있다. 그래서 그 클라이언트가 책임지고 종료 메서드를 호출해줘야 한다.
이제 코드로 확인해보자. 먼저 싱글톤 스코프로 스프링 빈을 2개 조회하는 테스트를 작성해보자.
package hello.core.scope;
import org.junit.jupiter.api.Test;
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.assertThat;
public class SingletonTest {
@Test
public 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);
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.PrototypeTest$SingletonBean@54504ecd
singletonBean2 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd
Closing SingletonBean.destroy
*/
실행해보면, 빈 초기화 메서드 init과 종료 메서드 destroy가 모두 호출되는 것을 확인할 수 있다.
이제 프로토타입 스코프 빈 테스트를 진행해보자.
package hello.core.scope;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import static org.assertj.core.api.Assertions.*;
public class PrototypeTest {
@Test
public 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");
}
}
}
/*
find prototypeBean1
PrototypeBean.init
find prototypeBean2
PrototypeBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@13d4992d
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@302f7971
*/
이처럼 싱글톤 빈은 스프링 컨테이너 생성 시점에 초기화 메서드가 실행되지만, 프로토타입 스코프 빈은 스프링 컨테이너에서 스프링 빈을 조회할 때 생성되고 초기화 메서드도 실행된다. 위의 예제에서는 프로토타입 빈을 2번 조회했으므로 완전히 다른 스프링 빈이 생성되고, 초기화도 2번 실행된 것을 확인할 수 있다.
그리고 싱글톤 빈은 스프링 컨테이너가 관리하기 때문에 종료될 때 종료 메시지가 실행됐다. 반면, 프로토타입 빈은 스프링 컨테이너가 생성과 의존 관계 주입, 초기화까지만 관여하고 더는 관리하지 않는다. 따라서 프로토타입 빈은 스프링 컨테이너가 종료될 때 @PreDestroy 메서드 같은 종료 메서드가 전혀 실행되지 않은 것을 볼 수 있다.
프로토타입 스코프의 빈을 싱글톤 스코프의 빈과 함께 사용할 때는 의도한 대로 잘 동작하지 않을 수 있으므로 매우 주의해야 한다. 일단 스프링 컨테이너에 프로토타입 빈을 직접 요청하는 예제를 살펴보자.

현재 클라이언트 A가 스프링 컨테이너에게 프로토타입 빈을 요청을 하면, 빈이 그 시점에 생성된다. 근데 만약, 그 빈 객체 안에 count 라는 필드가 있고 클라이언트 A가 addCount 라는 메서드를 호출해서 프로토타입 빈 내부에 있는 count 값이 1만큼 증가했다고 해보자.
근데 여기서 다른 클라이언트 B가 나타나서 또 스프링 컨테이너에게 요청을 하는 상황이 발생한다고 한다면?

동일하게 클라이언트 B가 프로토타입 빈을 반환받고 addCount 메서드를 호출한다면 어떻게 될까? 새로운 빈을 받았으니까 당연히 0에서 1로 증가할 것이다.
그러면 서로 다른 프로토타입 빈이 2개고 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);
}
@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");
}
}
}
그러면 이번엔 싱글톤 빈에서 프로토타입 빈을 사용하면 어떻게 되는지 살펴보도록 하자.

clientBean 싱글톤 빈에서 프로토타입 빈을 주입받아 사용하는 경우인 것이다. 그렇다면 clientBean은 스프링 컨테이너에게 프로토타입 빈을 요청할 것이다. 그럼 스프링 컨테이너는 요청한 그 시점에 프로토타입 빈을 생성해서 딱 주입을 해주겠지? 그럼 clientBean은 딱 초기화되는 그 시점, 의존 관계를 주입하는 그 시점에 프로토타입 빈을 가지고 있는 거다.
근데 여기서 스프링 컨테이너는 프로토타입 빈을 더 이상 관리하지 않게 되는 것이다. clientBean이 관리하게 된다. 여기서 클라이언트 A가 clientBean의 로직을 호출한다고 해보자.

일단 클라이언트 A는 clientBean을 스프링 컨테이너에 요청을 할 것이고, 당연하게도 clientBean은 싱글톤이므로 항상 같은 인스턴스를 반환할 것이다. 그리고 나서 clientBean.logic()을 호출하는 상황이다. 근데 저 logic()이 뭐냐? prototypeBean의 addCount() 메서드를 호출해주는 로직인 것이다. 그럼 프로토타입 빈의 count 필드를 1만큼 증가 시키겠지? 그리고 클라이언트 A는 1을 받게 될 것이다.
이제부터 중요하다. 아래 그림을 자세히 살펴보자.

클라이언트 B가 clientBean을 받아서 logic() 을 호출하면 prototypeBean 의 addCount() 메서드를 호출할 것이다. 어떻게 될까? 기존에 있던 프로토타입 빈을 그대로 사용한다. 그래서 결과적으로 클라이언트 B는 2를 받게 되는 것이다.
왜 이렇게 되는 걸까? 여기서 아주 중요한 점이 있는데, 일단 clientBean은 진짜 싱글톤 빈이기 때문에 클라이언트 A든 클라이언트 B든 모두 같은 clientBean을 사용할 것이다. 근데 clientBean이 내부에 가지고 있는 prototypeBean은 이미 과거에 주입이 끝난 빈이다. 주입 시점에 스프링 컨테이너에 요청을 해서 프로토타입 빈이 새로 생성된 것이지, logic() 안에서 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");
}
}
}
스프링은 일반적으로 싱글톤 빈을 사용하므로, 싱글톤 빈이 프로토타입 빈을 사용하게 된다. 그런데 싱글톤 빈은 생성 시점에만 의존 관계 주입을 받기 때문에, 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지되는 것이 문제인 것이다. 지금 원하는 것은 logic()을 호출할 때마다 해당 prototypeBean을 항상 새로 만들어서 사용하는 것이다. 그럼 어떻게 해야 할까?
어떻게 하면 사용할 때마다 항상 새로운 프로토타입 빈을 생성할 수 있을까?
가장 간단한 방법은 싱글톤 빈이 프로토타입을 사용할 때마다 스프링 컨테이너에 새로 요청하는 것이다. 아래 코드처럼 ClientBean이 그냥 무식하게 @Autowired로 ApplicationContext를 받는 것이다.
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");
}
}
}
실행해보면 ac.getBean()을 통해서 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다. 그리고 외부에서 의존 관계를 주입 받는 게 아니라 내게 필요한 의존 관계를 직접 찾는다. 이걸 Dependency Lookup(DL), 즉 의존 관계 조회(의존 관계 탐색)라고 한다.
하지만, 이렇게 스프링의 ApplicationContext 전체를 주입 받게 되면, 스프링 컨테이너에 너무 종속적인 코드가 되고, 나중에 단위 테스트를 짜기 어려워진다. 그래서 지금 필요한 기능은 지정한 프로토타입 빈을 컨테이너에서 대신 찾아주는 DL 정도의 기능만 제공하는 무언가가 있으면 된다. DL을 대신해주는 구세주가 없을까?
지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공하는 것이 바로 ObjectProvider이다. 참고로 과거에는 ObjectFactory가 있었는데, 여기에 편의 기능을 추가해서 ObjectProvider가 만들어졌다.
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
실행을 해보면 prototypeBeanProvider.getObject()를 통해서 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다. 하지만 프로토타입 전용으로 사용되니 마니 이런 것이 핵심이 아니다. 핵심 컨셉은 ObjectProvider는 스프링 컨테이너를 통해서 내가 직접 찔러서 조회하기보다는 대신 조회해주는 대리자 정도로 생각하자.
아무튼 ObjectProvider의 getObject()를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다. 근데 이게 프로토타입 빈이기 때문에 스프링 컨테이너가 요청을 하면 스프링 컨테이너는 그때 만들어서 반환해주는 것이다.
특징을 정리해보자.
ObjectFactory: 기능이 단순하다. 별도의 라이브러리가 필요 없다. 스프링에 의존적이다.ObjectProvider: ObjectFactory를 상속했기 때문에 옵션이나 스트림 처리 등 편의 기능이 많고, 별도의 라이브러리가 필요 없지만, 얘도 스프링에 의존적이다.그래서 스프링에 의존적이지 않는 기술이 등장한다.
바로 javax.inject.Provider 라는 JSR-330 자바 표준을 사용하는 방법이다. 스프링부트 3.0에서는 jakarta.inject.Provider을 사용한다.
이 방법을 사용하기 위해서는 SpringBoot 3.0 이상에서는 gradle에 jakarta.inject:jakarta.inject-api:2.0.1를 추가하면 된다.
// javax.inject.Provider 참고용 코드
package javax.inject;
public interface Provider<T> {
T get();
}
@Autowired
private Provider<PrototypeBean> provider;
public int logic() {
PrototypeBean prototypeBean = provider.get();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
실행해보면 provider.get()을 통해서 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다. 해당 메서드를 호출하면 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다(DL). 자바 표준이고, 기능이 단순하므로 단위 테스트를 만들거나 mock 코드를 만들기 훨씬 쉬워진다. Provider는 지금 딱 필요한 DL 정도의 기능만 제공한다.
JSR-330 표준을 사용하면 별도의 라이브러리를 설정해줘야 하지만, get() 메서드 하나로 기능이 매우 단순해지고, 스프링이 아닌 다른 컨테이너에서도 사용할 수 있다.
이제 다시 처음으로 돌아가보자. 프로토타입 빈을 언제 사용하는 걸까? 매번 사용할 때마다 의존 관계 주입이 완료된 새로운 객체가 필요하면 그때 사용하면 된다. 그런데 실무에서 웹 애플리케이션을 개발해보면, 싱글톤 빈으로 대부분의 문제를 해결할 수 있기 때문에 프로토타입 빈을 직접적으로 사용하는 일은 거의 없을 것이다.
지금까지 싱글톤과 프로토타입에 대해 학습했다. 싱글톤은 스프링 컨테이너의 시작과 끝, 그리고 프로토타입은 생성하고 의존 관계 주입 및 초기화하고 딱 던져주고 끝이었다.
그럼 웹 스코프는 뭐냐? 웹 환경에서만 동작하기 때문에 웹 스코프라고 한다. 얘는 프로토타입과는 다르게 스프링 컨테이너가 종료 시점까지 관리를 해준다. 그래서 종료 메서드 호출이 된다.
웹 스코프의 종류에는,
request : HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프를 말한다. HTTP 요청마다 각각 따로 호출이 되는 것이다. 그래서 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.
클라이언트 A, 클라이언트 B : 각각 독립적인 사용자가 브라우저 등에서 서버에 HTTP 요청을 보내는 주체다.
Controller@x01 : 싱글톤 스코프 컨트롤러 객체로, 모든 요청에 대해 동일한 인스턴스를 사용한다.
Service@x02 : 마찬가지로 싱글톤 스코프의 서비스 객체다.
myLogger : @RequestScope로 선언된 빈이다. HTTP 요청마다 새롭게 생성되서, 요청에 종속된 데이터를 기록하거나 처리한다.
A 전용@x03, B 전용@x04 : 각각 클라이언트 A와 클라이언트 B의 요청에서 생성된 myLogger 객체다. 이들은 서로 독립된 인스턴스다.
클라이언트 A가 요청 시(빨간색 흐름)
Controller@x01이 호출된다. 이 컨트롤러는 싱글톤 객체다.myLogger 빈을 요청한다.@RequestScope이기 때문에 클라이언트 A에 대해 전용 인스턴스 A 전용@x03이 생성된다.Service를 호출해서 로직을 처리하는데, 이때 Service도 myLogger를 사용한다.Service@x02는 동일한 A 전용@x03을 사용하게 된다.
클라이언트 B가 요청 시(파란색 흐름)
Controller@x01이 요청을 처리한다.myLogger 인스턴스 B 전용@x04가 생성된다.Service도 동일하게 B 전용@x04를 사용하여 로직을 처리한다.
핵심 개념을 정리하자면, @RequestScope는 요청마다 새로운 객체가 생성되어 공유되지 않고, 싱글톤 빈(Controller, Service)은 모든 요청에서 공유되는 객체다. myLogger는 요청마다 생성되는 객체로, 각 요청 사이에 데이터가 섞이지 않도록 보장한다.
session : HTTP Session과 동일한 생명주기를 가지고 있는 스코프다.application : 서블릿 컨텍스트(ServletContext)와 동일한 스코프를 가지고 있다.websocket : 웹 소켓과 동일한 생명주기를 가지는 스코프다.
일단 웹 환경이 동작하도록 build.gradle에 라이브러리를 추가해야 한다.
// web 라이브러리 추가
implementation 'org.springframework.book:spring-boot-starter-web'
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);
}
}
위 코드는 로그를 출력하기 위한 MyLogger 클래스다. @Scope(value = "request")를 사용해서 request 스코프로 지정했다. 이제 이 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.
빈이 생성되는 시점에 자동으로 @PostConstruct 초기화 메서드를 사용해서 uuid를 생성해서 저장해둔다. 이 빈은 HTTP 요청 당 하나씩 생성되므로, uuid를 저장해두면 다른 HTTP 요청과 구분할 수 있다. 빈이 소멸되는 시점에 @PreDestroy를 사용해서 종료 메시지를 남긴다. 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";
}
}
로거가 잘 작동하는지 확인하는 테스트용 컨트롤러로, 여기서 HttpServletRequest를 통해서 요청 URL을 받았다. 받은 requestURL 값을 myLogger에 저장해둔 것이다. myLogger는 HTTP 요청 당 각각 구분되므로 다른 HTTP 요청 때문에 값이 섞이는 걱정은 하지 않아도 된다.
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);
}
}
비즈니스 로직이 있는 서비스 계층에서도 로그를 출력해보는 과정이다. 여기서 중요한 점은, 만약 request 스코프를 사용하지 않고 파라미터를 넘긴다면, 파라미터가 많아서 지저분해진다. 더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 된다.
웹과 관련된 부분은 컨트롤러까지만 사용해야 한다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋다. request 스코프의 MyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, MyLogger의 멤버변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있다.

실행해보면, 기대했던 출력과 달리 오류가 발생한다. 왜냐? 스프링 컨테이너에게 request 스코프를 요청했는데, 아직 고객의 요청이 오지 않았기 때문에 오류가 발생한 것이다. 해당 빈은 실제 고객 요청이 와야 생성할 수 있는 것이다.
결국 스프링 컨테이너에 스프링 빈을 요청하는 단계를, 의존 관계 주입 단계가 아니라 실제 고객 요청이 오고 난 다음으로 지연시켜야 한다. 이때 Provider를 쓰면 해결이 되지 않을까?
위의 문제를 해결하기 위해 ObjectProvider를 사용해보자.
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가 살아 있는 상태이므로, 스코프를 사용할 수 있는 상황으로 바뀌었다. 그래서 이제 해당 빈을 꺼낼 수 있다.

보다시피, 잘 작동한다. ObjectProvider 덕분에 ObjectProvider.getObject()를 호출하는 시점까지 request 스코프 빈의 생성을 지연할 수 있는 것이다. ObjectProvider.getObject()를 호출하는 시점에 HTTP 요청이 진행 중이므로, request 스코프 빈이 정상적으로 생성되는 것이다.
ObjectProvider.getObject()를 LogDemoController, LogDemoService에서 각각 한번씩 따로 호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환된다. 핵심은, 동시에 여러 요청이 온다 하더라도 그 요청마다 각각 객체를 따로 관리를 해준다는 것이다.
이것도 충분히 훌륭하지만, 더 들어가보자.
이번에는 프록시 방식을 사용해보자. 아까 Provider를 사용하기 전의 오류나던 코드를 아래처럼 수정해보자.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
실행해보면, 일단 서버가 뜨고 실행된다. 마치 Provider를 사용하는 것 같다.
proxyMode = ScopedProxyMode.TARGET_CLASS)를 추가하면 된다. 현재는 MyLogger라는 클래스이기 때문에 TARGET_CLASS를 사용한 것이고, 만약 적용 대상이 인터페이스면 INTERFACES를 사용하면 된다. 하여튼 이런 식으로 작성하면, MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.
주입된 myLogger를 확인해보면,
System.out.println("myLogger = " + myLogger.getClass());
/*
myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d
*/
@Scope의 proxyMode = ScopedProxyMode.TARGET_CLASS)를 설정하면 스프링 컨테이너는 CGLIB이라는 바이트 코드를 조작하는 라이브러리를 가지고, MyLogger 클래스를 상속받은 가짜 프록시 객체를 생성한다.
결과를 확인해보면 내가 등록한 순수한 MyLogger 클래스가 아니라 MyLogger$ $EnhancerBySpringCGLIB이라는 클래스로 만들어진 객체가 대신 등록된 것을 확인할 수 있다. 그리고 스프링 컨테이너에 myLogger라는 이름으로 진짜 대신에 이 가짜 프록시 객체를 등록한다. ac.getBean("myLogger", MyLogger.class)로 조회해도 프록시 객체가 조회되는 것을 확인할 수 있다. 그래서 의존 관계 주입도 이 가짜 프록시 객체가 주입되는 것이다.

가짜 프록시 객체는 실제 요청이 오면 그제서야 내부에서 진짜 빈을 요청하는 위임 로직이 들어있다. 쉽게 말해, 실제 얘를 사용할 시점이 오면 진짜를 스프링 컨테이너에서 찾아온다는 것이다.
가짜 프록시 객체는 내부에 진짜 MyLogger를 찾는 방법을 알고 있는 것이다. 그래서 클라이언트가 myLogger.log()를 호출하면 사실은 가짜 프록시 객체의 메서드를 호출한 것이다. 가짜 프록시 객체(MyLoggerProxy)는 request 스코프의 진짜 myLogger.log()를 호출한다.
가짜 프록시 객체는 원본 클래스를 상속 받아서 만들어졌기 때문에 이 객체를 사용하는 클라이언트 입장에서는 원본인지 아닌지도 모르게, 동일하게 사용할 수 있다. 이게 바로 다형성의 힘이다.
<동작 정리>
CGLIB이라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 생성해서 주입한다.
이 가짜 프록시 객체는 실제 요청이 오면 그제서야 내부에서 실제 빈을 요청하는 위임 로직이 들어있다.
가짜 프록시 객체는 실제 request 스코프와는 관계가 없다. 그냥 가짜이고, 내부에 단순한 위임 로직만 있고, 마치 싱글톤처럼 동작한다고 이해하면 된다.
<최종 정리>
프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request 스코프를 사용할 수 있다.
사실 Provider를 사용하든, 프록시를 사용하든 핵심은 “진짜 객체 조회를 꼭 필요한 시점까지 지연 처리한다는 점이다.” 진짜 HTTP 요청이 들어오기 전까지는 가짜로 버티는거다.
단지 애노테이션 설정 변경만으로 원본 객체를 프록시 객체를 대체할 수 있다. 이것이 바로 다형성과 DI 컨테이너가 가진 큰 강점이다.
꼭 웹 스코프가 아니어도 다른 곳에서 프록시를 사용할 수 있다.
마치 싱글톤을 사용하는 것 같지만 다르게 동작하기 때문에 주의해서 사용해야 하고, 이런 특별한 스코프는 꼭 필요한 곳에서만 최소화해서 사용해야 한다.