본문 바로가기

Back-End/토비의 스프링3

1.3-IoC 컨테이너 : 프로토타입과 스코프

IoC 컨테이너 : 프로토타입과 스코프

스프링의 빈은 기본적으로 싱글톤으로 만들어진다. 요청이 있을때마다 매번 애플리케이션 로직을 담은 오브젝트를 새로 만드는 것은 비효율적이기 때문이다.

한 빈 오브젝트에 여러 개의 스레드가 동시에 접근하기 때문에 상태 값을 인스턴스 변수에 저장해두고 사용할 수 없다.

DB나 로직에 의해 새로 만들어지는 DTO 등은 파라미터나 리턴 값으로 전달하면 싱글톤으로 사용하는데 아무 문제가 없다.

먼저 스코프에 따라 어떻게 되는지 테스트 코드를 살펴보자.

@RunWith(SpringRunner.class)
@SpringBootTest
public class ScopeTests {

@Test
public void singletonScope() {
ApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class,
SingletonClientBean.class);
Set<SingletonBean> beans = Sets.newHashSet();

beans.add(ac.getBean(SingletonBean.class));
beans.add(ac.getBean(SingletonBean.class));

assertThat(beans.size(), is(1));

beans.add(ac.getBean(SingletonClientBean.class).bean1);
beans.add(ac.getBean(SingletonClientBean.class).bean2);

assertThat(beans.size(), is(1));

}

@Test
public void prototypeScope() {
ApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class,
PrototypeClientBean.class);
Set<PrototypeBean> beans = Sets.newHashSet();

// 컨테이너에 빈을 요청할 때 마다 새로운 빈 오브젝트가 생성되는 것을 확인
beans.add(ac.getBean(PrototypeBean.class));
assertThat(beans.size(), is(1));

beans.add(ac.getBean(PrototypeBean.class));
assertThat(beans.size(), is(2));

// DI 할 때도 주입받는 프로퍼티마다 다른 오브젝트가 만들어지는 것을 확인
beans.add(ac.getBean(PrototypeClientBean.class).bean1);
assertThat(beans.size(), is(3));

beans.add(ac.getBean(PrototypeClientBean.class).bean2);
assertThat(beans.size(), is(4));

// 프로토타입 빈은 독특하게 IoC 기본원칙( 빈 라이프사이클 관리는 IoC에서 함 )을 따르지 않는다.

// 빈이 한번 생성되고 나면 컨테이너는 더 이상 빈을 관리하지 않는다.

}

static class SingletonBean {

}

static class SingletonClientBean {

@Autowired
SingletonBean bean1;
@Autowired
SingletonBean bean2;
}

@Scope("prototype")
static class PrototypeBean {

}

static class PrototypeClientBean {

@Autowired
PrototypeBean bean1;
@Autowired
PrototypeBean bean2;
}
}

프로토타입 빈의 용도

그렇다면 프로토타입 빈은 언제 사용하는걸까?


서버가 요청에 따라 독립적으로 오브젝트를 생성해서 상태를 저장해둬야 하는 경우는 new 키워드로 생성하고 파라미터로 전달해서 사용하면 된다. 하지만, 드물게 컨테이너가 오브젝트를 만들고 초기화해줘야 하는 경우가 존재한다.


바로 DI 때문이다.


매번 새롭게 만들어지는 오브젝트가 컨테이너 내의 빈을 사용해야 하는 경우이다. 


프로토타입 빈 사용 예제

콜센터에서 고객의 A/S 신청을 받아서 신청하는 기능

public class ServiceRequest {

private String customerNo; // 폼에서 입력받은 고객번호를 저장할 프로퍼티
private String productNo;
private String description;


@RestController
public class ServiceRequestController {

private ServiceRequestService serviceRequestService;

public void serviceRequestFormSubmit(HttpServletRequest request) {

// 매 요청마다 새로운 ServiceRequest 오브젝트를 생성한다.
ServiceRequest serviceRequest = new ServiceRequest();
serviceRequest.setCustomerNo(request.getParameter("custno"));

// ...
serviceRequestService.addNewServiceRequest(serviceRequest);
// ...
}
}

폼으로부터 요청이 있을때마다 새로운 오브젝트를 만들고 폼의 필드에 입력된 고객번호를 저장하는 코드이다. 여기까지는 아무런 문제가 없다.


서비스 계층의 구현을 살펴보자. 

  1. 접수된 내용을 DB에 저장
  2. 신청한 고객에게 Email 전송
@Service
public class ServiceRequestService {

CustomerDao customerDao;

private EmailService emailService;
private ServiceRequestDao serviceRequestDao;

public void addNewServiceRequest(ServiceRequest serviceRequest) {

// 폼에서 입력받은 고객번호를 이용해 Customer 오브젝트를 가져온다. ( 이메일 정보를 가져오기 위해 )
Customer customer = customerDao.findCustomerByNo(serviceRequest.getCustomerNo());

serviceRequestDao.add(serviceRequest, customer);

emailService.sendEmail(customer.getEmail(), "A/S 신청 정상 처리");
}
}

ServiceRequest 오브젝트 : 폼의 정보를 전달해주는 DTO와 같은 데이터 저장용 오브젝트로 취급


ServiceRequest를 제외한 나머지 오브젝트는 스프링이 관리하는 싱글톤 빈이다.


  • 장점 : 쉬운 설계와 만들기 편함
  • 단점 : 폼의 고객정보 입력 방법이 모든 계층의 코드와 강하게 결합되어 있음 ( 고객번호말고 다른 값을 폼에서 받으면 모두 수정 필요 )

해결과정 1 - 결합도 낮추기

ServiceRequest에 폼 정보가 담겨 있지만, 도메인 모델을 반영하고 있다고 보기 힘들다. 모델 관점으로 보면 폼에서 어떻게 입력받는지에 따라 달라지는 customerNo, customerId가 아니라 Customer라는 고객 클래스와 연결되어 있어야 한다.
public class ServiceRequest {

// FIXME 1 - 결합도 낮추기
// private String customerNo; // 폼에서 입력받은 고객번호를 저장할 프로퍼티
private Customer customer;
private String productNo;
private String description;

ServiceRequest가 좀 더 도메인 모델에 가깝게 만들어져서, 서비스 계층의 코드도 깔끔해졌다.

// 수정된 서비스 계층 코드
public void addNewServiceRequest(ServiceRequest serviceRequest) {

serviceRequestDao.add(serviceRequest);
emailService.sendEmail(serviceRequest.getCustomer().getEmail(), "A/S 신청 정상 처리");
}

해결과정 2 - ServiceRequest에 Customer 넣어주기

ServiceRequest에 customerNo를 이용하여 찾은 Customer 오브젝트를 넣어줘야 한다. 어디서 해야 할까?

웹 컨트롤러에서 만들어줘도 되지만, ServiceRequest 자신이 처리하는게 좀더 나은 방법일 것이다.

public class ServiceRequest {

@Autowired
private CustomerDao customerDao;

public void setCustomerByCustomerNo(String customerNo) {
// 전달받은 고객번호를 customerDao를 이용해 Customer 오브젝트로 변환해서 저장
this.customer = customerDao.findCustomerByNo(customerNo);
}
  • 이제 서비스 계층에서는 ServiceRequest가 어떻게 조립되는지 전혀 신경쓰지 않아도 된다. 단지 고객정보가 도메인 모델을 따르는 오브젝트로 만들어져 있을거라 생각하고 사용하면 된다.
입력받는 방식이 바뀐다 하더라도, 아래와 같이 Customer 클래스에 추가만 해주면 된다. 즉, ServiceRequest를 사용하는 서비스 계층에서는 영향을 받지 않는다.
// 폼에서 입력받는 것이 고객번호가 아니라 고객의 ID라면, 
public void setCustomerByCustomerId(String customerId) {
this.customer = customerDao.getCustomer(customerId);
}

해결과정 3 - ServiceRequest에 CutomerDao DI 적용하기

CustomerDao를 DI하기 위해서는 ServiceRequest도 빈으로 등록해줘야 한다. 하지만, ServiceRequest는 컨테이너가 매번 같은 오브젝트가 아닌 새로운 객체를 만들어줘야 한다.

이 때가 프로토타입 스코프 빈이 필요할 때다.

@Component
@Scope("prototype")
public class ServiceRequest {

컨트롤러 코드도 수정하자.

@Autowired
ApplicationContext context;

public void serviceRequestFormSubmit(HttpServletRequest request) {

// 매 요청마다 새로운 ServiceRequest 오브젝트를 생성한다.
ServiceRequest serviceRequest = context.getBean(ServiceRequest.class);
serviceRequest.setCustomerNo(request.getParameter("custno"));

고객이 가입할 때 A/S 관련 통보 방법을 지정할 수 있게 해뒀다면 Customer 정보에서 이를 확인하고 메세지를 보내주는 작업을 ServiceRequest에 두는것도 나쁘지 않다.


public class ServiceRequest {

@Autowired
private EmailService emailService;
public void notifyServiceRequestRegistration() {
if (customer.serviceNotificationMethod == NotificationMethod.EMAIL) {
emailService.sendEmail(customer.getEmail(),"A/S 정상 접수");
}
}


서비스 계층의 메서드는 다음과 같이 수정할 수 있다.

public void addNewServiceRequest(ServiceRequest serviceRequest) {

serviceRequestDao.add(serviceRequest);

// 구체적인 통보작업은 ServiceRequest가 담당
serviceRequest.notifyServiceRequestRegistration();

정리

프로토타입 빈을 적용한 구조가 이전보다 더 나아서 무조건 이런 방식으로 개발해야 하는 것은 아니다. 하지만 좀 더 오브젝트 중심적이고 유연한 확장을 고려한다면 프로토타입 빈을 이용하는 편이 나을 것이다.

고급 AOP 기능을 사용하면 getBean()으로 가져오지 않고, 단순히 new  키워드로 생성해도 DI가 된다. ( 나중에 포스팅 예정 )

프로토타입 빈의 DL 전략


getBean() 메소드를 이용하여 DL 방식을 쓰는대신 프로토타입 빈을 직접 DI 해서 사용하면 어떻게 될까? 
@Autowired
private ServiceRequest serviceRequest;
public void serviceRequestFormSubmit(HttpServletRequest request) {

// FIXME 컨트롤러가 생성될 때 딱 한번만 ServiceRequest 오브젝트 생성
serviceRequest = context.getBean(ServiceRequest.class);


그리고, ApplicationContext라는 거대한 인터페이스와 스프링 API를 직접 사용한다는 문제가 있다.


여러가지 방법이 있지만, 가장 최근 방식 Provider<T>를 살펴보자. 


@Inject
Provider<ServiceRequest> serviceRequestProvider;

public void serviceRequestFormSubmit(HttpServletRequest request) {

ServiceRequest serviceRequest = serviceRequestProvider.get();