[Spring] 핵심원리 기본편 - 빈 스코프(끝!)
Java & Spring

[Spring] 핵심원리 기본편 - 빈 스코프(끝!)

이번 챕터에는 빈 스코프에 대해 알아본다.

 

빈 스코프란?

빈 스코프는 말 그대로 빈이 존재할 수 있는 범위를 뜻한다.

우리는 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되서 스프링 컨테이너가 종료될 때까지 유지된다고 학습했는데 이는 스프링의 기본 스코프가 싱글톤 스코프이기 때문이다. 사실 이 스코프는 여러가지가 있다.

 

  1. 싱글톤: 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 기본 스코프
  2. 프로토타입: 스프링 컨테이너가 빈의 생성과 의존관계 주입까지만 관여하는 매우 짧은 범위의 스코프
  3. 웹 관련 스코프
    1. request: 웹 요청이 들어오고 나갈때까지 유지되는 스코프
    2. session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
    3. application: 웹의 서블릿 컨텍스와 같은 범위로 유지되는 스코프

빈 스코프를 지정하는 방법은 아래와 같다.

// 자동 빈 등록의 경우
@Scope("prototype")
@Component
public class HelloBean {}

// 수동 빈 등록
@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
    return new HelloBean();
}

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.

반면에 프로토타입 스코프는 조회시 항상 새로운 인스턴스의 스프링 빈을 반환한다.

또한 프로토타입 스코프의 빈은 스프링 컨테이너에서 생성 및 의존관계 주입, 초기화까지만 관여하고 이후에는 관리하지 않는다.

그 이후의 관리는 클라이언트의 책임이다. 그래서 @PreDestory와 같은 종료 메서드를 사용할 수 없다.

코드로 한번 실행해보자.

 

우선 싱글톤 스코프인 경우다.

public class SingletonTest {

	@Test
	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 destory() {
			System.out.println("Singletonbean.destory");
		}
	}
}

여기서 알 수 있는 점은 해당 빈이 싱글톤 스코프이기 때문에 getBean 할때마다 같은 인스턴스의 빈이 반환되어서 같은 빈이 ...@3cdf2c61 이렇게 출력이 되었다는 점이고, 같은 빈이기 때문에 생성도 처음에 1번만 되어서 처음에만 SingletonBean.init이 호출된다는 점이다. 마찬가지로 Singletonbean.destory도 한번만 호출된다.

 

다음은 프로토타입 스코프다.

public class PrototypeTest {

   @Test
   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);
   }

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

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

여기서는 빈이 같은 인스턴스가 반환되는게 아니기 때문에 SingletonBean.init도 두번 호출되고, 두개의 빈이 주소값도 다른 것을 알 수 있다. 또한 여기서는 destory 메서드가 호출되지 않았는데 이는 프로토타입 스코프의 경우 스프링 컨테이너가 초기화 후 이후 관리를 하지 않기 때문이다.

 

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

 

+ 참고로 저렇게 빈을 스프링 컨테이너 인자에 바로 넣으면 빈이 바로 등록된다.


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

프로토타입 스코프의 빈을 싱글톤 빈과 사용하면 의도대로 동작하지 않아 문제가 생길 수 있다. 아래에서 자세히 알아보자.

 

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();
			return prototypeBean.getCount();
		}
	}

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

		public void addCount() {
			count++;
		}

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

위의 테스트를 돌리면 성공했다고 뜬다. 여기서 의문이 든다. PrototypeBean은 프로토타입 스코프의 빈이기 때문에 새로 만들어지는게 아닌가?

 

위의 코드를 보면 ClientBean은 싱글톤 빈이기 때문에 등록될 때 생성자가 실행된다. 그러면 생성자를 보면 PrototypeBean이 인자로 있기 때문에 스프링 컨테이너는 이때 PrototypeBean을 생성해서 넘겨준다. 즉, 이때 PrototypeBean이 만들어지는 것이다. 그래서 여기서는 처음에 주입된 같은 PrototypeBean을 계속 쓰게 된다. 우리가 생각했던 '프로토타입 스코프의 빈은 호출할 때마다 다른 인스턴스를 반환한다.' 에 위배되는 것이다.


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

그럼 위의 문제를 어떻게 해결하면 좋을까? 싱글톤 빈과 프로토타입 빈을 함께 사용할 때 항상 새로운 프로토타입 빈을 생성할 수 있을까?

 

가장 간단한 방법은 싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하는 것이다. 그렇게 의존관계를 외부에서 주입(DI) 받는게 아니라 직접 필요한 의존관계를 찾는 것을 Dependency Lookup(DL) 의존관계 조회라고 한다. 하지만 이렇게 하면 스프링 컨테이너에 종속적인 코드가 되고, 단위 테스트도 어려워진다. 따라서 아래와 같이 해결한다.

 

바로 ObjectFactory, ObjectProvider로 해결할 수 있다.

이 둘은 지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공한다. 차이점은 다음과 같다.

  • ObjectFactory: getObject 기능 하나만 제공한다.
  • ObjectProvider: 과거에는 ObjectFactory만 있다가 기능이 더해지면서 ObjectFactory를 상속받은 ObjectProvider가 추가되었다.
@Scope("singleton")
static class ClientBean {

   @Autowired
   private ObjectProvider<PrototypeBean> prototypeBeanProvider;

   public int logic() {
      PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
      prototypeBean.addCount();
      return prototypeBean.getCount();
   }
}

위와 같이 해주면 되는데, 이렇게 하면 prototypeBeanProvider.getObject()를 통해 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있다.

 

하지만 위의 방식은 스프링에 의존한다.

그래서 스프링에 의존하지 않는 방법이 아래의 방법이다.

 

JSR-330 Provider

(JSR은 자바표준을 의미. javax.inject.Provider에서 javax를 보면 알 수 있다.)

스프링에 의존하지 않고 자바 표준이다.

 

gradle에 추가

implementation 'javax.inject:javax.inject:1'
@Scope("singleton")
	static class ClientBean {

		@Autowired
		private Provider<PrototypeBean> prototypeBeanProvider;

		public int logic() {
			PrototypeBean prototypeBean = prototypeBeanProvider.get();
			prototypeBean.addCount();
			return prototypeBean.getCount();
		}
	}

* 주의: Provider import 패키지가 javax.inject.Provider가 맞는지 확인


웹 스코프

웹 스코프는 웹 환경에서만 동작하는 스코프다. 

프로토타입 스코프와는 다르게 해당 빈의 종료시점까지 관리한다.

 

종류

  • request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프. 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.
  • session: HTTP Session과 동일한 생명주기 가짐
  • application: 서블릿 컨텍스트와 동일한 생명주기 가짐
  • websocket: 웹 소켓과 동일한 생명주기 가짐

request 스코프 예제 만들기

build.gradle에 web library를 추가해준다.

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

 

CoreApplication을 실행시켜서 localhost:8080에 접속했을 때 아래와 같은 창이 뜨면 성공

웹 어플리케이션에서 동시에 여러 HTTP 요청이 오는 상황을 가정하고 로그가 편리하게 남을 수 있도록 request 스코프를 활용해 개발해본다. 의도하는 로그 형식은 아래와 같다.

[UUID][requestURL]{message}
-> UUID: HTTP 요청 구분용. request별로 unique
-> requestURL: 어떤 URL로 요청했는지

 

@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);
	}
}

위의 클래스는 로그를 출력하기 위한 클래스다. request 스코프이므로 HTTP 요청당 하나씩 생성되고, 빈 생성 시점에 @PostConstruct 초기화 메서드로 uuid를 생성해서 저장해둔다. 이후에 소멸되는 시점에 @PreDestroy를 사용해 종료 메시지를 남긴다. 이때 requestURL은 이 빈이 생성되는 시점을 알 수 없으므로, 외부에서 setter로 입력받는다.

 

이제 controller와 service를 만들어서 실제로 호출해본다.

@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";
	}

}
@Service
@RequiredArgsConstructor
public class LogDemoService {

	private final MyLogger myLogger;

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

단, 이대로 스프링을 실행하면 오류가 뜨는데, 이는 controller에서 MyLogger를 주입해줄 때 MyLogger가 존재하지 않기 때문이다. MyLogger는 'request' 스코프다. 즉, 요청이 들어와야 생성이 된다. 하지만 스프링 컨테이너가 처음 뜨고, 의존성 주입을 하려고 시도할 때에는 요청이 없기 때문에 MyLogger를 생성할 수 없다. 그래서 아래와 같은 오류가 뜬다.

Error creating bean with name 'myLogger': Scope 'request' is not active for the current thread;

다음 파트에서 해결법을 알아보자.

 

+ 참고) 서비스 계층에서는 해당 로그를 출력하지 않는게 좋다. 왜냐하면 requestURL과 같이 웹과 관련된 정보를 넘기지 않는게 좋기 때문이다. 파라미터가 많아져 지저분해지기도 한다. 따라서 서비스 계층은 웹 기술에 종속되지 않고 순수하게 유지하는 것이 유지보수 관점에서 좋다.


스코프와 Provider

위의 문제는 ObjectProvider로 해결할 수 있다.

ObjectProvider는 "지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스"였다. (위에 있음)

아래와 같이 컨트롤러와 서비스를 바꿔준다.

@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);
	}
}

 

이제 http://localhost:8080/log-demo 로 실행하고 로그를 보자.


스코프와 프록시

이번에는 위의 해결방법 대신 프록시를 사용해서 좀더 간결하게 작성해보자.

 

MyLogger의 경우 아래와 같이 애노테이션에 옵션 추가를 해준다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS) // interface의 경우 ScopedProxyMode.INTERFACES
public class MyLogger {
...
}

컨트롤러와 서비스는 모두 ObjectProvider를 빼고 코드 원상복구를 해준다.

이렇게 하면 아까는 오류가 났지만 proxyMode 옵션으로 문제가 생기지 않고 정상동작하는 것을 볼 수 있다.

 

이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request랑 상관없이 가짜 프록시 클래스를 다른 빈에 미리 주입해준다. 이 가짜 프록시 객체는 요청이 오면 그 때 내부에서 진짜 빈을 요청하는 위임 로직이 들어있다. 이 가짜 프록시 클래스는 원본 클래스를 상속 받아서 만들어졌기 때문에 클라이언트는 원본인지 아닌지 모르게 사용할 수 있다.(다형성)


정리

스프링 핵심 원리 기본편이 끝났다.

이전 입문 강의는 정말 기본 사용법만 익힌 느낌이었다면, 이번 핵심원리 기본편은 스프링의 실제 동작 원리를 이론적으로 공부하며 코드를 살펴본 느낌이었다. 다음에는 '스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술'을 듣기 전에 웹에서 중요한 네트워크 지식을 습득하기 위해 모든 개발자를 위한 HTTP 웹 기본 지식을 들어볼 예정이다. 잠시 다른 공부를 하는 시간을 가지고 이어서 들어야겠다:)

728x90
반응형