Spring/공부

[Spring Core] 스프링 빈 생명주기와 스코프

오잎 클로버 2023. 1. 14. 23:00
728x90

♻ 스프링 빈 생명주기(Life-Cycle)

I/O 작업들처럼 연결을 해주고, 필요없거나 종료된 경우 해당 연결을 끊어주기 위해서는
반드시 해당 작업의 초기화와 종료 작업이 필요하다.

스프링 빈 역시 이러한 점을 가지고 있다. 객체를 생성하고, 의존관계를 주입해준다.

객체 생성 → 의존관계 주입

스프링 빈은 객체를 생성하고, 의존관계를 주입이 완료가 된 후에야 비로소 필요한 데이터를 사용할 수 있는 준비를

  마치게 된다.

스프링 빈은 의존관계가 모두 주입된 이후, 호출해야한다.

그렇다면, 해당 빈의 초기화 작업이 모두 이루어졌는 지를 개발자가 알아야 한다.

당연하지만, 기화 작업이 끝난 후에 해당 데이터를 사용해야 예기치 못한 오류들을 피할 수 있기 때문이다.

이를 개발자가 알기 위해서는 우선, 스프링 빈의 이벤트 생명 주기에 대한 이해가 있어야 한다.

 

♾ 스프링 빈의 이벤트 라이프 사이클

스프링 컨테이너 생성 → 스프링 빈 생성(등록) → 의존관계 주입 → 초기화 콜백 → 필요한 데이터 사용
  →  ... → 빈 소멸 전 콜백 → 빈 소멸 → 스프링 종료

이때, 눈 여겨봐야하는 곳은 딱 2곳이다. 초기화 콜백소멸 전 콜백이다.

  특징
초기화 콜백 빈이 생성되고, 빈의 의존 관계가 주입이 완료되면,
콜백을 해준다.
소멸 전 콜백 빈이 소멸되기 직전에 콜백해준다.

스프링은 다양한 방식으로 생명주기 콜백을 지원한다. 다음 접은 글은 참고용이다.

더보기
  • 생성자는 필수정보(파라미터)를 받고, 메모리에 할당해서 객체를 생성하는 책임을 가진다.
  • 초기화는 생성자를 통해 생성된 값들을 활용하여, 외부 커넥션을 연결하는 등의 무거운 동작을 수행한다.

따라서 생성자 안에서 무거운 초기화 작업을 하는 것을 지양하자. 클래스를 작성하는 데 있어,
  생성자와 초기화 영역을 명확하게 나누는 편이 유지보수에 용이하다.

물론 초기화 작업이 내부 값들만 약간 변경하는 정도로 단순한 경우, 생성자에서 한 번에 처리하는 편이
  나을 수도 있다.

더보기

싱글톤 빈들은 스프링 컨테이너가 종료될 때, 싱글톤 빈들도 함께 종료된다.
즉, 스프링 컨테이너가 종료되기 직전에 콜백이 발생하게 된다.

 

물론, 생명주기가 짧은 빈들은 컨테이너와 무관하게 해당 빈이 종료되기 직전에 콜백이 발생한다.

 

↩ 스프링 빈 생명주기 콜백 종류

스프링 빈은 크게 3가지 방법으로 빈 생명주기 콜백을 지원한다.

InitalizingBean과 DisposableBean

InitalizingBean은 afterProperties 메소드를 통해 초기화를 지원한다. (의존관계 주입이 호출되는
    콜백 메소드이다.)
DisposableBean은 destroy 메소드를 통해 소멸을 지원한다. (빈이 소멸되기 직전에 호출되는
    콜백 메소드이다.)

초기화, 소멸 인터페이스의 단점

  1. 해당 인터페이스들은 모두 Spring Framework  에서 지원하는 인터페이스들이다.
        따라서, 스프링 프레임워크 없이는 단위 테스트가 불가하다.
  2. 초기화, 소멸 메소드의 이름을 변경할 수 없다. (인터페이스의 메소드를 오버라이딩 해야함)
초기화, 소멸 인터페이스들은 스프링 초창기 때부터 사용해오던 방법이다.
현재 역시 사용하는 데 큰 지장은 없으나, 더 나은 방법들이 지원하고 있다.

빈 등록 초기화 및 소멸 메소드 지정

빈 등록 설정에 @Bean(initMethod = "...", destroyMethod = "...") 처럼 초기화 및 소멸 메소드를 지정할 수 있다.

장점

  • 메소드명의 자유를 줄 수 있다.
  • 스프링 프레임워크에 의존도를 낮출 수 있다.
  • 코드가 아닌 설정정보를 사용하기에 코드를 고칠 수 없는 외부 라이브러리에도 초기화 및 종료 메소드를
        적용할 수 있다.

종료 메소드 추론

@Bean 어노테이션의 destroyMethod 속성에는 아주 특별한 기능이 있다.
  • 라이브러리 대부분은 close 혹은 shutdown 이라는 이름의 종료 메소드를 사용한다.
  • 따라서 직접 스프링 빈으로 등록하면 종료 메소드는 따로 적어주지 않아도 잘 작동한다.
  • 추론 기능을 사용하기 싫다면, destroyMethod = "" 처럼 공백처리해주면 된다.

@PostConstruct, @PreDestroy

가장 편리하게 초기화와 종료를 실행할 수 있는 방법이다.

최신 스프링에서 가장 권장하는 방법으로, 애노테이션 하나만으로 지정이 가능하므로 매우 편리하다.

스프링에 종속적인 기술이 아닌, 자바 표준이다. (물론 더 이상 아니다. jdk 9부터는 분리되었다.)

(jdk 1.8까지는 자바 표준 방식이다.)

@Bean 설정이 아닌 컴포넌트 스캔과 잘 어울리는 방법이다.

외부 라이브러리에는 적용하지 못한다는 단점이 있지만, @Bean 을 사용하여, 초기화 및 종료할 수 있다.

 

 

🔬 빈 스코프란?

앞서 설명한 것처럼 싱글톤 빈인 경우, 스프링 컨테이너의 시작과 함께 생성되어 스프링 컨테이너가 종료될 때까지
  유지된다고 서술하였다. 

스프링 빈은 다음과 같은 다양한 빈 스코프를 지원한다.

  • 싱글톤: default 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고, 더는 관리하지 않는
        매우 짧은 생명주기를 가진 스코프이다.
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈 때까지 유지되는 스코프
    • session: 웹 세션이 생성되고, 종료될 때까지 유지되는 스코프
    • application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프
    • websocket: 웹 소캣과 동일한 생명주기를 가지는 스코프

빈 스코프는 다음과 같이 지정할 수 있다.

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

 

🎃 프로토타입 스코프

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

즉, 요청 시마다 새로운 빈을 스프링 컨테이너에서 생성하며, DI 역시 해준다.

 

싱글톤 빈은 컨테이너 생성 시점과 같이 생성되고 초기화되지만, 프로토타입 빈은 스프링 컨테이너에서 빈을 

    조회할 때 생성되고 초기화 메소드가 실행된다.

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

클라이언트에게 빈을 반환했고, 이후 스프링 컨테이너는 생성된 프로토타입 빈을 관리하지 않는다.

프로토타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에게 있다. 따라서 @PreDestroy 같은 종료 콜백
    메소드가 호출되지 않는다.

 

프로토타입 빈 특징 정리

  • 스프링 컨테이너에 요청할 때마다 새로 생성된다.
  • 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입 그리고 초기화까지만 관여한다.
  • 종료 메서드가 호출되지 않는다.
  • 그래서 프로토타입 빈은 프로토타입 빈을 조회한 클라이언트가 관리해야한다. 종료 메서드에 대한 호출도 클라이언트가 직접 해야한다.

싱글톤 빈과 함께 사용 시 발생할 수 있는 문제점

스프링 컨테이너에 프로토타입 스코프의 빈을 요청하면, 항상 동일한 인스턴스를 반환하게 된다.
하지만, 싱글톤 빈을 함께 사용하게 된다면 의도한 대로 작동하지 않을 수도 있다.
(프로토타입 빈을 직접 요청 시에는 의도한 대로 작동한다.)

예를 들어 특정 글을 조회한 것을 카운트하고 기록하는 기능을 만들었다고 가정해보자.

추가로 자주 쿼리가 발생하는 것은 줄이기 위해, 특정 시간마다 새로 갱신된 카운트의 경우에만 기록한다고 하자.

(예를 들어 1시간마다 기록한다면 1시간동안 10이 카운트되었다면 하나의 쿼리에 10을 더하도록 하는 방식)

이때, 전임자가 해당 코드를 특정 글을 조회하는 빈(이하 S 빈)과 카운트하는 빈(이하 C 빈)을 따로 구성하였는 데,

S 빈은 싱글톤 스코프이고 C 빈은 프로토타입 스코프이다. 위 기능을 처리하는 빈은 R 빈이라 명명한다.

 

그렇다면, R 빈은 싱글톤이므로, 보통 스프링 컨테이너 생성 시점과 함께 생성되며, 의존관계 역시 주입되어야한다.

  1. R 빈은 의존관계 자동 주입을 사용한다. 주입 시점에서 스프링 컨테이너에게 S빈을 의존 주입 받았다.
  2. 이어서 프로토타입 빈인 C빈을 요청하였다.
  3. 스프링 컨테이너는 프로토타입 빈에 대한 요청을 받고, 프로토타입 빈인 C 빈을 생성하곤 R 빈에게 반환한다.
    이때, count 필드값은 0이다.
  4. 이제 R빈은 프로토타입 빈, C 빈을 내부 필드에 보관한다. (참조값을 보관)
  5. 만일 A 클라이언트가 R 빈을 통해 S 빈에서 특정 글을 조회하고, C 빈으로 α 글을 조회한 뒤,
        C 빈의 addCount 메소드를 호출하여, count 필드값 1을 추가한다.
  6. 그리곤 B 클라이언트가 A 클라이언트와 같이 동일한 α 글을 조회한 뒤, C 빈의 count 필드값을 1 추가하였다.
  7. 그리고 R 빈을 통해 C빈의 count 필드값을 가져오게 된다면, count 값은 2일 것이다.

아마 count값이 2라고 하여, 꽤 당혹스러울 것이다.

여기서 한 가지 알아두어야하는 점은 R 빈의 C빈은 이미 과거에 주입이 끝난 빈이다. 주입 시점에 스프링 컨테이너

    요청해서 프로토타입 빈이 새로 생성된 것이지, 사용할 때마다 새로 생성되어 다시 주입되는 것은 아니다.

싱글톤과 프로토타입을 함께 사용하는 경우, 기대한 대로 동작하지 않는다.

 

싱글톤 빈은 생성 시점에만 의존 관계를 주입받는다.

따라서, 스프링 싱글톤 빈이 생성되는 시점에 프로토타입 빈도 새로 생성되어서 주입되긴 하지만, 싱글톤 빈과 함께 계속 유지되는 것이 문제이다.

 

해결 방안

💉 수동 의존성 주입

ApplicationContext(스프링 컨테이너)를 주입을 받아 프로토타입이 바뀔 때마다 주입을 받을 수 있다.
@Component
@RequiredArgsConstructor
public class RBean {

    private final ApplicationContext context;
    private final SBean s;
    
    public int logic(int articleId) {
        Article article = s.findById(articleId);
        CBean c = context.getBean(CBean.class);
        c.addCount(article);
        return c.getCount(article);
    }

}

위와 같이 작성하는 경우, 항상 새로운 프로토타입 C 빈을 주입받아 사용할 수 있다.

이와 같은 방식을 DL(Dependency Lookup)이라고 부른다.

더보기

의존관계를 외부에서 주입(DI), 직접 필요한 의존관계를 찾는 것은 DL(의존관계 조회[혹은 탐색])

하지만 이 경우, 스프링 컨테이너에 매우 종속적인 코드가 되며, 단위 테스트 역시 매우 어려워지게 된다.

ObjectFactory, ObjectProvider

지정된 빈을 스프링 컨테이너가 아닌 ObjectProvider를 사용하여 대신 찾아서 제공해주도록 한다.

ObjectFactory 로도 가능하나, 현재는 편의 기능이 많이 추가된 ObjectProvider를 사용해도 된다.

@Autowired
private ObjectProvider<CBean> cProvider;

public int logic(int articleId) {
    Article article = s.findById(articleId);
    CBean c = cProvider.getObject();
    c.addCount(article);
    return c.getCount(article);
}

실행하게 되면, 항상 새로운 프로토타입 빈이 생성된다. ObjectProvider의 getObject 메소드를 호출하면, 내부에서는 
    스프링 컨테이너를 통해 해당 빈을 찾아서 반환해준다.

스프링에서 지원하는 방법이기는 하나, 기능이 단순하므로 단위 테스트를 만들거나 mock 코드를 작성하는 데,

    큰 부담이 없다.

 

요약

  • 프로토타입 빈은 매번 사용할 때마다 의존관계 주입이 완료된 새로운 객체가 필요할 때 사용하면 된다.
  • 물론 프로토타입 빈은 자주 사용하는 편이 아니다.
  • ObjectProvider, JSR330 Provider 등 프로토타입 빈 검색 외에도 DL이 필요한 경우, 손쉽게 언제든 사용 가능하다.

 

웹 스코프

웹 스코프는 웹 환경에서만 동작하는 독특한 스코프이다.
다른 스코프들과 달리 해당 스코프의 종료 시점까지 관리하며, 종료메소드가 호출된다.

Request 스코프

HTTP Request 요청당 각각 할당되는 스코프이다.

각각의 클라이언트 요청당 하나의 빈 인스턴스가 생성되고 반환된다.

스프링 빈 등록시 웹 스코프의 빈을 그대로 주입받을 경우, 오류가 발생한다.

  • 싱글톤 빈은 스프링 컨테이너와 함께 생성되어 라이프 사이클을 같이 하나, request 스코프의 경우
        HTTP 요청이 올 때 새로 생성되고, 응답하면 사라지므로 싱글톤 빈이 생성되는 시점에는 
        생성되지 않았기 때문이다.
  • 따라서 의존성 주입이 불가하다.

Provider의 사용

ObjectProvider를 사용하면, getObject 메소드를 호출할 때까지 request scope 빈의 생성을 지연할 수 있다.
HTTP 요청이 들어오는 시점에서 getObject 메소드를 호출하고, 아직 응답이 가지 않았더라면,
스프링 컨테이너는 request scope 빈을 생성한다.

getObject 메소드의 경우, 서로 다른 싱글톤 빈에서 각각 호출하더라도 같은 HTTP 요청이면, 같은 스프링 빈을
   반환하게 된다. 즉, 구분하기 굉장히 어렵다.

 

Scope와 Proxy의 사용

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

 

 

proxyMode 속성은 ScopedProxyMode의 넷 중 한 가지를 택하면 된다.
  • DEFAULT: 일반적으로 NO 와 동일합니다.
  • INTERFACES: 적용대상인 인터페이스인 경우
  • NO: 프록시 스코프인 빈을 생성하지 않는다.
  • TARGET_CLASS: CGLIB을 사용하여 클래스 기반 프록시를 만듭니다.

위와 같이 작성된 경우, HttpLogger 클래스는 가짜 프록시 클래스를 만들어두고, HTTP Request와 상관없이

    가짜 프록시 클래스를 다른 빈에 미리 주입해둘 수 있다.

 

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

  1. @Scope에 proxyMode = ScopedProxyMode.TARGET_CLASS 를 설정하면 스프링 컨테이너는 
        CGLIB라는 바이트코드 조작 라이브러리를 사용해서, 해당 웹 스코프 빈 클래스를 상속하는
        가짜 프록시 객체를 생성한다.
  2. 등록한 순수 자바 클래스 빈이 아닌 가짜 프록시 객체가 등록된다.
  3. 스프링 컨테이너를 통해 조회 혹은 주입을 해주더라도 가짜 프록시 객체가 주입된다.
  4. 프록시 객체는 요청이 들어오면, 그때 진짜 빈을 요청하는 위임 로직이 들어있어, 내부에 진짜 객체를
        찾을 수 있다.
  5. 즉, 클라이언트가 호출할 경우, 프록시를 거쳐 진짜 객체를 호출하게 된다.

동작 정리

  • CGLIB 라는 라이브러리로 내 클래스들 상속받은 가짜 프록시 객체를 만들어서 주입한다.
  • 이 프록시 객체는 실제 요청이 오면, 내부에서 실제 빈을 위임하는 로직이 작동된다.
  • 프록시 객체는 실제 request scope 과는 관계가 없다. 그냥 가짜에 불가하며, 내부에 단순한 위임 로직만
        있다. 또, 싱글톤처럼 동작한다.

특징 정리

  • 프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request scope 를 사용할 수 있다.
  • 사실 Provider 를 사용하든, 프록시를 사용하든 핵심은 진짜 객체 조회를 꼭 필요한 시점까지 지연한다는 점이다.
  • 단지 어노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있다. 
        다형성과 DI 컨테이너의 큰 강점이다.
  • 꼭 웹 스코프가 아니더라도 프록시는 사용할 수 있다.

주의할 점

  • 마치 싱글톤을 사용하는 것 같지만, 다르게 동작하기 때문에 결국 주의해서 사용해야한다.
  • 이런 특별한 scope 는 꼭 필요한 곳에만 최소화해서 사용하자. 무분별하게 사용한다면 유지보수가 
        매우 어려워질 것이다.

 

 

이상입니다.