[SpringBoot] AOP로 Exception 감지 / WebHook으로 디스코드 알림 연동하기

다은·2025년 4월 21일
2

SpringBoot

목록 보기
6/12
post-thumbnail

취준생을 위한 AI 경험 정리 서비스, MOAMOA 의 개발기입니다.

한국대학생 IT경영학회 큐시즘 30기를 통해 Team 뫄뫄는 MOAMOA라는 서비스를 개발했습니다. 현재 리팩토링 기간을 거친 후 본격적으로 서비스를 운영하고 있습니다.

https://www.moamoa.site
👆모아모아 서비스 링크👆

서비스를 운영하다보면, 운영 서버에서 발생한 예외에 대해 즉각적으로 대처하기 어렵습니다. 어느정도의 모니터링 시스템이 구축되어 있어야 예외에 대한 대처 및 추후 플랜을 성립하기 용이합니다.

따라서, 빠르게 문제를 파악하기 위해 1차적으로 운영 서버에서 예외가 발생하면 디스코드로 알림이 오도록 연결해보았습니다.


연동을 위해서 디스코드의 WebHook 기능을 사용했습니다.

WebHook?
서버에서 어떤 이벤트가 발생했을 때 클라이언트를 호출하는 API (역뱡향 API)


1. Discord WebHook 생성

  • 디스코드 채널 생성 - 설정 - 웹후크 만들기

    • 해당 단계를 위해서는 채널에 대한 전체 권한이 필요합니다
  • 이름 설정 후 생성된 웹후크 URI 복사



2. Discord 알림 보내기 설정

2-1. DTO 생성 및 WebHook URI 환경변수 설정

  • application.yml에 web-hook-url 추가
    logging:
      level:
        org.springframework.web: DEBUG
      discord:
        web-hook-url: https://discord.com/api/webhooks/...  #${DISCORD_WEBHOOK_URI}
  • API 통신을 위한 DTO 생성
       public class DiscordDto {
           @Builder
           @AllArgsConstructor
           @NoArgsConstructor
           @Getter
           public static class MessageDto {
               @JsonProperty("content")
               private String content;
       
               @JsonProperty("embeds")
               private List<EmbedDto> embeds;
           }
       
           @Builder
           @AllArgsConstructor
           @NoArgsConstructor
           @Getter
           public static class EmbedDto {
               @JsonProperty("title")
               private String title;
       
               @JsonProperty("description")
               private String description;
           }
       }

2-2. DiscordUtil

  • util을 통해 디스코드 알림에 들어갈 내용들을 파싱해 메세지 형태를 생성
    - 들어갈 내용은 얼마든지 커스텀할 수 있으며, 발생 시간, 요청 엔드포인트, 요청 클라이언트, 스택 트레이스를 description으로 가지도록 설정했습니다
    public DiscordDto.MessageDto createMessage(Exception exception, HttpServletRequest httpServletRequest) {
            return DiscordDto.MessageDto.builder()
                    .content("# 🚨 서버 에러 발생 🚨")
                    .embeds(List.of(DiscordDto.EmbedDto.builder()
                                    .title("에러 정보")
                                    .description("### 에러 발생 시간\n"
                                            + ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH시 mm분 ss초"))
                                            + "\n"
                                            + "### 요청 엔드포인트\n"
                                            + getEndPoint(httpServletRequest)
                                            + "\n"
                                            + "### 요청 클라이언트\n"
                                            + getClient(httpServletRequest)
                                            +"\n"
                                            + "### 에러 스택 트레이스\n"
                                            + "```\n"
                                            + getStackTrace(exception).substring(0, 1000)
                                            + "\n```")
                                    .build()
                            )
                    ).build();
        }
        
        private String getClient(HttpServletRequest httpServletRequest) {
            String ip = httpServletRequest.getRemoteAddr();
    
            Principal principal = httpServletRequest.getUserPrincipal();
            if (principal != null) {
                return "[IP] : " + ip + " / [Id] : " + principal.getName();
            }
            return "[IP] : " + ip;
        }
    
        private String getEndPoint(HttpServletRequest httpServletRequest) {
            String method = httpServletRequest.getMethod();
            String url = httpServletRequest.getRequestURI();
            return method + " " + url;
        }
    
        private String getStackTrace(Exception exception) {
            StringWriter stringWriter = new StringWriter();
            exception.printStackTrace(new PrintWriter(stringWriter));
            return stringWriter.toString();
        }

2-3. DiscordMessageSender

  • discordMessageSender을 통해 util으로 파싱된 메세지를 실제 디스코드 서버로 전송합니다
    • yml에 작성했던 webhook uri를 @Value로 가져옵니다
  • 불필요한 메세지를 방지하기 위해, env profile이 dev 모드일 때만 에러 메세지를 전송합니다
  • webClient를 이용해 메세지를 전송할 것이므로, 부가적인 webClient에 대한 config파일도 작성해주었습니다
@Slf4j
@Component
@RequiredArgsConstructor
public class DiscordAlarmSender {

    private final Environment environment;
    @Value("${logging.discord.web-hook-url}")
    private String webHookUrl;

    private final DiscordUtil discordUtil<;
    private final WebClient webClient = WebClient.create();

    public void sendDiscordAlarm(Exception exception, HttpServletRequest httpServletRequest) {
        if (Arrays.asList(environment.getActiveProfiles()).contains("dev")) {
            log.info("[*] Send Discord Alarm");
            webClient.post()
                    .uri(webHookUrl)
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(discordUtil.createMessage(exception, httpServletRequest))
                    .retrieve()
                    .bodyToMono(Void.class)
                    .block();
        }
    }
}



3. Exception 감지를 위한 AOP 설정

이제 디스코드 알림을 보내기 위한 설정은 다 마쳤습니다
그러나 언제 알림을 보낼지, 언제 sendDiscordAlarm을 보낼지를 결정해야 합니다

500 에러가 발생했을 때만 알림을 전송할 것이므로, 아래 두 가지 방법을 이용할 수 있습니다.

  1. 예외를 처리하는 클래스에서 직접 alarm sender 호출
  2. AOP를 이용해 특정 경우에 alarm sender 호출

저는 여기서 유지보수성과 레이어 구분 관점에 대해 주목했습니다.
위에서 작성한 discordUtil은 HttpServletRequest를 매개변수로 받고 있습니다.

따라서 1번 방법을 채택하면 예외 처리 클래스의 함수들이 HttpServletRequest 타입의 매개변수를 함께 가져야 합니다.

또한, 500에러의 종류는 다양합니다. NPE, 커스텀 예외 코드에서 지정한 500 에러, 정말 순수한 Internal Server Error, Illegal.... 등등 ....
1번 방법에서 이 다양한 예외들을 모두 처리하려면 각 예외를 처리하는 모든 함수에서 alarm sender을 호출해야 하는데, 이 과정이 저희에겐 상당히 귀찮게 느껴졌습니다 ..

그래서 2번 방법을 채택해 특정 함수(500 에러를 처리하는 함수)가 수행될 때 alarm sender을 호출하도록 구현했습니다.

물론 이 방법이 무조건 옳은 것은 아니기 때문에 관점에 따라, 필요성, 기능에 따라 알맞는 방법을 선택하시면 될 것 같습니다.


3-1. PointCut 설정

  • 현재 모든 예외는 GeneralExceptionAdvice에서 종류에 맞는 함수를 통해 처리되고 있습니다
  • 처리하고 싶은 예외를 처리하는 함수들에 대해서 pointcut을 설정해줍니다
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DiscordLoggerAop {

    private final DiscordAlarmSender discordAlarmSender;

    @Pointcut("execution(* corecord.dev.common.exception.GeneralExceptionAdvice.handleGeneralException(..))")
    public void generalExceptionErrorLoggerExecute() {}

    @Pointcut("execution(* corecord.dev.common.exception.GeneralExceptionAdvice.handleException(..))")
    public void serverExceptionErrorLoggerExecute() {}

    @Pointcut("execution(* corecord.dev.common.exception.GeneralExceptionAdvice.handleNullPointerException(..))")
    public void nullPointerExceptionErrorLoggerExecute() {}
  
  ...
  
 }

3-2. @Before 및 알림 전송 케이스 설정

  • 위에서 설정한 pointcut을 이용해 특정 경우에 어떤 행동을 할 지를 명시해줍니다
  • @Before 을 사용해, 예외 처리를 하기 전에 알림을 먼저 보내도록 설정했으며, 이 어노테이션은 기호에 따라 수정할 수 있습니다
  • 직접 커스텀한 예외들인 GeneralExceptionhandleGeneralException(..) 을 통해 처리됩니다
    • 따라서 해당 함수가 실행될 때, 코드가 500이라면 알림을 전송하도록 설정합니다
  • 이외의 서버 측에서 발생한 500 에러는 handleException(..) 또는 다른 함수를 통해 처리되기 때문에 해당 함수가 호출되면 바로 디스코드 알림을 전송합니다
// GeneralException이 발생했을 때
 @Before("generalExceptionErrorLoggerExecute()")
    public void generalExceptionLogging(JoinPoint joinpoint) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        GeneralException exception =  (GeneralException)joinpoint.getArgs()[0];

				// status가 500이면 알림 전송
        if (exception.getErrorStatus().getHttpStatus() == HttpStatus.INTERNAL_SERVER_ERROR)
            discordAlarmSender.sendDiscordAlarm(exception, request);
    }

// 외의 500 서버 에러 혹은 NPE 발생했을 때
    @Before("serverExceptionErrorLoggerExecute() & nullPointerExceptionErrorLoggerExecute()")
    public void serverExceptionLogging(JoinPoint joinpoint) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        Exception exception = (Exception)joinpoint.getArgs()[0];

        discordAlarmSender.sendDiscordAlarm(exception, request);
    }



4. 디스코드 알림 테스트

에러가 발생하면 다음과 같이 디스코드로 예쁘게 알림이 옵니다.
추후 LogBack을 이용해서 알림 연동 기능을 확장해보겠습니다.



참고자료

profile
CS 마스터를 향해 ..

0개의 댓글