👣 들어가기 전에

회사 프로젝트가 슬슬 외부로 나갈 준비(CBT)를 하면서 각종 모니터링 툴을 붙여야 할 때가 왔다.

 

그동안 테크 블로그나 유튜브에서 프로메테우스, 그라파나, Sentry, Pinpoint 등등등 키워드들을 많이 들어봤지만
실제로 사용해본 적이 없어서 수많은 툴 중에 무엇을 골라야 할지 막막하기도 하고
서비스를 운영해본 경험도 없는데 어디서부터 뭘 해야할지 살짝 막막하긴 하다.

 

일단 하나씩 차근차근 해보면서 하나씩 순서대로 기록해보려고 한다.

 

그동안 개발하면서 불편하다고 느낀 것 중 하나가 서버에서 에러가 발생했을 때 직접 도커 로그를 확인해보지 않으면
언제 에러가 발생한지 모른다는 것이었다.

 

나중에는 Logback을 통해 파일로도 저장할 계획이지만
일단은 모니터링에 대한 첫번째 스텝으로 서버에 에러가 발생하면 바로 인지할 수 있게
Discord에 알림을 발송하는 기능을 만들어 보려고 한다.

 


 

💬 예외 발생 시 Discord로 메시지 발송

구현을 다 해보고 나니 어려운 작업은 아니다.

프론트에서 백엔드로 API 요청하는 과정과 똑같다.

 

Discord 메시지 스펙에 맞게 객체(메시지)를 만들어주고,
발급한 Discord 웹훅으로 해당 객체를 Body에 담아 POST로 요청하면 끝이다.

 

이 기능을 만들기 위해 필요한 작업은 다음과 같다.

 

  1. Discord 웹훅 발급
  2. 디스코드 메시지 발송에 필요한 Body 생성
  3. API를 요청할 수 있는 Client 설정
  4. 예외가 발생하면 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라는 것이 있는데
embedcolor를 넣어주면 다음과 같이 메시지에 색상을 지정할 수 있다.

 

그런데 디스코드는 색상 코드가 아닌 숫자로 보내줘야 한다.

 

숫자는 기억하기 어렵고 사용하는 곳에서 어떤 색상인지 인지하기 어렵기 때문에 자주 사용할 것 같은 색상을 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() 는 스프링 프레임워크에서 제공해주는 메서드이다.

 

아까 만들어두었던 DiscordClientsend 메서드의 파라미터로 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.

https://velog.io/@minu1117/%EC%9E%90%EB%B0%94Spring%EC%9C%BC%EB%A1%9C-Discord-WebHook-%EC%82%AC%EC%9A%A9