스코프 빈이라는 새로운 용어가 등장했지만, 사실 스코프 빈은 지금까지 공부했던 내용이라고 생각해도 된다.
scope는 범위라는 뜻을 가진다. 즉, 스코프 빈은 빈이 스프링 컨테이너 내에서 생성되고 유지되며 소멸되는 범위를 말한다.
💡스코프 빈 VS 빈 생명주기
스코프 빈과 빈 생명주기는 비슷한 듯하지만, 정확히 따지면 다른 의미를 가진다. 스코프 빈은 "Bean이 몇 번 생성되며, 얼마나 오래 유지되는가?", 빈 생명주기는 "Bean이 컨테이너에서 어떤 과정을 거치는가?"를 의미한다.
스프링이 지원하는 스코프는 다음과 같다.
싱글톤 스코프
프로토타입 스코프
웹 스코프 : request, session, application, websocket
싱글톤 스코프
싱글톤 스코프은 지금까지 공부했던 스프링 빈을 말한다. 기본값으로 설정되어 있기에 따로 지정하지 않아도 되었던 것 뿐이다.
@Component
public class MemoryMemberRepository implements MemberRepository { ... }
이렇게 컴포넌트 스캔을 통해 자동으로 스프링 컨테이너에 등록할 때, 우리는 이 빈이 싱글톤으로 관리된다고 배웠다.
사실 이 코드에는 생략된 애노테이션이 존재한다.
@Component
@Scope("singleton")
public class MemoryMemberRepository implements MemberRepository { ... }
싱글톤 스코프는 기본값으로 설정되어 있기에, 이렇게 @Scope("singleton")를 작성하지 않아도 상관없다. 명시적으로 이 빈이 싱글톤이라고 표기하고 싶을 때만 사용하면 된다.
프로토타입 스코프
프로토타입이라는 단어가 낯설지만, 그냥 싱글톤으로 관리되지 않는 빈을 의미한다고 생각하면 된다. 프로토타입 스코프를 스프링 컨테이너에 조회하면 항상 새로운 인스턴스를 생성해 반환해준다.
개발을 할 때, 싱글톤으로 관리하지 않아야 더 편한 상황이 있을 수도 있다. 이럴 때 사용하는 것이 프로토타입 스코프다.
프로토타입 스코프를 사용하는 방법도 매우 간단하다. 그냥 @Scope("prototype")을 붙여주면 된다.
@Component
@Scope("prototype")
public class PrototypeBean { ... }
이렇게하면, 이 빈은 프로토타입 빈으로 인식되어, 요청할 때마다 스프링 컨테이너에서 새로운 인스턴스를 생성해 반환된다.
여기서 프로토타입 빈의 중요한 특징이 있는데, 스프링 컨테이너는 프로토타입 빈의 생성, 의존관계 주입, 초기화까지만 관여한다는 것이다. 즉, 빈 생명주기에서 초기화 이후의 과정 --빈 사용, 소멸 콜백(Destroy Callback), 스프링 종료--는 관여하지 않는다. (빈 생명주기에 관해 기억나지 않는다면, section9 빈 생명주기 콜백의 초반 부분을 보고 올 것을 추천한다.)
@Component
@Scope("prototype")
public class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
isNotSame() 메소드로 진행한 테스트가 통과되었다는 것. 즉, 프로토타입 빈은 요청할 때마다 새로운 인스턴스가 반환된다는 특징.
ac.close()로 컨테이너를 종료했음에도, 소멸 메소드가 실행되지 않았다는 것. 즉, 프로토타입 빈은 초기화 콜백 이후로는 스프링 컨테이너에서 관리하지 않는다는 특징.
이 특징을 잘 기억해두자.
싱글톤 빈에서 프로토타입 빈 사용하기
빈을 사용할 때 이렇게 사용하고 싶을 수가 있다.
public class SingletonWithPrototype {
@Scope("singleton")
static class ClientBean {
private final PrototypeBean prototypeBean;
public ClientBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
prototypeBean.addCount();
System.out.println(prototypeBean.getCount());
return prototypeBean.getCount();
}
}
@Scope("prototype")
static class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
}
}
이렇게 싱글톤 빈 안에서, 매번 새로이 생성되는 프로토타입 빈을 이용하고 싶은 것이다.
이 코드의 의도는 이것이다.
싱글톤 빈인 ClienBean에서 logic()을 호출하면,
매번 새로운 프로토타입 빈인 PrototypeBean이 스프링 컨테이너에서 생성되고
addCount()와 getCount()를 각각 호출한다.
결과적으로 한 번의 addCount()가 호출되었으므로, 1이 출력되어야 한다.
@Test
void singletonClientUsePrototype() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class, ClientBean.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);
}
즉, 우리가 의도한 것처럼 코드가 잘 동작했다면, 이 테스트 코드는 성공해야 할 것이다. ClientBean은 싱글톤이므로 clientBean1과 clientBean2은 같은 인스턴스지만, 이 빈은 프로토타입 빈을 각기 사용하므로 logic()을 두 번 호출하여도 출려되는 count의 값은 1이어야 한다.
하지만 이 테스트는 실패한다.
천천히 생각해보면 당연한 일이다. 싱글톤 빈은 단 하나의 인스턴스만을 사용한다. 당연히 싱글톤 빈이 가지고 있는 데이터들도 싱글톤 빈에 종속된다.
이는 아래와 같다.
@Scope("singleton")
static class ClientBean {
private final int abc = 0;
...
public int logic() {
abc++;
...
}
import 되는 라이브러리를 보면 알 수 있듯이 ObejectProvider는 스프링에서 제공하는 기능, JSR-330 Provider는 자바 표준 기능이다.
사용법은 비슷하므로 스프링에서 기본으로 제공하는 ObjectProvider를 기준으로만 작성했다.
@Scope("singleton")
static class ClientBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
ObjectProvider<PrototypeBean> prototypeBeanProvider : PrototypeBean*을 제공하는 *ObjectProvider 선언
prototypeBeanProvider.getObject(): 새로운 PrototypeObeject 생성
이렇게 Provider는 의존관계 조회 DL 기능만을 제공하는 것을 알 수 있다.
💡Autowired members must be defined in valid Spring bean 에러
IntelliJ IDEA 2024.3.1.1 ver 기준
인텔리제이에서 Provider에 @Autowired를 했을 때, 이런 오류가 나타나는 분들이 있을 것이다. (나도 그렇다.) 실제로 코드를 돌려보면, 컴파일 상에 오류가 나타났음에도 제대로 동작하는 것을 확인할 수 있다. 실제로 의존관계도 주입이 잘 된 것도 확인할 수 있을 것이다.
이는 코드가 잘못된 것이 아니라, IDE 자체에서 Provider의 복잡한 동작을 제대로 분석하지 못해서 나타나는 오류이다.
ChatGPT의 답변
ObjectProvider의 동적 동작 특성
ObjectProvider는 의존성을 즉시 주입하는 것이 아니라, 필요한 시점에 스프링 컨테이너로부터 의존성을 동적으로 가져오는 역할을 합니다.
IntelliJ는 @Autowired가 즉시 주입 가능한 Bean을 대상으로 사용된다고 가정하기 때문에, ObjectProvider의 지연 주입 동작을 잘못 해석할 수 있습니다.
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 빈은 HTTP 요청이 들어올 때 생성되는 스코프라고 하였다. 이 점을 유의하여 다시 한번 코드를 살펴보자.
@Autowired
public LogDemoController(MyLogger myLogger) {
this.myLogger = myLogger;
}
컨트롤러에서 request 빈인 MyLogger를 주입 받으려고 하고 있다. 즉, 스프링 애플리케이션을 실행하는 시점에 request 빈을 주입받으려고 하는 것이 문제다.
이 시점에서는 당연히 HTTP 요청이 들어오지 않았으므로, request 빈은 생성되지 않는다. 없는 것을 주입하려고 하기 때문에 스프링에서 에러를 발생시키는 것이다.
그렇다면 이 문제를 해결하는 방법은 2가지가 있다.
Provider
첫 번째는 간단하게 위에서 공부한 Provider를 사용하는 것이다. 즉, 실행할 때 의존관계 주입 DI를 하는 것이 아니라 필요한 시점에 의존관계 조회 DL을 하는 것이다.
@Controller
public class LogDemoController {
private final ObjectProvider<MyLogger> myLoggerProvider;
@Autowired
public LogDemoController(ObjectProvider<MyLogger> myLoggerProvider) {
this.myLoggerProvider = 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");
return "OK";
}
}
이렇게 하면, 스프링 애플리케이션을 실행하는 시점에는 request 빈이 생성되지 않고 실행 후, HTTP 요청이 들어오면 provider를 통해 DL을 해주므로 정상적으로 동작하게 할 수 있다.
프록시
Provider 보다도 간단하게 이를 해결할 수 있는 방법이 있다. 바로 프록시라는 것인데, 아래와 같이 매우 간단하게 설정할 수 있다.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger { ... }
이렇게만 해주면 된다.
프록시는 영어로 '대리인'을 의미한다. 스프링 애플리케이션 실행 시점에는 MyLogger 가짜 프록시 클래스를 생성하여 주입한 후, 실제 HTTP 요청이 들어왔을 때만 진짜 MyLogger 클래스를 호출하게끔 하는 것이다.
실제로 이 프록시 클래스를 출력하여 확인해보면,
class atrafelis.core.common.MyLogger$$SpringCGLIB$$0
이렇게 MyLogger 클래스가 아닌, 뒤에 무언가 이상한 문자열이 추가된 MyLogger$$SpringCGLIB$$0라는 클래스가 대신 주입되어 있는 것을 확인할 수 있다.
뭔가 익숙하지 않은가?
싱글톤 컨테이너에서 공부했던 스프링 컨테이너가 싱글톤을 유지하기 위해 하는 일과 비슷하다. 스프링에서 내부적으로 프록시를 위한 가짜 클래스를 만들어낸 후, 컨테이너에 등록해버리는 것이다. (당연히 ac.getBean("myLogger", MyLogger.class)로 조회해도 프록시 클래스가 조회된다.)
이후 이 프록시 클래스에 요청이 들어오면, 이 프록시 클래스가 진짜 클래스를 호출하는 방식으로 동작한다.
프록시 클래스의 동작 과정
여기서 중요한 것은 가짜 프록시 객체는 실제 request 스코프와는 관계가 없다는 것이다. 내부에 위임 로직만 있는 가짜 객체라는 사실을 명심해야 한다.