안녕하세요!

스프링 빈을 만들고 모든 빈이 행복하게 오래오래 살았으면 좋겠지만! 그렇지 않아야 유용할 때도 오겠죠?  그럴 땐 Scope라는 기능을 이용해 봅시다!

땅~

👉이전 글 보러가기


스코프(Scope) 알아보기

(난 스나이퍼 위에 조준경 그거인줄…)

스코프는 번역하면 범위라고 합니다. 그래서 말 그대로 스프링 빈이 살아서 내 코드에 영향을 줄 범위를 지정하는 것입니다.

여러분 혹시 이전에 싱글톤과 관련해서 배웠던 것을 기억하실까요? 싱글톤은 우리의 스프링 프로그램이 시작하면서 딱 하나만 만들어져서 그게 쭉 관리된다고 기억나실 겁니다. 그리고 스프링에선 빈들에게 기본값으로 싱글톤을 적용한다고 했었습니다.

바로 이 싱글톤이 우리가 오늘 배울 스코프의 한 종류입니다! 그렇다는 것은 싱글톤 방식이 아닌 다른 방식의 스코프도 있는 것이겠죠? 한번 알아봅시다.

  • 싱글톤(singleton): 기본값. 스프링 컨테이너가 시작하면서 종료될 때까지  유지되는 아주 긴 스코프
  • 프로토타입(prototype): 빈의 생성부터 의존관계 주입까지 관여되고 그 이후 사라지는 짧은 스코프
  • 웹 관련)
    • request: 웹의 요청이 들어오고 나갈 때까지 유지되는 스코프
    • session: 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
    • application: 웹의 서블릿 콘텍스트와 같은 범위로 유지되는 스코프

우린 여기서 싱글톤은 전에 한번 알아봤으니 프로토타입, 그리고 request에 대해 알아보겠습니다.


프로토타입(Prototype) 스코프

적용해 보기

일단 저희가 싱글톤이 적용됐던 방식부터 되짚어봅시다.

컨테이너는 딱 하나의 해당 빈을 생성하고 여러 클라이언트가 사용을 요청했을 때 만들어놓은 딱 하나의 동일한 빈을 사용할 수 있도록 반환해 주는 방식이었습니다. 그럼 프로토타입은 어떤 느낌으로 작동될까요?

먼저 컨테이너는 클라이언트에게 빈을 쓰겠다는 요청이 왔을 때 그제야 해당 빈을 생성하고, 필요한 의존관계를 주입합니다. 그것도 요청이 들어온 수만큼 여러 개 만들게 됩니다.

그 후에 각 클라이언트마다 물론 같은 동작을 할 빈이지만 별개의 독립된 빈들을 하나씩 반환을 해 줍니다. 그 이후로 컨테이너는 반환한 빈을 관리하지 않고 알아서 지지고 볶든 뭘 하든 맘대로 하라고 합니다.

중요한 점은 스프링 컨테이너는 프로토타입 빈을 생성하고 의존관계 주입까지만 해주고, 반환 후엔 만들어진 프로토타입 빈을 관리하지 않는다는 점입니다. 그래서 프로토타입 빈들은 빈을 받은 클라이언트가 관리할 책임이 있고, 스프링 컨테이너가 종료되는 거랑 만들어진 프로토타입 빈들과는 상관이 없어지기 때문에 @PreDestroy 같은 메서드가 호출되지 않습니다.

정말로 그런지 한번 봐볼까요? 테스트 쪽에 prototype패키지를 만들고 PrototypeTest.java를 만들어봅시다.

    @Scope("prototype")
    static class PrototypeBean {
 
        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init");
        }
 
        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
        
        private int count = 0;
        
        public void addCount() {
            count++;
        }
        
        public int getCount() {
            return count;
        }
    }

요렇게 임시로 PostConstruct와 PreDestroy때 기능할 메서드를 가지고 있는 빈 클래스를 하나 만들고, 추가로 아래 테스트도 추가해 봅시다.

    @Test
    @DisplayName("프로토타입 빈 조회")
    public void prototypeBeanFind() {
        AnnotationConfigApplicationContext 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);
        Assertions.assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
    }

PrototypeBean 타입의 빈을 2개 생성하면서 두 빈이 서로 달라야 성공하는 테스트입니다.

실행 결과

테스트 결과는 예상대로 성공했습니다.

보시면 init() 메서드가 두 번 실행된 것을 보실 수 있습니다. 이는 두번 사용요청이 들어간 PrototypeBean이 총 두번 생성이 되면서 init() 메서드가 두번 실행된 것입니다. 그리고 아래 결과에서 보시다시피 prototypeBean1과 prototypeBean2의 빈 주소가 다른 것을 보실 수 있습니다. 싱글톤 배웠었을 때는 같았었던 거 기억하시죠?

그리고 하나 더 저희가 만들어놨던 기능인 count를 증가시키고 조회하는 메서드를 통해 각각의 빈들이 별개의 환경에서 작동하는지 확인해 볼까요?

    @Test
    @DisplayName("프로토타입 빈 별개 환경 확인")
    public void prototypeBeanEachCountUp() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean1.addCount();
        System.out.println(prototypeBean1.getCount());
        Assertions.assertThat(prototypeBean1.getCount()).isEqualTo(1);
        prototypeBean2.addCount();
        System.out.println(prototypeBean1.getCount());
        Assertions.assertThat(prototypeBean2.getCount()).isEqualTo(1);
    }

싱글톤 방식이라면 위의 테스트는 문제가 발생할 것입니다. 하나의 빈을 공유하면서 작업이 되기 때문에 prototypeBean1에서 addCount()를 하면서 count는 1, 그리고 prototypeBean2에서 addCount()를 하면서 count는 2가 되었을 것 입니다. 하지만 프로토타입이라면?

실행결과

테스트는 역시 성공했습니다. 각각 별개의 PrototypeBean을 작업하는 것이기 때문에 prototypeBean1에서 addCount()를 했다고 하더라도 prototypeBean2에서는 count가 증가되지 않은 상태이기 때문에 위와 같이 테스트가 성공할 수 있었습니다.

싱글톤 빈과 함께 사용 시 문제점

일단 이렇게 프로토타입 빈을 잘 사용해 봤습니다. 하지만 프로토타입 빈이 싱글톤 타입의 빈에게 의존관계 주입이 되면 문제가 하나 생기게 됩니다.

우리가 만든 PrototypeBean을 싱글톤 타입의 빈이 의존관계 주입한다면 위와 같이 PrototypeBean의 참조값을 보관하게 됩니다.

만약 ClientBean에 logic()이라는 메서드가 있고 이게 PrototypeBean의 addCount()와 getCount()를 해서 count값을 return해주는 기능이라고 해봅시다.

한 클라이언트가 싱글톤 타입인 ClientBean에 logic()을 호출했다고 합시다. 그럼 count는 증가하여 1을 반환해 줄 것입니다.

또 다른 클라이언트가 ClientBean에 logic()을 호출했다고 합시다. 원래라면 프로토타입의 빈은 각각의 클라이언트마다 다른 별개의 Bean을 줘서 별개로 작동해야 하지만, 지금 PrototypeBean은 같은 하나의 ClientBean에서 작동하고 있기 때문에 다른 두 클라이언트에게서 logic()을 호출당했다 해도 이로 인해 addCount()와 getCount()를 실행시킨 빈은 싱글톤 타입의 ClientBean 하나이기 때문에 count는 2가 되어 2가 호출되어 버립니다.

코드로 알아봅시다! SingletonWithPrototypeTest1라는 테스트 클래스를 하나 만들어봅시다. 그리고 아래 클래스들과 테스트 코드를 넣어줍시다.

 // PrototypeBean(프로토타입)
 
     @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("PrototypeBean.destroy");
        }
    }
// ClientBean(싱글톤)
 
    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;
        }
    }
// 테스트 코드
 
    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
        ClientBean clientBean1 = ac.getBean(ClientBean.class);
        int count1 = clientBean1.logic();
        Assertions.assertThat(count1).isEqualTo(1);
        ClientBean clientBean2 = ac.getBean(ClientBean.class);
        int count2 = clientBean2.logic();
        Assertions.assertThat(count2).isEqualTo(2);
    }

실행 결과

우려한 대로 프로토타입이지만 싱글톤 빈에게 의존관계 주입이 되었다는 이유로 의도대로 작동하지 못하고 있습니다.

해결법

이 문제를 해결하는 방법은 스프링이 지원해 주는 기능과 자바에서 지원해주는 기능 두 가지가 있습니다.

1. ObjectProvider

// ClientBean(싱글톤)
 
    static class ClientBean {
        private final ObjectProvider<PrototypeBean> prototypeBeanProvider;
        
        @Autowired
        public ClientBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) { 
        	this.prototypeBeanProvider = prototypeBeanProvider;
        }
        
        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.getObject(); // 추가됨
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

의존성을 받을 때 ObjectProvider <> 타입으로 프로토타입 빈을 받은 후 getObject()로 해당 빈을 가져와 사용하면 됩니다. 스프링에 의존하고 있다는 특징이 있습니다.

2. JSR-330 Provider

우선 얘는 gradle에 라이브러리를 추가해줘야 합니다.

// build.gradle
 
dependencies {
	...
	implementation 'jakarta.inject:jakarta.inject-api:2.0.1'
}

그 후 Provider <> 타입으로 받아오면 된다. 위의 ObjectProvider랑 비슷합니다.

(jakarta.inject의 Provider로 가져오자!!)

// ClientBean(싱글톤)
 
    static class ClientBean {
        private final Provider<PrototypeBean> prototypeBeanProvider;
        
        @Autowired
        public ClientBean(Provider<PrototypeBean> prototypeBeanProvider) { 
        	this.prototypeBeanProvider = prototypeBeanProvider;
        }
        
        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.get(); // 얘가 getObject가 아닌 get
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

얘는 get() 메서드로 빈을 받아옵니다. 특징으로는 별도의 라이브러리가 필요하며 자바 표준이기 때문에 스프링이 아닌 다른 컨테이너에서도 사용할 수 있습니다.

실행 결과

별개로 logic()이 작용해서 2가 아닌 1이 나와야 하기 때문에 테스트는 실패합니다.


웹 스코프

얘는 웹 환경에서만 동작하는 애입니다.

웹은 여러 다양한 사용자들이 동시다발적으로 작업을 요청하게 됩니다. 이를 위해 만들어진 빈 스코프인데요! 얘는 스프링 컨테이너가 웹 요청이 시작하면서 빈을 만들어주고 요청이 끝나면서 없애주며 관리해 주기 때문에 PreDestroy가 호출 가능합니다.

위에서 보셨다시피 웹 스코프는 종류가 하나가 아닌 여러 가지 있습니다. 하지만 범위만 다르지 동작 방식은 비슷하기 때문에 여기선 request 스코프 하나만 다루도록 하겠습니다.

우선 저희는 로그를 남기는 시스템을 구현한다고 해봅시다. 아래와 같이 말이죠

[d06 b992 f…] request scope bean create
[d06 b992 f…][http://localhost:8080/log-demo] controller test
[d06b992f…][http://localhost:8080/log-demo] service id = testId
[d06b992f…] request scope bean close

맨 앞에는 UUID라는 사용자마다의 고유 ID값을 넣고 어떤 url을 요청해서 남은 로그인지도 확인해 봅시다. 아래 사진과 같이 패키지와 클래스를 만들어줍시다.

// MyLogger.java
 
package com.naver.shopping.webScope;
 
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
 
import java.util.UUID;
 
@Component
@Scope("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);
    }
}

PostConstruct 단계에서 UUID를 지정하게 되고, Scope는 “request”로 지정되어 요청이 이루어질 때마다 해당 요청마다 하나씩 생성되고, 요청이 끝나면서 소멸됩니다.

// LogService.java
 
package com.naver.shopping.webScope;
 
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
 
@Service
@RequiredArgsConstructor
public class LogService {
    private final MyLogger myLogger;
    public void logic(String id) {
        myLogger.log("service id = " + id);
    }
}

MyLogger로 service에 접속했을 때 로그를 남길 수 있게 합니다.

// LogController.java
 
package com.naver.shopping.webScope;
 
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
@RequiredArgsConstructor
public class LogController {
    private final LogService logService;
    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");
        logService.logic("testId");
        return "OK";
    }
}

/log-demo에 접속했을 때의 상황을 Controller에서 작성해 줍니다. 아까 만들었던 controller 내에서도 로그를 남겨주고 service의 logic()을 실행해 주는 작업을 해줍시다.

좋아요. 이제 ShoppingApplication을 실행해 봅시다!

결과 화면

Caused by: org.springframework.beans.factory.support.ScopeNotActiveExcepti on: Error creating bean with name ‘myLogger’: Scope ‘request’ is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton

엥 이런 에러가 떠버립니다. 

이 오류 메시지는 request 스코프 빈을 싱글턴 빈에서 직접 참조하려고 할 때 나타나는 오류입니다. request 스코프 빈은 고객의 요청이 와야 생성할 수 있는데 싱글턴 빈이 참조하면서 스프링이 서버가 시작하자마자 생성을 시도하려고 해서 그렇습니다.

오류를 해결하자

지금 문제가 되는 이유가 어째 프로토타입 빈을 싱글톤 타입의 빈에 의존관계 주입했을 때의 문제랑 비슷하네요. 그럼 같은 방식으로 Provider를 사용해서 MyLogger 빈을 불러봅시다.

// LogService.java
 
package com.naver.shopping.webScope;
 
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
 
@Service
@RequiredArgsConstructor
public class LogService {
    private final ObjectProvider<MyLogger> myLoggerProvider;
    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}
// LogController.java
 
package com.naver.shopping.webScope;
 
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
@RequiredArgsConstructor
public class LogController {
    private final LogService logService;
    private final ObjectProvider<MyLogger> myLoggerProvider;
    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        MyLogger myLogger = myLoggerProvider.getObject();
        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);
        myLogger.log("controller test");
        logService.logic("testId");
        return "OK";
    }
}

실행 결과

웹 서버가 잘 실행됐습니다!

http://localhost:8080/log-demo로 접속한 후 추가되는 log를 확인해 봅시다.

총 접속하고 2번의 새로고침을 하여 총 3번의 접속이 이루어졌습니다.

보시면 접속하면서 UUID가 새로 적용되는 것을 확인하실 수 있습니다. 그리고 접속한 url을 잘 가져오고, 그리고 각각의 접속들의 MyLogger의 아이디가 다르다는 것을 확인하실 수 있습니다.


프록시 방식을 써보자

이렇게 Provider 방식으로는 성에 안 찼던 코딩 고인물분들은 Proxy라는 방법으로 해당 문제를 해결하게 만들었습니다.

한번 해보죠! 먼저 Provider를 적용했던 방금 방식으로 원래대로 돌려주세요. 그리고 MyLogger의 @Scope 어노테이션을 수정해 주세요.

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

실행 결과

로그가 잘 생기고 있습니다.

ScopedProxyMode는 아래와 같이 지정해 줄 수 있습니다. 저희는 MyLogger라는 클래스를 프록시모드로 지정할 예정이라 TARGET_CLASS를 넣었고, 인터페이스는 INTERFACE 이렇게 넣어주시면 됩니다.

그리고 프록시 모드는 꼭 웹 스코프에만 쓸 수 있는것은 아니라 프로토타입 스코프 등에 사용하셔도 됩니다.

이 프록시 모드를 설정하게 되면 MyLogger를 상속받은 가짜 객체가 빈으로 등록이 됩니다. 그래서 의존관계 주입이 이 가짜 프록시 객체로 주입이 됩니다. 그리고 가짜 프록시 객체가 호출을 받으면 가짜 프록시 객체는 진짜 프록시 객체를 찾아서 호출을 시켜주는 역할을 하게 됩니다.


마치며

와!!! 드디어 저희가 배워야 할 기본적인 스프링 기능들이 마무리가 되었습니다.

오늘 배운 조금 특별한 Scope는 꼭 필요한 곳에서만 최소화해서 사용하는 게 유지보수에 좋을 것입니다.

지금까지 고생 많으셨고, 다음에 더 작성하게 된다면 웹 쪽으로 더 심화하여 작성해 보도록 하겠습니다.

감사합니다!!

오늘 작업한 내역은 아래 커밋에서 확인 가능합니다.

Learn Prototype Scope and Request Scope · KIMB0B/blog_spring@d4ebc9c (github.com)