본문 바로가기
카테고리 없음

Spring Slack Alarm Logger 만들기

by 손너잘 2021. 10. 30.

우아한테크코스에서 진행하고 있는 프로젝트에서는, 프로젝트의 에러에 빠르게 대처하기 위해 Error가 발생할 경우 슬랙으로 에러 메세지를 전송합니다.

이 방식은 logback에 의존하며, slack appender라는 오픈소스를 이용하여 구현하고 있습니다.

하지만 이 방식에는 몇가지 문제가 있었는데요, 슬랙에 에러메세지가 날아오기는 하지만 어떤 상황에서 발생하는 에러인지 전혀 파악이 안된다는 점이었습니다.

이러한 문제상황에 우리의 영원한 리더 브라운이 위와같은 요구사항을 주셨고 제가 덥썩 물어서 해결해보도록 하였습니다.

SlackAppender 분석

가장 먼저 제가 한 일은 기존의 SlackAppender를 분석하는 일이었습니다. 어떤 방식으로 로그를 전송하는지는 알아봐야 하니까요..?

그래서 소스를 까본 저는 매우 놀랐습니다.

엄청난 하드코딩 덩어리를 보게 됩니다.

단순하게 에러 정보를 이벤트로 받아와 slack api의 형식에 맞도록 변형 후 전송하는 코드로 구성되어있었습니다.

이 소스를 본 저는 *"이정도면 그냥 커스텀 Appender를 만들면 되겠는데?"* 라는 생각을 하게 됩니다. 특히 UnsynchronizedAppenderBase<ILoggingEvent> 라는 추상클래스를 통해 logback appender를 손쉽게 구현할 수 있다는 점이 저를 이런 생각으로 이끌었지만 한가지 문제에 봉착하게 됩니다.

*"사용자의 요청에 대한 정보를 어떻게 가져오지??"*

이러한 고민을 한 이유는 다음과 같습니다.

스프링에서 Request에 대한 데이터를 얻기 위해서는 HttpServletRequest를 통해 데이터를 가지고 와야 합니다. 하지만 logback에서 로깅을 하는 방식은 Event 방식입니다. 즉 Appender에서 받을 수 있는 데이터는 특정 데이터로 국한되며 이는 request데이터를 appender에서 얻을 수 없다는 것을 의미합니다.

따라서 logback을 이용한 슬랙 알람 구현을 과감히 포기했습니다.

AoP를 활용한 로깅

애초에 AoP는 로깅과 같은 횡단 관심사에 잘 맞는 방식입니다. 따라서 이를 통해 이 문제를 해결할 수 있을것이라 생각했습니다.

진행중인 프로젝트에서는 Exception을 Controller Advice에서 전역으로 잡아서 처리하고 있습니다. 따라서 JoinPoint를 Controller Advice의 Before로 잡아서 Exception에 대한 데이터를 얻을 수 있을것이라고 생각했습니다.

HttpServletRequest 데이터를 여러번 읽기

가장 먼저 해야 할 일은 서블릿의 HttpServletRequest로 부터 body를 추출할 수 있도록 하는 것 입니다. 서블릿의 HttpServletRequest는 body 데이터를 stream으로 제공합니다.

final BufferedReader reader = httpServletRequest.getReader();

따라서 위와같이 Reader를 통해서 데이터를 읽으며, 이 데이터는 한번만 읽을 수 있습니다. 따라서 만일 Filter나 Interceptor에서 데이터를 먼저 읽어버리면 Controller 앞딴의 binding에서 읽을 body가 없어 exception을 터트리게 됩니다. 이를 해결하기 위해서 스프링에서는 래퍼클래스를 하나 제공해주는데요

바로 ContentCachingRequestWrapper 입니다. (당연히 ContentCachingResponseWrapper 도 있습니다.)

일회성 읽기만을 제공하는 Request의 데이터를 캐싱하여 여러번 읽을 수 있도록 해주는 역할을 합니다.

기존의 HttpServletRequest를 위 래핑 클래스로 변경시킬 수 있는것은 Filter에서 가능합니다.

HttpServletRequest를 AoP 클래스로 가져오기

AoP 클래스로 사용자의 request 정보를 가져오기 위해 여러 방법을 시도한 결과 빈의 request scope를 이용하기로 했습니다. request scope는 스프링 코어에서 제공하는 scope는 아니고, Spring web에서 제공하는 scope이며 하나의 request에 대한 생명주기를 가집니다.

따라서 위와 같은 구조를 생각해 봤습니다. request가 들어오면 request scope로 생성된 RequestStorage라는 빈에 HttpServletRequest를 저장해 두었다가 Exception이 발생할 경우 Controller Advice에 걸어둔 AoP에서 HttpServletRequest를 끌어다 쓰는 방법입니다.

RequestStorage는 간단히 위와같이 구현했습니다. 그리고 위 클래스에 대한 빈 등록을 진행했습니다.

Scope를 request로 지정한 것을 보실 수 있습니다. 이때 proxyMode라는것을 설정한 것을 볼 수 있는데요 아래에서 함께 설명하겠습니다.

이제 위 구조에 따라 HttpServletRequest도 변경해줍니다. requestStorage를 생성자 주입받는것을 볼 수 있으며 해당 빈에 wrappedRequest를 set합니다. 여기에서 proxyMode를 설정한 이유를 말할 수 있습니다. 처음 Application Context를 load하는 시점에서 RequestStorage는 존재하지 않습니다. scope가 request이기 때문입니다. 따라서 빈 주입 시 에러가 발생합니다.

Caused by: org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name 'requestStorage': 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; nested exception is java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.

따라서 ProxyMode를 이용하여 프록시 객체를 주입시켜놓고 lazy하게 객체를 받아 사용합니다.

마찬가지로 AoP Class도 정의해 줍니다. 저같은 경우는 SlackAlarm이라는 Annotation을 정의하여 이 어노테이션이 붙은 Method의 Parameter에서 Exception을 추출하여 메세지를 만들고, 슬랙으로 전송하도록 했습니다.

SlackMessageGenerator는 슬랙으로 보낼 데이터를 정제하는 클래스이며 여기서 HttpServletRequest로 부터 데이터를 받아와 전체 메세지를 정제합니다. PrologSlack은 실제로 슬랙에 메세지를 보내는, Slack API를 정의해둔 클래스 입니다.

자세한 소스는 https://github.com/woowacourse/prolog/pull/530 에서 확인 가능합니다.

마무리

결론적으로 위와 같이, ControllerAdvice에서 @SlackAlarm이라는 어노테이션을 붙히면 Exception을 추출하고 Request데이터를 함께 포함하여 슬랙으로 메세지를 보낼 수 있도록 했습니다.

기존의 로직을 변경하지 않고 AoP를 이용해 로깅을 가능하게 했다는 점에 AoP의 위력과 관심사의 분리라는 개념을 조금 더 확실히 이해할 수 있었던 것 같습니다.

슬랙에는 위와 같이 알람이 오게 됩니다 🙂

댓글