[스프링 핵심 원리] 8

smj_716·2025년 5월 11일

스프링 완전 정복

목록 보기
11/16

1. 빈 스코프란?

빈이 존재하는 생명 주기 범위를 지정하는 것이다.

스프링에서는 기본적으로 모든 빈이 싱글톤이다.
즉, 애플리케이션 시작 시 한 번만 생성되고 모든 의존 주입 시 동일한 인스턴스를 공유하게 된다.
그런데 이렇게 항상 같은 객체를 공유하는 것이 불편한 상황도 있다.
예를 들어, 매번 새로운 객체가 필요한 경우나 요청마다 다른 객체가 필요할 경우이다. 이럴 때 사용하는 개념이 바로 빈 스코프이다.

👉 스프링은 아래와 같은 스코프를 지원한다.

  • 싱글톤 : 기본값. 컨테이너 시작~종료까지 1개의 인스턴스 유지
  • 프로토타입 : 요청할 때마다 매번 새로운 인스턴스 생성
  • 웹 관련 스코프:
    • request : 웹 요청마다 새로운 빈 생성
    • session : HTTP 세션마다 하나의 인스턴스 유지
    • application : 서블릿 컨텍스트 범위와 같은 스코프
    • websocket : 웹 소켓과 동일한 생명주기를 가지는 스코프

👉 빈 스코프 지정하는 방법

@Component
@Scope("prototype")
public class HelloBean {}

또는 수동으로

@Bean
@Scope("prototype")
public HelloBean helloBean() {
    return new HelloBean();
}

2. 프로토타입 스코프

싱글톤과 달리 프로토타입(prototype) 스코프는 스프링 컨테이너가 매번 새로운 인스턴스를 생성해서 반환한다.

💡 동작 흐름

  1. 스프링 컨테이너가 요청을 받으면
    (프로토타입 빈을 생성하고 필요한 의존 관계를 주입 + 초기화)
  2. 매번 새로운 객체를 생성하여 반환
  3. 이후 이 객체에 대한 생명주기 관리는 스프링이 아닌 클라이언트가 책임
    -> 따라서 @PreDestory 메서드도 호출되지 않음!!
@Scope("prototype")
static class PrototypeBean {
    @PostConstruct
    public void init() {
        System.out.println("init");
    }

    @PreDestroy
    public void destroy() {
        System.out.println("destroy");
    }
}

이렇게 작성하고 ac.getBean(PrototypeBean.class)을 2번 호출하면 서로 다른 인스턴스가 생성되어init()은 각각 호출되지만 destroy()는 호출되지 않는다.
‼️ 즉, 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입 그리고 초기화까지만 관여한다.


3. 싱글톤 빈과 함께 사용시 문제점

아래 그림은 싱글톤 빈(clientBean)이 프로토타입 빈(PrototypeBean)을 주입받아 사용하는 구조를 보여준다.

  1. clientBean이 생성될 때 프로토타입 빈이 한 번 주입
  2. 클라이언트 A가 logic()을 호출하면 count는 1
  3. 클라이언트 B도 같은 clientBean을 사용하므로 다시 호출 시 count가 2로 증가

⚠️ 싱글톤 빈은 생성 시점에만 의존성이 주입되기 때문에 프로토타입 빈이 사용 시마다 새로 생성되지 않고 처음 주입된 인스턴스를 계속 사용하게 된다. 즉, 프로토타입의 의미가 사라지는 것이다!

// 생략된 코드 동일하게 유지
ClientBean clientBean1 = ac.getBean(ClientBean.class);
int count1 = clientBean1.logic(); // count = 1

ClientBean clientBean2 = ac.getBean(ClientBean.class);
int count2 = clientBean2.logic(); // count = 2

프로토타입의 생명주기를 활용하고 싶다면 매번 새롭게 요청하는 방식으로 로직을 수정할 필요가 있다.


4. Provider

싱글톤 빈에서 프로토타입 빈을 사용할 때마다 새로 생성되게 하려면 가장 간단한 방법은 필요할 때 스프링 컨테이너에 직접 요청하는 것(DL)이다.

이는 의존관계를 외부에서 주입받는 것(DI)이 아니라 필요한 의존관계를 직접 찾는 것(Dependency Lookup)이다.

그런데 ApplicationContext 전체를 주입받는 방식은 스프링 컨테이너에 종속적인 코드가 되고 단위 테스트도 어려워진다.

1️⃣ ObjectProvider 사용 (스프링 제공)

@Autowired
private ObjectProvider<PrototypeBean> provider;

public int logic() {
    // 매번 새 인스턴스 반환
    PrototypeBean prototypeBean = provider.getObject(); 
    prototypeBean.addCount();
    return prototypeBean.getCount();
}
  • 스프링은 지금 필요한 프로토타입 빈을 컨테이너에서 대신 찾아주는 즉 DL 역할만 하는 ObjectProvider를 제공한다
  • provider.getObject()를 호출하는 시점에 스프링 컨테이너가 새로운 프로토타입 빈을 생성하여 반환한다.

2️⃣ JSR-330 Provider 사용 (자바 표준)

@Autowired
private javax.inject.Provider<PrototypeBean> provider;

public int logic() {
    PrototypeBean prototypeBean = provider.get();
    prototypeBean.addCount();
    return prototypeBean.getCount();
}
  • 자바 표준 방식으로 다른 DI 컨테이너에서도 사용 가능하다.
  • 단점은 별도 라이브러리 의존이 필요하다.
    • ex) javax.inject:javax.inject:1

자바 표준과 스프링 기능이 겹치는 경우, 특별히 다른 컨테이너를 사용할 일이 없다면 스프링이 제공하는 기능을 사용하자!


5. 웹 스코프와 프록시 사용

스프링에서는 웹 애플리케이션을 개발할 때 요청마다 새롭게 생성되어야 하는 객체가 필요할 때가 있다. 예를 들어, HTTP 요청마다 고유한 로깅 정보를 담고 싶은 경우이다.
이럴 때 사용하는 것이 바로 웹 스코프(Web Scope)이다.

아래는 가장 많이 사용되는 request 스코프를 중심으로 설명한다.

✅ 예제 – 요청마다 고유한 로그를 남기고 싶다!

MyLogger라는 클래스를 만들어 요청마다 고유한 UUID를 생성하고 로그에 찍도록 설계한다.

@Component
@Scope(value = "request")
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();
        System.out.println("[" + uuid + "] request scope bean created: " + this);
    }

    @PreDestroy
    public void destroy() {
        System.out.println("[" + uuid + "] request scope bean closed: " + this);
    }
}

❌ 그런데 문제 발생!

다음처럼 MyLogger를 일반 싱글톤 컨트롤러에 그냥 주입하면 애플리케이션 실행 시점에 에러가 난다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final MyLogger myLogger; // ❌ 에러 발생
    ...
}

💡 왜 에러가 날까?

  • @Scope("request")는 HTTP 요청이 있을 때만 생성될 수 있다.
  • 하지만 싱글톤 빈은 애플리케이션 실행 시점에 먼저 생성된다.
  • 이 시점에서는 HTTP 요청이 없기 때문에 MyLogger 빈도 만들 수 없다.

✅ 해결 1: ObjectProvider

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("/log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        // 요청 시점에 빈 생성
        MyLogger myLogger = myLoggerProvider.getObject(); 
        myLogger.setRequestURL(request.getRequestURL().toString());
        myLogger.log("controller test");
        return "OK";
    }
}

이렇게 하면 getObjcet()를 호출하는 시점에 HTTP 요청이 진행중이므로MyLogger 빈이 생성된다.
즉, 빈 생성을 지연시켜서 문제를 해결하는 것이다.

✅ 해결 2: 프록시(proxy)

더 간단히 처리할 수 있는 방법은 프록시 객체를 사용하는 것이다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
    ...
}

이 설정을 추가하면 스프링이 MyLogger를 상속받은 가짜 객체(프록시)를 먼저 만들어서 싱글톤 빈들에 미리 주입해놓는다.
그럼 컨트롤러는 아래와 같이 평범하게 사용할 수 있다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final MyLogger myLogger; // ➡️ 프록시 객체가 들어옴

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

프록시 객체는 내부에서 실제 request scope 빈을 찾아서 필요할 때 위임한다.
즉, 개발자는 마치 싱글톤처럼 쓰지만 내부에서는 요청마다 새 객체가 사용되는 것이다.

프록시가 실제로 동작하는지 확인하려면
System.out.println("myLogger = " + myLogger.getClass()); 로 출력을 하면
myLogger = ..MyLogger$$EnhancerBySpringCGLIB... 라고 출력이된다.

  • CGLIB 라는 라이브러리로 가짜 객체가 생성된 것을 확인할 수 있다.
  • 이 객체는 내부적으로 진짜 request scope빈을 찾아서 사용하는 기능만 가지고 있는 프록시이다.
  • 가짜 프록시 객체는 원본 클래스를 상속 받아서 만들어졌기 때문에 이 객체를 사용하는 클라이언트 입장에서는 사실 원본인지 아닌지도 모르게 동일하게 사용할 수 있다. (다형성)

특별한 scope는 꼭 필요한 곳에만 최소화해서 사용하자. 무분별하게 사용하면 유지보수하기 어려워진다.

🌟 두 방식의 핵심 공통점은 모두 실제 빈 생성을 필요한 시점까지 미룬다는 것이다. 실제 객체는 지연 생성되고 로직은 그대로 유지할 수 있다는 점이 큰 장점이다.

0개의 댓글