[Spring]Request Scope를 사용해서 깔끔하게 로그남기기
[Spring]Request Scope를 사용해서 깔끔하게 로그남기기
스프링을 이용해서 프로젝트를 만들고 배포하여 운영해본 분이라면
문제가 생겨서 로그를 확인할 때 동시에 접속한 여러 고객들의 요청이 뒤섞이면서 로그가 뒤죽박죽으로 남아서 디버깅 작업에 애를 먹었던 경험을 한번씩 해봤을 것이다.
그럴 때 아래와 같이 로그가 찍히면 로그 추적이 한결 수월해 질 것이다.
[clientA...][http://localhost:8080/login] controller test
[clientB...][http://localhost:8080/login] controller test
[clientA...][http://localhost:8080/login] service id = testId
[clientB...][http://localhost:8080/login] service id = testId
이럴 때 이러한 문제점을 해결해 줄 수 있는 방법이 스프링이 제공해주는 웹 스코프 기능중 request scope를 이용하는 것이다.
1. 웹 스코프란?
request scope는 웹 스코프의 한 종류로 써 먼저 웹 스코프에 대해서 알면 좋다.
기존의 스프링이 사용하는 싱글톤 스코프는 스프링 컨테이너의 시작부터 끝까지 함께하는 스코프이고 프로토타입 스코프는 생성과 의존관계 주입 및 초기화까지만 진행하는 스코프였다.
웹 스코프는 웹 환경에서만 동작하는 스코프이며 프로토타입과 다르게 특정 주기가 끝날때 까지 관리를 해준다. 따라서 @PreDestroy와 같은 종료메서드들이 호출된다는 특징이 있다.
웹 스코프 종류는 다음과 같다. 각 종류에 따라 생성되어서 종료되는 시점이 다르다.
- request: HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
- session: HTTP Session과 동일한 생명주기를 가지는 스코프
- application: 서블릿 컨텍스트( ServletContext )와 동일한 생명주기를 가지는 스코프
- websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프
여기서 나오는 request scope개념을 활용하면 서비스 운영을 하면서 발생하는 문제를 해결하면서 로그를 효율적으로 찍을 수 있다.
2. Request Scope의 개념
request scope가 동작하는 방식을 예시 상황을 통해 살펴보자.
우리가 MyLogger라는 로그찍는 클래스를 request scope로 등록해놓았고
한 고객(클라이언트) A가 요청을 보냈다고 가정해보자.
컨트롤러에서 myLogger를 요청받았다면 스프링 컨테이너는 A 전용으로 사용할 수 있는 빈을 생성해서 컨트롤러에 주입해준다. (request scope이기 때문에)
그리고 로직이 진행되면서 서비스에서 다시 myLogger가 필요해서 요청을 하게 되면 방금 A전용으로 생성했던 빈을 그대로 활용해서 주입받을 수 있다.
만약 다른 고객 B가 A고객과 동시에 요청을 보냈었다고 가정해보자.
고객 B도 역시 컨트롤러와 서비스에서 각각 myLogger가 필요한데 이 때는 A고객에게 주입해주었던 빈이 아닌 새로 생성해서 주게된다.
따라서 우리는 request scope를 활용하면 아래와 같이 디버깅하기 쉬운 로그환경을 만들 수 있다.
그러면 실제로 예제 코드를 짜보자.
3. 예제 코드
목표는 다음과 같이 로그가 남도록 request scope를 활용해서 개발해보자.
[d06b992f...] request scope bean create
[d06b992f...][http://localhost:8080/log-demo] controller test
[d06b992f...][http://localhost:8080/log-demo] service id = testId
[d06b992f...] request scope bean close
* 포맷: [UUID][requestURL]message
* UUID를 사용해서 http요청을 구분
* requestURL 정보도 추가해주면 한층 로그 추적이 더 수월할 것이다.
3-1) build.gradle에 web 라이브러리 추가
웹 스코프는 웹 환경에서만 동작하므로 web 환경이 동작하도록 라이브러리를 추가하자.
//web 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
3-2) MyLogger
@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("request scope bean create uuid = " + uuid);
}
@PreDestroy
public void close() {
System.out.println("request scope bean destroy uuid = " + uuid); }
}
로그를 남길 request scope bean 클래스이다.
생성되는 시점에 랜덤으로 UUID를 생성하여 가지고 있고 생성되는 시점에는 requestURL을 알수없으므로 외부에서 setter함수로 입력받는다.
3-3) Controller
@RequiredArgsConstructor
@Controller
public class UserController {
private final MyLogger myLogger;
private final UserService userService;
@GetMapping("/login")
@ResponseBody
public String login(HttpServletRequest request){
String requestUrl=request.getRequestURL().toString();
myLogger.setRequestURL(requestUrl);
myLogger.log("controller");
userService.logic("service logic");
return "login";
}
}
컨트롤러 클래스이다.
유저가 로그인 하는 상황이라 가정하고 로그를 남겨보자.
매개변수에 HttpServletRequest를 넣어 request정보를 가져온 뒤에 url 을 가져와서 myLogger에 url을 넣어 초기화를 해준다.
(지금은 예시로 컨트롤러에 넣었지만 실제로 적용할 때는 인터셉터와 같이 공통처리해주는 곳에서 초기화를 진행하면 더 효율적일 것이다.)
3-4) Service
@RequiredArgsConstructor
@Service
public class UserService {
private final MyLogger myLogger;
public void logic(String msg) {
myLogger.log(msg);
}
}
비즈니스 로직이 들어가는 서비스 계층 클래스이다.
여기서 중요한점이 있다.
만약 request scope를 사용하지 않고 파라미터로 이 모든 정보를 서비스 계층에 넘긴 다면, 파라미터가 많아서 지저분해진다. 더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 된다. 웹과 관련된 부분은 컨트롤러까지만 사용해야 한다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋다.
따라서 request scope의 MyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, MyLogger의 멤버변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있는 것이다.
여기까지 코드를 짜고 실행해보면 서버가 뜨지않고 다음과 같은 오류가 뜬다.
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;
이 오류는 스프링이 실행되면서 TestController의 @RequiredArgsConstructor를 사용했기 때문에
모든 final 멤버 변수를 생성자로 만들어서 주입해주는 과정에서
myLogger클래스를 생성해서 주입해주려고 하는데
이 MyLogger클래스는 request스코프이기 때문에 스프링이 생성되는 시점에는 생성되지 않는다.
(http요청이 들어와야만 생성될 수 있기때문에)
그래서 스프링이 request스코프를 생성을 못했다고 에러를 뿜는 것이다.
4. 해결법
4-1) Provider 사용하기
첫번째 방법은 ObjectProvider를 사용하는 것이다.
ObjectProvider는 지정한 빈을 getObject()를 호출한 시점에 스프링 컨테이너에 요청해서 제공해주는 기능이다.
먼저 예제코드는 다음과 같다.
MyLogger를 주입받으려고 했던 컨트롤러와 서비스만 수정하면 된다.
4-1-1) Controller
@RequiredArgsConstructor
@Controller
public class CmmnController {
private final ObjectProvider<MyLogger> provider;
private final UserService userService;
@GetMapping("/login")
@ResponseBody
public String login(HttpServletRequest request){
String requestUrl=request.getRequestURL().toString();
MyLogger myLogger = provider.getObject();
myLogger.setRequestURL(requestUrl);
myLogger.log("controller");
userService.logic("service logic");
return "login";
}
}
4-1-2) Service
@RequiredArgsConstructor
@Service
public class UserService {
private final ObjectProvider<MyLogger> provider;
public void logic(String msg) {
MyLogger myLogger = provider.getObject();
myLogger.log(msg);
}
}
위의 코드대로 수정하고 실행하면 이번에는 오류없이 스프링이 띄워지고
localhost/login 으로 요청을 보내보면 아래와 같이 로그가 찍힌다.
이번에는 왜 된 것일까?
에러의 원인이 스프링 생성시점에 주입해야하는 MyLogger 클래스를 생성해야 하는 데 MyLogger는 http요청이 오지않으면 생성하지 못하는 데 있다는 것이였다.
따라서 MyLogger대신 ObjectProvider<MyLogger>의 형태로 선언함으로써 스프링 컨테이너를 띄우는 시점에서는 MyLogger 생성을 지연시키고 필요한 시점에 provider가 제공해주는 .getObject() 메서드로 사용하는 것이다.
이렇게 하면 getObject를 호출하는 시점에서는 http요청이 진행중이므로 정상적으로 MyLogger가 생성이 되며 우리 의도에 맞게 request별로 빈을 생성할수 있다.
4-2) 프록시 사용하기
위의 Provider를 사용하는 방법은 MyLogger를 사용할 때마다 필드를 Provider로 선언해주고 getObject()를 선언해야 한다.
에러는 해결했지만 다소 귀찮은 감이 있다.
이번엔 프록시를 사용해서 에러를 해결해보자.
MyLogger클래스에 다음과 같이 적는다.
(혹시 provider로 코드를 수정했었다면 아래 코드 외의 다른 코드들은 원래대로 되돌려놓자)
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
...
}
@Scope에 proxyMode를 ScopedProxyMode.TARGET_CLASS 를 추가해주자
- 적용 대상이 인터페이스가 아닌 클래스면 TARGET_CLASS 를 선택
- 적용 대상이 인터페이스면 INTERFACES 를 선택
이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있다.
이렇게 한 뒤 스프링을 동작시켜 요청을 보내보면 아까와 같이 로그가 찍힌다.
그러면 이 프록시모드라는 것은 뭘까?
MyLogger 를 사용하기 전에 컨테이너에게 주입받은 MyLogger를 로그를 찍어보자.
@GetMapping("/login")
@ResponseBody
public String login(HttpServletRequest request){
String requestUrl=request.getRequestURL().toString();
System.out.println("myLogger = " + myLogger.getClass()); //로그
myLogger.setRequestURL(requestUrl);
myLogger.log("controller");
userService.logic("service logic");
return "login";
}
이렇게 로그를 남겨놓고 찍히는 클래스를 살펴보면 아래와 같이 찍힌다.
myLogger = class com.chung.board.cmmn.MyLogger$$EnhancerBySpringCGLIB$$d71119bc
우리가 만든 클래스가 아닌 왠 EnhancerBySpringCGLIB라는 이상한 녀석이 찍혀있다.
CGLIB라는 라이브러리는 프록시로 선언한 클래스를 상속 받은 가짜 프록시 객체를 만들어서 그자리에 주입한다.
따라서 @Scope 의 proxyMode = ScopedProxyMode.TARGET_CLASS) 를 설정하면 스프링 컨테이너는 CGLIB 라는 바이트코드를 조작하는 라이브러리를 사용해서, MyLogger를 상속받은 가짜 프록시 객체를 생성하는 것이다.
그리고 이 가짜 프록시 객체는 요청이 들어오면 그 때 내부에서 진짜 빈을 요청하는 위임 로직이 들어가 있다.
때문에 이 가짜 프록시를 주입받아도 요청이 들어온 순간에 진짜 빈을 생성해와서 myLogger.logic()을 호출하게 되는 것이다.
마찬가지로 이렇게 하면 스프링 내 로그를 한번에 여러 요청이 뒤섞여도 UUID를 통해 로그를 제대로 확인할 수 있게된다.
Reference
이 포스팅은 아래의 강좌를 참고하여 만들어졌습니다.
- 스프링 핵심 원리-기본편
스프링 핵심 원리 - 기본편 - 인프런
스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다. 초급 프레임워크 및 라이브러리 Back-End Spring 객체지향 온라인 강의 직접 자바
www.inflearn.com