👣 들어가기 전에
회사 프로젝트가 슬슬 외부로 나갈 준비(CBT)를 하면서 각종 모니터링 툴을 붙여야 할 때가 왔다.
그동안 테크 블로그나 유튜브에서 프로메테우스
, 그라파나
, Sentry
, Pinpoint
등등등 키워드들을 많이 들어봤지만
실제로 사용해본 적이 없어서 수많은 툴 중에 무엇을 골라야 할지 막막하기도 하고
서비스를 운영해본 경험도 없는데 어디서부터 뭘 해야할지 살짝 막막하긴 하다.
일단 하나씩 차근차근 해보면서 하나씩 순서대로 기록해보려고 한다.
그동안 개발하면서 불편하다고 느낀 것 중 하나가 서버에서 에러가 발생했을 때 직접 도커 로그를 확인해보지 않으면
언제 에러가 발생한지 모른다는 것이었다.
나중에는 Logback
을 통해 파일로도 저장할 계획이지만
일단은 모니터링에 대한 첫번째 스텝으로 서버에 에러가 발생하면 바로 인지할 수 있게Discord
에 알림을 발송하는 기능을 만들어 보려고 한다.
💬 예외 발생 시 Discord로 메시지 발송
구현을 다 해보고 나니 어려운 작업은 아니다.
프론트에서 백엔드로 API
요청하는 과정과 똑같다.
Discord
메시지 스펙에 맞게 객체(메시지)를 만들어주고,
발급한 Discord
웹훅으로 해당 객체를 Body
에 담아 POST
로 요청하면 끝이다.
이 기능을 만들기 위해 필요한 작업은 다음과 같다.
Discord
웹훅 발급- 디스코드 메시지 발송에 필요한
Body
생성 API
를 요청할 수 있는Client
설정- 예외가 발생하면
GlobalExceptionHandler
에서 알림을 발송하는 기능 구현
본문은 Discord
채널이 있고 웹훅을 발급받았다는 가정하에 진행되기 때문에 2번 내용부터 정리를 하려고 한다.
2. Body 만들기
2.1 Body 스펙 파악하기
Body 를 보내려면 당연하게도 디스코드에서 원하는 스펙을 알아야 한다.
스펙은 아래 링크에서 확인 가능하다.
Discord Webhooks Guide: https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html
해당 문서에 있는 Body
예시는 다음과 같다.
예시
{
"username": "Webhook",
"avatar_url": "<https://i.imgur.com/4M34hi2.png>",
"content": "Text message. Up to 2000 characters.",
"embeds": [
{
"author": {
"name": "Birdie♫",
"url": "<https://www.reddit.com/r/cats/>",
"icon_url": "<https://i.imgur.com/R66g1Pe.jpg>"
},
"title": "Title",
"url": "<https://google.com/>",
"description": "Text message. You can use Markdown here. *Italic* **bold** __underline__ ~~strikeout~~ [hyperlink](<https://google.com>) `code`",
"color": 15258703,
"fields": [
{
"name": "Text",
"value": "More text",
"inline": true
},
{
"name": "Even more text",
"value": "Yup",
"inline": true
},
{
"name": "Use `\\"inline\\": true` parameter, if you want to display fields in the same line.",
"value": "okay..."
},
{
"name": "Thanks!",
"value": "You're welcome :wink:"
}
],
"thumbnail": {
"url": "<https://upload.wikimedia.org/wikipedia/commons/3/38/4-Nature-Wallpapers-2014-1_ukaavUI.jpg>"
},
"image": {
"url": "<https://upload.wikimedia.org/wikipedia/commons/5/5a/A_picture_from_China_every_day_108.jpg>"
},
"footer": {
"text": "Woah! So cool! :smirk:",
"icon_url": "<https://i.imgur.com/fKL31aD.jpg>"
}
}
]
}
각 필드가 어떻게 표시되는지는 문서에도 잘 나와있고
글로보는 것보다는 눈으로 확인하는 게 이해가 더 빨라서 필요하다면 하나씩 직접 쏴보면서 확인해보는 것도 좋을 것 같다.
해당 스펙에서 현재 프로젝트에서 필요한 정보만 추리니 이러한 JSON
형태가 되었다.
실제로 필요한 JSON
데이터
{
"content": "Text message. Up to 2000 characters.",
"embeds": [
{
"title": "Title",
"fields": [
{
"name": "Text",
"value": "More text",
},
{
"name": "Even more text",
"value": "Yup"
}
]
}
]
}
2.2 Body 스펙에 맞는 Dto 만들기
다른 블로그를 참고해보니 List<Map<String, String>>
형태로 embeds
를 만들어서 바로 보내는 것도 보았는데,
디스코드 알림은 다른 기능에서도 사용할 계획이 있어서 여러 곳에서도 활용할 수 있게
위 JSON
스펙에 맞게 record
클래스를 작성해주었다.
DiscordEmbedMessge.java
@Builder
public record DiscordEmbedMessage(
String content,
@Singular
List<Embed> embeds
) {
public record Embed(
String title,
String description,
int color,
List<Field> fields
) {
@Builder
public Embed(String title, String description, DiscordMessageColor color, @Singular List<Field> fields) {
this(title, description, color.getNumber(), fields);
}
}
@Builder
public record Field(
@NonNull String name,
@NonNull String value,
Boolean inline
) {
public Field {
inline = inline == null ? false : inline;
}
}
}
💡 롬복의 @Singular:
갑자기 이전에 테크 블로그에서 봤던 @Singular가 떠올라 처음 써봤는데 Field가 여러개 생성되는 상황에서 사용하기 괜찮았다.
Singular는 Collection에 값을 하나씩 추가할 수 있는 기능이다.예시:
Embed.builder() .field(new Field()) .field(new Field()) .build();
아참 해당 코드를 보면 DiscordMessageColor
라는 것이 있는데embed
에 color
를 넣어주면 다음과 같이 메시지에 색상을 지정할 수 있다.
그런데 디스코드는 색상 코드가 아닌 숫자로 보내줘야 한다.

숫자는 기억하기 어렵고 사용하는 곳에서 어떤 색상인지 인지하기 어렵기 때문에 자주 사용할 것 같은 색상을 Enum
으로 만들어주었다.
DiscordMessageColor.java
@Getter
@RequiredArgsConstructor
public enum DiscordMessageColor {
RED("#FF0000", 16711680),
YELLOW("#FFFF00", 16776960),
GREEN("#00FF00", 65280),
BLUE("#0000FF", 255);
private final String hexCode;
private final int number;
}
3. Client 설정
Body
를 완성했으니 이제 디스코드 웹훅으로 메시지를 발송하기 위한 Client
를 만들어야 한다.
Spring
에서 사용할 수 있는 대표적인 Client
를 간단히 정리하자면 다음과 같다.
Client 종류 | 특징 |
RestTemplate | Spring에서 더이상 유지보수 안하기로 결정하여 사용을 권장하지 않음 |
RestClient | RestTemplate의 대체제, 동기 방식 통신 |
WebClient | Non-Blocking 기반 비동기 통신, WebFlux 의존성 필요 |
FeignClient | 인터페이스 기반 *선언형 통신 |
💡 선언적 통신:
선언적 통신(Declarative Communication)이란 "어떻게 호출할지"를 코드로 직접 작성하는 게 아니라
"무엇을 호출할 건지"만 선언해놓고, 나머지는 프레임워크가 처리하도록 맡기는 방식
일단 현재 프로젝트 상황상 RestClient
가 적합하다고 생각해서 RestClient
로 구현했다.
(물론 다른 Client
로도 디스코드에 메시지를 발송할 수 있다.)
3.1 ClientConfig 설정
Bean
등록을 하지 않고 사용하는 곳에서 new
연산자로 RestClient
객체를 생성해서 사용해도 되지만
추후 구현할 기능에도 필요하여 Bean
으로 등록을 해주었다.
더불어 사용하는 곳에서 모두 JSON
으로만 통신할 예정이기 때문에 미리 header
설정을 해주었다.
ClientConfig.java
@Configuration
public class ClientConfig {
@Bean
public RestClient restClient() {
return RestClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name())
.build();
}
}
3.2 DiscordClient 만들기
RestClient
를 완성했으니, 이제 Discord
랑 직접적으로 통신하는 DiscordClient
를 만들 것이다.Discord
웹훅은 사용하는 곳마다 url
이 다르기 때문에 파라미터로 받는다.
DiscordClient.java
@Slf4j
@Component
@RequiredArgsConstructor
public class DiscordClient {
private final RestClient restClient;
private final ObjectMapper objectMapper;
public void send(String uri, DiscordEmbedMessage message) {
try {
String payload = objectMapper.writeValueAsString(message);
ResponseEntity<String> response = restClient.post()
.uri(uri)
.body(payload)
.retrieve()
.toEntity(String.class);
log.info("[Discord Client] 메시지 발송 성공: {}", response.getStatusCode());
} catch (Exception ex) {
log.error("[Discord Client] 메시지 전송 중 오류 발생: {}", ex.getMessage());
}
}
}
4. GlobalExceptionHandler에서 Discord로 알림
이제 디스코드로 알림을 발송하는 기능은 모두 준비되었으니 GlobalExceptionHandler
에서 예외가 발생할 때 호출만 해주면 된다.
GlobalExceptionHandler
의 전체 코드는 다음과 같다.
GlobalExceptionHandler.java
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
@ExceptionHandler(ApiException.class)
protected ResponseEntity<ErrorResponse> handleApiException(ApiException e, HttpServletRequest request) {
log.error("API Exception - {}: {}", e.getApiError().getCode(), e.getMessage(), e);
ApiError apiError = e.getApiError();
// 500 번대 에러면 의도한 예외가 아니기 때문에 디스코드 알림 발송
if (apiError.getHttpStatus().is5xxServerError()) {
discordClient.send(errorUrl, buildDiscordEmbedMessage(e, request));
}
return ResponseEntity.status(apiError.getHttpStatus())
.body(new ErrorResponse(apiError.getCode(), apiError.getMessage(), getRequestTime(request)));
}
private DiscordEmbedMessage buildDiscordEmbedMessage(Exception e, HttpServletRequest request) {
return DiscordEmbedMessage.builder()
.content("# 🔥 서버 비상!! 비상!!")
.embed(
Embed.builder()
.color(DiscordMessageColor.RED)
.field(Field.createRequestInfo(WebUtil.getFullRequestURI(request)))
.field(Field.createTime(getRequestTime(request)))
.field(Field.createStackTrace(getStackTrace(e)))
.build()
)
.build();
}
private Instant getRequestTime(HttpServletRequest request) {
return (Instant) request.getAttribute(CoreConfig.REQUEST_TIME.getKey());
}
private String getStackTrace(Exception e) {
StringWriter stringWriter = new StringWriter();
e.printStackTrace(new PrintWriter(stringWriter));
String[] lines = stringWriter.toString().split(System.lineSeparator());
int limit = 5;
String trace = Arrays.stream(lines)
.limit(limit)
.collect(Collectors.joining(System.lineSeparator()));
return lines.length > limit ? trace + System.lineSeparator() + "...(생략)" : trace;
}
}
결과적으로 메시지는 이렇게 표시된다.

메시지 형식은 해당 블로그를 참고했다.
그럼 코드를 하나씩 살펴보자.
4.1 application.yml 설정
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
}
디스코드 채널을 환경별로 나눠놨기 때문에 실행 환경에 따라 디스코드 웹훅이 바뀌도록 yml
에 설정해두었다.
디스코드 채널

application-discord.yml
spring:
config:
activate:
on-profile: dev
discord:
webhook: url
---
spring:
config:
activate:
on-profile: prod
discord:
webhook: url
4.2 예외가 발생했을 때 알림을 발송하는 로직
CustomException
으로 만든 ApiException
에서 500
번대 에러는 의도된 것이 아니기 때문에
해당 상황에서만 알림을 받도록 조건문 설정을 해주었다.
참고로 is5xxServerError()
는 스프링 프레임워크에서 제공해주는 메서드이다.
아까 만들어두었던 DiscordClient
의 send
메서드의 파라미터로 error
알림을 받을 채널의 웹훅 주소와 DiscordEmbedMessage
를 넣어주었다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
@ExceptionHandler(ApiException.class)
protected ResponseEntity<ErrorResponse> handleApiException(ApiException e, HttpServletRequest request) {
log.error("API Exception - {}: {}", e.getApiError().getCode(), e.getMessage(), e);
ApiError apiError = e.getApiError();
// 500 번대 에러면 의도한 예외가 아니기 때문에 디스코드 알림 발송
if (apiError.getHttpStatus().is5xxServerError()) {
// buildDiscordEmbedMessage에 대한 설명은 밑에서 바로 이어서 한다.
discordClient.send(errorUrl, buildDiscordEmbedMessage(e, request));
}
return ResponseEntity.status(apiError.getHttpStatus())
.body(new ErrorResponse(apiError.getCode(), apiError.getMessage(), getRequestTime(request)));
}
}
4.3 요청 URL, 요청 시간, 스택 트레이스 정보 가져오기
API
요청 URL
정보는 로깅할 때도 사용하고 있어서 기존에 만들어둔 Util
클래스의 코드를 활용했다.
public class WebUtil {
public static String getFullRequestURI(HttpServletRequest request) {
String method = request.getMethod();
String requestUri = request.getRequestURI();
String queryString = request.getQueryString();
if (queryString != null) {
return String.format("%s - %s?%s", method, requestUri, queryString);
}
return String.format("%s - %s", method, requestUri);
}
}
API
요청 시간은 뭔가 Spring
에서 정보를 저장해놓은 객체가 있을 것 같아서 찾아봤는데 아무리 찾아봐도 안나왔다.
그래서 Filter
에서 attribute
에 직접 넣어준 정보를 사용했다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
// ... 생략
private Instant getRequestTime(HttpServletRequest request) {
return (Instant) request.getAttribute(CoreConfig.REQUEST_TIME.getKey());
}
}
그리고 스택트레이스를 문자열로 만들어주기 위한 기능을 만들었다.
메시지 내용이 너무 길면 디스코드에서 400
에러를 뱉어내기 때문에 적절한 단위로 잘라주었다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
// ... 생략
private String getStackTrace(Exception e) {
StringWriter stringWriter = new StringWriter();
e.printStackTrace(new PrintWriter(stringWriter));
String[] lines = stringWriter.toString().split(System.lineSeparator());
int limit = 5;
String trace = Arrays.stream(lines)
.limit(limit)
.collect(Collectors.joining(System.lineSeparator()));
return lines.length > limit ? trace + System.lineSeparator() + "...(생략)" : trace;
}
}
🔗 Ref.
'💻 프로그래밍 > Spring' 카테고리의 다른 글
[SpringBoot] WebMvcConfiguration의 동작 원리 (1) | 2024.09.08 |
---|---|
[Spring] MVC 요청 흐름 - Intercerptor가 먼저일까? DispatcherServlet이 먼저일까? (7) | 2024.08.27 |
[SpringBoot] WebMvcConfigurer CORS 설정이 적용 안되는 문제(feat. preflight 요청)(수정) (0) | 2024.08.17 |
[Spring] 멀티 모듈 + Redis 클러스터 환경에서 Test Container 연동하기 (0) | 2024.07.27 |
👣 들어가기 전에
회사 프로젝트가 슬슬 외부로 나갈 준비(CBT)를 하면서 각종 모니터링 툴을 붙여야 할 때가 왔다.
그동안 테크 블로그나 유튜브에서 프로메테우스
, 그라파나
, Sentry
, Pinpoint
등등등 키워드들을 많이 들어봤지만
실제로 사용해본 적이 없어서 수많은 툴 중에 무엇을 골라야 할지 막막하기도 하고
서비스를 운영해본 경험도 없는데 어디서부터 뭘 해야할지 살짝 막막하긴 하다.
일단 하나씩 차근차근 해보면서 하나씩 순서대로 기록해보려고 한다.
그동안 개발하면서 불편하다고 느낀 것 중 하나가 서버에서 에러가 발생했을 때 직접 도커 로그를 확인해보지 않으면
언제 에러가 발생한지 모른다는 것이었다.
나중에는 Logback
을 통해 파일로도 저장할 계획이지만
일단은 모니터링에 대한 첫번째 스텝으로 서버에 에러가 발생하면 바로 인지할 수 있게Discord
에 알림을 발송하는 기능을 만들어 보려고 한다.
💬 예외 발생 시 Discord로 메시지 발송
구현을 다 해보고 나니 어려운 작업은 아니다.
프론트에서 백엔드로 API
요청하는 과정과 똑같다.
Discord
메시지 스펙에 맞게 객체(메시지)를 만들어주고,
발급한 Discord
웹훅으로 해당 객체를 Body
에 담아 POST
로 요청하면 끝이다.
이 기능을 만들기 위해 필요한 작업은 다음과 같다.
Discord
웹훅 발급- 디스코드 메시지 발송에 필요한
Body
생성 API
를 요청할 수 있는Client
설정- 예외가 발생하면
GlobalExceptionHandler
에서 알림을 발송하는 기능 구현
본문은 Discord
채널이 있고 웹훅을 발급받았다는 가정하에 진행되기 때문에 2번 내용부터 정리를 하려고 한다.
2. Body 만들기
2.1 Body 스펙 파악하기
Body 를 보내려면 당연하게도 디스코드에서 원하는 스펙을 알아야 한다.
스펙은 아래 링크에서 확인 가능하다.
Discord Webhooks Guide: https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html
해당 문서에 있는 Body
예시는 다음과 같다.
예시
{
"username": "Webhook",
"avatar_url": "<https://i.imgur.com/4M34hi2.png>",
"content": "Text message. Up to 2000 characters.",
"embeds": [
{
"author": {
"name": "Birdie♫",
"url": "<https://www.reddit.com/r/cats/>",
"icon_url": "<https://i.imgur.com/R66g1Pe.jpg>"
},
"title": "Title",
"url": "<https://google.com/>",
"description": "Text message. You can use Markdown here. *Italic* **bold** __underline__ ~~strikeout~~ [hyperlink](<https://google.com>) `code`",
"color": 15258703,
"fields": [
{
"name": "Text",
"value": "More text",
"inline": true
},
{
"name": "Even more text",
"value": "Yup",
"inline": true
},
{
"name": "Use `\\"inline\\": true` parameter, if you want to display fields in the same line.",
"value": "okay..."
},
{
"name": "Thanks!",
"value": "You're welcome :wink:"
}
],
"thumbnail": {
"url": "<https://upload.wikimedia.org/wikipedia/commons/3/38/4-Nature-Wallpapers-2014-1_ukaavUI.jpg>"
},
"image": {
"url": "<https://upload.wikimedia.org/wikipedia/commons/5/5a/A_picture_from_China_every_day_108.jpg>"
},
"footer": {
"text": "Woah! So cool! :smirk:",
"icon_url": "<https://i.imgur.com/fKL31aD.jpg>"
}
}
]
}
각 필드가 어떻게 표시되는지는 문서에도 잘 나와있고
글로보는 것보다는 눈으로 확인하는 게 이해가 더 빨라서 필요하다면 하나씩 직접 쏴보면서 확인해보는 것도 좋을 것 같다.
해당 스펙에서 현재 프로젝트에서 필요한 정보만 추리니 이러한 JSON
형태가 되었다.
실제로 필요한 JSON
데이터
{
"content": "Text message. Up to 2000 characters.",
"embeds": [
{
"title": "Title",
"fields": [
{
"name": "Text",
"value": "More text",
},
{
"name": "Even more text",
"value": "Yup"
}
]
}
]
}
2.2 Body 스펙에 맞는 Dto 만들기
다른 블로그를 참고해보니 List<Map<String, String>>
형태로 embeds
를 만들어서 바로 보내는 것도 보았는데,
디스코드 알림은 다른 기능에서도 사용할 계획이 있어서 여러 곳에서도 활용할 수 있게
위 JSON
스펙에 맞게 record
클래스를 작성해주었다.
DiscordEmbedMessge.java
@Builder
public record DiscordEmbedMessage(
String content,
@Singular
List<Embed> embeds
) {
public record Embed(
String title,
String description,
int color,
List<Field> fields
) {
@Builder
public Embed(String title, String description, DiscordMessageColor color, @Singular List<Field> fields) {
this(title, description, color.getNumber(), fields);
}
}
@Builder
public record Field(
@NonNull String name,
@NonNull String value,
Boolean inline
) {
public Field {
inline = inline == null ? false : inline;
}
}
}
💡 롬복의 @Singular:
갑자기 이전에 테크 블로그에서 봤던 @Singular가 떠올라 처음 써봤는데 Field가 여러개 생성되는 상황에서 사용하기 괜찮았다.
Singular는 Collection에 값을 하나씩 추가할 수 있는 기능이다.예시:
Embed.builder() .field(new Field()) .field(new Field()) .build();
아참 해당 코드를 보면 DiscordMessageColor
라는 것이 있는데embed
에 color
를 넣어주면 다음과 같이 메시지에 색상을 지정할 수 있다.
그런데 디스코드는 색상 코드가 아닌 숫자로 보내줘야 한다.

숫자는 기억하기 어렵고 사용하는 곳에서 어떤 색상인지 인지하기 어렵기 때문에 자주 사용할 것 같은 색상을 Enum
으로 만들어주었다.
DiscordMessageColor.java
@Getter
@RequiredArgsConstructor
public enum DiscordMessageColor {
RED("#FF0000", 16711680),
YELLOW("#FFFF00", 16776960),
GREEN("#00FF00", 65280),
BLUE("#0000FF", 255);
private final String hexCode;
private final int number;
}
3. Client 설정
Body
를 완성했으니 이제 디스코드 웹훅으로 메시지를 발송하기 위한 Client
를 만들어야 한다.
Spring
에서 사용할 수 있는 대표적인 Client
를 간단히 정리하자면 다음과 같다.
Client 종류 | 특징 |
RestTemplate | Spring에서 더이상 유지보수 안하기로 결정하여 사용을 권장하지 않음 |
RestClient | RestTemplate의 대체제, 동기 방식 통신 |
WebClient | Non-Blocking 기반 비동기 통신, WebFlux 의존성 필요 |
FeignClient | 인터페이스 기반 *선언형 통신 |
💡 선언적 통신:
선언적 통신(Declarative Communication)이란 "어떻게 호출할지"를 코드로 직접 작성하는 게 아니라
"무엇을 호출할 건지"만 선언해놓고, 나머지는 프레임워크가 처리하도록 맡기는 방식
일단 현재 프로젝트 상황상 RestClient
가 적합하다고 생각해서 RestClient
로 구현했다.
(물론 다른 Client
로도 디스코드에 메시지를 발송할 수 있다.)
3.1 ClientConfig 설정
Bean
등록을 하지 않고 사용하는 곳에서 new
연산자로 RestClient
객체를 생성해서 사용해도 되지만
추후 구현할 기능에도 필요하여 Bean
으로 등록을 해주었다.
더불어 사용하는 곳에서 모두 JSON
으로만 통신할 예정이기 때문에 미리 header
설정을 해주었다.
ClientConfig.java
@Configuration
public class ClientConfig {
@Bean
public RestClient restClient() {
return RestClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name())
.build();
}
}
3.2 DiscordClient 만들기
RestClient
를 완성했으니, 이제 Discord
랑 직접적으로 통신하는 DiscordClient
를 만들 것이다.Discord
웹훅은 사용하는 곳마다 url
이 다르기 때문에 파라미터로 받는다.
DiscordClient.java
@Slf4j
@Component
@RequiredArgsConstructor
public class DiscordClient {
private final RestClient restClient;
private final ObjectMapper objectMapper;
public void send(String uri, DiscordEmbedMessage message) {
try {
String payload = objectMapper.writeValueAsString(message);
ResponseEntity<String> response = restClient.post()
.uri(uri)
.body(payload)
.retrieve()
.toEntity(String.class);
log.info("[Discord Client] 메시지 발송 성공: {}", response.getStatusCode());
} catch (Exception ex) {
log.error("[Discord Client] 메시지 전송 중 오류 발생: {}", ex.getMessage());
}
}
}
4. GlobalExceptionHandler에서 Discord로 알림
이제 디스코드로 알림을 발송하는 기능은 모두 준비되었으니 GlobalExceptionHandler
에서 예외가 발생할 때 호출만 해주면 된다.
GlobalExceptionHandler
의 전체 코드는 다음과 같다.
GlobalExceptionHandler.java
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
@ExceptionHandler(ApiException.class)
protected ResponseEntity<ErrorResponse> handleApiException(ApiException e, HttpServletRequest request) {
log.error("API Exception - {}: {}", e.getApiError().getCode(), e.getMessage(), e);
ApiError apiError = e.getApiError();
// 500 번대 에러면 의도한 예외가 아니기 때문에 디스코드 알림 발송
if (apiError.getHttpStatus().is5xxServerError()) {
discordClient.send(errorUrl, buildDiscordEmbedMessage(e, request));
}
return ResponseEntity.status(apiError.getHttpStatus())
.body(new ErrorResponse(apiError.getCode(), apiError.getMessage(), getRequestTime(request)));
}
private DiscordEmbedMessage buildDiscordEmbedMessage(Exception e, HttpServletRequest request) {
return DiscordEmbedMessage.builder()
.content("# 🔥 서버 비상!! 비상!!")
.embed(
Embed.builder()
.color(DiscordMessageColor.RED)
.field(Field.createRequestInfo(WebUtil.getFullRequestURI(request)))
.field(Field.createTime(getRequestTime(request)))
.field(Field.createStackTrace(getStackTrace(e)))
.build()
)
.build();
}
private Instant getRequestTime(HttpServletRequest request) {
return (Instant) request.getAttribute(CoreConfig.REQUEST_TIME.getKey());
}
private String getStackTrace(Exception e) {
StringWriter stringWriter = new StringWriter();
e.printStackTrace(new PrintWriter(stringWriter));
String[] lines = stringWriter.toString().split(System.lineSeparator());
int limit = 5;
String trace = Arrays.stream(lines)
.limit(limit)
.collect(Collectors.joining(System.lineSeparator()));
return lines.length > limit ? trace + System.lineSeparator() + "...(생략)" : trace;
}
}
결과적으로 메시지는 이렇게 표시된다.

메시지 형식은 해당 블로그를 참고했다.
그럼 코드를 하나씩 살펴보자.
4.1 application.yml 설정
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
}
디스코드 채널을 환경별로 나눠놨기 때문에 실행 환경에 따라 디스코드 웹훅이 바뀌도록 yml
에 설정해두었다.
디스코드 채널

application-discord.yml
spring:
config:
activate:
on-profile: dev
discord:
webhook: url
---
spring:
config:
activate:
on-profile: prod
discord:
webhook: url
4.2 예외가 발생했을 때 알림을 발송하는 로직
CustomException
으로 만든 ApiException
에서 500
번대 에러는 의도된 것이 아니기 때문에
해당 상황에서만 알림을 받도록 조건문 설정을 해주었다.
참고로 is5xxServerError()
는 스프링 프레임워크에서 제공해주는 메서드이다.
아까 만들어두었던 DiscordClient
의 send
메서드의 파라미터로 error
알림을 받을 채널의 웹훅 주소와 DiscordEmbedMessage
를 넣어주었다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final DiscordClient discordClient;
@Value("${discord.webhook.error}")
private String errorUrl;
// ... 생략
@ExceptionHandler(ApiException.class)
protected ResponseEntity<ErrorResponse> handleApiException(ApiException e, HttpServletRequest request) {
log.error("API Exception - {}: {}", e.getApiError().getCode(), e.getMessage(), e);
ApiError apiError = e.getApiError();
// 500 번대 에러면 의도한 예외가 아니기 때문에 디스코드 알림 발송
if (apiError.getHttpStatus().is5xxServerError()) {
// buildDiscordEmbedMessage에 대한 설명은 밑에서 바로 이어서 한다.
discordClient.send(errorUrl, buildDiscordEmbedMessage(e, request));
}
return ResponseEntity.status(apiError.getHttpStatus())
.body(new ErrorResponse(apiError.getCode(), apiError.getMessage(), getRequestTime(request)));
}
}
4.3 요청 URL, 요청 시간, 스택 트레이스 정보 가져오기
API
요청 URL
정보는 로깅할 때도 사용하고 있어서 기존에 만들어둔 Util
클래스의 코드를 활용했다.
public class WebUtil {
public static String getFullRequestURI(HttpServletRequest request) {
String method = request.getMethod();
String requestUri = request.getRequestURI();
String queryString = request.getQueryString();
if (queryString != null) {
return String.format("%s - %s?%s", method, requestUri, queryString);
}
return String.format("%s - %s", method, requestUri);
}
}
API
요청 시간은 뭔가 Spring
에서 정보를 저장해놓은 객체가 있을 것 같아서 찾아봤는데 아무리 찾아봐도 안나왔다.
그래서 Filter
에서 attribute
에 직접 넣어준 정보를 사용했다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
// ... 생략
private Instant getRequestTime(HttpServletRequest request) {
return (Instant) request.getAttribute(CoreConfig.REQUEST_TIME.getKey());
}
}
그리고 스택트레이스를 문자열로 만들어주기 위한 기능을 만들었다.
메시지 내용이 너무 길면 디스코드에서 400
에러를 뱉어내기 때문에 적절한 단위로 잘라주었다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
// ... 생략
private String getStackTrace(Exception e) {
StringWriter stringWriter = new StringWriter();
e.printStackTrace(new PrintWriter(stringWriter));
String[] lines = stringWriter.toString().split(System.lineSeparator());
int limit = 5;
String trace = Arrays.stream(lines)
.limit(limit)
.collect(Collectors.joining(System.lineSeparator()));
return lines.length > limit ? trace + System.lineSeparator() + "...(생략)" : trace;
}
}
🔗 Ref.
'💻 프로그래밍 > Spring' 카테고리의 다른 글
[SpringBoot] WebMvcConfiguration의 동작 원리 (1) | 2024.09.08 |
---|---|
[Spring] MVC 요청 흐름 - Intercerptor가 먼저일까? DispatcherServlet이 먼저일까? (7) | 2024.08.27 |
[SpringBoot] WebMvcConfigurer CORS 설정이 적용 안되는 문제(feat. preflight 요청)(수정) (0) | 2024.08.17 |
[Spring] 멀티 모듈 + Redis 클러스터 환경에서 Test Container 연동하기 (0) | 2024.07.27 |