🚀 프로젝트/🥁 두둥

[Spring] 스프링 500 에러 발생 시 Slack 알림 전송하기 (슬랙 봇)

gengminy 2023. 3. 15. 23:21

 

500 에러 발생시 메세지를 보내주는 슬랙 봇 2

 

지난 글에 이은,

본격적으로 500 내부 서버 에러 발생 시

슬랙으로 알림을 전송해주는 봇을 만들어보는 글이다.

 

Slack API 를 활용하여 이러한 알림 서비스를 구현할 수 있다.

 

 

⚙️ Dependency

implementation 'com.slack.api:slack-api-client:1.27.2'

다음과 같은 의존성을 build.gradle 에 추가해준다.

 

 

🚀 Slack API 사용하기 위한 설정

📝 SlackApiConfig

@Configuration
public class SlackApiConfig {

    @Value("${slack.webhook.token}")
    private String token;

    @Bean
    public MethodsClient getClient() {
        Slack slackClient = Slack.getInstance();
        return slackClient.methods(token);
    }
}

우선 지속적으로 알림을 보내는 봇으로 만들고자 슬랙 봇을 추가했다.

슬랙 봇을 사용할 때는 슬랙 관련 설정이 필요하다.

 

MethodClient 빈에 슬랙 웹훅 토큰을 등록하는 과정이 필요한데,

슬랙 웹훅 토큰(OAuth Token)은 xoxb- 로 시작하는 문자열이다.

 

자세한 발급 과정은 추가 게시글에 올려두었다.

https://gengminy.tistory.com/52

 

[Slack] 슬랙 봇 설정 및 슬랙 OAuth Token 발급받기

스프링에서 Slack API 를 사용하기 위해서는 슬랙 웹훅 토큰이 필요하다. 따라서 슬랙 토큰을 발급받고 슬랙 봇을 등록하는 과정을 알아보자. 1. 워크스페이스 및 슬랙 APP 생성 https://api.slack.com/apps

gengminy.tistory.com

 

 

🚀 슬랙 알림 구현하기

📝 SlackHelper

@Component
@RequiredArgsConstructor
@Slf4j
public class SlackHelper {
    private final SpringEnvironmentHelper springEnvironmentHelper;

    private final MethodsClient methodsClient;

    public void sendNotification(String CHANNEL_ID, List<LayoutBlock> layoutBlocks) {
        if (!springEnvironmentHelper.isProdAndStagingProfile()) {
            return;
        }
        ChatPostMessageRequest chatPostMessageRequest =
                ChatPostMessageRequest.builder()
                        .channel(CHANNEL_ID)
                        .text("")
                        .blocks(layoutBlocks)
                        .build();
        try {
            methodsClient.chatPostMessage(chatPostMessageRequest);
        } catch (SlackApiException | IOException slackApiException) {
            log.error(slackApiException.toString());
        }
    }
}

SpringEnvironmentHelper 는 현재 프로파일을 가져오도록 만든

커스텀 유틸 클래스이다.

Prod 환경일 때만 Slack 알림을 보여주기 위해 도입했다.

이 클래스에서는 실제 Slack API 를 호출하여 알림을 전송한다.

 

ChatPostMessageRequest 인스턴스를 생성하여

methodsClient.chatPostMessage 의 인자로 넣고 호출하면

슬랙 메세지가 전송된다.

 

 

📝 SlackErrorNotificationProvider

@Component
@RequiredArgsConstructor
@Slf4j
public class SlackErrorNotificationProvider {

    private final SlackHelper slackHelper;

    private final int MAX_LEN = 500;

    @Value("${slack.webhook.id}")
    private String CHANNEL_ID;

    public String getErrorStack(Throwable throwable) {
        String exceptionAsString = Arrays.toString(throwable.getStackTrace());
        int cutLength = Math.min(exceptionAsString.length(), MAX_LEN);
        return exceptionAsString.substring(0, cutLength);
    }

    @Async
    public void sendNotification(List<LayoutBlock> layoutBlocks) {
        slackHelper.sendNotification(CHANNEL_ID, layoutBlocks);
    }
}

워크스페이스의 채널 ID를 설정하고 전송 메소드를 호출하는 클래스

 

getErrorStack 는 해당 에러의 StackTrace 를 꺼내어

최대 길이를 제한해주는 유틸 메소드이다.

 

 

📝 SlackInternalErrorSender

@Component
@RequiredArgsConstructor
@Slf4j
public class SlackInternalErrorSender {
    private final ObjectMapper objectMapper;

    private final SlackErrorNotificationProvider slackProvider;

    public void execute(ContentCachingRequestWrapper cachingRequest, Exception e, Long userId)
            throws IOException {
        final String url = cachingRequest.getRequestURL().toString();
        final String method = cachingRequest.getMethod();
        final String body =
                objectMapper.readTree(cachingRequest.getContentAsByteArray()).toString();

        final String errorMessage = e.getMessage();
        String errorStack = slackProvider.getErrorStack(e);
        final String errorUserIP = cachingRequest.getRemoteAddr();

        List<LayoutBlock> layoutBlocks = new ArrayList<>();
        layoutBlocks.add(
                Blocks.header(
                        headerBlockBuilder ->
                                headerBlockBuilder.text(plainText("Error Detection"))));
        layoutBlocks.add(divider());

        MarkdownTextObject errorUserIdMarkdown =
                MarkdownTextObject.builder().text("* User Id :*\n" + userId).build();
        MarkdownTextObject errorUserIpMarkdown =
                MarkdownTextObject.builder().text("* User IP :*\n" + errorUserIP).build();
        layoutBlocks.add(
                section(
                        section ->
                                section.fields(List.of(errorUserIdMarkdown, errorUserIpMarkdown))));

        MarkdownTextObject methodMarkdown =
                MarkdownTextObject.builder()
                        .text("* Request Addr :*\n" + method + " : " + url)
                        .build();
        MarkdownTextObject bodyMarkdown =
                MarkdownTextObject.builder().text("* Request Body :*\n" + body).build();
        List<TextObject> fields = List.of(methodMarkdown, bodyMarkdown);
        layoutBlocks.add(section(section -> section.fields(fields)));

        layoutBlocks.add(divider());

        MarkdownTextObject errorNameMarkdown =
                MarkdownTextObject.builder().text("* Message :*\n" + errorMessage).build();
        MarkdownTextObject errorStackMarkdown =
                MarkdownTextObject.builder().text("* Stack Trace :*\n" + errorStack).build();
        layoutBlocks.add(
                section(section -> section.fields(List.of(errorNameMarkdown, errorStackMarkdown))));

        slackProvider.sendNotification(layoutBlocks);
    }
}

본격적으로 내부 에러를 처리하기 위한 클래스이다.

 

슬랙 메세지에는 마크다운 언어를 지원하기 때문에

커스텀하여 메세지 형식을 바꿔서 예쁘게 보여줄 수 있다.

 

마크다운 오브젝트는 Slack API 패키지에 포함되어 있어 즉시 사용 가능하다.

 

여기서 주목해야 할 점은 ContentCachingRequestWrapper 인데 후술하겠다.

 

 

📝 GlobalExceptionHandler

@RestControllerAdvice
@Slf4j
@RequiredArgsConstructor
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    private final SlackInternalErrorSender slackInternalErrorSender;
    
    //...(생략)
    
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e, HttpServletRequest request)
            throws IOException {
        final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;
        final Long userId = SecurityUtils.getCurrentUserId();
        String url =
                UriComponentsBuilder.fromHttpRequest(new ServletServerHttpRequest(request))
                        .build()
                        .toUriString();

        log.error("INTERNAL_SERVER_ERROR", e);
        GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR;
        ErrorResponse errorResponse =
                new ErrorResponse(
                        internalServerError.getStatus(),
                        internalServerError.getCode(),
                        internalServerError.getReason(),
                        url);

        slackInternalErrorSender.execute(cachingRequest, e, userId);
        return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus()))
                .body(errorResponse);
    }
}

최종 목표인 내부 서버 에러에 대해 처리하는

GlobalExceptionHandler 이다.

앞서 비즈니스 익셉션 등의 처리에 걸러지지 못하고 남은 애들은 이곳으로 온다.

 

여기서 HttpServletRequest 를 즉시 사용하지 않고

ContentCachingRequestWrapper 로 감싸주는 형태를 사용하게 된다.

 

httpServletRequest의 내용을 가져오기 위해 getInputStream() 을 사용해야하는데,

한번 사용하게 될 경우 내용이 사라지게 된다.

그래서 java.lang.IllegalStateException: getReader() has already been called for this request 에러가 발생한다.

그렇기 때문에 ContentCachingRequestWrapper 로 감싸주어 전달하는 것이다.

 

이렇게 되면 원하는 처리가 이루어지게 된다.