Programming/Spring

[Spring] 싱글톤과 프로토타입 스코프

kmindev 2024. 1. 10. 19:09

 

김영한님의 Spring 핵심원리 강의를 듣고 정리하는 글입니다.

 

1. 빈 스코프란(Bean Scope)?

빈 스코프는 말 그대로 빈이 존재할 수 있는 범위를 뜻한다. 스프링에서는 다양한 빈 스코프를 지원한다.

 

2. 빈 스코프 종류

  • 싱글톤: 기본 스코프, 스프링 컨테이너의 시작부터 종료까지 유지되는 스코프
  • 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하는 소코프
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈 때까지 유지되는 스코프
    • session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
    • application: 웹의 서플릿 컨텍스트와 같은 범위로 유지되는 스코프

 

스코프를 설정하는 방법이다.

 

컴포넌트 스캔 자동 등록

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

 

수동 등록

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

 

 

3. 싱글톤 스코프

Scope("singleton") // 생략가능(기본 등록시 singleton 빈으로 등록)
@Component
public class MemberServiceImpl implements MemberService {...}
  • 빈을 등록할 때 아무런 설정을 하지 않으면 기본적으로 싱글톤 스코프를 갖는다.
  • 싱글톤 스코프는 스프링 컨테이너의 시작부터 종료까지 유지되는 스코프이다.

 

 

위 그림을 보면 모든 클라이언트 요청을 같은 객체 인스턴스의 스프링 빈을 반환하는 것을 확인할 수 있다.

 

 

싱글톤 스코프 테스트 예제

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 destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

실행결과
SingletonBean.init
singletonBean1 = hello.core.scope.SingletonTest$SingletonBean@1cf56a1c
singletonBean2 = hello.core.scope.SingletonTest$SingletonBean@1cf56a1c
SingletonBean.destroy
  • 실행결과를 보면 같은 getBean() 요청을 할 때마다 같은 인스턴스를 반환하는 것을 확인할 수 있다.
  • init(), destory()가 모두 호출된 것을 확인할 수 있다. (스프링 컨테이너가 빈의 생성부터 소멸까지 전체 라이플 사이클을 관리하는 것을 확인)

 

4. 프로토타입 스코프

  • 프로토타입 스코프는 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.
  • 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여

 

프로토타입 생성 및 요청과정

  1. 프로토타입 스코프의 빈을 스프링 컨테이너에 요청
  2. 스프링 컨테이너는 이 시점에 프로토타입 빈을 생성하고, 필요한 의존관계를 주입
  3. 스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환
  4. 요청이 오면 항상 새로운 프로토타입 빈을 생성해서 반환

 

 

핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입, 초기화까지만 처리한다.

 

 

5. 싱글톤과 프로토타입을 함께 사용할 때 문제점

스프링 컨테이너에 프로토타입 스코프의 빈을 요청하면 항상 새로운 객체 인스턴스를 생성해서 반환한다. 하지만 싱글톤 빈과 함께 사용할 때는 의도한 대로 잘 동작하지 않을 수 있으므로 이에 대한 원인을 알아보자.

 

왜 제대로 동작할지 않을까?

정리부터 하자면 싱글톤 스코프의 빈은 스프링 컨테이너 생성시점에 생성 → 의존관계 주입 → 초기화 작업을 수행하기 때문에 최초 의존관계 주입 시점에 하나의 빈 인스턴스 정보를 가지기 때문이다.

 

예시코드를 살펴보자.

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(1);
    }

    @Scope("singleton")
    static class ClientBean {

        @Autowired
        private 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("Prototype.destroy");
        }
    }
}

 

 

예상된 결과는 1인데 2가 출력된 것을 확인할 수 있다.

 

 

왜 예상된 결과와 다른값이 나올까?

 

과정을 보면,

  1. 의존관계 주입 시점에 프로토타입 빈을 스프링 컨테이너에 요청하게 된다.
  2. 스프링 컨테이너는 프로토타입 빈을 생성하고, 클라이언트 빈에 반환한다. 이 때 count(필드)는 초기화 시점 상태로 0을 갖는다.
  3. clientBean은 프로토타입 빈의 참조값을 보관한다.
  4. 프로토타입 빈의 참조값을 가지기 때문에 클라이언트A가 호출할 때 1을 증가 시켜서 count는 1이 되고, 클라이언트B가 호출할 때 또 1을 증가시켜 count는 2를 갖는다.

 

그럼 싱글톤과 프로토타입을 같이 쓸 때 매번 새로운 프로토타입 주입 받는 방법은 없을까?

 

 

6. 싱글톤과 프로토타입을 함께 사용할 때 문제점 해결 방법

a. ObjectFactory로 문제 해결

@Scope("singleton")
static class ClientBean {
     @Autowired
     private ObjectFactory<PrototypeBean> prototypeBeanFactory;
     
     public int logic() {
          PrototypeBean prototypeBean = prototypeBeanFactory.getObject();
          prototypeBean.addCount();
          int count = prototypeBean.getCount();
          return count;
      }
}

 

b. ObjectProvider로 문제 해결

		@Scope("singleton")
    static class ClientBean {

				// **ObjectProvider 사용**
        @Autowired
        **private ObjectProvider<PrototypeBean> prototypeBeanProvider;**

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

  • 실행결과를 보면 새로운 프로토타입 빈을 생성되는 것을 확인할 수 있다.
  • ObjectFactory, ObjectProvider의 getObject() 를 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다. (DL)
  • ObjectFactory가 ObjectProvider 에 비해 기능이 단순하다. (ObjectFactory에서 여러 기능을 추가한 게 ObjectProvider 이다.)
  • ObjectFactory, ObjectProvider 스프링에 의존적이다.

 

c. JSR-330 Provider 문제 해결

  • jakarta.inject.Provider라는 JSR-330 자바 표준을 사용하는 방법이다.
  • 스프링 부트 3.0 이하는 javax.inject.Provider를 사용한다.
  • 자바 표준이지만 사용 전에 라이브러리 추가해야 한다.
    1. 스프링 부트 3.0 이상: jakarta.inject:jakarta.inject-api:2.0.1 라이브러리를 gradle에 추가
    2. 스프링 부트 3.0 미만: javax.inject:javax.inject:1 라이브러리를 gradle에 추가
@Scope("singleton")
static class ClientBean {
        
        // ObjectProvider 사용
        @Autowired
        private Provider<PrototypeBean> provider;
        
        public int logic() {
            PrototypeBean prototypeBean = provider.get();
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

  • 실행결과를 보면 새로운 프로토타입 빈을 생성되는 것을 확인할 수 있다.
  • provider 의 get()을 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환한다. (DL)
  • 스프링이 아닌 다른 컨테이너에서도 사용할 수 있다.

 

7. 정리

  • 싱글톤과 프로토타입 빈을 함께 사용할 때 발생할 수 있는 문제는 DL을 통해 해결할 수 있다.
  • 해결 방식으로는 자바 표준 방식인 JSR-330 Provider와 스프링에서 제공하는ObjectFactory, ObjectProvider방식이 있다.
  • 스프링 외에 다른 컨테이너를 사용할 때는 JSR-330 Provider 방식을 사용하자.
  • 이외에도 @Lookup 어노테이션을 사용하는 방법도 있다. 기회가 된다면 다음에 정리해보겠다.