이번에 시작하게 된 TIFY 프로젝트에서 서버 내부 오류(500 에러)가 발생하면 팀 슬랙에 위와 같이 알림이 오도록 구현하게 되었다.
로컬 서버에서 일어나는 에러는 탐지하기가 쉬우나,
배포 중인 서버에서 일어나는 에러는 탐지하기 어렵기 때문에
500에러를 팀 슬랙에 나타나게 한다면 개발이 쉬워질 것이라 생각하여 알림 기능을 구현하였다.
코드는 아래 Github PR에 자세히 나와있다.
feat: 서버 에러 Slack 알림 기능 추가 #7 by bongsh0112 · Pull Request #8 · Team-TIFY/TIFY-SERVER
📝 PR Summary close #7 🌲 Working Branch 🌲 TODOs Related Issues #7
github.com
아직은 CI/CD 파이프라인 구축과 약간의 도메인 설계만 해놓은 상태이고
웹훅 테스트만 하는 환경을 구축했기 때문에 yml 설정이나 .env 파일의 상태는 완벽하진 않다.
0) build.gradle 설정
dependencies {
api("com.slack.api:slack-api-client:1.27.2")
}
Infrastructure 모듈의 build.gradle에 위와 같은 의존성을 넣어준다.
그렇다고 한다..
아래 그림(우리 모듈 구조와는 약간 다르지만)에 나와있는 멀티 모듈에서의 API 레이어가 모든 레이어를 참조하는 특성 때문인지..
그렇다고 한다.. 가 아니라
Api 모듈은 build.gradle에서 project(:Infrastructure)와 같이 Infrastructure 모듈을 의존하고 있다.
Infrastructure 모듈의 build.gradle에서 api로 의존성을 추가했기 때문에
Infrastructure 모듈을 의존하는 상위 모듈에 slack 의존성이 모두 추가된다.
이는 build.gradle의 implementation과 api의 차이점을 알고 있으면 쉽다,,
0.5) Slack 봇 만들고 팀 Slack에 추가하기
이제부터 Slack 봇을 만들고 팀 Slack에 추가해보자.
이를 위해서는 워크스페이스와 봇이 만들어져야 하는데, 아래 더보기에 방법을 기입해놓았다.
1. 워크스페이스 생성
Slack API: Applications | Slack
Your Apps Don't see an app you're looking for? Sign in to another workspace.
api.slack.com
![](https://blog.kakaocdn.net/dn/OPEFk/btsmtD2V1AP/slBtNzohZ89ne4kYv8YY00/img.png)
나같은 경우 이미 앱을 만들어놨기 때문에 앱이 존재하는 것이고, Create New App을 눌러 새 앱을 만들 수 있다.
새로 만들 봇의 이름은 slacksupervisor로 지정하겠다.
![](https://blog.kakaocdn.net/dn/2nh9u/btsmsbToW6a/F7B9SiAakKqLe3JJgZCgn0/img.png)
From scratch로 생성해야 Slack에서 디폴트로 주는 세팅을 쓸 수 있다.
app manifest 선택 시 yml, json같은 파일을 직접 수정해야 한다.
2. Bot 유저 추가
![](https://blog.kakaocdn.net/dn/bUmpc5/btsmnCLBrZj/Lix2G60yTmW8wCWgPaWoYk/img.png)
위 화면에서 Bots를 선택하고 좌측 메뉴바에서 OAuth & Permissions에 들어간다.
3. 봇 Scope 지정
![](https://blog.kakaocdn.net/dn/JWQ3h/btsmrXHSH38/UiNJDh83Cxfne1xxlbpwW0/img.png)
Add an OAuth Scope를 누르고 chat:write, app_mentions:read를 추가해준다.
Slack Bot이 수행할 수 있는 기능을 제한할 수 있다.
4. OAuth Token 발급
이후 OAuth Tokens for Your Workspace에서 Install to Workspace를 누르고
![](https://blog.kakaocdn.net/dn/vyGBM/btsmnQbMoYa/kZbtjcKfGLk2I8moHzBnJ1/img.png)
허용을 눌러주면
![](https://blog.kakaocdn.net/dn/cnmhzT/btsmupXF5qn/DeFfGu19D0QokWh8LCKpk0/img.png)
위와 같이 Token이 발급된다.
Slack OAuth Token의 경우 xoxb- 로 시작한다.
5. 워크스페이스에 봇 유저 추가
위 과정을 모두 거쳤다면 Slack 워크스페이스에서 봇을 추가할 채널에 앱을 추가하자.
채널의 세부정보에서
![](https://blog.kakaocdn.net/dn/ckNv5d/btsmsz05HqC/7VgVwci3cCkwWRo372v8W0/img.png)
앱 추가를 해주자.
![](https://blog.kakaocdn.net/dn/vAgmi/btsmnCY982q/aiK6wwM4ZQJOisO8lE4Xv0/img.png)
채널에 앱이 추가되었다고 로그가 뜬다면 성공이다.
1) Slack API 사용을 위한 설정
@Configuration
public class SlackApiConfig {
@Value("${SLACK_TOKEN}")
private String token;
@Bean
public MethodsClient getClient() {
Slack slackClient = Slack.getInstance();
return slackClient.methods(token);
}
}
위에서 생성한 Slack 봇을 사용하기 위한 설정이다.
MethodClient Bean에 Slack 웹훅 토큰을 등록하는 과정을 @Value("${SLACK_TOKEN}")
으로 받아온다.
코드 리뷰를 받기 전에는 발급받은 토큰을 어디에 기입하며 어디에서 가져오는지 알지 못했으나,
application-infrastructure.yml 파일에 똑같은 형식의 값이 있는 것을 들었고
yml 파일에서 가져오는 것임을 알게 되었다.
slack:
webhook:
token: ${SLACK_TOKEN:}
id: ${SLACK_CHANNEL_ID:}
service-alarm-channel: ${SLACK_CHANNEL_ID:}
토큰이나 Slack 채널 ID 같은 것들은 yml 파일에 위와 같이 기입한다.
그렇다면 yml 파일에 들어가는 값은 어디에서 가져오는 것인가?
서버에 올라갈 .env 파일에서 가져올 수 있다.
환경변수를 기입하는 .env 파일에는 REDIS_HOST와 같은 host나 REDIS_PORT와 같은 port도 작성할 수 있다.
아직 많이 공부해보지 못하여 확실하진 않으나,
yml 파일에서 .env 파일을 보고 변수를 갖고오게 설정을 하는 방법을 사용한 듯 하다.
이는 SpringBoot로 API 어플리케이션을 실행할 시
.env 파일을 주입해주기 때문에 가능한 방법이다.
따라서 .env 파일에 아까 받아온 토큰과 채널 ID를 넣어준다.
2) Slack 알림 인프라 구축
Slack API를 이용하여
알림이 들어갈 채널, 알림의 송수신 환경 설정, 나타날 알림의 길이 제한 등을 구현한다.
Web API methods | Slack
Automating Slack with workflows Customizing Slack with apps
api.slack.com
주 비즈니스 로직이 아니라 Slack 알림을 구현하는 것이기 때문에 Infrastructure 모듈에 작성한다.
2-1) 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.isProdProfile()) {
// 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는 Core 모듈에 구현된 커스텀 유틸 클래스이다.
현재 프로파일을 가져올 수 있는 클래스로,
프로파일 환경에 따라 Slack 알림 여부를 결정할 수 있게 한다.
MethodClient 인터페이스의 ChatPostMessage를 이용하여 실제 Slack 알림 메세지를 구현한다.
ChatPostMessageResponse chatPostMessage(ChatPostMessageRequest req) throws IOException, SlackApiException;
ChatPostMessageResponse chatPostMessage(RequestConfigurator<ChatPostMessageRequest.ChatPostMessageRequestBuilder> req) throws IOException, SlackApiException;
2-2) SlackErrorNotificationProvider
@Component
@RequiredArgsConstructor
@Slf4j
public class SlackErrorNotificationProvider {
private final SlackHelper slackHelper;
private final int MAX_LEN = 500;
@Value("${SLACK_CHANNEL_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);
}
}
이 클래스에서는 서버 에러를 나타낼 때에 길이를 제한하고
application.yml 파일에서 채널 아이디를 가져와
SlackHelper 객체를 이용하여 아이디에 해당하는 채널에 알림을 전송하는 메소드를 호출한다.
3) Slack 알림 구현
Slack에 나타날 실제 알림을 구현한다.
500 에러를 파싱하여 알림이 언제, 어떻게 나타날지 구현한다.
서버 에러 발생 시의 url과 HttpMethod, RequestBody 등을 이용하여 Slack에 알림을 보내기 때문에
API 모듈에 작성하였다.
3-1) SlackInternalErrorSender
@Component
@RequiredArgsConstructor
@Slf4j
public class SlackInternalErrorSender {
private final ObjectMapper objectMapper;
private final SlackErrorNotificationProvider slackErrorNotificationProvider;
public void execute(ContentCachingRequestWrapper cachingRequest, Exception e, Long userId)
throws Exception {
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 = slackErrorNotificationProvider.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()); // Slack API에서 제공하는 단락 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))));
slackErrorNotificationProvider.sendNotification(layoutBlocks);
}
}
이 클래스에서는 500에러 발생 시의 url, HttpMethod, RequestBody를 받고
실제로 Slack 화면에 나타날 에러를 마크다운 형식으로 build하여
SlackErrorNotificationProvider의 sendNotification 함수에 layoutBlocks으로 넘겨준다.
중요한 것은 ContentCachingRequestWrapper인데, 아래 더보기는 이에 대한 설명이다.
ContentCachingRequestWrapper는 HttpServletRequestWrapper를 상속받은 클래스이다.
HttpServletRequest를 직접 쓰지 않고
ContentCachingRequestWrapper로 한번 감싼 후 사용한 이유는
스프링에서 HttpServletRequest의 body를 한 번 사용하게 되면 다시 볼 수 없기 때문이다.
왜 body를 다시 볼 수 없을까?
스프링의 기본 정책 자체가 한번 본 body를
다시 확인할 수 없게 하기 때문이다.
그렇다면 request의 body는 언제 제일 먼저 확인될까?
HttpServletRequest/Response는 기본적으로
InputStream과 OutputStream으로 구현되어있다.
따라서 getInputStream(), OutputStream()으로 확인이 가능한데
클라이언트가 요청을 보내오면 디스패처 서블릿에서 제일 먼저 확인하고,
디스패처 서블릿에서 공통 작업을 수행한 이후에
요청을 어떤 컨트롤러에서 수행할지를 찾아(Handler Mapping) 그 컨트롤러에 요청을 위임(Handler Adaptor)할 때
클라이언트의 요청을 컨트롤러의 객체로 바인딩하는 과정(Interceptor ~ Handler Adaptor 과정)에서
Interceptor가 HttpServletRequest의 body를 getInputStream()으로 확인한다.
이 문제를 해결하기 위해 아래 docs를 참고하면 좋다.
ContentCachingRequestWrapper (Spring Framework 6.0.10 API)
handleContentOverflow protected void handleContentOverflow(int contentCacheLimit) Template method for handling a content overflow: specifically, a request body being read that exceeds the specified content cache limit. The default implementation is empt
docs.spring.io
HttpServletRequest의 body를 어딘가에 저장하고 계속 열람하기 위해
ContentCachingRequestWrapper에 복사해놓고 사용하는 것이다.
이 방법을 nginx를 이용한 프록시 환경에서 사용하기 위해서는
ForwardedHeaderFilter와 HttpContentCacheFilter의 순서를 조정해주어야 하는데
아직 로컬에서 개발하는 단계이므로 시도해보지 않았다.
[스프링 프록시 환경에서 HttpContentCache 적용하기] 블로그를 참고하여
나중에 시도해보자.
3-2) GlobalExceptionHandler
@RestControllerAdvice
@Slf4j
@RequiredArgsConstructor
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private final SlackInternalErrorSender slackInternalErrorSender;
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> internalServerExceptionHandle(
Exception e, HttpServletRequest req) throws Exception {
final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) req;
final Long userId = SecurityUtils.getCurrentUserId();
String url =
UriComponentsBuilder.fromHttpRequest(new ServletServerHttpRequest(req))
.build()
.toUriString();
log.error(
"서버 내부 오류 발생: {} {} errMessage={} \n detail={}\n",
req.getMethod(),
req.getRequestURI(),
e.getMessage(),
e.getCause());
GlobalException internalServerError = GlobalException.INTERNAL_SERVER_ERROR; // 추후 에러 추가하여 수정
ErrorResponse errorResponse = new ErrorResponse(internalServerError.getErrorDetail());
slackInternalErrorSender.execute(cachingRequest, e, userId);
return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatusCode()))
.body(errorResponse);
}
}
500 에러를 잡아주는 internelServerExceptionHandle 메소드를 GlobalExceptionHandler에 추가했다.
여기에서 ContentCachingRequestWrapper와 같이 Request를 사용하게 된 이유는
GlobalException internalServerError = GlobalException.INTERNAL_SERVER_ERROR;
어떤 요청이던지 모두 500 에러로 강제해주는 이 코드 덕분(?)이다.
Error Handling에서
Response가 아닌 Request를 사용하는 것과
500 에러 강제가 무슨 관련이 있나?
현재 구현한 Slack 에러 알림은 500 에러만 나타낸다.
위 코드의 internalServerExceptionHandle()은 이를 위해 구현한 메소드이다.
Controller로부터 RuntimeException과 같은 에러가 발생하면
internalServerExceptionHandle() 메소드가 실행되고,
internalServerError 객체는 GlobalException의 'INTERNAL_SERVER_ERROR'로 강제(?) 설정된다.
이렇게 되면 어떤 request가 들어오던지 모두 500 에러로 강제된다.
따라서 response로 나타내는 에러에 대해
분류하지 않아도 request 차원에서 이미 500 에러로 분류되어 있으므로
500 에러만 다루는 메소드가 되는 것이다.
Core 모듈의 GlobalException에 5xx 에러가 지금은 두개지만
나중에 더 에러를 추가하게 된다면 다른 5xx 에러는 다른 방식으로 처리해야 할 것 같다.
참고) [스프링 500 에러 발생 시 Slack 알림 전송하기], [두둥 프로젝트], [스프링 프록시 환경에서 HttpContentCache 적용하기]
'🎉 프로젝트 > 🎁 TIFY' 카테고리의 다른 글
[TIFY] 검색 기능 성능 개선 with DB index (2) | 2024.01.28 |
---|---|
[TIFY] Apple Login 구현 시 OIDC 사용하기 (1) | 2024.01.16 |
[TIFY] AWS S3 생성 및 Presigned URL 도입 (0) | 2024.01.15 |
[TIFY] Selenium을 이용한 올리브영 크롤링 in SpringBoot (0) | 2023.08.23 |