AtraFelis's Develop Diary

[Spring] 스코프 빈 (스프링 핵심원리 - 기본편 Section 10) 본문

Programming/Spring

[Spring] 스코프 빈 (스프링 핵심원리 - 기본편 Section 10)

AtraFelis 2025. 2. 2. 03:35

스프링 핵심 원리 - 기본편 강의를 수강하며 작성한 글입니다.
Section 10. 스코프 빈

 

스코프 빈이라는 새로운 용어가 등장했지만, 사실 스코프 빈은 지금까지 공부했던 내용이라고 생각해도 된다.

scope는 범위라는 뜻을 가진다. 즉, 스코프 빈은 빈이 스프링 컨테이너 내에서 생성되고 유지되며 소멸되는 범위를 말한다.

💡 스코프 빈 VS 빈 생명주기

스코프 빈과 빈 생명주기는 비슷한 듯하지만, 정확히 따지면 다른 의미를 가진다. 스코프 빈은 "Bean이 몇 번 생성되며, 얼마나 오래 유지되는가?", 빈 생명주기는 "Bean이 컨테이너에서 어떤 과정을 거치는가?"를 의미한다.

스프링이 지원하는 스코프는 다음과 같다.

  • 싱글톤 스코프
  • 프로토타입 스코프
  • 웹 스코프 : request, session, application, websocket

 

싱글톤 스코프

싱글톤 스코프은 지금까지 공부했던 스프링 빈을 말한다. 기본값으로 설정되어 있기에 따로 지정하지 않아도 되었던 것 뿐이다.

@Component  
public class MemoryMemberRepository implements MemberRepository { ... }

이렇게 컴포넌트 스캔을 통해 자동으로 스프링 컨테이너에 등록할 때, 우리는 이 빈이 싱글톤으로 관리된다고 배웠다.

사실 이 코드에는 생략된 애노테이션이 존재한다.

@Component  
@Scope("singleton")
public class MemoryMemberRepository implements MemberRepository { ... }

싱글톤 스코프는 기본값으로 설정되어 있기에, 이렇게 @Scope("singleton")를 작성하지 않아도 상관없다. 명시적으로 이 빈이 싱글톤이라고 표기하고 싶을 때만 사용하면 된다.

 

프로토타입 스코프

프로토타입이라는 단어가 낯설지만, 그냥 싱글톤으로 관리되지 않는 빈을 의미한다고 생각하면 된다. 프로토타입 스코프를 스프링 컨테이너에 조회하면 항상 새로운 인스턴스를 생성해 반환해준다.

개발을 할 때, 싱글톤으로 관리하지 않아야 더 편한 상황이 있을 수도 있다. 이럴 때 사용하는 것이 프로토타입 스코프다.

프로토타입 스코프를 사용하는 방법도 매우 간단하다. 그냥 @Scope("prototype")을 붙여주면 된다.

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

이렇게하면, 이 빈은 프로토타입 빈으로 인식되어, 요청할 때마다 스프링 컨테이너에서 새로운 인스턴스를 생성해 반환된다.

여기서 프로토타입 빈의 중요한 특징이 있는데, 스프링 컨테이너는 프로토타입 빈의 생성, 의존관계 주입, 초기화까지만 관여한다는 것이다. 즉, 빈 생명주기에서 초기화 이후의 과정 --빈 사용, 소멸 콜백(Destroy Callback), 스프링 종료--는 관여하지 않는다. (빈 생명주기에 관해 기억나지 않는다면, section9 빈 생명주기 콜백의 초반 부분을 보고 올 것을 추천한다.)

@Component
@Scope("prototype")  
public class PrototypeBean {  
    @PostConstruct  
    public void init() {  
        System.out.println("PrototypeBean.init");  
    }  

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

이렇게 초기화 콜백과 소멸 콜백을 등록한 후, 이 프로토타입 빈을 사용해보자.

@Test  
private void prototypeBeanFind() {  
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);

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

    assertThat(bean1).isNotSameAs(bean2);  

    ac.close();
}

이 코드에서 확인할 수 있는 프로토타입 빈의 특징은 2가지다.

  1. isNotSame() 메소드로 진행한 테스트가 통과되었다는 것. 즉, 프로토타입 빈은 요청할 때마다 새로운 인스턴스가 반환된다는 특징.
  2. ac.close()로 컨테이너를 종료했음에도, 소멸 메소드가 실행되지 않았다는 것. 즉, 프로토타입 빈은 초기화 콜백 이후로는 스프링 컨테이너에서 관리하지 않는다는 특징.

이 특징을 잘 기억해두자.

 

싱글톤 빈에서 프로토타입 빈 사용하기

빈을 사용할 때 이렇게 사용하고 싶을 수가 있다.

public class SingletonWithPrototype {  
    @Scope("singleton")  
    static class ClientBean {
        private final PrototypeBean prototypeBean;

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

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

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

        public void addCount() {  
            count++;  
        }  

        public int getCount() {  
            return count;  
        }
    }  
}

이렇게 싱글톤 빈 안에서, 매번 새로이 생성되는 프로토타입 빈을 이용하고 싶은 것이다.

이 코드의 의도는 이것이다.

  1. 싱글톤 빈인 ClienBean에서 logic()을 호출하면,
  2. 매번 새로운 프로토타입 빈인 PrototypeBean이 스프링 컨테이너에서 생성되고
  3. addCount()getCount()를 각각 호출한다.
  4. 결과적으로 한 번의 addCount()가 호출되었으므로, 1이 출력되어야 한다.
@Test  
void singletonClientUsePrototype() {  
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class, ClientBean.class);  

    ClientBean clientBean1 = ac.getBean(ClientBean.class);  
    int count1 = clientBean1.logic();  
    assertThat(count1).isEqualTo(1);  

    ClientBean clientBean2 = ac.getBean(ClientBean.class);  
    int count2 = clientBean2.logic();  
    assertThat(count2).isEqualTo(1);
}

즉, 우리가 의도한 것처럼 코드가 잘 동작했다면, 이 테스트 코드는 성공해야 할 것이다. ClientBean은 싱글톤이므로 clientBean1clientBean2은 같은 인스턴스지만, 이 빈은 프로토타입 빈을 각기 사용하므로 logic()을 두 번 호출하여도 출려되는 count의 값은 1이어야 한다.

하지만 이 테스트는 실패한다.

천천히 생각해보면 당연한 일이다. 싱글톤 빈은 단 하나의 인스턴스만을 사용한다. 당연히 싱글톤 빈이 가지고 있는 데이터들도 싱글톤 빈에 종속된다.

이는 아래와 같다.

@Scope("singleton")  
static class ClientBean {
    private final int abc = 0;

    ...

    public int logic() {
        abc++;
        ...
    }
@Test  
void test() {  
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class, ClientBean.class);  

    ClientBean clientBean1 = ac.getBean(ClientBean.class);  
    clientBean1.logic();

    ClientBean clientBean2 = ac.getBean(ClientBean.class);  
    clientBean2.logic();
}

이렇게 코드를 돌린다면, abc의 값은 2가 될 것이다. 왜냐? ClientBean은 하나의 인스턴스를 공유해서 사용하므로, 내부의 변수 abc도 공유된다.

즉, 싱글톤 빈 내부에서 private final PrototypeBean prototypeBean; 이렇게 선언된 프로토타입 빈도 abc와 마찬가지로 이미 생성된 인스턴스를 싱글톤 빈이 계속 들고서 공유하고 있는 것이다.

 

해결방법 1

우리가 지금까지 배운 내용 내에서 해결할 수 있는 방법도 있다.

public int logic() {
    PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
    prototypeBean.addCount();
    System.out.println(prototypeBean.getCount());
    return prototypeBean.getCount();
}  

이렇게 로직을 호출할 때마다, 스프링 컨테이너에서 프로토타입 빈을 호출하도록 하는 것이다.

하지만 이렇게 하면 스프링 컨테이너에 종속적인 코드가 되며 단위 테스트도 진행하기 어려워 진다. 스프링 컨테이너에 종속적이라는 것은 이 코드를 실행할 때, 반드시 스프링 컨테이너(ApplicationContext)가 있어야 한다는 것이다.

이전 포스팅에도 언급했지만, 단위 테스트 코드는 순수한 자바로만 작성하는 것이 좋다. 하지만 이렇게 코드를 작성하면, ApplicationContext 없이 순수한 자바 환경에서 실행할 수 없게 되는 것이다.

💡Dependency Lookup : DL

이렇게 직접 필요한 의존관계를 찾는 것을 Dependency Injection이 아니라 Dependency Lookup이라고 한다. 번역하면 의존관계 조회이다.

 

해결방법 2 - Provider

해결방법 1의 문제점은 DL을 하기 위해서 ApplicationContext, 즉 스프링 컨테이너에 종속적이 되어버린다는 것이었다. 그렇다면 이 DL 기능만 대신해주는 무언가가 있으면 되지 않겠는가? 이것이 바로 Provider라는 기능이다.

Provider는 두 가지가 존재한다.

  1. ObjectProvider : org.springframework.beans.factory.ObjectProvider
  2. JSR-330 Provider : jakarta.inject.Provider

import 되는 라이브러리를 보면 알 수 있듯이 ObejectProvider는 스프링에서 제공하는 기능, JSR-330 Provider는 자바 표준 기능이다.

사용법은 비슷하므로 스프링에서 기본으로 제공하는 ObjectProvider를 기준으로만 작성했다.

@Scope("singleton")  
static class ClientBean {
    @Autowired
    private ObjectProvider<PrototypeBean> prototypeBeanProvider;  

    public int logic() {  
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();  
        prototypeBean.addCount();  
        return prototypeBean.getCount();  
    }  
}
  • ObjectProvider<PrototypeBean> prototypeBeanProvider : PrototypeBean*을 제공하는 *ObjectProvider 선언
  • prototypeBeanProvider.getObject(): 새로운 PrototypeObeject 생성

이렇게 Provider는 의존관계 조회 DL 기능만을 제공하는 것을 알 수 있다.

💡Autowired members must be defined in valid Spring bean 에러

IntelliJ IDEA 2024.3.1.1 ver 기준

인텔리제이에서 Provider@Autowired를 했을 때, 이런 오류가 나타나는 분들이 있을 것이다. (나도 그렇다.) 실제로 코드를 돌려보면, 컴파일 상에 오류가 나타났음에도 제대로 동작하는 것을 확인할 수 있다. 실제로 의존관계도 주입이 잘 된 것도 확인할 수 있을 것이다.

이는 코드가 잘못된 것이 아니라, IDE 자체에서 Provider의 복잡한 동작을 제대로 분석하지 못해서 나타나는 오류이다.


ChatGPT의 답변

  1. ObjectProvider의 동적 동작 특성
    • ObjectProvider는 의존성을 즉시 주입하는 것이 아니라, 필요한 시점에 스프링 컨테이너로부터 의존성을 동적으로 가져오는 역할을 합니다.
    • IntelliJ는 @Autowired즉시 주입 가능한 Bean을 대상으로 사용된다고 가정하기 때문에, ObjectProvider의 지연 주입 동작을 잘못 해석할 수 있습니다.
  2. IDE의 제한된 분석 능력
    • IntelliJ는 컴파일러 수준에서 코드나 애노테이션을 분석하고, 스프링 컨텍스트를 예측하여 Bean 정의를 추적하려고 합니다.
    • 하지만 ObjectProvider처럼 동적이고 유연한 Bean 조회 방식은 이 분석을 방해하거나, IDE가 이를 인식하지 못할 가능성이 있습니다.

 

웹 스코프

웹 스코프는 이름에서부터 알 수 있듯이 웹 환경에서 동작하는 스코프이다.

프로토타입 스코프와 같이 호출 시마다 새로운 인스턴스가 생성되지만, 종료 시점까지 해당 스코프를 관리한다. 즉, 종료 메서드가 호출된다.

처음에 언급했듯, 웹 스코프에는 request, session, application, websocket 이렇게 네 가지 종류가 존재한다.

스코프 생명주기 사용 예
request HTTP 요청이 들어오면 생성되고, 응답 후 소멸 각 요청마다 새로운 Bean 필요할 때 (ex: 로그인 요청 정보)
session HTTP 세션이 시작될 때 생성되고, 세션 종료 시 소멸 (HTTP seesion과 동일한 생명주기를 가짐) 로그인 사용자 정보 유지 등 개별 사용자 데이터 관리
application 애플리케이션 시작 시 생성되고 종료될 때까지 유지 (ServletContext와 동일한 생명주기를 가짐) 글로벌 설정 정보 저장 등 전역 객체로 사용
websocket 웹소켓 세션이 유지되는 동안 존재 실시간 채팅, 웹소켓 기반 데이터 처리

이 네 개의 웹 스코프의 동작 방식은 비슷하므로, request 스코프를 대표로 하여 작성했다.

request 빈은 HTTP 요청이 들어올 때마다 생성이 되므로, 이를 이용해 접속한 사용자를 구분할 수 있게 로그가 남도록 하는 예시 코드를 작성해보자.

@Component  
@Scope(value = "request")
public class MyLogger {  
    private String uuid;
    private String requestURL;

    public void setRequestURL(String requestURL) { ... }

    public void log(String message) {  
        System.out.println("[" + uuid + "]" + "[" + requestURL + "]" + message);  
    }  

    @PostConstruct  
    public void init() { ... }  

    @PreDestroy  
    public void close() { ... }  
}

다른 스코프들과 마찬가지로 @Scope("request") 애노테이션을 붙여 설정할 수 있다.

@Controller
public class LogDemoController {  
    private final MyLogger myLogger;

    @Autowired
    public LogDemoController(MyLogger myLogger) {  
        this.myLogger = myLogger;  
    }

    @RequestMapping("log-demo")  
    @ResponseBody  
    public String LogDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();

        myLogger.setRequestURL(requestURL);  
        myLogger.log("controller test");
        return "OK";
    }  
}

이제 HTTP 요청을 받을 컨트롤러를 임시로 만들어준다.

이렇게 log-demo 페이지로 접속하면 "OK" 문자열을 띄워주는 간단한 코드다. 이 페이지에 사용자가 접속할 때마다 우리의 서버로 HTTP 요청이 날아올 것이고, MyLogger를 통해 서버에는

[6581e535-35a6-483d-8d9c-eb904e4e9f46][http://localhost:8080/log-demo] controller test

이런 로그 메시지를 남게 하는 것이 목적이다.

하지만 이렇게 했을 때에는 서버 실행 시점부터 아래와 같은 에러가 발생한다.

Error creating bean with name 'myLogger': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton;

request 빈은 HTTP 요청이 들어올 때 생성되는 스코프라고 하였다. 이 점을 유의하여 다시 한번 코드를 살펴보자.

@Autowired
public LogDemoController(MyLogger myLogger) {  
    this.myLogger = myLogger;  
}

컨트롤러에서 request 빈인 MyLogger를 주입 받으려고 하고 있다. 즉, 스프링 애플리케이션을 실행하는 시점에 request 빈을 주입받으려고 하는 것이 문제다.

이 시점에서는 당연히 HTTP 요청이 들어오지 않았으므로, request 빈은 생성되지 않는다. 없는 것을 주입하려고 하기 때문에 스프링에서 에러를 발생시키는 것이다.

그렇다면 이 문제를 해결하는 방법은 2가지가 있다.

Provider

첫 번째는 간단하게 위에서 공부한 Provider를 사용하는 것이다. 즉, 실행할 때 의존관계 주입 DI를 하는 것이 아니라 필요한 시점에 의존관계 조회 DL을 하는 것이다.

@Controller
public class LogDemoController {
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @Autowired
    public LogDemoController(ObjectProvider<MyLogger> myLoggerProvider) {
        this.myLoggerProvider = myLoggerProvider;  
    }

    @RequestMapping("log-demo")  
    @ResponseBody  
    public String LogDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        MyLogger myLogger = myLoggerProvider.getObject();

        myLogger.setRequestURL(requestURL);  
        myLogger.log("controller test");
        return "OK";
    }  
}

이렇게 하면, 스프링 애플리케이션을 실행하는 시점에는 request 빈이 생성되지 않고 실행 후, HTTP 요청이 들어오면 provider를 통해 DL을 해주므로 정상적으로 동작하게 할 수 있다.

프록시

Provider 보다도 간단하게 이를 해결할 수 있는 방법이 있다. 바로 프록시라는 것인데, 아래와 같이 매우 간단하게 설정할 수 있다.

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

이렇게만 해주면 된다.

프록시는 영어로 '대리인'을 의미한다. 스프링 애플리케이션 실행 시점에는 MyLogger 가짜 프록시 클래스를 생성하여 주입한 후, 실제 HTTP 요청이 들어왔을 때만 진짜 MyLogger 클래스를 호출하게끔 하는 것이다.

실제로 이 프록시 클래스를 출력하여 확인해보면,

class atrafelis.core.common.MyLogger$$SpringCGLIB$$0

이렇게 MyLogger 클래스가 아닌, 뒤에 무언가 이상한 문자열이 추가된 MyLogger$$SpringCGLIB$$0라는 클래스가 대신 주입되어 있는 것을 확인할 수 있다.

뭔가 익숙하지 않은가?

싱글톤 컨테이너에서 공부했던 스프링 컨테이너가 싱글톤을 유지하기 위해 하는 일과 비슷하다. 스프링에서 내부적으로 프록시를 위한 가짜 클래스를 만들어낸 후, 컨테이너에 등록해버리는 것이다. (당연히 ac.getBean("myLogger", MyLogger.class)로 조회해도 프록시 클래스가 조회된다.)

이후 이 프록시 클래스에 요청이 들어오면, 이 프록시 클래스가 진짜 클래스를 호출하는 방식으로 동작한다.

프록시 클래스의 동작 과정

여기서 중요한 것은 가짜 프록시 객체는 실제 request 스코프와는 관계가 없다는 것이다. 내부에 위임 로직만 있는 가짜 객체라는 사실을 명심해야 한다.

 


 

References

스프링 핵심 원리 - 기본편