콘텐츠로 이동

빈 스코프 (Bean Scope)


왜 필요한가

스프링 빈은 기본적으로 싱글톤이다. 컨테이너당 하나의 인스턴스만 생성하고 공유한다.

하지만 경우에 따라 다른 방식이 필요하다.

  • "이 빈은 요청할 때마다 새 인스턴스를 줘야 해" → 프로토타입
  • "HTTP 요청 하나 동안만 같은 인스턴스를 써야 해" → request 스코프
  • "로그인한 세션 동안 같은 인스턴스를 써야 해" → session 스코프

스코프는 빈이 존재할 수 있는 범위(생명주기)를 정의한다.


스코프 종류

스코프 설명 사용 환경
singleton 기본값. 컨테이너당 1개 모든 환경
prototype 요청마다 새 인스턴스 생성 모든 환경
request HTTP 요청 1개당 1개
session HTTP 세션 1개당 1개
application 서블릿 컨텍스트 1개당 1개 (싱글톤과 유사)
websocket 웹 소켓 1개당 1개 웹 소켓

싱글톤 vs 프로토타입

싱글톤

@Component
// @Scope("singleton") — 기본값이므로 생략 가능
public class SingletonBean {
    private int count = 0;
    public void addCount() { count++; }
    public int getCount() { return count; }
}
클라이언트 A  getBean()  SingletonBean@x01 반환
클라이언트 B  getBean()  SingletonBean@x01 반환   같은 인스턴스!

스프링 컨테이너: 생성  관리  소멸 콜백  소멸 ( 과정 관리)

프로토타입

@Component
@Scope("prototype")
public class PrototypeBean {
    private int count = 0;
    public void addCount() { count++; }
    public int getCount() { return count; }
}
클라이언트 A  getBean()   인스턴스 생성  PrototypeBean@x01 반환
클라이언트 B  getBean()   인스턴스 생성  PrototypeBean@x02 반환   다른 인스턴스!

스프링 컨테이너: 생성  DI  @PostConstruct 초기화 콜백까지만 관여
               이후(소멸 포함) 클라이언트 책임!

중요: 프로토타입 빈은 @PreDestroy가 호출되지 않는다. 스프링이 생성·DI·초기화까지만 관여하고 이후 소멸은 클라이언트 책임이다.

비교

구분 싱글톤 프로토타입
인스턴스 수 1개 요청마다 새로 생성
생성 시점 컨테이너 시작 시 (기본) getBean() 호출 시마다
관리 주체 스프링 컨테이너 클라이언트
초기화 콜백 (@PostConstruct) O O
소멸 콜백 (@PreDestroy) O X
사용 시기 대부분의 빈 매번 독립적인 상태가 필요한 빈
// 테스트로 확인
AnnotationConfigApplicationContext ac =
    new AnnotationConfigApplicationContext(PrototypeBean.class);

PrototypeBean bean1 = ac.getBean(PrototypeBean.class);
PrototypeBean bean2 = ac.getBean(PrototypeBean.class);

assertThat(bean1).isNotSameAs(bean2); // true — 다른 인스턴스

ac.close(); // 프로토타입 빈의 @PreDestroy는 호출되지 않음!

싱글톤 빈에서 프로토타입 빈을 사용할 때 문제

문제 발생 원리

프로토타입 빈의 의도는 "요청할 때마다 새 인스턴스"다. 그런데 싱글톤 빈이 프로토타입 빈을 @Autowired로 주입받으면 문제가 생긴다.

주입은 싱글톤 빈이 생성될 때 딱 한 번만 일어난다. 이후 싱글톤 빈이 살아있는 동안 계속 같은 프로토타입 빈 인스턴스를 재사용한다.

@Component // 싱글톤
public class ClientBean {

    @Autowired
    private PrototypeBean prototypeBean; // 생성 시 딱 한 번 주입됨 — 이후 계속 재사용!

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

@Component
@Scope("prototype")
public class PrototypeBean {
    private int count = 0;
    public void addCount() { count++; }
    public int getCount() { return count; }
}
[기대]
  logic() 1 호출   PrototypeBean 생성  count = 1
  logic() 2 호출   PrototypeBean 생성  count = 1

[실제]
  ClientBean 생성  PrototypeBean@x01    주입됨
  logic() 1 호출  기존 @x01 재사용  count = 1
  logic() 2 호출  기존 @x01 재사용  count = 2  의도와 다름!

프로토타입 빈을 쓰는 의미가 사라진다.


해결 방법 1: ObjectProvider

빈을 직접 주입받는 대신, 빈을 조회하는 기능(Provider)을 주입받아서 필요할 때마다 새로 꺼낸다.

@Component
public class ClientBean {

    @Autowired
    private ObjectProvider<PrototypeBean> prototypeBeanProvider;
    // PrototypeBean 자체가 아니라, PrototypeBean을 조회하는 기능을 주입받음

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        // getObject() 호출 시마다 스프링 컨테이너에서 새 인스턴스를 생성해서 반환
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}
logic() 1 호출  getObject()   PrototypeBean@x01 생성  count = 1
logic() 2 호출  getObject()   PrototypeBean@x02 생성  count = 1 

ObjectProvider가 제공하는 다른 기능들:

// 빈이 없어도 예외 없이 null 반환
PrototypeBean bean = provider.getIfAvailable();

// 빈이 없으면 기본값 사용
PrototypeBean bean = provider.getIfAvailable(() -> new PrototypeBean());

// 스트림으로 여러 빈 처리
provider.stream().forEach(bean -> System.out.println(bean));

ObjectProvider는 스프링 전용이다. 스프링 프로젝트에서 가장 많이 쓰는 방식.


해결 방법 2: JSR-330 Provider (표준)

스프링에 의존하지 않는 자바 표준 인터페이스다. 다른 DI 컨테이너에서도 동작하는 이식성이 필요할 때 사용한다.

// build.gradle 의존성 추가 필요
implementation 'jakarta.inject:jakarta.inject-api:2.0.1'
import jakarta.inject.Provider;

@Component
public class ClientBean {

    @Autowired
    private Provider<PrototypeBean> provider; // 자바 표준 Provider

    public int logic() {
        PrototypeBean prototypeBean = provider.get(); // 매번 새 인스턴스
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}
구분 ObjectProvider Provider (JSR-330)
표준 여부 스프링 전용 자바 표준
기능 다양 (getIfAvailable, stream 등) 단순 (get() 하나)
의존성 추가 불필요 필요
권장 상황 스프링 프로젝트 스프링 외 이식성 필요할 때

해결 방법 3: 스코프 프록시 (가장 편리)

프록시 객체가 싱글톤 빈에 주입되고, 메서드 호출마다 실제 빈을 새로 조회해서 위임한다. 클라이언트 코드 변경 없이 가장 편리하게 해결할 수 있다.

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
// TARGET_CLASS = CGLIB 프록시 생성 (클래스 기반 — 인터페이스 없어도 됨)
public class PrototypeBean {
    private int count = 0;
    public void addCount() { count++; }
    public int getCount() { return count; }
}
@Component
public class ClientBean {

    @Autowired
    private PrototypeBean prototypeBean;
    // 실제로 주입되는 건 PrototypeBean이 아니라 CGLIB이 만든 프록시 객체!
    // 프록시는 PrototypeBean을 상속하므로 타입은 호환됨

    public int logic() {
        prototypeBean.addCount();    // 프록시의 addCount() 호출
        return prototypeBean.getCount(); // 프록시의 getCount() 호출
        // 프록시는 메서드 호출이 올 때마다 스프링 컨테이너에서 새 PrototypeBean을 조회하고
        // 실제 인스턴스에 위임
    }
}
[ClientBean]
    prototypeBean = PrototypeBeanCGLIB@proxy (프록시  싱글톤 라이프사이클)
                            addCount() 호출마다
                  스프링 컨테이너에서  PrototypeBean 인스턴스 조회  위임

ScopedProxyMode 옵션:

옵션 설명 사용 시기
TARGET_CLASS CGLIB 프록시 (클래스 상속 기반) 인터페이스 없는 경우도 가능
INTERFACES JDK 동적 프록시 (인터페이스 기반) 인터페이스가 있는 경우
NO 프록시 없음 (기본값)

세 가지 해결 방법 비교

구분 ObjectProvider JSR-330 Provider 스코프 프록시
코드 변경 주입 타입 변경 주입 타입 변경 없음 (가장 편리)
스프링 의존 있음 없음 (표준) 있음
추가 의존성 없음 필요 없음
가독성 중간 (getObject() 명시) 중간 (get() 명시) 가장 자연스러움
권장 상황 일반적 이식성 필요 시 가장 편리, 웹 스코프에 특히 유용

웹 스코프

웹 환경에서만 동작한다. 싱글톤과 달리 스프링이 해당 스코프의 종료 시점까지 직접 관리하므로 소멸 콜백(@PreDestroy)도 호출된다.

request 스코프

HTTP 요청 하나당 하나의 빈이 생성되고, 그 요청이 끝나면 소멸된다. 같은 요청 안에서는 어디서 꺼내든 같은 인스턴스를 반환한다.

언제 쓰는가: 로그 추적, 요청 ID, 트랜잭션 컨텍스트 등 요청 단위 데이터 관리.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
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(); // 요청마다 다른 UUID 생성
        System.out.println("[" + uuid + "] request 스코프 빈 생성");
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request 스코프 빈 소멸");
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final MyLogger myLogger; // 프록시 주입 (실제 빈은 요청 시 생성)

    public void logic(String id) {
        myLogger.log("service id=" + id);
    }
}

@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final LogDemoService logDemoService;
    private final MyLogger myLogger; // 같은 요청이면 서비스와 동일한 인스턴스

    @GetMapping("/log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        myLogger.setRequestURL(request.getRequestURL().toString());
        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}

요청 처리 흐름

HTTP 요청 A (사용자 김철수)
   스프링이 MyLogger  생성 (UUID: aaaa-bbbb)
   LogDemoController: myLogger.log("controller")  [aaaa-bbbb][/log-demo] controller test
   LogDemoService:    myLogger.log("service")     [aaaa-bbbb][/log-demo] service id=testId
   HTTP 응답 완료  MyLogger@aaaa-bbbb 소멸 (@PreDestroy 호출)

HTTP 요청 B (사용자 이영희, 동시)
   스프링이 MyLogger  생성 (UUID: cccc-dddd)  완전히 다른 인스턴스
   LogDemoController: myLogger.log("controller")  [cccc-dddd][/log-demo] controller test
   ...

request 스코프가 없다면 uuid, requestURL을 모든 메서드 파라미터로 계속 전달해야 한다. request 스코프를 쓰면 파라미터 없이 메서드 어디서든 myLogger.log()만 호출하면 된다.

proxyMode가 필요한 이유

@Scope("request")만 붙이면 문제가 생긴다.

문제:
  LogDemoController는 스프링 시작 시점에 생성됨 (싱글톤)
  이 때 MyLogger가 주입되어야 하는데...
  MyLogger는 HTTP 요청이 와야 생성되는 빈
  → 스프링 시작 시점에는 MyLogger를 만들 수 없음 → 오류!

proxyMode = ScopedProxyMode.TARGET_CLASS를 붙이면: - 스프링 시작 시점에 가짜 프록시 객체를 만들어서 LogDemoController에 주입 - HTTP 요청이 오면 프록시가 실제 MyLogger를 조회해서 메서드 위임

스프링 시작: MyLoggerCGLIB@proxy (프록시)  LogDemoController에 주입
HTTP 요청:  프록시.log() 호출  스프링이  요청에 해당하는 실제 MyLogger 조회  위임

스코프 선택 기준

상황 스코프
상태 없는 서비스, 레포지토리 (대부분) singleton
매 요청마다 독립적인 상태가 필요한 빈 prototype
HTTP 요청 단위 상태 관리 (로그 추적, 요청 컨텍스트) request
로그인 사용자 정보 등 세션 단위 관리 session

실무에서는 singleton이 거의 전부고, request 스코프가 간헐적으로 쓰인다. prototype은 매우 드물게 사용된다.


내부 동작 원리

Request 스코프 — RequestContextHolder 내부 메커니즘

request 스코프 빈이 어떻게 HTTP 요청마다 독립적인 인스턴스를 유지하는가?

핵심: RequestContextHolder가 내부적으로 ThreadLocal을 사용한다. 톰캣은 스레드풀을 사용하고, HTTP 요청 하나가 스레드 하나를 독점한다. 따라서 ThreadLocal(스레드별 독립 저장소)에 요청 정보를 저장하면 = 요청별로 독립된다.

HTTP 요청 도착
DispatcherServlet.service() 실행
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request))
  → ThreadLocal<RequestAttributes>에 현재 스레드의 요청 정보 저장

비즈니스 로직 처리 (Controller → Service → Repository)
  같은 스레드이므로 ThreadLocal에서 항상 같은 요청 정보 조회 가능

HTTP 응답 완료
RequestContextHolder.resetRequestAttributes()
  → ThreadLocal에서 요청 정보 제거 (스레드풀 반환 전 정리)

request 스코프 빈이 요청 정보를 조회하는 실제 흐름:

myLogger.log("message") 호출 (프록시를 통해)
CGLIB 프록시가 가로챔
RequestScope.get(beanName, objectFactory)
  → RequestContextHolder.currentRequestAttributes() 호출
  → ThreadLocal에서 현재 스레드의 RequestAttributes 조회
  → request.getAttribute("scopedTarget.myLogger") 확인
  → 없으면: objectFactory.getObject() → 새 MyLogger 인스턴스 생성
            request.setAttribute("scopedTarget.myLogger", newBean) → 요청 속성에 저장
  → 있으면: 기존 인스턴스 반환 (같은 요청 내에서는 항상 동일 인스턴스)
실제 MyLogger 인스턴스에 log("message") 위임

왜 ThreadLocal인가? 톰캣의 스레드풀 모델: 요청마다 풀에서 스레드를 꺼내 처리하고 반환한다. 한 요청은 처음부터 끝까지 같은 스레드가 처리한다. ThreadLocal에 저장된 값은 해당 스레드 내에서만 접근되므로, 요청 격리가 자연스럽게 달성된다.


CGLIB 스코프 프록시 내부 동작

proxyMode = ScopedProxyMode.TARGET_CLASS 설정 시 스프링이 생성하는 프록시의 실제 동작:

// 스프링이 CGLIB으로 생성하는 프록시 (의사코드)
public class MyLogger$$EnhancerBySpringCGLIB extends MyLogger {

    // 실제 스코프 관리자 (request, prototype 등에 따라 다름)
    private final Scope scope;
    private final String targetBeanName;

    @Override
    public void log(String message) {
        // 1. 현재 스코프에서 실제 빈 조회
        MyLogger target = (MyLogger) scope.get(targetBeanName, () -> {
            // 없으면 새로 생성 (prototype이면 항상 새로, request면 요청당 한 번)
            return beanFactory.createBean(MyLogger.class);
        });
        // 2. 실제 빈에 위임
        target.log(message);
    }

    @Override
    public void setRequestURL(String url) {
        MyLogger target = (MyLogger) scope.get(targetBeanName, ...);
        target.setRequestURL(url);
    }
}
LogDemoController가 보는 :
  myLogger = MyLogger$$EnhancerBySpringCGLIB@proxy (항상 같은 프록시 객체)

실제 동작:
  myLogger.log("test") 호출
     프록시: RequestScope.get()  ThreadLocal로 현재 요청의 MyLogger 조회
     실제 MyLogger@{요청별_인스턴스}.log("test") 실행

요청 A: MyLogger@x01 사용
요청 B: MyLogger@x02 사용 (완전히 다른 인스턴스, 다른 스레드의 ThreadLocal)
프록시 관점 설명
컨트롤러/서비스가 주입받는 것 항상 동일한 CGLIB 프록시 객체 (싱글톤처럼 보임)
실제 메서드 호출 시 프록시가 현재 스코프에서 실제 빈을 조회해서 위임
request 스코프의 격리 ThreadLocal + HttpServletRequest.getAttribute()로 요청별 인스턴스 구분