이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Spring › IoC 컨테이너와 DI
빈 스코프
프로토타입 빈인데 왜 객체가 하나뿐인가
스코프는 컨테이너가 빈 객체를 몇 개 만들고 언제까지 쓰는지를 정합니다. 싱글톤은 컨테이너에 하나, 프로토타입은 컨테이너에 달라고 할 때마다 새로 하나, request는 HTTP 요청마다 하나입니다. 그런데 싱글톤 빈이 다른 스코프의 빈을 주입받으면 기대와 다른 일이 생깁니다.
실행으로 대조 · 스프링 부트 4.1.1 서버를 띄워 아홉 가지 조합에 HTTP 요청을 보내 대조
이 실습에 나오는 개념
- 빈 스코프
- 빈 인스턴스가 만들어지고 유지되는 범위다. 기본값인 singleton은 컨테이너에 하나만 만들어 모두가 공유한다. prototype은 요청할 때마다 새로 만든다. 웹 환경에는 HTTP 요청마다 만드는 request, 세션마다 만드는 session 스코프가 있다.
- 스프링 빈(Bean)
- 스프링 IoC 컨테이너가 생성하고 관리하는 객체다. 개발자가 new로 직접 만든 객체와 달리 컨테이너가 의존성을 주입하고 초기화와 소멸을 관리하며, 프록시를 통해 트랜잭션이나 AOP 같은 부가 기능을 적용할 수 있다.
- ObjectProvider
- 빈 객체를 바로 주입받지 않고, 필요한 시점에 컨테이너에서 꺼낼 수 있게 해 주는 스프링의 도구다. 생성자나 필드의 타입을 ObjectProvider<Cart>처럼 적으면 컨테이너가 넣어 주고, getObject()를 부를 때마다 컨테이너에 그 타입의 빈을 달라고 요청한다. 그래서 프로토타입 빈은 부를 때마다 새 객체가, request 스코프 빈은 지금 처리 중인 요청의 객체가 나온다. 싱글톤 빈이 자기보다 수명이 짧은 빈을 쓸 때 주로 쓴다. 한 번의 작업 안에서 같은 객체를 계속 써야 하면 한 번만 꺼내 지역 변수에 담아 둔다.
- 스코프 프록시
- 싱글톤이 아닌 스코프의 빈을 다른 빈에 주입할 때, 진짜 객체 대신 넣어 주는 대리 객체다. @Scope에 proxyMode = ScopedProxyMode.TARGET_CLASS를 적으면 컨테이너가 그 클래스를 상속한 프록시를 만들어 주입한다. 프록시는 메서드가 불릴 때마다 컨테이너에서 그 스코프에 맞는 진짜 객체를 찾아 호출을 넘긴다. request 스코프와 함께 쓰면 싱글톤 빈의 코드를 바꾸지 않고도 요청마다 다른 객체를 쓰게 되며, @RequestScope에는 이 설정이 들어 있다. 프로토타입 스코프와 함께 쓰면 메서드를 부를 때마다 새 객체가 만들어지므로 주의한다.
- 프록시(Proxy)
- 실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.
이렇게 바꿔 보세요
- 처음 설정으로 — 프로토타입 빈을 싱글톤 빈에 주입했습니다. 요청을 몇 번 보내도 같은 객체입니다.
- ObjectProvider로 꺼내면 — 꺼낼 때마다 새 객체가 만들어집니다.
- 싱글톤이면 — 어떻게 얻든 컨테이너에 하나뿐인 객체입니다.
- request 스코프를 그대로 주입 — 기동할 때는 요청이 없어서 만들 수 없습니다. 기동이 실패합니다.
- request 스코프 + ObjectProvider — 같은 요청 안에서는 같은 객체, 다른 요청에는 다른 객체입니다.
- request 스코프 + 프록시 — OrderService의 코드는 그대로 두고 요청마다 하나를 씁니다.
- 프로토타입 + 프록시 — 메서드를 부를 때마다 새 객체가 만들어집니다.
처음 설정으로 실행하면
- 컨테이너 시작: orderController, orderService, cart 세 빈을 등록합니다. orderController와 orderService는 싱글톤이고, cart의 스코프는 프로토타입입니다.
- orderService 빈 만들기: 생성자가 Cart를 요구해 컨테이너에 Cart를 달라고 합니다. 프로토타입 빈은 달라고 할 때마다 새로 만들므로, 컨테이너가 Cart 1번을 새로 만들어 넘깁니다. OrderService는 이 객체를 필드에 넣어 둡니다.
- 기동이 끝났습니다. 지금까지 만들어진 Cart 객체는 1개입니다.
- 요청 1: GET /order가 들어옵니다. OrderController의 order()가 orderService.cartNo()를 두 번 부릅니다.
- cartNo() 1번째 호출: 필드에 들어 있는 Cart 1번의 no()를 부릅니다. 주입은 OrderService를 만들 때 한 번뿐이었으므로, 프로토타입이어도 새 객체가 만들어지지 않습니다.
- cartNo() 2번째 호출: 필드에 들어 있는 Cart 1번의 no()를 부릅니다. 주입은 OrderService를 만들 때 한 번뿐이었으므로, 프로토타입이어도 새 객체가 만들어지지 않습니다.
- 요청 1의 응답은 "1,1"입니다.
- 요청 2: GET /order가 들어옵니다. OrderController의 order()가 orderService.cartNo()를 두 번 부릅니다.
- cartNo() 1번째 호출: 필드에 들어 있는 Cart 1번의 no()를 부릅니다. 주입은 OrderService를 만들 때 한 번뿐이었으므로, 프로토타입이어도 새 객체가 만들어지지 않습니다.
- cartNo() 2번째 호출: 필드에 들어 있는 Cart 1번의 no()를 부릅니다. 주입은 OrderService를 만들 때 한 번뿐이었으므로, 프로토타입이어도 새 객체가 만들어지지 않습니다.
- 요청 2의 응답은 "1,1"입니다.
실행 결과
- 프로토타입은 "컨테이너에 달라고 할 때마다" 새로 만든다는 뜻입니다. 싱글톤 빈에 주입하는 일은 그 빈을 만들 때 한 번뿐이므로, 그때 받은 객체 하나를 계속 쓰게 됩니다. 쓸 때마다 새 객체를 기대했다면 버그입니다.
- 쓸 때마다 새 객체가 필요하면 ObjectProvider를 주입받아 getObject()로 꺼냅니다. 프로토타입 빈을 주입받는 싱글톤 빈이 둘이면, 두 빈은 서로 다른 객체를 하나씩 받습니다.
코드
@Component
@Scope("prototype")
class Cart {
private static int created = 0; // 지금까지 만들어진 Cart 객체 수
private final int no; // 몇 번째로 만들어진 객체인가
Cart() {
created++;
no = created;
}
public int no() {
return no;
}
}
@Component
class OrderService { // 스코프를 적지 않았으므로 싱글톤
private final Cart cart;
OrderService(Cart cart) { // 주입은 OrderService가 만들어질 때 한 번뿐
this.cart = cart;
}
int cartNo() {
return cart.no();
}
}
@RestController
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/order")
String order() {
int first = orderService.cartNo();
int second = orderService.cartNo(); // 한 요청 안에서 두 번 쓴다
return first + "," + second;
}
}
이 모형의 전제
아홉 가지 조합을 이 PC의 스프링 부트 4.1.1(스프링 프레임워크 7.0.9, 내장 톰캣 11)에서 실행해 대조했습니다. 조합마다 화면과 같은 구조의 소스를 컴파일해 서버를 띄우고, 실제로 GET /order 요청을 두 번 보내 응답에 적힌 Cart 번호를 읽었습니다. Cart의 생성자가 불린 횟수(기동 직후, 요청 두 번 뒤), OrderService의 필드에 들어 있는 것(Cart, ObjectProvider, 프록시), 기동 실패의 예외 사슬(UnsatisfiedDependencyException → UnsatisfiedDependencyException → ScopeNotActiveException → IllegalStateException)을 비교했습니다. 요청 두 개는 하나가 끝난 뒤 다음 것을 보냈습니다(동시에 보낸 경우는 "싱글톤 빈과 상태" 실습에서 다룹니다). 다음도 실행으로 확인했습니다: 프로토타입 빈을 주입받는 싱글톤 빈이 둘이면 서로 다른 객체를 받는 것, 프로토타입에 프록시를 쓰면 add로 넣은 값이 바로 다음 size 호출에서 보이지 않는 것(0이 나옴), @RequestScope를 붙인 빈은 그대로 주입받아도 기동되고 요청마다 하나인 것, request 스코프 빈의 @PreDestroy가 요청이 끝날 때마다 불리는 것. 다루지 않은 것: session 스코프와 application 스코프, 프로토타입 빈을 꺼내는 다른 방법(@Lookup, jakarta.inject.Provider), 프록시 방식 가운데 인터페이스를 쓰는 것(ScopedProxyMode.INTERFACES). 화면의 Cart는 객체를 세려고 static 변수를 썼는데, 요청이 동시에 들어오는 실제 코드라면 이렇게 세면 안 됩니다.
앞 실습: 싱글톤 빈과 상태 · 다음 실습: 빈 생명주기 콜백