Search
Duplicate

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점

목차

빈 스코프란?

스프링 빈(Bean): 스프링 컨테이너에서 관리하는 자바 객체
스코프 : 존재할 수 있는 범위
즉, 빈 스코프는 스프링 빈이 존재할수 있는 범위를 뜻한다. 지금까지는 기본적으로 스프링 컨테이너에서 스프링 빈이 싱글톤 스코프로 생성되었기 때문에, 스프링 컨테이너와 생명주기를 같이 했기에 신경쓸 필요가 없었다.
하지만, 빈 스코프를 어떻게 설정하느냐에 따라 스프링 빈의 생성과 소멸을 클라이언트에서 관리해줘야하는 경우도 생길 수 있고, 다양한 요구사항에 맞는 스코프를 지정해 사용할 수 있다.

웹 관련 스코프

request: 웹 요청이 들어오고 나갈때까지 유지되는 스코프
session: 웹 세션이 생셩되고 종료될 때까지 유지되는 스코프.
application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프

빈 스코프 사용 방법

컴포넌트 스캔 자동 등록
@Scope("prototype") @Component public class PrototypeBean {}
Java
복사
수동 등록
@Scope("prototype") @Bean PrototypeBean prototypeBean(){ return new PrototypeBean(); }
Java
복사

프로토타입 스코프

기존의 스프링 컨테이너에 등록된 스프링 빈은 싱글톤 스코프라고 말을 하였으며, 그렇기에 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.
싱글톤 스코프 의 스프링 빈 요청
싱글톤 스코프의 빈을 요청하면 모두 동일한 스프링 빈을 반환한다.
프로토타입 스코프의 스프링 빈 요청
1.
클라이언트에서 프로토타입 스코프의 스프링 빈을 스프링 컨테이너에 요청
2.
스프링 컨테이너는 이 시점에서 프로토타입 빈을 생성하고, 의존관계 주입(DI)
3.
생성한 프로토타입 빈을 클라이언트에 반환
여기서 프로토타입은 싱글톤 타입의 스피링 빈과는 다르게 빈 생성, 의존관계 주입, 초기화까지만 진행한다. 그렇기에 그 이후 스프링 빈을 클라이언트에 반환한 이후로는 관리하지 않기에 소멸 메서드같은것은 모두 클라이언트에서 자체적으로 관리해야 한다.

Singleton 스코프 테스트

package hello.core.scope; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Scope; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import static org.assertj.core.api.Assertions.assertThat; public class SingletonTest { @Test public void singletonBeanFind() { ConfigurableApplicationContext 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"); } } }
Java
복사
싱글톤 스코프의 스프링 빈은 여러번 호출해도 모두 같은 인스턴스 참조 주소값을 가진다.
스프링 컨테이너 종료시 소멸 메서드도 자동으로 실행된다.

Prototype 스코프 빈 테스트

package hello.core.scope; import org.junit.jupiter.api.Test; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Scope; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import static org.assertj.core.api.Assertions.assertThat; public class PrototypeTest { @Test public void prototypeBeanFind() { ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class); PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class); 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"); } } }
Java
복사
프로토타입 빈 2개가 참조하는 인스턴스 참조 주소값이 다르다.
스프링 컨테이너 종료시 소멸 메서드가 실행되지 않았다.

정리

싱글톤은 스프링 컨테이너와 생명주기를 같이하지만, 프로토타입 스프링 빈은 생명주기를 달리한다.
싱글톤 스프링 빈은 매번 스프링 컨테이너에서 동일한 인스턴스를 반환하지만, 프로토타입 스프링 빈은 스프링 컨테이너에 요청할 때마다 새로운 스프링 빈을 생성후 의존관계까지 주입및 초기화 진행후 반환한다.
프로토타입 스프링 빈은 소멸 메서드가 호출되지 않는다.
클라이언트가 프로토타입 스프링 빈은 직접 관리해야 한다. (소멸 메서드도 직접 호출해야 한다. )

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점

프로토타입 스코프의 스프링 빈을 싱글톤 빈과 함께 사용하게 되면 의도한대로 동작하지않고 문제가 발생한다. 이에 대해 알아보고 해결책에 대해 학습하자.

문제 발생 케이스

싱글톤 스프링 빈 내부에 의존관계로 주입되는 스프링 빈이 프로토타입인 경우
package hello.core.scope; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Scope; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import static org.assertj.core.api.Assertions.assertThat; public class SingletonWithPrototypeTest1 { @Test void singletonClientUserPrototype() { 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"); } @PreDestroy public void destroy() { System.out.println("PrototypeBean.destroy"); } } }
Java
복사
PrototypeBean은 프로토타입 스코프지만 clientBean은 싱글톤 스코프이기 때문에, 싱글톤 빈에서 프로토타입 빈을 사용한다.
싱글톤 빈의 스코프는 스프링 컨테이너와 같은데, 프로토타입 스코프의 스프링 빈이 새로 생성되기는 했지만 싱글톤 빈과 함께 사용되기 때문에 계속 유지된다.
그래서 빈을 2회 요청하지만 동일한 프로토타입 빈을 사용하게되어 count는 1이 아닌 2가된다.
프로토타입 빈만 클라이언트가 직접 사용하는경우라면 상관없지만 싱글톤 빈과 함께 사용하면서 프로토타입 빈이 자기의 스코프를 지키고 매번 새롭게 생성하기 위해서는 어떻게 해야할까?
singletonClientUserPrototype 흐름도

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provider

위에서 싱글톤 빈과 프로토타입 빈을 혼용하는 경우 프로토타입의 의도대로 동작하지 않는 문제점을 발견했다.
그럼 어떻게 싱글톤 빈과 혼용하더라도 프로토타입 빈을 매번 새롭게 생성하면서 사용할 수 있을까?
간단히 사용해보면 싱글톤 빈에서 프로토타입 빈을 매번 새로 호출해서 사용하는 방법이 있을 것이다.
ClientBean 핵심 코드 수정
static class ClientBean{ @Autowired private ApplicationContext ac; public int logic() { PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class); prototypeBean.addCount(); int count = prototypeBean.getCount(); return count; } }
Java
복사
매번 프로토타입 빈(PrototypeBean)을 새로 생성하는 것을 확인할 수 있다.
이렇게 의존관계를 외부에서 주입(DI)받는 것이 아닌 직접 필요한 의존관계를 찾는 것을 Dependency Lookup(DL) 의존관계 조회(탐색) 이라 한다.
하지만, 이렇게 스프링 애플리케이션 컨텍스트 전체를 주입받게 되면 스프링 컨테이너와 종속성이 생기고 테스트도 어려워진다.

ObjectFactory, ObjectProvider

ObjectFactory: 지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공해준다. 아주 단순하게 getObject 하나만 제공하는 FunctionalInterface이고, 별도의 라이브러리도 필요 없다. 그리고 스프링에 의존한다.
ObjectProvider : ObjectFactory에 편의기능들(Optional, Stream...)추가해서 만들어진 객체 별도의 라이브러리는 필요 없고 스프링에 의존한다.
적용 코드
static class ClientBean{ @Autowired private ObjectProvider<PrototypeBean> prototypeBeanProvider; public int logic() { PrototypeBean prototypeBean = prototypeBeanProvider.getObject(); prototypeBean.addCount(); int count = prototypeBean.getCount(); return count; } }
Java
복사
→ 위에서 실행한 ac.getBean(PrototypeBean.class) 와 동일하게 매번 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다.
→ ObjectProvider의 getObject()를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다.(DL)
→ 스프링에 종속적인것은 동일하지만, 기능이 단순해서 단위테스트 및 Mock 을 이용한 테스트 더블을 준비하기 쉽다.

JSR-330 Provider

이런 스프링의 의존성이 마음에 들지 않으면 javax.inject.Provider 패키지의 JSR-330 자바 표준을 사용하는 방법이 있다. 이 방법을 사용하기 위해서는 javax.inject:javax.inject:1 라이브러리를 추가하자.
1.
build.gradle에 라이브러리 추가
... dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'javax.inject:javax.inject:1' ... }
Java
복사
2. 테스트 코드 변경
import javax.inject.Provider; ... static class ClientBean{ @Autowired private Provider<PrototypeBean> prototypeBeanProvider; public int logic() { PrototypeBean prototypeBean = prototypeBeanProvider.get(); prototypeBean.addCount(); int count = prototypeBean.getCount(); return count; } }
Java
복사
의도한대로 매번 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다.
ObjectProvider의 getObject대신 get메서드를 사용해 Dependency Lookup(DL)한다.
자바 표준이고, 기능이 단순하기에 단위테스트도 가능하고 테스트 더블도 쉽다.
그렇기에 스프링이 아닌 다른 컨테이너에서도 사용 가능하다.
별도의 라이브러리가 필요하다.

프로토타입 빈을 언제 사용해야 하는가?

javax.inject 패키지에 가보면 DL을 언제 사용하는지에 대한 예시가 Document로 작성되어 있다.
여러 인스턴스를 검색해야 하는 경우
인스턴스를 지연 혹은 선택적으로 찾아야 하는 경우
순환 종속성을 깨기 위해서
스코프에 포함된 인스턴스로부터 더 작은 범위의 인스턴스를 찾아 추상화 하기 위해서 사용한다.
자바 표준과 스프링에서 제공하는 기능중 무엇을 사용해야 하는가? 대부분 스프링이 더 다양하고 편리한 기능을 제공하기 때문에, 특별한 이유가 없다면 스프링이 제공하는 기능을 사용하면 된다. 하지만, 해당 기능이 다른 컨테이너에서도 모두 호환이되야 한다면 자바 표준을 사용하면 된다.

웹 스코프

웹 환경에서만 동작하는 스코프
스프링이 해당 스코프의 종료시점까지 관리하며, 종료 메서드도 호출된다.

종류

request: HTTP 요청이 들어오고 나갈때까지 유지되는 스코프로 각각의 요청마다 별도의 빈 인스턴스가 생성및 관리된다.
session: HTTP Session과 동일한 생명주기를 가진다.
application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
websocket: 웹소켓과 동일한 생명주기를 가지는 스코프
네 종류 모두 범위는 다르지만 동작 방식은 비슷하기에 대표적으로 Request 웹 스코프를 가지고 학습하자.

request 스코프 예제 만들기

실제로 예제를 만들어보면서 학습하자.
만들어볼 웹 스코프 범위의 빈은 로깅 빈이다.
Format: [UUID][requestURL]{message}

1. build.gradle에 web 환경 추가

//web 라이브러리 추가 implementation 'org.springframework.boot:spring-boot-starter-web'
Groovy
복사
해당 라이브러리를 추가하면 스프링 부트는 내장 톰캣 서버를 활용해 웹 서버와 스프링을 함께 실행한다.
해당 라이브러리가 없으면 지금까지처럼 AnnotationConfigApplicationContext 를 기반으로 애플리케이션을 구동한다.
웹 라이브러리가 추가되면 웹 관련 기능및 설정이 필요하기에 AnnotationConfigServletApplicationContext 를 기반으로 애플리케이션을 구동한다.
만약 실행했을때 기본 포트인 8080 포트가 사용중이라면 main/resources/application.properties에서 다음 내용을 추가해 포트를 변경하자.
server.port=9000
Groovy
복사

2. 코드 작성

MyLogger
LogDemoController
LogDemoService

3. 실행

: 이제 테스트를 위해 CoreApplication을 실행시켜보면, 내 예상과 다르게 에러가 발생할 것이다.
스프링 애플리케이션을 실행하는 시점에 싱글톤 빈은 생성해서 주입이 가능하지만, request 스코프 빈은 아직 생성되지 않고 실제 고객의 요청이 와야 생성할수 있기 때문이다.
다시말해, 해당 프로젝트가 구동될때 스프링 빈들이 컴포넌트 스캔이 되며 등록 및 의존관계 주입이 되는데, 여기서 웹스코프인 MyLogger 빈의 경우 HTTP request 요청이 올때 생성되는 빈이기 때문에 스프링 구동단계에서는 아직 생성을 할 수 없다. 그렇기에 해당 에러가 발생하는 것이다.
마지막으로 정리하면, 스프링 구동시 MyLogger 스프링 빈을 등록을 요구하는데 해당 빈은 자신이 생성되야 할 스코프 범위에 해당되지 않았기 때문에 에러가 발생한다.
그럼 해당 스프링 빈은 스프링 구동시점이아닌 사용자의 HTTP request 요청 시점에 생성될 수 있다는 말인데,
이를 해결하기위한 방법들에 대해 알아보자.

스코프와 Provider

위에서 말한것처럼 스프링 빈의 생성시점이 구동시점이아닌 지연된 시점인 경우 해결책에 대해 알아보자.

1. Provider

위에서 배운 Provider를 사용해보자.
ObjectProvider 활용

2. 프록시 활용

스코프 속성을 이용해 스프링 빈을 프록시객체로 만들어줄 수 있다.
프록시 활용

웹 스코프와 프록시의 동작 원리

myLogger의 클래스를 출력해보면 우리가 만든 hello.core.common.MyLogger가 출력되지 않고 이를 상속한 임의의 객체가 생성되는것을 볼 수 있다.
System.out.println("myLogger: "+myLogger.getClass()); //myLogger: class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$ac52ff68
Java
복사
@Scope의 proxyMode를 설정하면 스프링 컨테이너는 CGLIB이라는 바이트코드 조작 라이브러리를 사용해 MyLogger를 상속받은 가짜 프록시 객체를 생성한다.
스프링 컨테이너에는 이 프록시 객체가 등록된다.
getBean을 통해 해당 클래스 타입을 조회해도 가짜 프록시 객체가 조회되는 것을 볼 수 있다.
의존관계에서도 프록시 객체가 주입되는걸 알 수 있다.
즉, 정리하면
CGLIB라는 바이트코드 조작 라이브러리로 클래스를 상속받은 가짜 프록시 객체를 만들어 의존관계 주입.
핵심은 지연로딩
애노테이션 속성 추가만으로 원본 객체를 프록시 객체로 대체할 수 있다.
다형성과 DI 컨테이너의 장점.

이전 챕터로