현재 진행중인 프로젝트는 개발팀 뿐만 아니라 기획, 디자인 등 여러 팀이 디스코드를 이용해 소통하고있다.
그리고 API 서버는 AWS를 이용해서 돌리고있다. 여러가지 기능 구현을 하던중, FE 측으로 부터
~api 에서 오류가 발생하는 것 같은데 확인 해주실 수 있을까요?
라는 요청이 왔다.
그럴때마다 인스턴스에 접속해서 로그를 따서 확인 해야하는 번거로움이 있었다.
마침 맡은 작업이 없었고, 귀찮은 일을 조금이라도 줄일 수 있는 방법이 없을까 하는 생각으로 은밀하게 시작했다.
가장 먼저 든 생각은 많이들 사용하는 슬랙 알림 오픈소스 같이 디스코드 알림 오픈소스가 있나? 였다.
찾아본 결과 logback-discord-appender 라는 오픈소스가 있기는 했다.
하지만 코드를 뜯어보니 본인에게는 필요하지 않은 것도 있고, 하드코딩 되어있는 것들도 많고 여러모로 마음에 들지 않았다.
또한 클라이언트의 Request에 대해 여러가지 데이터를 가지고 알림을 보내야 버그 픽스, 리팩토링이 용이할 것으로 판단이 되어 logback 방식을 이용한 구현은 포기했다.
위 이미지를 보면 알 수 있듯이, AOP는 로깅과 같은 횡단 관심사(Cross Cutting Concerns)와 딱 맞아 떨어지는 방식이다.
횡단 관심사
예를 들어 비즈니스 레이어의 모든 클래스에 로그 기능을 추가 한다면 하나의 부가 기능(로깅)을 여러 곳에서 동일하게 사용하게 된다. 이러한 부가 기능을 횡단 관심사라고 한다.
따라서 부가 기능(로깅)을 핵심 기능(비즈니스 레이어 등)에서 분리해 한 곳으로 관리하도록 하고, 이 부가 기능을 어디에 적용할지 선택하는 기능을 합한 하나의 모듈인 AOP를 통해 구현하기로 마음 먹었다.
가장 먼저 AOP의 적용 지점을 선정해야 했다. 공교롭게도 현재 진행중인 프로젝트의 모든 Exception은 @RestControllerAdvice
를 적용한 GlobalExceptionHandler
(구현 포스팅)에서 전역으로 처리하고 있다. 또한 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메소드 실행 지점으로 제한되고 있었다.
따라서 Join Point를 RestControllerAdvice의 Before로 적용해 Exception이 발생한 데이터를 로깅할 수 있을 것이라는 판단이 들었다.
다음으로 해야할 일은 서블릿의 HttpServletReqest로 부터 request를 추출하는 것이다.
BufferedReader br = httpServeletRequest.getReader();
그러나 단순히 Filter 혹은 Interceptor에서 먼저 데이터를 읽어버리게 되면 한 번 밖에 읽지 못한다. 그리고 당연하게도 Controller는 binding할 request가 없기 때문에 exception이 터진다.
다행히 스프링에서는 ContentCachingRequestWrapper라는 wrapper 클래스를 제공해주고있다.
이름 그대로 Request를 캐싱하여 여러 번 읽을 수 있게 해주는 역할을 한다.
따라서 기존 HttpServeltRequest를 Filter을 이용해 위 wrapper 클래스로 변경 해줬다.
또한 사용자의 요청 한 번에 필터가 한 번만 실행
되게 하기 위해 OncePerRequestFilter 클래스를 확장했다.
@Component
public class ServletRequestWrappingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
filterChain.doFilter(requestWrapper, response);
}
}
다음으로는 Request를 AOP 클래스에서 사용하기 위해 Spring Web이 제공하는 bean scope 'request'를 사용하기로 했다. Request scope는 사용자의 요청 하나가 들어오고 나갈 때까지 유지되는 스코프로, 각각의 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.
따라서 위와 같은 구조로 관리 되도록했다.
public class CachedRequest {
private ContentCachingRequestWrapper requestWrapper;
public void set(ContentCachingRequestWrapper requestWrapper) {
this.requestWrapper = requestWrapper;
}
public ContentCachingRequestWrapper get() {
return requestWrapper;
}
}
CahcedRequest
클래스는 간단히 set, get 정도로만 구현하고, 빈으로 등록 해줬다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper;
}
@Bean
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public CachedRequest cachedRequest() {
return new CachedRequest();
}
}
위와 같이 CachedRequest
클래스의 Scope를 request로 지정하고 proxyMode
설정을 해줬다. 또한 ObjectMapper
도 Bean으로 등록 했는데 이는 아래에서 설명하고, 위에서 작성한 ServletRequestWrappingFilter
클래스에서 request를 캐싱 하도록 바꿔줬다.
@RequiredArgsConstructor
@Component
public class ServletRequestWrappingFilter extends OncePerRequestFilter {
private final CachedRequest cachedRequest;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
cachedRequest.set(requestWrapper);
filterChain.doFilter(requestWrapper, response);
}
}
처음에 proxyMode를 설정하지 않았을 때 아래와 같은 오류가 발생했었다.
Caused by: org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name 'cachedRequest': 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
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:373) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:910) ~[spring-beans-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:788) ~[spring-beans-6.0.14.jar:6.0.14]
... 59 common frames omitted
Caused by: 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.
at org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes(RequestContextHolder.java:131) ~[spring-web-6.0.14.jar:6.0.14]
at org.springframework.web.context.request.AbstractRequestAttributesScope.get(AbstractRequestAttributesScope.java:42) ~[spring-web-6.0.14.jar:6.0.14]
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:361) ~[spring-beans-6.0.14.jar:6.0.14]
... 65 common frames omitted
오류를 읽어보면 Application Context load 시점에 CachedRequest는 존재하지 않고 모시깽모시깽...
하기 때문이었다. 생각해보면 위에 서술 했듯이 request scope
는 사용자의 요청의 시작점 부터 나갈때까지의 생명주기를 가지기 때문에 위 시점에서는 당연히 오류가 발생하는 것이다.
따라서 proxyMode
를 이용해 가짜 프록시 객체를 만들어두고 request
여부에 상관 없이 빈에 미리 주입을 하는 방법으로 해결했다.
처음엔 ObjectMapper 빈을 주입하지 않고 로컬에서 알림이 오는지 확인 후 PR을 올리고 병합했다.
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.fasterxml.jackson.databind.ObjectMapper' available: expected at least 1 bean which qualifies as autowire candidate.
그러나 테스트코드 구동시 위 오류가 발생했다. ObjectMapper 빈을 찾거나 정의하지 못했다는건데, 테스트 환경에서만 오류가 발생해서 의아했지만 위 WebConfig
클래스에 빈 주입을 해줌으로써 해결했다.
해결 후 PR을 올렸는데, 리뷰어 분께서 리뷰를 남겨주셨다.
2024-04-27T14:33:08.241+09:00 ERROR 80774 --- [nio-8080-exec-1] o.i.i.g.discordLogger.DiscordAppender : Original Exception:
org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class java.time.ZonedDateTime]
at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:489) ~[spring-web-6.0.14.jar:6.0.14] ...
이번엔 또 뭐가 문제일까? ObjectMapper를 빈으로 등록하고 직렬화 하도록 했었으나 제대로 작동하지 않았고, ZonedDateTime
직렬화 역시 실패했기 때문에 위와같은 오류가 발생한 것으로 보였다. 넷플릭스의 dgs-framework에서도 마침 위 오류가 발생해서 참고하여 objectMapper에 javaTimeModule을 주입
해줌으로써 해결했다. 넷플릭스 이슈
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
@Bean
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public CachedRequest cachedRequest() {
return new CachedRequest();
}
}
다음으로 GlobalExceptionHandler
에 구현 되어있는 exception 메소드에 붙여 줄 어노테이션을 정의 해야했다. 커스텀 어노테이션은 간단하다.
public enum DiscordAlarmErrorLevel {
TRACE,
DEBUG,
INFO,
WARN,
ERROR
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DiscordAlarm {
DiscordAlarmErrorLevel level() default DiscordAlarmErrorLevel.WARN;
}
디스코드 웹훅 API를 통해 메시지를 보내는 역할의 클래스를 정의 해줬다.
먼저, 디스코드 웹훅 API를 참고하여 전송
과 json 변환
메소드를 정의 해줬다.
(클래스 명의 xxxxx는 프로젝트 명이어서 지웠다.)
@Slf4j
@RequiredArgsConstructor
@Component
public class xxxxxDiscord {
private static final String CONTENT_MESSAGE = "## 에러가 발생 했어요!";
private static final int COLOR_RED = 16711680;
@Value("${discord.webhook}")
private String DISCORD_WEBHOOK_URL;
private final ObjectMapper objectMapper;
public void send(String title, String description) {
try {
WebClient.create(DISCORD_WEBHOOK_URL)
.post()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(toJson(title, description))
.retrieve()
.bodyToMono(String.class)
.block();
} catch (Exception e) {
log.error(e.getMessage());
}
}
private String toJson(String title, String description) {
try {
Map<String, Object> map = new HashMap<>();
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> embedsValues = new HashMap<>();
embedsValues.put("title", title);
embedsValues.put("description", description);
embedsValues.put("color", COLOR_RED);
list.add(embedsValues);
map.put("content", CONTENT_MESSAGE);
map.put("embeds", list);
return objectMapper.writeValueAsString(map);
} catch (JsonProcessingException ignored) {
}
return "{\"content\" : \"Json parsing error.\"}";
}
}
DISCORD_WEBHOOK_URL
필드는 환경변수를 통해 주입 하도록 했다.
메시지 구성은 본인이 필요한 부분만 알잘딱하게 만들면 되기에 자세한 설명은 생략하겠다.
HttpServletRequest로 부터 저장된 데이터를 받아와 실제로 보낼 메시지를 만드는 클래스를 구성 해줬다.
본인이 생각하기에 필요했던 부분은 제목, 에러 레벨, 로그, 발생한 프로필
정도 였기에 아래와 같이 작성했다.
@Component
@RequiredArgsConstructor
public class DiscordMessageGenerator {
private static final String EMPTY_BODY = "*BODY IS EMPTY*";
private static final String EXCEPTION_MESSAGE_FORMAT = "**%s**%s.%s:%d - %s";
private static final String TITLE_FORMAT = "[%s] %s";
private static final String DESCRIPTION_FORMAT = "**[ERROR LOG]**\n%s\n\n**[DESCRIPTION]**\n**[%s]** %s\n\n%s";
private static final String GENERATE_ERROR_MESSAGE = "An error occurred while extracting content\n%s";
private final Environment environment;
public String generateTitle() {
return String.format(TITLE_FORMAT, getConfigProfile(), getCurrentTime());
}
public String generateDescription(ContentCachingRequestWrapper requestWrapper,
Exception exception,
DiscordAlarmErrorLevel level) {
try {
String exceptionMessage = extractExceptionMessage(exception, level);
String method = requestWrapper.getMethod();
String requestURI = requestWrapper.getRequestURI();
String body = getBody(requestWrapper);
return toDescription(exceptionMessage, method, requestURI, body);
} catch (Exception e) {
return String.format(GENERATE_ERROR_MESSAGE, e.getMessage());
}
}
private String getConfigProfile() {
return String.join(",", environment.getActiveProfiles()).toUpperCase();
}
private String getCurrentTime() {
return LocalDateTime.now().toString();
}
private String getBody(ContentCachingRequestWrapper requestWrapper) {
String body = new String(requestWrapper.getContentAsByteArray());
if (body.isEmpty()) {
body = EMPTY_BODY;
}
return body;
}
private String extractExceptionMessage(Exception e, DiscordAlarmErrorLevel level) {
StackTraceElement trace = e.getStackTrace()[0];
String className = trace.getClassName();
int lineNumber = trace.getLineNumber();
String methodName = trace.getMethodName();
String message = e.getMessage();
if (Objects.isNull(message)) {
return Arrays.stream(e.getStackTrace())
.map(StackTraceElement::toString)
.collect(Collectors.joining("\n"));
}
return String.format(
EXCEPTION_MESSAGE_FORMAT, level.name(), className, methodName, lineNumber, message
);
}
private String toDescription(String exceptionMessage, String method, String requestURI, String body) {
return String.format(
DESCRIPTION_FORMAT, exceptionMessage, method, requestURI, body
);
}
}
이것도 웹훅 클래스와 마찬가지로 자신의 필요에 맞게 알잘딱하게 구성하면 된다.
마지막으로 AOP 클래스를 정의해줬다. AOP 클래스는 위에서 정의한 CachedRequest
, DiscordMessageGenerator
그리고 xxxxxDiscord
클래스를 주입 받아 구현했다.
@Slf4j
@Aspect
@Component
@Profile({"local", "dev", "..."})
@AutoConfigurationPackage
@RequiredArgsConstructor
public class DiscordAppender {
private final CachedRequest cachedRequest;
private final DiscordMessageGenerator messageGenerator;
private final xxxxxDiscord xxxxxDiscord;
@Before("@annotation(org.xxxxx.xxxxxxx_api.global_exception.discordLogger.DiscordAlarm)")
public void appendExceptionToResponseBody(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (!hasOneArgument(args)) {
return;
}
if (!isException(args)) {
return;
}
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
DiscordAlarm annotation = signature.getMethod().getAnnotation(DiscordAlarm.class);
DiscordAlarmErrorLevel level = annotation.level();
String title = messageGenerator.generateTitle();
String description = messageGenerator.generateDescription(cachedRequest.get(),
(Exception) args[0],
level);
xxxxxDiscord.send(title, description);
}
private boolean isException(Object[] args) {
if (!(args[0] instanceof Exception)) {
log.warn("[DiscordAlarm] argument is not Exception");
return false;
}
return true;
}
private boolean hasOneArgument(Object[] args) {
if (args.length != 1) {
log.warn("[DiscordAlarm] require only one Exception");
return false;
}
return true;
}
}
이제 GlobalExceptionHandler
의 메소드에 어노테이션을 달아주면 끝이다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@DiscordAlarm(level = ERROR)
@ExceptionHandler({
example.exampleNotExistException.class,
exampleException.exampleNotExistException.class,
exampleException.exampleNotExistException.class,
exampleException.exampleNotExistException.class,
exampleException.exampleNotExistException.class
})
public ResponseEntity<BaseResponse<ErrorResponse>> handleGlobalNotFoundException(final CustomException e) {
log.error(e.getErrorInfoLog());
BaseResponseConverter<ErrorResponse> converter = new BaseResponseConverter<>();
ErrorResponse errorResponse = ErrorResponse.from(e);
return new ResponseEntity<>(converter.generateErrorResponse(
false,
errorResponse.getCode(),
errorResponse.getMessage(),
null
), HttpStatus.NOT_FOUND);
}
}
호기롭게 시작했지만 생각보다 더 많이 어려웠고 공식문서도 여러 개 참고하며 여러모로 깨부한 것 같다. AOP에 대해서 공부할 때는 단순히 '알고있다' 정도 였지만 이번 계기를 통해 AOP를 구현 해보고 관심사 분리에 대해 조금 더 잘 이해할 수 있게 된 것 같다.
마지막으로 알림의 예시를 첨부하며 글을 마치겠다.
구현하고 팀원 분들이 잘 사용 해주시는걸 보니 뿌듯하다. 🤩