프레임워크/스프링

스프링의 Scope(스코프): 싱글톤(Singleton)과 프로토타입(Prototype)의 결정적 차이

eodevelop 2025. 12. 23. 23:47
반응형

@Service로 등록한 Bean을 여러 곳에서 주입받으면, 같은 객체일까요 다른 객체일까요? 당연히 같은 객체입니다. 그런데 왜 같은 객체인지, 다른 객체로 만들 수는 없는지 생각해본 적 있으신가요?

이 질문의 답이 바로 스코프(Scope)입니다. 스프링이 Bean을 어떻게 생성하고 관리할지 결정하는 설정인데, 이걸 모르고 사용하면 예상치 못한 버그를 만날 수 있습니다.


스코프(Scope)란?

좀 더 정확히 말하면, 스코프는 Bean이 존재할 수 있는 범위입니다. 스프링 컨테이너가 Bean 객체를 언제 생성하고, 얼마나 오래 유지할지를 결정합니다.

스프링은 다양한 스코프를 지원하지만, 가장 기본이 되는 두 가지가 있습니다:

  • 싱글톤(Singleton): 컨테이너에 딱 하나만 존재
  • 프로토타입(Prototype): 요청할 때마다 새로 생성

이 외에도 웹 환경에서 사용하는 request, session, application 스코프가 있지만, 이들도 결국 싱글톤과 프로토타입의 변형이라고 볼 수 있습니다. 오늘은 핵심인 싱글톤과 프로토타입에 집중해보겠습니다.


싱글톤(Singleton) 스코프

기본 동작

싱글톤은 Spring의 기본 스코프입니다. 별도로 설정하지 않으면 모든 Bean은 싱글톤으로 관리됩니다.

@Component
public class SingletonBean {

    public SingletonBean() {
        System.out.println("SingletonBean 생성됨: " + this);
    }
}

 

이 Bean을 여러 곳에서 주입받아 사용해도 항상 같은 인스턴스입니다:

@Service
public class ServiceA {
    private final SingletonBean singletonBean;

    public ServiceA(SingletonBean singletonBean) {
        this.singletonBean = singletonBean;
        System.out.println("ServiceA가 받은 Bean: " + singletonBean);
    }
}

@Service
public class ServiceB {
    private final SingletonBean singletonBean;

    public ServiceB(SingletonBean singletonBean) {
        this.singletonBean = singletonBean;
        System.out.println("ServiceB가 받은 Bean: " + singletonBean);
    }
}

 

실행 결과:

SingletonBean 생성됨: com.example.SingletonBean@1a2b3c4d
ServiceA가 받은 Bean: com.example.SingletonBean@1a2b3c4d
ServiceB가 받은 Bean: com.example.SingletonBean@1a2b3c4d

 

SingletonBean은 딱 한 번만 생성되고, ServiceAServiceB같은 인스턴스를 공유합니다.

싱글톤의 특징

  • 스프링 컨테이너가 시작될 때 Bean이 생성됩니다 (@Lazy를 사용하면 지연 초기화도 가능)
  • 컨테이너가 종료될 때까지 유지됩니다
  • 모든 요청에서 같은 인스턴스를 반환합니다
  • 메모리 효율적이고 성능이 좋습니다

프로토타입(Prototype) 스코프

기본 동작

프로토타입 스코프는 Bean을 요청할 때마다 새로운 인스턴스를 생성합니다. @Scope 어노테이션으로 지정할 수 있습니다:

@Component
@Scope("prototype")
public class PrototypeBean {

    public PrototypeBean() {
        System.out.println("PrototypeBean 생성됨: " + this);
    }
}

 

또는 ConfigurableBeanFactory의 상수를 사용할 수도 있습니다:

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class PrototypeBean {
    // ...
}

 

이 Bean을 여러 곳에서 주입받으면:

@Service
public class ServiceA {
    private final PrototypeBean prototypeBean;

    public ServiceA(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
        System.out.println("ServiceA가 받은 Bean: " + prototypeBean);
    }
}

@Service
public class ServiceB {
    private final PrototypeBean prototypeBean;

    public ServiceB(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
        System.out.println("ServiceB가 받은 Bean: " + prototypeBean);
    }
}

 

실행 결과:

PrototypeBean 생성됨: com.example.PrototypeBean@1a2b3c4d
ServiceA가 받은 Bean: com.example.PrototypeBean@1a2b3c4d
PrototypeBean 생성됨: com.example.PrototypeBean@5e6f7g8h
ServiceB가 받은 Bean: com.example.PrototypeBean@5e6f7g8h

 

PrototypeBean이 두 번 생성되었고, ServiceAServiceB서로 다른 인스턴스를 가지고 있습니다.

프로토타입의 특징

  • Bean을 요청할 때마다 새로운 인스턴스를 생성합니다
  • 스프링 컨테이너는 생성과 초기화까지만 관여합니다
  • 이후 관리 책임은 클라이언트에게 있습니다
  • @PreDestroy 같은 소멸 콜백이 호출되지 않습니다 (따라서 리소스 정리는 클라이언트가 직접 해야 합니다)

결정적 차이: 상태 관리

두 스코프의 가장 중요한 차이는 상태를 가지는 객체를 다룰 때 드러납니다.

싱글톤에서 상태를 가지면 생기는 문제

@Component
public class Counter {
    private int count = 0;

    public void increase() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

 

이 Bean은 싱글톤이므로, 모든 곳에서 같은 인스턴스를 공유합니다:

@RestController
public class CounterController {

    private final Counter counter;

    public CounterController(Counter counter) {
        this.counter = counter;
    }

    @GetMapping("/increase")
    public int increase() {
        counter.increase();
        return counter.getCount();
    }
}

 

여러 사용자가 동시에 /increase를 호출하면 어떻게 될까요?

사용자A 호출 -> count: 1
사용자B 호출 -> count: 2
사용자C 호출 -> count: 3
사용자A 다시 호출 -> count: 4

 

모든 사용자가 하나의 count 값을 공유하게 됩니다. 만약 사용자별로 독립적인 카운터가 필요했다면 이는 심각한 버그입니다. 더 나아가 멀티스레드 환경에서는 동시성 문제까지 발생할 수 있습니다.

싱글톤은 무상태(Stateless)로 설계해야 한다

이런 이유로 싱글톤 Bean은 무상태로 설계해야 합니다:

@Component
public class Calculator {
    // 필드에 상태를 저장하지 않는다

    public int add(int a, int b) {
        return a + b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }
}

 

상태가 필요하다면 메서드의 파라미터나 로컬 변수를 활용해야 합니다. 로컬 변수는 스레드마다 독립적인 스택 메모리에 저장되므로 공유 문제가 없습니다.

프로토타입은 상태를 가져도 된다

프로토타입 Bean은 요청마다 새 인스턴스가 생성되므로, 상태를 가져도 괜찮습니다:

@Component
@Scope("prototype")
public class UserSession {
    private String username;
    private List<String> cart = new ArrayList<>();

    public void setUsername(String username) {
        this.username = username;
    }

    public void addToCart(String item) {
        cart.add(item);
    }

    public List<String> getCart() {
        return cart;
    }
}

 

각 요청마다 새로운 UserSession이 생성되므로, 사용자 간에 데이터가 섞이지 않습니다.


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

여기서 한 가지 함정이 있습니다. 싱글톤 Bean이 프로토타입 Bean을 주입받으면 예상과 다르게 동작합니다.

문제 상황

@Component
@Scope("prototype")
public class PrototypeBean {
    private int count = 0;

    public void addCount() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

@Component
public class SingletonClient {
    private final PrototypeBean prototypeBean;

    public SingletonClient(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
    }

    public int logic() {
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

 

SingletonClient는 싱글톤이고, 생성자를 통해 PrototypeBean을 주입받습니다. 이 상황에서 logic() 메서드를 여러 번 호출하면:

@Test
void 프로토타입_주입_테스트() {
    SingletonClient client = context.getBean(SingletonClient.class);

    int count1 = client.logic();  // 1
    int count2 = client.logic();  // 2 (예상: 1)
    int count3 = client.logic();  // 3 (예상: 1)
}

 

프로토타입인데 왜 count가 계속 증가할까요?

싱글톤 Bean은 생성 시점에 한 번만 의존성을 주입받습니다. 그래서 SingletonClient가 처음 생성될 때 PrototypeBean도 함께 생성되어 주입되고, 이후로는 계속 같은 인스턴스를 사용하게 됩니다. 프로토타입의 의미가 없어지는 것입니다.

해결 방법 1: ObjectProvider 사용

@Component
public class SingletonClient {
    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public SingletonClient(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

 

ObjectProvidergetObject()를 호출할 때마다 스프링 컨테이너에서 새로운 프로토타입 Bean을 조회합니다. 의존성을 주입(Injection)받는 것이 아니라, 필요한 시점에 직접 찾아오는(Lookup) 방식이라서 Dependency Lookup (DL)이라고 부릅니다.

해결 방법 2: @Lookup 사용

@Component
public abstract class SingletonClient {

    public int logic() {
        PrototypeBean prototypeBean = getPrototypeBean();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }

    @Lookup
    protected abstract PrototypeBean getPrototypeBean();
}

 

@Lookup 어노테이션을 사용하면 스프링이 해당 메서드를 오버라이드해서 매번 새로운 프로토타입 Bean을 반환하도록 만들어줍니다.

해결 방법 3: 프록시 모드 사용

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PrototypeBean {
    // ...
}

 

프록시 모드를 사용하면, 싱글톤 Bean에는 실제 프로토타입 Bean 대신 프록시(가짜) 객체가 주입됩니다. 이 프록시 객체의 메서드를 호출할 때마다 내부적으로 진짜 프로토타입 Bean을 새로 생성해서 요청을 위임합니다.


언제 어떤 스코프를 사용해야 할까?

싱글톤을 사용하는 경우 (대부분의 경우)

  • 상태가 없는 서비스 객체 (Service, Repository 등)
  • 공유해도 문제없는 읽기 전용 데이터
  • 설정 객체나 유틸리티 객체
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    // 상태 없이 로직만 수행

    public Order createOrder(OrderRequest request) {
        // ...
    }
}

프로토타입을 사용하는 경우

  • 각 사용 시점마다 독립적인 상태가 필요한 경우
  • 복잡한 초기화가 필요한 객체를 매번 새로 만들어야 하는 경우
  • 스레드 안전성이 보장되어야 하는 상태 객체
@Component
@Scope("prototype")
public class ReportGenerator {
    private List<String> data = new ArrayList<>();
    private StringBuilder result = new StringBuilder();

    public void addData(String item) {
        data.add(item);
    }

    public String generate() {
        // data를 기반으로 리포트 생성
        return result.toString();
    }
}

정리

핵심을 요약하면 다음과 같습니다:

싱글톤(Singleton)

  • 스프링의 기본 스코프
  • 컨테이너에 Bean이 딱 하나만 존재
  • 모든 곳에서 같은 인스턴스를 공유
  • 무상태(Stateless)로 설계해야 함
  • 메모리 효율적이고 성능이 좋음

프로토타입(Prototype)

  • @Scope("prototype")으로 지정
  • 요청할 때마다 새 인스턴스 생성
  • 생성과 초기화만 컨테이너가 관리
  • 상태를 가져도 안전함
  • 소멸 콜백이 호출되지 않음

주의할 점

  • 싱글톤에서 프로토타입을 주입받으면, 프로토타입도 사실상 싱글톤처럼 동작
  • ObjectProvider, @Lookup, 프록시 모드로 해결 가능

실무에서는 대부분 싱글톤만 사용하게 됩니다. 싱글톤의 무상태 원칙만 잘 지키면 문제없이 사용할 수 있기 때문입니다. 프로토타입은 정말 필요한 경우에만 제한적으로 사용하는 것이 좋습니다.

반응형