Spring Boot - 서버 내부 에러발생 시 Discord 알림 설정

도비·2023년 12월 19일
2

Spring Boot

목록 보기
10/13
post-thumbnail

ASAP 프로젝트에서는 Slack Webhook과 연동해서 예외 처리를 해놓지 않은 서버 내부 오류 발생 시 Slack에 알림을 보내도록 설정했었다.
코드는 👉 참고

그런데 슬랙은 평가판 90일이 지나면 오래된 메시지는 볼 수 없어서 났었던 에러 사항을 볼 수 없다는 점이 아쉬웠다.

새롭게 들어가는 프로젝트에서는 디스코드에 에러 사항을 로깅하기로 결정했고, 그에 대한 글을 남겨보려 한다.

여기서 잠깐, 왜 채널에 에러를 로깅해야하는데요?

만약에 서버를 로컬에서 돌리고 있다고 생각해보자. 그러면 우리는 콘솔에서 바로 예외를 확인할 수 있을 것이다.
그런데 가상 머신에 서버를 nohup 명령어를 사용해 배포를 해두었다면, 우리는 매번 에러가 발생할 때마다 nohup.out 파일에 들어가서 에러 로그를 확인해야 할 것이다.
그래서 에러 로깅을 할 때 서버 내에 로깅 파일을 두어 확인하거나, 슬랙이나 디스코드 채널을 통해 더 읽기 쉽게 관리한다.

Discord Webhook 설정

디스코드 서버에 들어가 채널을 만들면, 채널 옆 설정 아이콘을 누른다.

설정에 들어간 후

연동 창에서 웹후크를 만든다. (이름은 자유)

Spring Discord Appender 설정

build.gradle 파일 설정

repositories {
    mavenCentral()
    maven { url 'https://jitpack.io' }
}
dependencies {
... 생략... 
	implementation('com.github.napstr:logback-discord-appender:1.0.0')
}

의존성을 위처럼 추가해준 후 application.yml 을 설정해준다.

application.yml 파일 설정

logging:
    discord:
      webhook-uri: https://discord.com/api/webhooks/1186554772329857085/NiDo5TL7FLdsp9FOurVGp-cqczLSi6QPbBM8X9Z6zY6_nFfKqHfgeztIosCCMxTIhgwH
    config: classpath:logback-spring.xml

LoggingAspect.java

기존 로깅 관련 글에서 작성했던 LoggingAspect 클래스이다.

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Pointcut("execution(* com.laboratory.auth.controller..*(..)) || ( execution(* com.laboratory.auth.common.advice..*(..)) && !execution(* com.laboratory.auth.common.advice.ControllerExceptionAdvice.handleException*(..)))")
    public void controllerInfoLevelExecute() {
    }

    @Pointcut("execution(* com.laboratory.auth.common.advice.ControllerExceptionAdvice.handleException*(..))")
    public void controllerErrorLevelExecute() {
    }

    @Around("com.laboratory.auth.common.aspect.LoggingAspect.controllerInfoLevelExecute()")
    public Object requestInfoLevelLogging(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;
        long startAt = System.currentTimeMillis();
        Object returnValue = proceedingJoinPoint.proceed(proceedingJoinPoint.getArgs());
        long endAt = System.currentTimeMillis();

        log.info("================================================NEW===============================================");
        log.info("====> Request: {} {} ({}ms)\n *Header = {}", request.getMethod(), request.getRequestURL(), endAt - startAt, getHeaders(request));
        if ("POST".equalsIgnoreCase(request.getMethod())) {
            log.info("====> Body: {}", objectMapper.readTree(cachingRequest.getContentAsByteArray()));
        }
        if (returnValue != null) {
            log.info("====> Response: {}", returnValue);
        }
        log.info("================================================END===============================================");
        return returnValue;
    }

    @Around("com.laboratory.auth.common.aspect.LoggingAspect.controllerErrorLevelExecute()")
    public Object requestErrorLevelLogging(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;
        long startAt = System.currentTimeMillis();
        Object returnValue = proceedingJoinPoint.proceed(proceedingJoinPoint.getArgs());
        long endAt = System.currentTimeMillis();

        log.error("====> Request: {} {} ({}ms)\n *Header = {}", request.getMethod(), request.getRequestURL(), endAt - startAt, getHeaders(request));
        if ("POST".equalsIgnoreCase(request.getMethod())) {
            log.error("====> Body: {}", objectMapper.readTree(cachingRequest.getContentAsByteArray()));
        }
        if (returnValue != null) {
            log.error("====> Response: {}", returnValue);
        }
        log.error("================================================END===============================================");
        return returnValue;
    }

    private Map<String, Object> getHeaders(HttpServletRequest request) {
        Map<String, Object> headerMap = new HashMap<>();

        Enumeration<String> headerArray = request.getHeaderNames();
        while (headerArray.hasMoreElements()) {
            String headerName = headerArray.nextElement();
            headerMap.put(headerName, request.getHeader(headerName));
        }
        return headerMap;
    }
}

이 LoggingAspect 파일을 통해서 로깅 상황과, 로깅 방식, 로깅 포맷을 작성해주었다. (이전 블로그 글에 리퀘스트를 Wrapping 하는 내용이 담겨있으니 참고하세요!)

이제 기존에 사용하던 logback-spring.xml 파일에서 discord-appender를 사용해야 한다.

logback-spring.xml

<configuration>
    <property name="LOG_PATTERN"
              value="[%d{yyyy-MM-dd HH:mm:ss}:%-4relative] %green([%thread]) %highlight(%-5level) %boldWhite([%C.%M:%yellow(%L)]) - %msg%n"/>
    <springProperty name="DISCORD_WEBHOOK_URI" source="logging.discord.webhook-uri"/>
    <springProfile name="local">
        <include resource="console-appender.xml"/>
        <include resource="discord-appender.xml"/>
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="ASYNC_DISCORD" />
        </root>
    </springProfile>
</configuration>

기존에 작성했던 logback-spring.xml에서 discord-appender.xml을 include한다.

<include resource="discord-appender.xml"/>
<!-- 또 아래처럼 appender-ref에 discord-appender에 지정해준 appender를 넣어줘야한다. -->
<appender-ref ref="ASYNC_DISCORD" />

discord-appender.xml

<included>
    <appender name="DISCORD" class="com.github.napstr.logback.DiscordAppender">
        <webhookUri>${DISCORD_WEBHOOK_URI}</webhookUri>
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS}  %green([%thread]) %highlight(%-5level) %boldWhite([%C.%M:%yellow(%L)]) - %msg%n</pattern>
        </layout>
        <username>에러났대...</username>
        <avatarUrl>https://www.greenart.co.kr/upimage/new_editor/20212/20210201112021.jpg</avatarUrl>
        <tts>false</tts>
    </appender>
    <appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="DISCORD" />
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>
</included>

이렇게 xml 파일은 모두 끝났다. 마지막으로 컨트롤러에 RuntimeException을 던지는 테스트 메서드를 만들고 테스트해보자!

테스트

ErrorLoggingTestController.java

@RestController
@RequestMapping("/test")
public class ErrorLoggingTestController {

    @GetMapping("")
    public void testThrowInternalServerError() {
        throw new RuntimeException();
    }
}

그리고 RestContollerAdvice를 통해 에러를 잡을 때 다음과 같이 처리해주면,

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class ControllerExceptionAdvice {
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    protected ErrorResponse handleException(final Exception error, final HttpServletRequest request) throws IOException {
        log.error("================================================NEW===============================================");
        log.error(error.getMessage(), error);
        return ErrorResponse.of(ErrorMessage.INTERNAL_SERVER_ERROR);
    }

}

/test api를 호출하면 아래와 같이 console에도 에러가 나오고, 디스코드에도 에러가 전달되는 것을 볼 수 있다.!

콘솔

디스코드 알림

마무리

이렇게 서버 에러 로깅을 디스코드에서 해봤다.
기존에 구현했던 파일로 저장하는 방식보다 간편하고 빠르게 확인해볼 수 있어졌다!
다음은 회원 가입같은 이벤트 발생 시 디스코드에 알림을 보내는 것을 구현해보겠다!🔥

profile
하루에 한 걸음씩

0개의 댓글