Backend/Spring

빈 스코프

olsohee 2023. 12. 30. 16:54

빈 스코프는 빈이 존재할 수 있는 범위를 뜻한다. 스프링은 다음과 같은 스코프를 지원한다.

  • 싱글톤: 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • 프로토타입: 스프링 컨테이너가 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는, 매우 짧은 범위의 스코프이다.
  • 웹 관련 스코프: 
    • request: HTTP 요청 하나가 들어오고 나갈 때까지 유지된다. 각각의 HTTP 요청마다 별도로 빈 객체가 생성되고 관리된다.
    • session: HTTP Session과 동일한 생명주기를 갖는다. 
    • application: 웹의 서블릿 컨텍스트와 동일한 생명주기를 갖는다. 
    • websocket: 웹 소켓과 동일한 생명주기를 갖는다.

싱글톤 스코프

싱글톤 빈은 스프링 컨테이너 생성 시점에 빈이 생성되고 스프링 컨테이너 종료 시점에 빈이 소멸된다. 따라서 스프링 컨테이너는 싱글톤 객체를 반환할 때 항상 같은 객체를 반환한다.

프로토타입 스코프

프로토타입 빈의 특징은 다음과 같다.

  • 싱글톤 빈을 스프링 컨테이너에서 조회하면 항상 같은 객체를 반환한다.
    반면, 프로토타입 빈은 스프링 컨테이너에서 조회할 때마다 항상 새로운 인스턴스가 생성되어 반환된다.
  • 싱글톤 빈은 스프링 컨테이너가 생성될 때 스프링 빈이 생성되고 의존관계가 주입되고 초기화 메소드가 실행된다.
    반면, 프로토타입 빈은 스프링 컨테이너에서 프로토타입 빈을 조회할 때 생성되고 의존관계가 주입되고 초기화 메소드가 실행된다. 
  • 싱글톤 빈은 스프링 컨테이너가 계속 관리하기 때문에 스프링 컨테이너가 종료될 때 빈의 종료 메소드가 실행되고 빈이 소멸된다.
    반면, 프로토타입 빈은 스프링 컨테이너가 빈의 생성과 의존관계 주입, 그리고 초기화까지만 관여하고, 그 뒤로는 더이상 관리하지 않는다. 따라서 프로토타입 빈은 스프링 컨테이너가 종료될 때 종료 메소드가 실행되지 않는다.

프로토타입 빈과 싱글톤 빈을 함께 사용할 때 주의점

프로토타입 빈과 싱글톤 빈을 함께 사용할 때는 주의할 점이 있다. 만약 다음과 같이 싱글톤 빈이 프로토타입 빈에 의존한다고 가정하자. 

public class ClientBean {

	private final PrototypeBean prototypeBean;

	@Autowired
	public ClientBean(PrototypeBean prototypeBean) {
		this.prototypeBean = prototypeBean;
	}
}

@Scope("prototype") // 프로토타입 스코프 지정
public class PrototypeBean {}

  • ClientBean은 싱글톤이므로, 스프링 컨테이너 시점에 함께 생성되고 의존관계도 주입된다. 
  • 의존관계 주입은 생성자를 통해 이뤄진다. 따라서 ClientBean의 생성자가 호출될 때, 스프링 컨테이너에게 PrototypeBean 빈을 요청한다. 
  • PrototypeBean는 프로토타입 빈이다. 따라서 스프링 컨테이너는 새로 프로토타입 빈을 생성해서 반환한다.
  • 이제 ClientBean은 내부에 프로토타입 빈을 보관한다. (정확히는 프로토타입 빈의 참조값을 보관한다.)

  • 클라이언트 A가 ClientBean을 스프링 컨테이너에게 요청해서 빈 객체를 받는다. ClientBean은 싱글톤이므로 항상 같은 객체가 반환된다.
  • 클라이언트 A는 ClientBean의 메소드를 통해 PrototypeBean의 내부 필드 값을 변경한다. 

  • 클라이언트 B도 ClientBean을 스프링 컨테이너에게 요청해서 빈 객체를 받는다. 이때 ClientBean는 싱글톤이므로 클라이언트 A가 사용한 객체와 같은 객체이다. 
  • 그리고 클라이언트 B가 ClientBean의 메소드를 통해 PrototypeBean의 내부 필드 값을 변경한다.
  • 여기서 문제가 발생한다. 클라이언트 A와 B가 각각 다른 프로토타입 빈의 필드 값을 변경한 것이 아니라, 같은 프로토타입 빈의 필드 값을 변경했기 때문이다. 그 이유는 다음과 같다.
    • 아무리 PrototypeBean이 프로토타입 빈이라고 하더라도, ClientBean이 생성될 때 PrototypeBean의 생성과 두 객체 간의 의존성 주입이 끝났기 때문에 ClientBean이 참조하는 PrototypeBean은 딱 하나로 고정되어 있다.
    • 즉, ClientBean이 내부에 가지고 있는 프로토타입 빈은 이미 과거에 주입이 끝난 빈이다. PrototypeBean은 주입 시점에 스프링 컨테이너에게 요청해서 새로 생성되는 것이지, ClientBean과 의존성이 주입된 PrototypeBean는 하나이다.

Provider로 문제 해결

그렇다면 싱글톤 빈과 프로토타입 빈을 함께 사용할 때 어떻게 사용할 때마다 프로토타입 빈을 새로 생성할 수 있을까? 바로 다음과 같이 로직을 실행할 때마다 새로 스프링 컨테이너에게 프로토타입 빈을 요청하면 된다.

public class ClientBean {

	@Autowired
	private ApplicationContext ac;

	public int logic() {
		PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class); // 스프링 컨테이너로부터 새로운 프로토타입 빈을 받는다.
		// 로직 
    }
}

위 예제에서는 PrototypeBean와의 의존성을 외부로부터 주입받는 것이 아니라, ClientBean이 자기 자신의 내부에서 스프링 컨테이너로부터 필요한 PrototypeBean 객체를 찾는다. 이렇게 의존관계를 외부에서 주입받는게 아니라 직접 필요한 의존관계를 찾는 것을 의존관계 탐색(Dependency Lookup, DL)이라고 한다.

 

그러나 위 방법은 DL을 하기 위해 ApplicationContext를 주입받는다. 즉 DL을 위해 ApplicationContext와의 의존성이 생겨버린다. 따라서 스프링에서 제공하는 ObjectProvider를 사용하면 ApplicationContext의 주입없이 딱 DL만 수행할 수 있다. ObjectProvider는 다음과 같이 사용하면 된다.

@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider; 

public int logic() {
	PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
	// 로직
}
  • 스프링 컨테이너에는 기본으로 ObjectProvider가 스프링 빈으로 등록되어 있다. 따라서 @Autowired를 통해 ObjectProvider를 주입받을 수 있다.
  • ObjectProvider의 getObject() 메소드를 호출하면 ObjectProvider의 제네릭 타입으로 선언된 PrototypeBean을 스프링 컨테이너에서 조회해서 반환한다. 이때 PrototypeBean는 프로토타입 빈이기 때문에 스프링 컨테이너에서 조회할 때 새로 생성된다.
  • 즉, ApplicationContext가 아닌 ObjectProvider를 주입받음으로써, ObjectProvider를 통해 스프링 컨테이너에서 새로운 프로토타입 빈을 반환받을 수 있게 되었다.

웹 스코프

웹 스코프는 웹 환경에서만 동작하고 프로토타입 스코프와 달리 스프링이 해당 스코프의 종료 시점까지 관리한다. 따라서 종료 메소드가 호출된다.

 

웹 스코프의 종류는 다음과 같다.

  • request: HTTP 요청 하나가 들어오고 나갈 때까지 유지된다. 각각의 HTTP 요청마다 별도로 빈 객체가 생성되고 관리된다.
  • session: HTTP Session과 동일한 생명주기를 갖는다. 
  • application: 웹의 서블릿 컨텍스트와 동일한 생명주기를 갖는다. 
  • websocket: 웹 소켓과 동일한 생명주기를 갖는다.

이 중 request 스코프에 대해 알아보자.

  • request 스코프는 HTTP 요청이 들어오면 빈이 생성되고, 요청에 대한 응답이 나갈때 빈이 소멸된다. 즉 HTTP 요청마다 별도로 생성되고 관리된다.
  • 동시에 여러 HTTP 요청이 들어오면 정확히 어떤 요청이 남긴 로그인지 구분하기 어렵다. 따라서 이럴 때 request 스코프를 활용하면 좋다. HTTP 요청마다 각각의 request 스코프 빈이 생성되기 때문에 구분할 수 있다.

다음은 request 스코프를 가진 MyLogger 객체이다.

@Component
@Scope(value = "request") // request 스코프 지정
public class MyLogger {
	private String uuid;
	private String requestURL;
}

그리고 다음은 MyLogger 객체와 의존관계를 갖는 컨트롤러와 서비스 객체이다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {

	private final LogDemoService logDemoService;
	private final MyLogger myLogger;

	@RequestMapping("log-demo")
	@ResponseBody
	public String logDemo(HttpServletRequest request) {
		myLogger.setRequestURL(request.getRequestURL().toString());
		myLogger.log("controller test");
		logDemoService.logic("testId");
		return "OK";
	}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {

	private final MyLogger myLogger;

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

문제는 이 상태에서 스프링 애플리케이션을 실행시키면 다음과 같은 오류가 발생한다는 것이다. 

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 요청이 들어올 때 생성된다. 그런데 위 코드에서는 싱글톤 빈인 LogDemoController와 LogDemoService가 request 빈인 MyLogger 객체와 의존관계를 갖는다. 즉, 스프링 컨테이너가 생성되고 LogDemoController와 LogDemoService 객체가 생성될 때 MyLogger와의 의존관계 주입을 위해 스프링 컨테이너에서 MyLogger를 찾는다. 그러나 MyLogger는 request 스코프로, 아직 HTTP 요청이 들어오지 않았기 때문에 생성되지 않았다. 따라서 request 스코프 빈의 생성 시점을 HTTP 요청이 들어왔을 때로 지연시켜야 한다.

해결방안 1: Provider

위 문제를 해결하기 위해 Provider를 사용할 수 있다. 

@Controller
@RequiredArgsConstructor
public class LogDemoController {

	private final LogDemoService logDemoService;
	private final ObjectProvider<MyLogger> myLoggerProvider;

	@RequestMapping("log-demo")
	@ResponseBody
	public String logDemo(HttpServletRequest request) {
		myLogger.setRequestURL(request.getRequestURL().toString());
		MyLogger myLogger = myLoggerProvider.getObject(); // HTTP 요청이 들어온 후에 Provider를 통해 MyLogger 객체를 찾는다.
		myLogger.log("controller test");
		logDemoService.logic("testId");
		return "OK";
	}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {

	private final ObjectProvider<MyLogger> myLoggerProvider;

	public void logic(String id) {
		MyLogger myLogger = myLoggerProvider.getObject();
		myLogger.log("service id = " + id);
	}
}
  • LogDemoController와 LogDemoService는 이제 request 스코프의 빈이 아닌 ObjectProvider와 의존관계를 갖는다.
  • 따라서 ObjectProvider.getObject() 메소드를 호출하는 시점까지 request 스코프의 빈 생성을 지연할 수 있다. 
  • ObjectProvider.getObject() 메소드 호출 시점은 HTTP 요청이 들어온 후이기 때문에 request 스코프 빈의 생성이 정상 처리된다.
  • LogDemoController와 LogDemoService에서 각각 ObjectProvider.getObject() 메소드를 통해 request 스코프 빈을 요청해도, 같은 HTTP 요청이면 같은 스프링 빈이 반환된다. 

해결방안 2: 프록시

빈의 생성 시점을 지연하는 방법으로는 ObjectProvider 외에 프록시를 사용하는 방법도 있다. 방법은 다음과 같이 @Scope 어노테이션의 proxyMode 속성을 설정해주면 된다.

  • 적용 대상이 클래스이면 TARGET_CLASS로 설정한다.
  • 적용 대상이 인터페이스이면 TARGET_INTERFACES로 설정한다.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
	private String uuid;
	private String requestURL;
}

그리고 컨트롤러와 서비스의 코드도 원래대로 MyLogger와의 의존관계를 갖도록 변경한다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {

	private final LogDemoService logDemoService;
	private final MyLogger myLogger;

	@RequestMapping("log-demo")
	@ResponseBody
	public String logDemo(HttpServletRequest request) {
		myLogger.setRequestURL(request.getRequestURL().toString());
		myLogger.log("controller test");
		logDemoService.logic("testId");
		return "OK";
	}
}
@Service
@RequiredArgsConstructor
public class LogDemoService {

	private final MyLogger myLogger;

	public void logic(String id) {
		myLogger.log("service id = " + id);
	}
}
  • 프록시를 사용하면 MyLogger를 상속받는 가짜 프록시 객체가 생성된다.
    • 즉, 스프링 컨테이너가 생성되고 LogDemoController와 LogDemoService가 생성될 때, MyLogger의 가짜 프록시 객체가 생성되고 스프링 컨테이너에 "myLogger"라는 이름으로 관리된다.
    • 그리고 의존관계 주입 시에도 이 프록시 객체가 주입된다.
    • getBean("myLogger", MyLogger.class)를 통해 스프링 컨테이너에서 MyLogger 객체를 조회하면 정상적으로 조회된다.
  • 프록시 객체는 요청이 오면 그때 진짜 빈에게 요청을 위임한다.
    • 프록시 객체는 내부에 진짜 빈인 MyLogger를 찾는 방법을 알고 있다.
    • 따라서 클라이언트가 myLogger.log()를 요청하면 이때는 프록시 객체에게 요청한 것이다.
    • 그리고 요청을 받은 프록시 객체는 진짜 빈의 myLogger.log()를 호출한다(진짜 객체에게 요청을 위임).
    • 프록시 객체는 request 스코프와 관련이 없다. 가짜 객체이고 그저 요청이 들어오면 진짜 객체에게 요청을 위임한다.
  • 프록시 객체 덕분에 클라이언트는 싱글톤 빈을 사용하듯이 편리하게 request 스코프 빈을 사용할 수 있다.

Provider를 사용하든 프록시를 사용하든 두 가지 방법의 공통점은, request 스코프 빈의 생성과 조회를 꼭 필요한 시점(HTTP 요청이 들어온 후)까지 지연시킨다는 점이다. 


Reference

  • 인프런, 스프링 핵심 원리 - 기본편, 김영한