[SpringBoot] Annotation을 이용해 Slack에 Error log 남기기

2023. 3. 18. 00:11Spring 기초

이번 프로젝트에서 인프라를 도맡았습니다.
배포한 개발 서버와 운영 서버의 로그를 CloudWatch에 파일로 관리하였습니다.

하지만
1) 모든 팀원이 AWS에 접속하여 로그를 확인하는 것은 번거롭다고 느껴졌습니다.
2) AWS lambda는 요금 폭탄이 터질 수 있어서(재귀 호출) 제가 속한 교육기관에서 지원하지 않는 서비스였습니다.
3) Error 레벨의 로그는 신속한 대응이 필요하다고 생각했으며, 모든 팀원에게 빠르게 공유될 수 있어야 한다고 생각했습니다.

따라서 사용중인 협업 툴 중 슬랙을 이용해 알림을 받도록 설정했습니다.

Pull Request


​logback-slack-appender

처음에는 Logback 설정으로 에러 로그를 Slack에 전송했습니다.

하지만 가독성이 좋지 못하고, 사용자의 요청정보(request)를 이용할 수 없어 Slack API를 택했습니다.


Slack 설정

1. 채널 생성 후, 채널 세부정보 보기 > 통합 > 앱 추가

저는 프로젝트명 kkini에 맞추어 kkini-error라는 채널을 생성했습니다.

2. incoming - webhook 추가

저는 이미 추가한 상태라 Incoming Webhook이 여러개가 뜨는 것 같습니다. 

 

3. Incoming webhook을 생성한 채널에 등록

 

4. WebhookURL 발급받기

  • 3번까지 차례대로 진행하셨다면 Webhook URL이 생성됩니다.
  • 이 URL에 POST로 메시지를 보내면 슬랙 채널에 메시지가 전송됩니다.


SpringBoot 설정

  • 에러가 발생 했을 때 웹훅으로 슬랙에 알림 메시지를 보내도록 설정합니다.

 

1. 의존성 추가

implementation("net.gpedro.integrations.slack:slack-webhook:1.4.0")

 

2. @SlackNotification 어노테이션 선언

  • 해당 어노테이션이 달린 메소드가 실행될 때, Slack으로 알림이 오도록 설정할 예정입니다.

 

3. Controller Advice 수정

  • 저희 프로젝트에서는 예상치 못한 서버의 에러의 경우 Error 로그가 발생합니다.
  • 슬랙 알림을 원하는 handler 메소드에 @SlackNotification 어노테이션을 추가합니다.
  • HttpServletRequest는 아래의 handleException 메소드에서 사용하지 않지만, AOP 설정을 하는 클래스에서 HttpServletRequest에 담긴 정보가 필요하여 파라미터로 추가했습니다.

 

4. application.yml에 Webhook URL 등록

  • application.yml에 슬랙 설정을 통해 발급받은 Webhook URL을 등록합니다.
  • 보안에 민감한 정보는 .env 파일을 이용해 아래처럼 변수로 관리하였습니다. 
slack:
  webhook: ${SLACK_WEBHOOK}

 

5. ThreadPoolTaskExecutor 빈 등록

  • 하나의 쓰레드로 모든 요청을 처리하는 것보다, 슬랙 알림 처리는 비동기 방식의 멀티쓰레드로 처리하는 것이 더 빠를 것이라고 생각하여 Spring이 제공하는 ThreadPoolTaskExecutor를 사용했습니다.
  • ThreadPoolTaskExecutor는 매번 스레드를 생성하는 것이 아니라, 스레드 풀에 특정 개수만큼의 스레드를 생성해 작업 큐에 적재된 작업들을 꺼내 하나씩 처리한다고 합니다. 작업 처리 요청이 급등해도 스레드의 수가 한정되므로 서버의 성능이 크게 저하되지 않을 것입니다.
    아래 링크를 참고했습니다. 
  • https://coor.tistory.com/33
@EnableAsync
@Configuration
public class ThreadPoolConfig {

	@Bean
	public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
		ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
		threadPoolTaskExecutor.setMaxPoolSize(5); //최대 스레드 수
		threadPoolTaskExecutor.setCorePoolSize(5); //기본 스레드 수
		threadPoolTaskExecutor.initialize();

		threadPoolTaskExecutor.setThreadNamePrefix("async-task-");
		threadPoolTaskExecutor.setThreadGroupName("async-group");

		return threadPoolTaskExecutor;
	}
}

 

6. SlackNotificationAspect 작성

  • 개발, 운영 서버에서만 동작하도록 Profile 설정을 넣었습니다.
  • @Around 어노테이션을 이용하면 @SlackNotification 어노테이션이 부착된 메소드가 실행되기 전/후에 원하는 동작을 실행할 수 있습니다. 
@Aspect
@Component
@Profile(value = {"dev", "prod"})
public class SlackNotificationAspect {
	private final SlackApi slackApi;
	private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
	private final Environment env;

	public SlackNotificationAspect(@Value("${spring.slack.webhook}") String webhook,
		ThreadPoolTaskExecutor threadPoolTaskExecutor, Environment env) {
		this.slackApi = new SlackApi(webhook);
		this.threadPoolTaskExecutor = threadPoolTaskExecutor;
		this.env = env;
	}

	@Around("@annotation(com.prgrms.mukvengers.global.slack.annotation.SlackNotification) && args(request, e)")
	public void slackNotificate(ProceedingJoinPoint proceedingJoinPoint, HttpServletRequest request,
		Exception e) throws Throwable {

		proceedingJoinPoint.proceed();
        
		//HttpServletRequest를 RequestInfo라는 DTO에 복사
		RequestInfo requestInfo = new RequestInfo(request);

		threadPoolTaskExecutor.execute(() -> sendSlackMessage(requestInfo, e));
	}

 

private void sendSlackMessage(RequestInfo request, Exception e) {
   SlackAttachment slackAttachment = new SlackAttachment();
   slackAttachment.setFallback("Error");
   slackAttachment.setColor("danger");

   slackAttachment.setFields(
      List.of(
         new SlackField().setTitle("Exception class").setValue(e.getClass().getCanonicalName()),
         new SlackField().setTitle("예외 메시지").setValue(e.getMessage()),
         new SlackField().setTitle("Request URI").setValue(request.requestURL()),
         new SlackField().setTitle("Request Method").setValue(request.method()),
         new SlackField().setTitle("요청 시간")
            .setValue(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"))),
         new SlackField().setTitle("Request IP").setValue(request.remoteAddress()),
         new SlackField().setTitle("Profile 정보").setValue(Arrays.toString(env.getActiveProfiles()))
      )
   );

   SlackMessage slackMessage = new SlackMessage();
   slackMessage.setAttachments(singletonList(slackAttachment));
   slackMessage.setIcon(":ghost:");
   slackMessage.setText("Error Detect");
   slackMessage.setUsername("kkiniRobot");

   slackApi.call(slackMessage);
}

[Problem] HttpServletRequest 정보를 비동기 쓰레드에서 사용할 수 없는 문제

마지막에 작성한 SlackNotificationAspect 클래스를 보시면

	@Around("@annotation(com.prgrms.mukvengers.global.slack.annotation.SlackNotification) && args(request, e)")
	public void slackNotificate(ProceedingJoinPoint proceedingJoinPoint, HttpServletRequest request,
		Exception e) throws Throwable {

		proceedingJoinPoint.proceed();
		
        //HttpServletRequest를 RequestInfo Dto에 복사
		RequestInfo requestInfo = new RequestInfo(request);

		threadPoolTaskExecutor.execute(() -> sendSlackMessage(requestInfo, e));
	}

HttpServletRequest를 RequestInfo에 복사하고 있으며 sendSlackMessage 메소드에는 RequestInfo를 넘겨주고 있습니다.

처음에는 HttpServletRequest를 파라미터로 받고 비동기로 실행할 때도 동일한 HttpServletRequest 객체를 사용했는데, 비동기 메소드가 실행되기 전에 해당 HttpServletRequest의 요청이 끝나는 경우 비동기 메소드에서 동일한 HttpServletRequest를 사용할 수 없었습니다. 

비동기 메소드 내에서 HttpServletRequest가 null이 되는 경우

따라서 HttpServletRequest를 사용하기 위해, 비동기 메소드를 실행하기 전에 HttpServletRequest의 정보를 RequestInfo라는 DTO에 담았고, 비동기 메소드에서는 RequestInfo를 사용하도록 하였습니다.

 

 

 Logging이라는 관심사를 따로 분리하면서, AOP가 무엇인지 찔끔 맛보기 한 것 같습니다.


참고

https://blog.gangnamunni.com/post/mdc-context-task-decorator/

https://coor.tistory.com/33

https://shanepark.tistory.com/430