취준생을 위한 AI 경험 정리 서비스, MOAMOA 의 개발기입니다.
한국대학생 IT경영학회 큐시즘 30기를 통해 Team 뫄뫄는 MOAMOA라는 서비스를 개발했습니다. 현재 리팩토링 기간을 거친 후 본격적으로 서비스를 운영하고 있습니다.
https://www.moamoa.site
👆모아모아 서비스 링크👆
서비스를 운영하다보면, 운영 서버에서 발생한 예외에 대해 즉각적으로 대처하기 어렵습니다. 어느정도의 모니터링 시스템이 구축되어 있어야 예외에 대한 대처 및 추후 플랜을 성립하기 용이합니다.
따라서, 빠르게 문제를 파악하기 위해 1차적으로 운영 서버에서 예외가 발생하면 디스코드로 알림이 오도록 연결해보았습니다.
연동을 위해서 디스코드의 WebHook 기능을 사용했습니다.
WebHook?
서버에서 어떤 이벤트가 발생했을 때 클라이언트를 호출하는 API (역뱡향 API)
디스코드 채널 생성 - 설정 - 웹후크 만들기
이름 설정 후 생성된 웹후크 URI 복사
logging:
level:
org.springframework.web: DEBUG
discord:
web-hook-url: https://discord.com/api/webhooks/... #${DISCORD_WEBHOOK_URI}
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;
}
}
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();
}
@Value
로 가져옵니다@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();
}
}
}
이제 디스코드 알림을 보내기 위한 설정은 다 마쳤습니다
그러나 언제 알림을 보낼지, 언제 sendDiscordAlarm을 보낼지를 결정해야 합니다
500 에러가 발생했을 때만 알림을 전송할 것이므로, 아래 두 가지 방법을 이용할 수 있습니다.
저는 여기서 유지보수성과 레이어 구분 관점에 대해 주목했습니다.
위에서 작성한 discordUtil은 HttpServletRequest
를 매개변수로 받고 있습니다.
따라서 1번 방법을 채택하면 예외 처리 클래스의 함수들이 HttpServletRequest
타입의 매개변수를 함께 가져야 합니다.
또한, 500에러의 종류는 다양합니다. NPE, 커스텀 예외 코드에서 지정한 500 에러, 정말 순수한 Internal Server Error, Illegal.... 등등 ....
1번 방법에서 이 다양한 예외들을 모두 처리하려면 각 예외를 처리하는 모든 함수에서 alarm sender을 호출해야 하는데, 이 과정이 저희에겐 상당히 귀찮게 느껴졌습니다 ..
그래서 2번 방법을 채택해 특정 함수(500 에러를 처리하는 함수)가 수행될 때 alarm sender을 호출하도록 구현했습니다.
물론 이 방법이 무조건 옳은 것은 아니기 때문에 관점에 따라, 필요성, 기능에 따라 알맞는 방법을 선택하시면 될 것 같습니다.
GeneralExceptionAdvice
에서 종류에 맞는 함수를 통해 처리되고 있습니다@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() {}
...
}
@Before
을 사용해, 예외 처리를 하기 전에 알림을 먼저 보내도록 설정했으며, 이 어노테이션은 기호에 따라 수정할 수 있습니다GeneralException
은 handleGeneralException(..)
을 통해 처리됩니다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);
}
에러가 발생하면 다음과 같이 디스코드로 예쁘게 알림이 옵니다.
추후 LogBack을 이용해서 알림 연동 기능을 확장해보겠습니다.