프로젝트 도중 배포 서버에서 에러가 발생하면 슬랙 채널로 알람이 간다면 좋겠다는 생각에, 구현하여 적용해보기로 했다!
무서운 당직병의 에러 알람이 슬랙 채널로 꽂히는 걸 잘 볼 수 있었다!! 그런데..
5번의 에러가 발생해서 슬랙으로 5번의 알람이 전달되면, 1번씩은 꼭 위와 같은 에러가 발생하며, 슬랙 알람 전송이 실패했다.
이를 그대로 직역해보면,
java.lang.IllegalStateException: The request object has been recycled and is no longer associated with this facade
요청 객체가 이미 재사용되었고, 더 이상 facade와 관련이 없어진다. (뭔 소리지?)
사실 오류 메세지만으로는 제대로 된 원인을 파악하기 힘들었다.
StackoverFlow를 찾아보니, 위와 같은 오류를 찾을 수 있었는데, 오류에 대한 감을 잡을 수 있었다. 답변을 번역해보니,
이 오류는 Spring이 더 이상 유효하지 않은 요청 객체에 대한 스레드 로컬 핸들을 유지하도록 한다는 사실로 인해 발생할 수 있습니다.
보자마자 어디서 문제가 발생했는지 알 수 있었다.
아래 코드는 에러가 발생하면 발생한 오류의 request와 exception 정보를 가져와 적절히 파싱해주어 슬랙으로 메세지를 보내는 과정이다.
@Around("@annotation(kr.co.shophub.shophub.global.slack.SlackNotification) && args(request, e)")
fun slackNotification(
proceedingJoinPoint: ProceedingJoinPoint,
request: HttpServletRequest,
e: Exception
) {
proceedingJoinPoint.proceed()
val context = RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes
val requestCopy = context.request
threadPoolTaskExecutor.execute { sendSlackMessage(requestCopy, e) }
}
서버에서 클라이언트로 오류를 응답하는 과정과 슬랙으로 오류 메세지를 전달하는 과정은 하나의 스레드에서 처리하면 응답 결과가 늦어지므로 이를 threadPoolTaskExecutor
를 통해 분리하였다.
분리를 한 이유는, 1. 발생한 오류를 클라이언트로 전달(10ms) 2. 발생한 오류를 슬랙 채널로 전송(20ms) 이렇게 두가지의 기능을 하나의 스레드로 보내게 되면, 클라이언트는 30ms의 응답 시간이 걸릴 것이다. 클라이언트는 받지도 않는 슬랙 오류 전송을 기다리게 되는 것인데, 이를 각각의 스레드로 분리하여 필요한 응답만 받는 것이다. 그렇게 되면 10ms만에 받을 수 있을 것이다. (걸리는 시간의 수치는 예시로 든 것이고 실제와 다르다.)
서론이 조금 길었는데, 결국 문제는 두 개의 스레드를 사용하면서 공유되는 자원이 생명주기가 끝나버려 이후에 접근하는 스레드에서 오류가 발생한 것이다. 공유되고 있는 자원은 Exception과 HttpServletRequest 두 개인데, 문제가 된 공유 자원은 HttpRequestServlet이었다.
requestCopy
로 요청 정보를 복사하여 전달하는 코드가 있다. 바로 여기가 문제였다. 1. 발생한 오류를 클라이언트로 전달(10ms)에서 사용하는 HttpRequestServlet은 2. 발생한 오류를 슬랙 채널로 전송(20ms)보다 일찍 끝나버리는 바람에 생명주기가 끝나버렸던 것이다. context
를 통해 접근하는 reqeust
는 HttpServletReqeust
를 참조하는 것이지 이를 정확하게 복사해둔 것이 아니었다.(복사가 아니라 참조였다는 걸 모르고 쓴 내가 바보다.)
문제의 원인이 명확하다보니, 해결 방법 또한 명확하고 간단했다. 참조하고 있던 객체의 생명주기가 끝나 더이상 참조할 수 없는 것이 문제니, 생명주기가 끝나기 전에 이를 '복사'해놓고 사용하면 된다. HttpServletRequest
에서 사용할 객체 정보를 따로 ReqeustInfo
라는 객체로 복사해두었다.
data class RequestInfo (
val requestUrl: String,
val method: String,
val remoteAddr: String,
)
복사한 객체는 아래와 같이 사용하였다.
@Around("@annotation(kr.co.shophub.shophub.global.slack.SlackNotification) && args(request, e)")
fun slackNotification(
proceedingJoinPoint: ProceedingJoinPoint,
request: HttpServletRequest,
e: Exception
) {
proceedingJoinPoint.proceed()
val requestInfo = RequestInfo(
requestUrl = request.requestURL.toString(),
method = request.method,
remoteAddr = request.remoteAddr
)
threadPoolTaskExecutor.execute { sendSlackMessage(requestInfo, e) }
}
스레드에 대한 개념이 부족했다. 정확히 말하면, 스레드가 뭔지는 잘 알고 있었으나, 이게 확실히 코드를 작성하고 두 눈으로 보는 것이 더 확실히 체득이 된다. 글로만 혹은 예제로만 보는 것과는 천지차이인 것 같다. 스레드를 사용할 때 공유자원에 대한 위험성 또한 충분히 알고 있었는데.. 이젠 헷갈리지 않는다!
ㅋㅋㅋ 너무 재밌는 프로젝트네요! 잘 보고 갑니다 :)