스프링 핵심 원리 (15) - 빈 스코프
1. 개요
지금까지 다룬 스프링 빈은 모두 컨테이너가 시작될 때 생성되어 종료될 때까지 유지되는 싱글톤이었다. 하지만 스프링은 이 외에도 빈이 살아있는 범위, 즉 스코프를 다양하게 지정할 수 있게 해준다. 특히 매번 새 인스턴스가 필요한 프로토타입 스코프를 싱글톤 빈과 함께 쓰면 의도와 다르게 동작하는 문제가 생기는데, 이번 글에서는 그 원인과 해결 방법, 그리고 HTTP 요청 단위로 빈을 관리하는 웹 스코프까지 정리한다.
2. 빈 스코프란
스코프는 말 그대로 “빈이 살아있을 수 있는 유효 범위”를 가리키는 개념이다. 앞선 글들에서 다룬 빈은 모두 컨테이너와 생명주기를 함께하는 싱글톤이었지만, 스프링은 이보다 더 짧게 혹은 다른 기준으로 사는 빈도 지정할 수 있게 해준다.
- 싱글톤: 별도로 지정하지 않았을 때의 기본값. 컨테이너가 뜰 때 만들어져 컨테이너가 내려갈 때까지 그대로 유지된다.
- 프로토타입: 컨테이너가 빈을 만들고 의존관계를 주입하는 단계까지만 관여하며, 그 뒤로는 관리에서 손을 뗀다.
- 웹 관련 스코프
request: 하나의 HTTP 요청이 들어와서 응답이 나갈 때까지만 유지된다.session: HTTP 세션 하나가 만들어지고 없어질 때까지 유지된다.application: 서블릿 컨텍스트와 생명주기를 같이한다.
빈 스코프는 @Scope 애노테이션으로 지정한다.
1
2
3
4
5
6
7
8
9
10
11
// 컴포넌트 스캔 자동 등록
@Scope("prototype")
@Component
public class HelloBean {}
// 수동 등록
@Scope("prototype")
@Bean
PrototypeBean helloBean() {
return new HelloBean();
}
3. 프로토타입 스코프
싱글톤과 프로토타입의 차이는 “조회할 때 컨테이너가 무엇을 돌려주는가”에서 갈린다. 싱글톤은 몇 번을 조회하든 처음 만들어둔 그 인스턴스를 계속 돌려주지만, 프로토타입은 조회 요청이 들어올 때마다 그 자리에서 새 인스턴스를 찍어낸다.
즉 프로토타입 빈은 애초에 미리 만들어져 대기하고 있는 것이 아니라, 클라이언트가 컨테이너에 조회를 요청하는 바로 그 순간에 생성과 의존관계 주입이 이루어진다. 그렇게 만들어진 인스턴스를 컨테이너가 클라이언트에게 넘겨주고 나면, 다음 조회 요청이 왔을 때는 또 별개의 새 인스턴스를 만들어 건네주는 식으로 반복된다.
여기서 짚어야 할 부분은 컨테이너의 역할이 딱 “생성 → 의존관계 주입 → 초기화”까지로 끝난다는 점이다. 일단 클라이언트 손에 넘어간 프로토타입 빈은 더 이상 컨테이너가 챙기지 않으므로, 이후 이 빈을 없애거나 정리하는 책임은 온전히 그 빈을 받아 쓴 클라이언트 몫이 된다. 그래서 컨테이너를 종료해도 @PreDestroy로 등록해 둔 종료 메서드는 실행되지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Scope("prototype")
static class PrototypeBean {
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init");
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
이 빈을 두 번 조회하면 다음과 같은 결과가 나온다.
1
2
3
4
5
6
find prototypeBean1
PrototypeBean.init
find prototypeBean2
PrototypeBean.init
prototypeBean1 = ...PrototypeBean@13d4992d
prototypeBean2 = ...PrototypeBean@302f7971
싱글톤은 초기화 시점 자체가 다르다는 것도 눈여겨볼 부분이다. 싱글톤 빈의 초기화 메서드는 컨테이너가 뜨는 순간 딱 한 번 실행되지만, 프로토타입 빈은 조회당할 때마다 그 시점에 새로 초기화가 일어난다. 위 예제처럼 두 번 조회했다면 init 로그도 두 번 찍히고, prototypeBean1과 prototypeBean2는 주소값부터가 다른 완전히 별개의 객체다. 그리고 컨테이너를 close()로 내려도 PrototypeBean.destroy는 끝내 출력되지 않는다.
이를 종합하면 프로토타입 빈은 대략 이런 성격을 가진다고 정리할 수 있다.
- 조회할 때마다 매번 새 인스턴스로 만들어진다.
- 컨테이너의 개입은 생성·의존관계 주입·초기화 단계에서 끝난다.
- 소멸 콜백이 자동으로 호출되지 않으므로, 정리가 필요하다면 빈을 받아 쓴 쪽에서 직접 처리해야 한다.
4. 프로토타입 스코프와 싱글톤 빈을 함께 쓸 때의 문제
프로토타입 빈을 컨테이너에서 직접 요청하면 매번 새 인스턴스가 나오므로 기대한 대로 동작한다.
1
2
3
4
5
6
7
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); // 새 인스턴스이므로 1
문제는 clientBean처럼 싱글톤 빈이 프로토타입 빈을 의존관계로 주입받아 사용할 때 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
static class ClientBean {
private final PrototypeBean prototypeBean;
@Autowired
public ClientBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
prototypeBean.addCount();
return prototypeBean.getCount();
}
}
1
2
3
4
5
6
7
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); // 기대와 다르게 2가 나온다
여기서 벌어지는 일을 시간 순서로 풀어보면 이렇다. clientBean은 싱글톤이라 컨테이너가 뜰 때 단 한 번만 만들어지고, 그 생성 과정 중에 딱 한 번 PrototypeBean을 컨테이너에 요청해서 필드에 채워 넣는다. 문제는 그다음부터다. clientBean을 아무리 다시 조회해도 여전히 같은 싱글톤 객체이고, 그 객체가 필드에 붙잡고 있는 prototypeBean 참조 역시 맨 처음 주입받았던 그 인스턴스 그대로다. logic()을 호출할 때마다 매번 새 프로토타입 빈을 받아오는 게 아니라, 이미 고정된 하나의 인스턴스를 계속 재사용하는 셈이다. 그러니 count는 리셋되지 않고 호출할 때마다 1, 2, 3으로 누적된다.
참고: 만약 서로 다른 싱글톤 빈 A, B가 각각 같은 프로토타입 빈을 주입받는 구조라면, A와 B는 주입 시점이 다르므로 각자 다른 인스턴스를 받게 된다. 다만 A 하나만 놓고 보면 자신이 최초에 받은 인스턴스를 계속 붙들고 쓴다는 점은 동일하다.
5. Provider로 문제 해결
싱글톤 빈이 프로토타입 빈을 사용할 때마다 새 인스턴스를 받으려면, 사용 시점마다 컨테이너에 다시 요청해야 한다.
5.1 ApplicationContext 직접 사용
가장 단순한 방법은 ApplicationContext를 주입받아 그때그때 getBean()을 호출하는 것이다.
1
2
3
4
5
6
7
8
@Autowired
private ApplicationContext ac;
public int logic() {
PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
prototypeBean.addCount();
return prototypeBean.getCount();
}
외부에서 값을 받는 DI(Dependency Injection)와 달리, 이렇게 코드가 능동적으로 컨테이너를 뒤져서 필요한 빈을 찾아오는 방식을 DL(Dependency Lookup)이라고 부른다. 다만 이 방식은 ApplicationContext 자체를 필드로 끌어안고 있어야 하므로, 클래스가 스프링이라는 프레임워크에 강하게 묶여버리고 순수하게 단위 테스트를 짜기도 번거로워진다.
5.2 ObjectProvider
스프링은 DL 기능만 필요한 상황을 위해 ObjectProvider를 제공한다. 과거의 ObjectFactory에 옵션, 스트림 처리 등 편의 기능을 더해 만들어졌다.
1
2
3
4
5
6
7
8
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
return prototypeBean.getCount();
}
ApplicationContext를 통째로 들고 있는 대신, getObject()가 호출되는 그 순간에만 컨테이너에 접근해 필요한 빈을 찾아준다. 덕분에 호출할 때마다 새 프로토타입 빈을 받으면서도, 딱 이 조회 기능 하나만 흉내 내면 되므로 mock 객체를 만들어 테스트하기도 한결 수월해진다.
5.3 JSR-330 Provider
자바 표준(JSR-330)인 javax.inject.Provider(스프링 부트 3.0부터는 jakarta.inject.Provider)를 사용하는 방법도 있다. 이 방법을 쓰려면 별도 라이브러리(jakarta.inject:jakarta.inject-api 등)를 의존성에 추가해야 한다.
1
2
3
public interface Provider<T> {
T get();
}
1
2
3
4
5
6
7
8
@Autowired
private Provider<PrototypeBean> provider;
public int logic() {
PrototypeBean prototypeBean = provider.get();
prototypeBean.addCount();
return prototypeBean.getCount();
}
기능이 get() 호출 하나로 끝날 만큼 가볍고, 스프링이 만든 게 아니라 자바 표준 자체이므로 나중에 다른 DI 프레임워크로 옮기더라도 코드를 그대로 재사용할 수 있다는 이점이 있다.
| 방법 | 특징 |
|---|---|
ApplicationContext 직접 주입 | 기능은 충분하지만 컨테이너에 종속적, 테스트 어려움 |
ObjectProvider | 스프링 제공, DL에 필요한 기능과 편의 기능 제공, 별도 라이브러리 불필요 |
JSR-330 Provider | 자바 표준, get() 하나로 단순, 별도 라이브러리 필요, 다른 컨테이너에서도 사용 가능 |
사실 웹 애플리케이션을 만들다 보면 싱글톤 빈만으로 웬만한 요구사항이 해결되기 때문에, 프로토타입 빈을 직접 꺼내 쓸 일 자체가 흔하지 않다. 다만 ObjectProvider와 JSR-330 Provider는 프로토타입 빈이 아니더라도 “조회 시점을 늦추고 싶은” 모든 DL 상황에 두루 활용할 수 있는 도구다. 다른 컨테이너로 옮길 계획이 없다면, 편의 기능이 더 풍부한 ObjectProvider 쪽을 기본 선택지로 삼는 편이 무난하다.
6. 웹 스코프
웹 스코프는 이름 그대로 서블릿 컨테이너가 떠 있는 웹 환경에서만 의미가 있는 스코프다. 앞서 본 프로토타입과 결정적으로 다른 지점은, 빈을 클라이언트에 넘긴 뒤에도 스프링이 손을 떼지 않는다는 것이다. 각 스코프가 끝나는 시점을 스프링이 계속 추적하고 있다가, 그 시점에 맞춰 소멸 콜백까지 챙겨서 호출해 준다.
request: HTTP 요청 하나당 독립된 빈 인스턴스가 만들어지고, 그 요청이 끝나면 함께 정리된다.session: 하나의 HTTP 세션이 열려 있는 동안만 유지된다.application: 서블릿 컨텍스트가 떠 있는 동안 유지된다.websocket: 웹 소켓 연결이 살아있는 동안 유지된다.
request 스코프를 예로 들면, 동시에 여러 HTTP 요청이 들어와도 요청마다 독립된 빈 인스턴스가 만들어지므로, 어떤 로그가 어떤 요청에서 남았는지 뒤섞이지 않고 구분할 수 있다.
7. request 스코프 예제: MyLogger
동시에 여러 요청이 들어올 때 어떤 요청이 남긴 로그인지 구분하기 위해, request 스코프로 로거를 만들어본다. web 스코프를 쓰려면 spring-boot-starter-web 의존성이 필요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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);
}
}
빈이 생성되는 시점에 @PostConstruct로 UUID를 만들어 두면, 이 UUID로 서로 다른 HTTP 요청을 구분할 수 있다. requestURL은 빈 생성 시점에는 알 수 없으므로 컨트롤러에서 요청을 받은 뒤 setter로 넣어준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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";
}
}
서비스 계층에서도 같은 MyLogger를 주입받아 로그를 남긴다. request 스코프 덕분에 requestURL 같은 웹 관련 정보를 파라미터로 줄줄이 넘기지 않고도, 서비스 계층까지 로그 컨텍스트를 자연스럽게 공유할 수 있다. 웹 기술에 종속된 정보를 서비스 계층까지 파라미터로 흘려보내지 않아도 되므로, 서비스 계층을 순수하게 유지하는 데도 도움이 된다.
그런데 이 상태로 애플리케이션을 실행하면 다음과 같은 오류가 발생한다.
1
2
3
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;
타이밍이 어긋난 것이 원인이다. 컨트롤러는 싱글톤이라 애플리케이션이 뜨는 순간 바로 만들어지면서 MyLogger를 필드에 채우려 드는데, request 스코프 빈은 그 시점엔 아직 만들어질 수조차 없다. 누군가 브라우저로 실제 요청을 보내야만 비로소 생성 근거가 생기기 때문이다. 결국 아직 존재할 수 없는 빈을 미리 당겨쓰려다 생기는 오류이고, 앞서 4장에서 본 프로토타입 문제와 뿌리가 같다.
8. 스코프와 Provider
앞서 익힌 ObjectProvider로 이 문제를 그대로 해결할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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";
}
}
앞서 오류가 났던 것은 “컨테이너가 뜨는 시점”에 빈을 만들려고 했기 때문이었는데, getObject()를 호출하는 지점은 컨트롤러의 메서드 안, 즉 이미 요청이 도착해 처리되고 있는 도중이다. 그러니 이 시점이라면 request 스코프 빈을 만들어도 아무 문제가 없다. 흥미로운 점은 컨트롤러와 서비스가 서로 다른 myLoggerProvider를 통해 각자 getObject()를 호출해도, 하나의 HTTP 요청 안에서는 결국 같은 MyLogger 인스턴스를 돌려받는다는 것이다.
9. 스코프와 프록시
ObjectProvider를 쓰면 매번 getObject()를 호출해야 하는 코드가 남는다. @Scope에 proxyMode를 지정하면 이 호출 없이도 마치 싱글톤 빈을 쓰듯 자연스럽게 코드를 작성할 수 있다.
1
2
3
4
5
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
// ...
}
- 적용 대상이 클래스면
TARGET_CLASS, 인터페이스면INTERFACES를 선택한다.
이렇게 지정하면 컨트롤러와 서비스 코드는 ObjectProvider를 쓰기 이전과 완전히 동일한 형태로 되돌릴 수 있다.
1
private final MyLogger myLogger; // ObjectProvider 없이 그냥 주입
9.1 동작 원리
주입된 myLogger의 클래스를 출력해보면 다음과 같이 나온다.
1
myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d
비밀은 클래스 이름에 붙은 $$EnhancerBySpringCGLIB$$에 있다. proxyMode = ScopedProxyMode.TARGET_CLASS를 지정하는 순간, 스프링은 CGLIB라는 바이트코드 조작 도구를 동원해 MyLogger를 상속하는 이름뿐인 대역 객체를 하나 찍어낸다. 그리고 컨테이너에는 진짜 MyLogger가 아니라 이 대역 객체를 "myLogger"라는 빈 이름으로 슬쩍 등록해버린다. 결국 다른 빈에 주입되는 것도, 우리가 손에 쥐게 되는 것도 이 대역 객체다.
이 대역 객체 안에는 “실제 호출이 들어오면 진짜 request 스코프 빈을 찾아 그쪽으로 넘겨준다”는 위임 코드 한 줄이 전부 들어있다고 봐도 무방하다. 그래서 myLogger.log()라고 적어도 실제로는 대역 객체의 log()가 먼저 실행되고, 그 안에서 현재 요청에 해당하는 진짜 MyLogger를 찾아 진짜 log()를 대신 호출해 주는 흐름이다. 대역 객체가 원본을 상속받아 만들어진 덕분에, 이걸 쓰는 코드 입장에서는 지금 손에 든 게 진짜인지 대역인지 구분할 필요 없이 똑같이 다룰 수 있다. request 스코프와는 실제로 아무 관계가 없는 대역 객체 자체는 싱글톤처럼 컨테이너에 딱 하나만 존재하고, 진짜 빈을 찾아 넘기는 역할은 매 호출마다 새로 수행된다.
결국 ObjectProvider로 조회 시점을 늦추든, 프록시로 위임 껍데기를 씌우든 노리는 효과는 동일하다. 진짜 빈을 만드는 일을 실제로 그 빈이 필요해지는 순간까지 미뤄두는 것이다. 다만 프록시 쪽은 @Scope 설정 하나만 바꾸면 알아서 대역 객체로 치환되기 때문에, 이를 사용하는 컨트롤러나 서비스 코드는 프록시라는 사실조차 몰라도 되는 것이 차이점이다. 그만큼 겉으로는 싱글톤처럼 보이지만 속은 전혀 다르게 동작하는 코드라는 뜻이기도 하므로, 이런 특수 스코프는 정말 필요한 자리에만 아껴서 쓰는 편이 나중에 코드를 읽는 사람에게도 덜 헷갈린다.
10. 정리
빈 스코프는 빈이 살아있는 범위를 결정한다. 싱글톤은 컨테이너와 생명주기를 같이하지만, 프로토타입은 생성·의존관계 주입·초기화까지만 컨테이너가 관여하고 그 이후는 클라이언트가 책임진다. 이 차이 때문에 싱글톤 빈이 프로토타입 빈을 필드로 들고 있으면, 주입 시점에 딱 한 번 생성된 인스턴스를 계속 재사용하게 되어 “매번 새 인스턴스”라는 프로토타입 본연의 목적이 무색해진다.
이 문제는 ObjectProvider나 JSR-330 Provider처럼 사용 시점까지 조회를 미루는 DL 방식으로 해결한다. request 스코프처럼 HTTP 요청 단위로 관리되는 웹 스코프 빈을 싱글톤 빈에 주입할 때도 같은 원리의 문제(요청이 없는 시점에는 빈을 만들 수 없음)가 발생하는데, 여기서는 ObjectProvider 외에 proxyMode = ScopedProxyMode.TARGET_CLASS로 CGLIB 가짜 프록시를 주입하는 방법도 쓸 수 있다. 두 방법 모두 “진짜 객체 조회를 필요한 시점까지 지연한다”는 같은 아이디어에 기반하며, 프록시 방식은 코드를 싱글톤을 다루듯 그대로 유지할 수 있다는 장점이 있다.
다만 프록시는 겉보기와 실제 동작이 다르다는 점에서 주의가 필요하다. 실무에서는 대부분의 문제를 싱글톤 빈만으로 해결할 수 있으므로, 프로토타입이나 웹 스코프, 프록시 같은 특수한 도구는 꼭 필요한 지점에만 최소한으로 적용하는 편이 코드를 예측 가능하게 유지하는 데 도움이 된다.