지난 글에 이은,
본격적으로 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
🚀 슬랙 알림 구현하기
📝 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 로 감싸주어 전달하는 것이다.
이렇게 되면 원하는 처리가 이루어지게 된다.
'🚀 프로젝트 > 🥁 두둥' 카테고리의 다른 글
[DuDoong] 두둥 프로젝트 관련 글 정리 (0) | 2023.03.15 |
---|---|
[Spring] 스프링 Slack 메세지 전송하기 (Incoming WebHooks 활용하여 슬랙봇 만들기) (0) | 2023.03.03 |
[Spring] 스프링 날짜 타입 JSON 변환 및 포맷팅하기 - @JsonFormat, @JacksonAnnotationsInside (0) | 2023.02.27 |
[Spring] 스프링 Custom Enum Deserializer 구현으로 JSON Enum null 로 파싱하기 (0) | 2023.02.21 |
[Spring] 스프링 Enum Validator Reflection 으로 개선 및 구현하기 (0) | 2023.02.21 |