이 전 글에서는 스프링 빈의 스코프에 관해서 싱글톤과 프로토타입에 관해 포스팅을 했습니다.
스프링의 빈 스코프(bean scope) 이해하기
1. 빈 스코프란? 스프링 프레임워크에서 빈 스코프는 빈(Bean) 객체의 생성과 소멸을 어떻게 관리할 것인지를 정의하는 중요한 요소 중 하나입니다. 각각의 빈 스코프는 다양한 상황과 요구사항에
fox-dev-diary.tistory.com
이번 글에서는 웹 스코프에 관해서 포스팅하겠습니다.
웹 스코프는 스프링에서 제공하는 빈의 스코프 중 하나로,
웹 애플리케이션에서 HTTP 요청마다 생성되고 해당 요청이 처리되는 동안만 유지되는 빈을 의미합니다.
웹 스코프 종류
- request: HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프입니다. 각각의 HTTP 요청마다 별도의 빈 인스턴 스가 생성되고, 관리됩니다. 이 글에서 다룰 대상이 되겠습니다.
- session: HTTP Session과 동일한 생명주기를 가지는 스코프
- application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
- websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프
이 글에서는 웹 스코프 중에서도 주로 사용되는 Request Scope에 중점을 둘 것입니다.
미리 중요하게 생각하셔야 할 점은 HTTP 요청이 들어올 때마다 새로운 빈 인스턴스가 생성된다는 것입니다.밑에서 반복적으로 나오는 내용입니다. 중요합니다.
나머지도 범위만 다르지 동작 방식은 비슷합니다.
2. Request Scope의 특징
1. 생성과 소멸 주기
- 생성: 각각의 HTTP 요청이 들어올 때마다 새로운 빈 인스턴스가 생성됩니다.
- 소멸: 해당 HTTP 요청이 완료되면 빈 인스턴스가 소멸됩니다.
- 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리합니다.(종료 메서드가 호출 됨)
2. 빈의 공유 범위
- 웹 스코프의 빈은 하나의 HTTP 요청에 대해 고유한 인스턴스를 가지므로, 같은 빈을 여러 클라이언트가 공유하지 않습니다.
3. 웹 환경에서 사용
- 웹 스코프는 웹 환경에서만 동작합니다.
- 웹 스코프는 주로 웹 환경에서 각각의 요청에 대한 데이터를 유지하고자 할 때 활용됩니다.
3. Request Scope 빈 설정하기
가장 먼저 웹 스코프는 웹 환경에서만 동작하므로
web 환경이 동작하도록 라이브러리를 추가해야합니다.
다음 내용을 build.gradle에 추가합니다. (되어 있으면 넘어갑니다.)
//web 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
이제 사용 준비가 되었으니 request 스코프 예제를 봅시다.
동시에 여러 HTTP 요청이 오면 정확히 어떤 요청이 남긴 로그인지 구분하기 어렵습니다.
이럴때 사용하기 좋은것이 바로 request 스코프입니다.
클라이언트 A와 B가 각각 HTTP 요청을 했을 때 구분을 해서 로그를 남겨야 하겠죠?
다음과 같이 로그가 남도록 request 스코프를 활용해서 기능을 개발해봅시다.
UUID를 사용해서 HTTP 요청을 구분할 것이고,
requestURL 정보도 추가로 넣어서 어떤 URL을 요청해서 남은 로그인지 확인하겠습니다.
MyLogger라는 빈을 먼저 생성할 것이고 그 다음 컨트롤러, 서비스 순으로 보겠습니다.
package hello.core.common;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@Scope(value = "request") // 하나일 땐 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);
}
}
위 코드를 보시면 어려운 부분은 없습니다.
로그를 출력하기 위한 MyLogger 클래스입니다.
@Scope(value = "request") 를 사용해서 request 스코프로 지정했습니다.
이제 이 빈은 HTTP 요청당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸됩니다.
이 빈이 생성되는 시점에 자동으로 @PostConstruct 초기화 메서드를 사용해서 uuid를 생성해서 저장해둡니다.
이 빈은 HTTP 요청 당 하나씩 생성되므로, uuid를 저장해두면 다른 HTTP 요청과 구분할 수 있습니다.
이 빈이 소멸되는 시점에 @PreDestroy 를 사용해서 종료 메시지를 남깁니다.
requestURL은 이 빈이 생성되는 시점에는 알 수 없으므로, 외부에서 setter로 입력 받습니다.
다음은 Controller입니다.
참고로 Controller를 들어가기 전에 주의사항을 말씀드리면
아무 작업 없이 Controller에서 MyLogger를 주입 받고 실행시키면 에러가 납니다.
왜 그럴까요? 생각해보면 쉽습니다.
MyLogger는 스코프가 request입니다.
다시 강조하지만 스코프가 request면 HTTP 요청이 들어와야 생성이됩니다.
근데 HTTP 요청이 없어서 빈이 생성이 안됐는데 주입을 해버리면 당연히 에러가 뜨겠죠?
그래서 프로토타입 스코프 빈에서 사용했었던 ObjectProvider를 이용하여 request 빈의 생성을 지연시키거나
프록시를 이용해서 가짜 프록시 클래스를 미리 주입시켜 놓는 방법이 있습니다.
먼저 ObjectProvider를 이용한 방법을 소개하고
프록시는 밑에서 다시 다루겠습니다.
4. 스코프와 Provider
package hello.core.web;
import hello.core.common.MyLogger;
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 LogDemoController {
private final LogDemoService logDemoService;
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");
logDemoService.logic("testId");
return "OK";
}
}
위 코드를 보시면 Controller에서 MyLogger를 그대로 주입 받고 있지 않습니다.
ObjectProvider를 주입 받아서 ObjectProvider.getObject()를 호출하는 시점까지 빈의 생성을 지연시킨 것입니다.
requestURL은 HttpServletRequest를 인자로 받아서 getRequestURL() 메서드를 사용하면 받아올 수 있습니다.
요청이 들어왔으니 setter를 이용하여 URL을 저장해줍니다.
참고로 requestURL을 MyLogger에 저장하는 부분은 Controller보다는
공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋습니다.
여기서는 예제를 단순화 했습니다.
다음은 service단입니다.
package hello.core.web;
import hello.core.common.MyLogger;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final ObjectProvider<MyLogger> myLoggerProvider;
public void logic(String id) {
MyLogger myLogger = myLoggerProvider.getObject();
myLogger.log("service id = " + id);
}
}
위 코드를 보듯이 service에서도 마찬가지로 objectProvider를 사용하여 MyLogger의 생성을 지연했습니다.
여기서 중요한 점은,
ObjectProvider.getObject()를 Controller, Service에서 각각 한번씩 따로 호출해도
같은 HTTP 요청이면 같은 스프링 빈이 반환됩니다.
중요한 점이 한가지 더 있습니다.
request scope를 사용하지 않고 파라미터로 이 모든 정보를 서비스 계층에 넘긴다면, 파라미터가 많아서 지저분해집니다.
더 큰 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 됩니다.
웹과 관련된 부분은 컨트롤러까지만 사용해야 합니다.
서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋습니다.
request scope의 MyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고,
MyLogger의 멤버변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있습니다.
이제 main() 메서드로 스프링을 실행하고
웹 브라우저에 http://localhost:8080/log-demo를 입력해보면
잘 나오는 모습입니다.
몇번 더 요청하면 uuid는 계속 바뀌겠죠. 다른요청이란 뜻입니다.
이제 프록시와 함께 사용해보겠습니다.
프록시를 사용하면 더 깔끔하고 간단한 코드를 볼 수 있습니다.
5. 스코프와 프록시
MyLogger 클래스부터 보겠습니다.
package hello.core.common;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
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);
}
}
위 코드를 보시면 달라진 점은
@Scope 안에 proxyMode = ScopedProxyMode.TARGET_CLASS 이부분입니다.
(적용 대상이 인터페이스가 아닌 클래스면 TARGET_CLASS를 선택, 적용 대상이 인터페이스면 INTERFACES를 선택)
이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고
HTTP request와 상관없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.
Controller를 보겠습니다.
package hello.core.web;
import hello.core.common.MyLogger;
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 LogDemoController {
private final LogDemoService logDemoService;
private final MyLogger myLogger;
@RequestMapping("log-demo")
@ResponseBody
public String logDemo(HttpServletRequest request) {
// System.out.println("myLogger = " + myLogger.getClass());
String requestURL = request.getRequestURL().toString();
myLogger.setRequestURL(requestURL);
myLogger.log("controller test");
logDemoService.logic("testId");
return "OK";
}
}
위 코드를 보면 Controller에서 MyLogger를 바로 주입 받았습니다.
HTTP 요청이 들어오기 전에 만들어지지도 않은 빈을 주입 받은건데, 에러가 날거 같지만 문제 없이 돌아갑니다.
MyLogger에서 프록시 설정을 해주어서 가짜 프록시 객체를 만들어서 주입해버립니다.
주석으로 된 println 부분을 주석 풀고 실행하면
myLogger = class hello.core.common.MyLogger$$SpringCGLIB$$0라 뜨는 것을 볼 수 있는데요
@Scope의 proxyMode = ScopedProxyMode.TARGET_CLASS를 설정하면
스프링 컨테이너는 CGLIB라는 바이트코드를 조작하는 라이브러리를 사용해서,
MyLogger를 상속받은 가짜 프록시 객체를 생성합니다.
우리가 등록한 순수한 MyLogger 클래스가 아니라
MyLogger$ $EnhancerBySpringCGLIB이라는 클래스로 만들어진 객체가 대신 등록됩니다.
그리고 스프링 컨테이너에 "myLogger"라는 이름으로 진짜 대신에 이 가짜 프록시 객체를 등록합니다.
그래서 의존관계 주입도 이 가짜 프록시 객체가 주입됩니다.
가짜 프록시 객체는 요청이 오면 그때 내부에서 진짜 빈을 요청하는 위임 로직이 들어있습니다.
가짜 프록시 객체는 내부에 진짜 myLogger를 찾는 방법을 알고 있습니다.
클라이언트가 myLogger.log()을 호출하면 사실은 가짜 프록시 객체의 메서드를 호출한 것입니다.
가짜 프록시 객체는 request 스코프의 진짜 myLogger.log() 를 호출합니다.
가짜 프록시 객체는 원본 클래스를 상속 받아서 만들어졌기 때문에
이 객체를 사용하는 클라이언트 입장에서는 사실 원본인지 아닌지도 모르게, 동일하게 사용할 수 있습니다.(다형성)
service단도 보겠습니다. 동일합니다.
package hello.core.web;
import hello.core.common.MyLogger;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LogDemoService {
private final MyLogger myLogger;
public void logic(String id) {
myLogger.log("service id = " + id);
}
}
service도 그렇고 controller도 그렇고 코드가 간결해진 모습입니다.
동작을 다시한번 간단하게 정리하자면
- CGLIB라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입한다.
- 이 가짜 프록시 객체는 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직이 들어있다.
- 가짜 프록시 객체는 실제 request scope와는 관계가 없다.
- 그냥 가짜이고, 내부에 단순한 위임 로직만 있고, 싱글톤 처럼 동작한다.
프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request scope를 사용할 수 있습니다.
사실 Provider를 사용하든, 프록시를 사용하든 핵심 아이디어는 진짜 객체 조회를
꼭 필요한 시점까지 지연처리한다는 점입니다.
단지 애노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있습니다.
이것이 바로 다형성과 DI 컨테이너 가 가진 큰 강점입니다.
꼭 웹 스코프가 아니어도 프록시는 사용할 수 있습니다.
주의할 점으로는
마치 싱글톤을 사용하는 것 같지만 다르게 동작하기 때문에 결국 주의해서 사용해야 합니다.
이런 특별한 scope는 꼭 필요한 곳에만 최소화해서 사용해야합니다.
무분별하게 사용하면 유지보수하기 어려워집니다.
마무리
웹 스코프는 스프링에서 웹 애플리케이션의 요청 단위로 빈의 생명주기를 관리하는 중요한 스코프 중 하나입니다. Request Scope는 각각의 HTTP 요청에 대해 고유한 빈 인스턴스를 생성하므로,
사용자의 상태를 유지하거나 요청에 따른 데이터를 처리하는 데 유용하게 활용됩니다.
스코프 프록시와 함께 사용하여 정확한 동작을 보장하도록 주의해야 합니다.
<김영한 선생님의 스프링 핵심원리 기본편 예제 및 설명이 들어갔습니다.>
'Spring(boot)' 카테고리의 다른 글
Springboot-Gradle 프로젝트 build, out 폴더 (1) | 2024.02.06 |
---|---|
Logging (SLF4J) 사용하기 (1) | 2024.02.02 |
스프링의 빈 스코프(bean scope) 이해하기 (0) | 2024.01.18 |
스프링의 빈 생명주기 콜백 방법과 권장 사항 (0) | 2024.01.16 |
SOLID: 좋은 객체 지향 설계의 5가지 원칙 (0) | 2024.01.05 |