Spring/SpringCore - basic

스프링 핵심 원리 - 빈 스코프와 웹 스코프( Provider와 Proxy 활용 지연처리 )

_Jin_ 2025. 1. 14.

Bean Scope

지금까지 배운 스프링과 관련한 핵심 원리의 Main은 스프링 컨테이너와 Bean에 대한 이해 및 원리였다.

그리고 스프링 Bean은 컨테이너의 시작과 함께 등록되어 컨테이너 종료 시점까지 유지됨을 봤다.

 

하지만 이는 무조건적인 사항이 아니다.

Bean 생성과 소멸에 대한 적용의 범위가 존재하며, 이를 사용하여 개발에 적용할 수 있다.

이것을 Bean Scope라고 부른다.

 

 

Bean Scope는 다음과 같이 지정할 수 있다.

 

프로토타입 스코프 Bean

 

컴포넌트 스캔 자동 등록

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

 

수동 등록

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

 

지금까지 사용했던, Scope는 싱글톤에 해당하기에 프로토타입을 사용한 Bean Scope 실습을 진행해보겠다.

 

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다. 반면에 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.

 

 

반면 프로토 타입 Bean은 싱글톤 Bean과 달리 아래와 같이 요청에 따른 응답을 처리한다.

 

 

 

위와 같은 동작에 따라서 프로토 타입으로 Bean을 설정할 경우, 컨테이너는 요청에 따라 Bean을 반환하고 이후에 관리하지 않는다. ( 따라서 @PreDestroy 같은 종료 메서드가 호출되지 않는다. )

 

두 Scope의 차이에 대해 알아보기 위한 테스트를 진행해보자.

먼저, 싱글톤 Scope의 경우이다.

public class SingletonTest {

    @Test
    public void singletonBeanFind() {
        AnnotationConfigApplicationContext ac = new
        AnnotationConfigApplicationContext(SingletonBean.class);
        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
        System.out.println("singletonBean1 = " + singletonBean1);
        System.out.println("singletonBean2 = " + singletonBean2);
        assertThat(singletonBean1).isSameAs(singletonBean2);
        ac.close(); //종료
    }
    
    @Scope("singleton")
    static class SingletonBean {
        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }
        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

 

SingletonBean.init
singletonBean1 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd
singletonBean2 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing SingletonBean.destroy

서로 다른 요청을 보냈지만, 같은 참조값을 지녔다.

그리고 종료 메서드까지 정상 호출된 것을 확인할 수 있었다.

 

반면, 프로토타입의 경우에 해당하는 테스트이다.

public class PrototypeTest {

    @Test
    public void prototypeBeanFind() {
        AnnotationConfigApplicationContext ac = new
        AnnotationConfigApplicationContext(PrototypeBean.class);
        System.out.println("find prototypeBean1");
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        System.out.println("find prototypeBean2");
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        System.out.println("prototypeBean1 = " + prototypeBean1);
        System.out.println("prototypeBean2 = " + prototypeBean2);
        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
        ac.close(); //종료
    }
    
    @Scope("prototype")
    static class PrototypeBean {
        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init");
        }
        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

 

find prototypeBean1
PrototypeBean.init
find prototypeBean2
PrototypeBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@13d4992d
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@302f7971
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing

서로 다른 요청을 보내고 다른 참조값을 지닌 인스턴스가 조회된다.

또한 종료 메서드에 따른 출력을 보여주지 않는다.

 

이를 정리하자면, 아래와 같다.

싱글톤 빈은 스프링 컨테이너 생성 시점에 초기화 메서드가 실행 되지만, 프로토타입 스코프의 빈은 스프링 컨테이너에서 빈을 조회할 때 생성되고, 초기화 메서드도 실행된다.

 

프로토타입 빈을 2번 조회했으므로 완전히 다른 스프링 빈이 생성되고, 초기화도 2번 실행된 것을 확인할 수 있다.

싱글톤 빈은 스프링 컨테이너가 관리하기 때문에 스프링 컨테이너가 종료될 때 빈의 종료 메서드가 실행되지만,프로토타입 빈은 스프링 컨테이너가 생성과 의존관계 주입 그리고 초기화 까지만 관여하고, 더는 관리하지 않는다.

따라서 프로토타입 빈은 스프링 컨테이너가 종료될 때 @PreDestroy 같은 종료 메서드가 전혀 실행되지 않는다.

 

싱글톤 Bean과 프로토 타입 Bean 사용에 따른 유의점

 

이런 프로토타입 스코프를 활용하여 싱글톤 빈과 함께 사용할 경우 유의할 점들이 있다.

싱글톤 빈과 함께 프로토타입 빈을 사용할 경우 의도한 대로 작동하지 않을 수 있다.

먼저, 프로토타입 빈을 사용하여 다음과 같은 작동을 기대하였다고 고려해보자.

 

 

 

이를 테스트 코드로 확인하면 아래와 같이 작성할 수 있다.

public class SingletonWithPrototypeTest1 {
    @Test
    void prototypeFind() {
        AnnotationConfigApplicationContext ac = new
        AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        prototypeBean1.addCount();
        assertThat(prototypeBean1.getCount()).isEqualTo(1);
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean2.addCount();
        assertThat(prototypeBean2.getCount()).isEqualTo(1);
    }
    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;
        public void addCount() {
            count++;
        }
        public int getCount() {
            return count;
        }
        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }
        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

프로토 타입 Bean 자체로 계속 새로운 인스턴스를 반환하기에 addCount()를 각 1번씩 호출하면 1씩 증가된 상태이다.

 

그럼, 이번에는 싱글톤 Bean에서 프로토타입 Bean을 주입받아 사용하는 경우를 고려해보자.

 

 

 

싱글톤에 프로토타입 객체를 주입받아 사용하는 경우, 생성 시점에 주입이 끝나기에 사용에 따라 본래 프로토타입 Bean에 기대하는 작동처럼 동작하지 않는다.

 

각 요청에 새로운 객체를 생성하지 않기에 addCount()에 따라 count값이 싱글톤 Bean에 의해 관리되며 값이 누적된다.

public class SingletonWithPrototypeTest1 {

    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.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(2);
    }
    static class ClientBean {
       
        private final PrototypeBean prototypeBean;
        
        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }
        
        public int logic() {
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }
    
    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;
        public void addCount() {
            count++;
        }
        public int getCount() {
            return count;
        }
        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }
        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

값이 누적된 것을 확인할 수 있는 테스트이다.

대게 어플리케이션은 싱글톤 Bean을 사용하고 경우에 따라서 프로토타입의 Bean을 사용할 경우 싱글톤 Bean과 함께 쓰일 경우가 많을 것이다.

 

그럼 이를 해결하고 프로토타입 Bean이 의도대로 동작하게 만드려면 어떤 방법이 있을까?

 

Provider 활용 문제 해결

싱글톤 빈과 프로토타입 빈을 함께 사용할 때, 어떻게 하면 사용할 때 마다 항상 새로운 프로토타입 빈을 생성할까?

 

먼저, 가장 간단한 방법에는 스프링 컨테이너에 직접 새로 요청하도록 구현하는 것이다.

public class PrototypeProviderTest {
    @Test
    void providerTest() {
        AnnotationConfigApplicationContext ac = new
        AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.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);
    }
    static class ClientBean {
        
        @Autowired
        private ApplicationContext ac;
        
        public int logic() {
		        // 직접 주입받은 컨테이너로 Bean을 요청한다.
            PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
            prototypeBean.addCount();
            
            int count = prototypeBean.getCount();
            return count;
        }
    }
    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;
        public void addCount() {
            count++;
        }
        public int getCount() {
            return count;
        }
        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }
        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

하지만, 이런 방식은 컨테이너에 종속적인 코드 형태이며, 단위 테스트를 어렵게 만든다..

 

대신 Provider를 사용해보자.

위의 컨테이너에서 직접 코드하던 코드를 ObjectFactory, ObjectProvider 중 하나를 사용하여, 필요한 시점에 탐색하여 요청하도록 만드는 것이다.

 

public int logic() {
	// 직접 주입받은 컨테이너로 Bean을 요청한다.
	PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
	prototypeBean.addCount();
            
	int count = prototypeBean.getCount();
	return count;
}

위의 코드를 아래와 같이 변경한다.

 

@Autowired
private ObjectProvider < PrototypeBean > prototypeBeanProvider;

public int logic() {

    PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
    prototypeBean.addCount();
    int count = prototypeBean.getCount();
    return count;
}

prototypeBeanProvider.getObject()을 통해서 항상 새로운 프로토타입 Bean이 생성된다.

이는 ObjectProvider 의 getObject() 를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환하기에 가능하다.

이는 컨테이너를 직접 조회하던 방식에 비하면, 단위 테스트하기 편리하게 만든다.

 

또 다른 방법이 하나 더 있는데, 은 javax.inject.Provider 라는 JSR-330 자바 표준을 사용하는 방법이다.

이를 사용하기 위해선 jakarta.inject:jakarta.inject-api:2.0.1 라이브러리를 gradle에 추가해야 한다.( 3.0 이상 ver )

 

이를 사용한 코드는 아래와 같다.

@Autowired
private Provider < PrototypeBean > provider;

public int logic() {

    PrototypeBean prototypeBean = provider.get();
    prototypeBean.addCount();
    int count = prototypeBean.getCount();
    return count;
}

provider.get() 을 통해서 항상 새로운 프로토타입 빈이 생성된다.

이 방법은 자바 표준이고, 기능이 단순하므로 단위테스트를 만들거나 mock 코드를 만들기는 훨씬 쉬워진다. 또한 자바 표준으로 스프링에 종속되지 않는다.

 

웹 스코프

이어서 웹 스코프에 대해 알아보고, 실습을 진행해보자.

웹 스코프는 웹 환경에서 동작하는 Bean Scope이다. 종류로는 아래와 같은 것들이 있다.

 

이들 중 request 스코프를 활용하여 Logger를 만들어보자.

웹 환경에서 작동하는 Bean Scope이기에 web 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-web'

 

이제 request 스코프로 다음과 같은 로그를 생성하도록 실습해보겠다.

 

@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 create:" + this);
    }
    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean close:" + this);
    }
}

로그를 출력하기 위한 MyLogger 클래스이다. @Scope(value = "request") 를 사용해서 request 스코프로 지정했다. 이제 이 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.

 

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final MyLogger myLogger;
    
    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);
        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}

로거가 잘 작동하는지 확인하는 테스트용 컨트롤러다. 여기서 HttpServletRequest를 통해서 요청 URL을 받았다. requestURL 값 http://localhost:8080/log-demo 이렇게 받은 requestURL 값을 myLogger에 저장해둔다.

 

@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final MyLogger myLogger;
    public void logic(String id) {
        myLogger.log("service id = " + id);
    }
}

비즈니스 로직이 있는 서비스 계층에서도 로그를 출력한다.

 

이제 의도한 바에 따라 출력이 생성되도록 실행하였지만, 실행과 동시에 에러가 발생한다.

 

그 이유는 실행 시점에 어떤 request( 요청 )이 들어오지 않는다.

즉, 아직 request 스코프 Bean이 생성되지 않았고, 싱글톤 Bean만 존재하는 상태에서 해당 객체를 주입받아 사용하기 때문이다.

 

이에 대한 해결 방법으로는 먼저 앞서 다룬 Provider가 있다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final ObjectProvider < MyLogger > 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");
        logDemoService.logic("testId");
        return "OK";
    }
}

 

@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final ObjectProvider < MyLogger > myLoggerProvider;
    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}

이처럼 해당 Bean이 필요한 시점에 찾아서 사용할 수 있도록 코드를 변경하면, 호출 시점에 생성된 request Scope Bean을 사용할 수 있다.

 

프록시 활용 해결(Proxy)

그리고 이를 해결하는 또 다른 방법은 프록시를 활용하는 것이다.

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

위의 코드처럼 Bean 설정에 proxyMode = ScopedProxyMode.TARGET_CLASS을 추가하고, 나머지 코드는 Provider를 사용하기 이전의 코드를 사용하면 해결된다.

이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.

 

프록시 동작 원리

앞서 @Configuration 을 사용할 때 나왔던 CGLIB를 기억하는가?

지금의 경우도 마찬가지로 CGLIB가 사용된다. 상속 받은 가짜 객체를 만들어 주입하는 것이다.

 

이제 프록시 기술을 사용하여 진짜 객체 조회가 필요한 시점까지 지연처리하여 편리하게 request scope Bean을 사용할 수 있다.

댓글