Spring

싱글톤 빈에서 프로토타입 빈 주입, Provider로 깔끔하게 해결하기

초코너무조코 2025. 6. 22. 17:46
728x90

 

스프링 애플리케이션을 만들다 보면 싱글톤(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());
        // ...
    }
}

동작 원리

  1. 컨테이너 초기화 시 OrderService는 싱글톤으로 등록되며, 내부에 Provider<Cart>가 주입됩니다.
  2. OrderService 빈 자체는 다음에 설명할 프록시가 아니라 실제 객체입니다.
  3. placeOrder()가 호출될 때마다 cartProvider.get()이 실행되고, 스프링은 그때 새로운 Cart 프로토타입 인스턴스를 반환합니다.
  4. 반환된 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에서의 사용 팁

  1. 라이브러리 의존성 – Spring Boot 스타터에는 이미 jakarta.inject 모듈이 들어 있으므로 별도 설정이 필요 없습니다.
  2. Kotlin과도 궁합 OK! – Provider<T>를 그대로 주입해 suspend 함수 안에서 get()을 호출하면 매번 새 빈을 안전하게 사용할 수 있습니다.
  3. 테스트 – @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 스펙 문서를 참고하시길 권장합니다.

 

728x90