싱글톤 빈에서 프로토타입 빈 주입, Provider로 깔끔하게 해결하기
스프링 애플리케이션을 만들다 보면 싱글톤(Singleton) 범위의 빈(Bean)에 프로토타입(Prototype) 범위의 빈을 주입해야 하는 상황이 자주 생깁니다. 그런데 이때 단순 필드·생성자 주입을 사용하면 애플리케이션 구동 시 주입된 단 하나의 프로토타입 인스턴스를 끝까지 공유하게 되어, 의도한 대로 매번 새 객체를 받지 못하는 문제가 발생합니다. 이 글에서는 javax.inject.Provider(또는 스프링의 ObjectProvider)를 사용해 이 문제를 우아하게 해결하는 방법을 정리했습니다.
TL;DR
- 싱글톤 빈에서 프로토타입 빈을 주입하면 한 번만 생성되어 재사용된다.
- Provider<T> 또는 ObjectProvider<T>를 주입하면 필요할 때마다 새로 빈을 꺼내 쓸 수 있다.
- 코드가 간결해지고, 결합도가 낮아지며, 테스트도 수월해진다.
1. 문제 상황 살펴보기
@Component
@Scope("prototype")
public class Cart {
private final String id = UUID.randomUUID().toString();
// ...
}
@Service
public class OrderService {
private final Cart cart; // ❌ 단순 주입: 같은 인스턴스가 계속 사용됨
public OrderService(Cart cart) {
this.cart = cart;
}
// ...
}
OrderService는 사용자가 결제할 때마다 새로운 Cart 객체가 필요하지만, 위와 같이 작성하면 컨테이너가 시작될 때 1개의 Cart만 생성해 끝까지 재사용합니다. 즉, 프로토타입 범위의 의미가 사라집니다.
2. 전통적(레거시) 해결책과 한계
방법 | 장점 | 단점 |
lookup-method (XML) | 스프링 2.x 시절부터 지원 | XML & 추상 메서드 필요, 가독성 ↓ |
ApplicationContext#getBean() | 코드 한 줄이면 해결 | 컨테이너 의존 → 결합도↑, 테스트 어려움 |
ObjectFactory<T> | 인터페이스가 간단 | 여전히 스프링 API에 결합 |
3. Provider란?
javax.inject.Provider<T>는 JSR‑330(Dependency Injection for Java) 표준 인터페이스입니다.
public interface Provider<T> {
T get();
}
- 표준이므로 특정 DI 컨테이너에 종속되지 않습니다.
- @Inject 애너테이션과 함께 사용하면 스프링, Jakarta EE, CDI 모두 동일하게 동작합니다.
스프링은 이를 확장한 org.springframework.beans.factory.ObjectProvider<T>도 제공합니다. 추가 기능(옵셔널 제공, 스트림, 기본값 등)이 필요하면 ObjectProvider를, 단순 공급자 역할이면 Provider를 쓰면 됩니다.
4. Provider로 해결하기 – 코드 예제
@Component
@Scope("prototype")
public class Cart {
private final String id = UUID.randomUUID().toString();
// ...
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final Provider<Cart> cartProvider;
public void placeOrder(Order order) {
Cart cart = cartProvider.get(); // ✅ 호출할 때마다 새 Cart 생성
cart.addItems(order.getItems());
// ...
}
}
동작 원리
- 컨테이너 초기화 시 OrderService는 싱글톤으로 등록되며, 내부에 Provider<Cart>가 주입됩니다.
- OrderService 빈 자체는 다음에 설명할 프록시가 아니라 실제 객체입니다.
- placeOrder()가 호출될 때마다 cartProvider.get()이 실행되고, 스프링은 그때 새로운 Cart 프로토타입 인스턴스를 반환합니다.
- 반환된 Cart는 호출자 코드에서 자유롭게 사용되며, 다른 호출과 인스턴스를 공유하지 않습니다.
💡 Tip Provider<T>에는 어떤 부가 기능도 없기 때문에, 단순히 get()만 호출하도록 코드를 짜면 테스트에서 () -> mock(Cart.class) 같은 람다나 () -> new FakeCart()로 쉽게 대체할 수 있습니다.
5. ObjectProvider vs javax.inject.Provider 차이
기능 | Provider<T> | ObjectProvider |
표준/비표준 | 표준(JSR‑330) | 스프링 전용 |
getIfAvailable() | ❌ | ✅ |
ifAvailable(Consumer) | ❌ | ✅ |
스트림 API (stream()) | ❌ | ✅ |
의존성 최소화 | 🌟 | – |
실제 프로젝트에서는 간단한 경우엔 Provider, 스프링 API를 적극 활용하려면 ObjectProvider를 선택하면 됩니다.
6. Spring Boot 3.x에서의 사용 팁
- 라이브러리 의존성 – Spring Boot 스타터에는 이미 jakarta.inject 모듈이 들어 있으므로 별도 설정이 필요 없습니다.
- Kotlin과도 궁합 OK! – Provider<T>를 그대로 주입해 suspend 함수 안에서 get()을 호출하면 매번 새 빈을 안전하게 사용할 수 있습니다.
- 테스트 – @MockBean으로 대체하거나, 테스트 컨피그에서 Provider<T>를 간단한 람다로 덮어쓸 수 있습니다.
7. 단위 테스트 예시
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
Provider<Cart> cartProvider;
@Mock
Cart cart;
@InjectMocks
OrderService orderService;
@Test
void placeOrder_createsNewCartEveryTime() {
when(cartProvider.get()).thenReturn(cart);
orderService.placeOrder(new Order());
verify(cartProvider, times(1)).get();
}
}
8. 결론
- 싱글톤–프로토타입 문제는 의외로 자주 만나지만, Provider(또는 ObjectProvider)를 사용하면 프록시 없이도 쉽게 해결할 수 있습니다.
- 표준 DI 인터페이스를 활용해 프레임워크 결합을 최소화하고, 코드를 테스트하기 좋게 만드는 습관을 들여 보세요
- 더 깊이 있는 내용은 스프링 공식 레퍼런스 문서의 Bean Scopes 챕터와 JSR‑330 스펙 문서를 참고하시길 권장합니다.