[Spring] 스프링 빈 스코프(Bean Scope)
빈 스코프(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이 되어야 한다.
- 싱글톤 빈에서 프로토타입 빈을 사용할 경우, 싱글톤이 생성되는 시점에 의존 관계 주입을 받으며 프로토타입 빈이 생성되지만 싱글톤 빈과 함께 계속 유지가 된다.
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