SpringBoot Discord 알림 연동(+Logback)

신민철·2024년 1월 15일
5

Spring

목록 보기
4/5

Discord 알림 웹훅을 구현한 이유

클라이언트에서 API를 호출할 때 에러를 확인하기 위해서는, Docker에선 ec2 서버에 직접 접속해

docker logs [container id] --tail [로그 찍을 개수]

해당 명령어를 쳐서 어떤 에러가 발생하였는지 stack trace를 봐야 한다.


하지만 매번 그렇게 되면 ec2 서버에 접속하는 시간 + docker log 명령어 치는 시간이 낭비되는 셈이기 때문에 디스코드에 매번 알람이 오도록 하면 좋을 것 같다는 생각을 하여 이번 Moonshot 서버 구현에 1순위로 생각하고 구현하였다.


Stack trace와 함께 사용자의 정보를 함께 띄워주어 어떤 상황인지 더욱 자세히 파악하고 싶다는 생각을 하게 되었다.

그렇게 처음에 생각하게 된건 Filter인데, Filter는 뭘까?


💡 필터(Filter)는 디스패처 서블릿(Dispatcher Servlet)에 요청이 전달되기 전/후에 url 패턴에 맞는 모든 요청에 대해 부가작업을 처리할 수 있는 기능을 제공한다. 디스패처 서블릿은 스프링의 가장 앞단에 존재하는 프론트 컨트롤러이므로, 필터는 스프링 범위 바깥에서 동작하는 것이다.


이 필터를 통해, 요청자의 URI 정보, IP 정보, HTTP 헤더 정보, Cookie 정보, Parameter 정보, HTTP body 정보를 파싱하여 디스코드에 알림이 가도록 구상하였다.

@Slf4j
@RequiredArgsConstructor
@Component
public class MDCFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        HttpServletRequest httpReq = WebUtils.getNativeRequest(request, HttpServletRequest.class);

        MDCUtil.setJsonValue(MDCUtil.REQUEST_URI_MDC, HttpRequestUtil.getRequestUri(Objects.requireNonNull(httpReq)));
        MDCUtil.setJsonValue(MDCUtil.USER_IP_MDC, HttpRequestUtil.getUserIP(Objects.requireNonNull(httpReq)));
        MDCUtil.setJsonValue(MDCUtil.HEADER_MAP_MDC, HttpRequestUtil.getHeaderMap(httpReq));
        MDCUtil.setJsonValue(MDCUtil.USER_REQUEST_COOKIES, HttpRequestUtil.getUserCookies(httpReq));
        MDCUtil.setJsonValue(MDCUtil.PARAMETER_MAP_MDC, HttpRequestUtil.getParamMap(httpReq));
        MDCUtil.setJsonValue(MDCUtil.BODY_MDC, HttpRequestUtil.getBody(httpReq));

        filterChain.doFilter(request, response);

    }
}

MDCFilter는 HttpServletRequest에 존재하는 사용자의 정보를 MDCUtil에 파싱하는 역할을 맡는다.

막간으로 MDC는 Mapped Diagnostic Context 입니다!

이 필터를 등록해주기 위해 FilterConfig 를 설정해주었다.

@Profile("!local")
@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<ServletWrappingFilter> secondFilter() {
        FilterRegistrationBean<ServletWrappingFilter> filterRegistrationBean = new FilterRegistrationBean<>(
                new ServletWrappingFilter());
        filterRegistrationBean.setOrder(0);
        return filterRegistrationBean;
    }

    @Bean
    public FilterRegistrationBean<MDCFilter> thirdFilter() {
        FilterRegistrationBean<MDCFilter> filterRegistrationBean = new FilterRegistrationBean<>(
                new MDCFilter());
        filterRegistrationBean.setOrder(1);
        return filterRegistrationBean;
    }
}

여러개의 필터를 등록할 때, setOrder();를 통하여 순서를 지정해줄수도 있다. 또한 @Profile("!local")을 통해 local 프로필이 아닐 때만 필터가 동작하도록 설정하였다.


그러면 필터를 통해 사용자의 정보를 MDCUtil에 저장을 했는데 이 정보를 언제 Discord에 메시지를 발송하면 좋을까?

기본적으로 에러가 발생할 때 디스코드 알림을 보내는게 맞는데 그 트리거가 되는 것을 Logback을 통하여 구현할 것이다!


💡 Logback이란?

로깅 프레임워크 중 하나로, 우리가 자주 사용하는 @Slf4j의 구현체 중 하나다.


Logback에서 log.error()를 사용할 때 콘솔에 로그를 찍고, Discord에 알림이 가도록 Logback 설정 파일을 조작할 것이다.

<configuration>
    <property name="LOG_PATTERN"
              value="[%d{yyyy-MM-dd}] [%d{HH:mm:ss.SSS}] [%p] ${PID:-} [%F] %M (%L\) : %m%n"/>
    <springProfile name="local">
        <include resource="console-appender.xml"/>
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>
</configuration>

local profile에서 사용될 logback-local.xml 파일부터 살펴보자. 을 통해 설정할 수 있는데 springProfile로 local profile을 설정해주고 appender-ref는 를 통해 import된 console-appender.xml을 호출하는 역할을 할 것이다.



그럼 다음으로 logback-deploy.xml을 보자.
<configuration>
    <property name="LOG_PATTERN"
              value="[%d{yyyy-MM-dd HH:mm:ss}:%-4relative] %green([%thread]) %highlight(%-5level) %boldWhite([%C.%M:%yellow(%L)]) - %msg%n"/>
    <springProperty name="DISCORD_WEBHOOK_URI" source="logging.discord.webhook-uri"/>
    <springProfile name="deploy">
        <include resource="console-appender.xml"/>
        <include resource="discord-appender.xml"/>
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="ASYNC_DISCORD" />
        </root>
    </springProfile>
</configuration>

여기서 다른 점은 springProperty를 통해 DISCORD_WEBHOOK_URI를 설정해주고 include에 discord-appender.xml이 생겼다. 또한 ASYNC_DISCORD를 참조하고 있다.

즉, deploy profile일 때는 콘솔에 로깅하는 것 뿐만 아니라, discord-appender.xml에 설정되어 있는 로직을 수행하게 되는 것이다.


console은 넘기고 중요한 discord-appender.xml을 보자!
<included>
    <appender name="DISCORD"
               class="org.moonshot.server.global.external.discord.DiscordAppender">
        <discordWebhookUrl>${DISCORD_WEBHOOK_URI}</discordWebhookUrl>
        <username>Error Log</username>
        <avatarUrl>https://www.greenart.co.kr/upimage/new_editor/20212/20210201112021.jpg</avatarUrl>
    </appender>
    <appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="DISCORD" />
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>
</included>

여기서는 <에서 DiscordAppender.class를 지정하고 있고 discord-appender.xml이 호출되는 순간 DiscordAppender의 로직이 수행될 것이다.

그 아래에 써 있는 설정들은 읽어보면 어떤 내용인지 알 것이다. 중요한 부분은 logback-deploy.xml에 DISCORD_WEBHOOK_URI를 프로퍼티에 설정해주어야 discord-appender.xml에서 해당 값을 받아서 사용할 수 있다.


물론 임베딩해서 사용할 수 있지만, 웹훅 URI를 외부에 공개하는 건 좋은 것은 아닐 것이다!
@Slf4j
@Setter
public class DiscordAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {

    private String discordWebhookUrl;
    private String username;
    private String avatarUrl;

    private static Color getLevelColor(ILoggingEvent eventObject) {
        String level = eventObject.getLevel().levelStr;
        if (level.equals("WARN")) {
            return Color.yellow;
        } else if (level.equals("ERROR")) {
            return Color.red;
        }

        return Color.blue;
    }

    @Override
    protected void append(ILoggingEvent eventObject) {
        DiscordWebHook discordWebhook = new DiscordWebHook(discordWebhookUrl, username, avatarUrl, false);
        Map<String, String> mdcPropertyMap = eventObject.getMDCPropertyMap();
        Color messageColor = getLevelColor(eventObject);

        String level = eventObject.getLevel().levelStr;
        String exceptionBrief = "";
        String exceptionDetail = "";
        IThrowableProxy throwable = eventObject.getThrowableProxy();
        log.info("{}", eventObject.getMessage());

        if (throwable != null) {
            exceptionBrief = throwable.getClassName() + ": " + throwable.getMessage();
        }

        if (exceptionBrief.equals("")) {
            exceptionBrief = "EXCEPTION 정보가 남지 않았습니다.";
        }

        discordWebhook.addEmbed(new EmbedObject()
                .setTitle("[" + level + " - 문제 간략 내용]")
                .setColor(messageColor)
                .setDescription(exceptionBrief)
                .addField("[" + "Exception Level" + "]",
                        StringEscapeUtils.escapeJson(level),
                        true)
                .addField("[문제 발생 시각]",
                        LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
                        false)
                .addField(
                        "[" + MDCUtil.REQUEST_URI_MDC + "]",
                        StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.REQUEST_URI_MDC)),
                        false)
                .addField(
                        "[" + MDCUtil.USER_IP_MDC + "]",
                        StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.USER_IP_MDC)),
                        false)
                .addField(
                        "[" + MDCUtil.HEADER_MAP_MDC + "]",
                        StringEscapeUtils.escapeJson(mdcPropertyMap.get(MDCUtil.HEADER_MAP_MDC).replaceAll("[\\{\\{\\}]", "")),
                        true)
                .addField(
                        "[" + MDCUtil.USER_REQUEST_COOKIES + "]",
                        StringEscapeUtils.escapeJson(
                                mdcPropertyMap.get(MDCUtil.USER_REQUEST_COOKIES).replaceAll("[\\{\\{\\}]", "")),
                        false)
                .addField(
                        "[" + MDCUtil.PARAMETER_MAP_MDC + "]",
                        StringEscapeUtils.escapeJson(
                                mdcPropertyMap.get(MDCUtil.PARAMETER_MAP_MDC).replaceAll("[\\{\\{\\}]", "")),
                        false)
                .addField("[" + MDCUtil.BODY_MDC + "]",
                        StringEscapeUtils.escapeJson(StringUtil.translateEscapes(mdcPropertyMap.get(MDCUtil.BODY_MDC))),
                        false)
        );

        if (throwable != null) {
            exceptionDetail = ThrowableProxyUtil.asString(throwable);
            String exception = exceptionDetail.substring(0, 4000);
            discordWebhook.addEmbed(
                    new EmbedObject()
                            .setTitle("[Exception 상세 내용]")
                            .setColor(messageColor)
                            .setDescription(StringEscapeUtils.escapeJson(exception))
            );
        }

        try {
            discordWebhook.execute();
        } catch (IOException ioException) {
            throw new ErrorLogAppenderException();
        }
    }
}

discord-appender.xml이 호출되면 DiscordAppender의 append 메소드가 호출된다.
public class DiscordAppender extends UnsynchronizedAppenderBase<ILoggingEvent>

보다시피, DiscordAppender는 UnsynchronizedAppenderBase를 상속하고 있는데, Logback의 Appender 기능을 사용하여 Log Event를 처리하기 위해 사용되는 부분이다.

ILoggingEvent는 Logback에서 사용되는 Logging Event를 나타내는 Interface이다.

EmbedObject()에 아까 MDCFilter에서 MDCUtil에 파싱해두었던 정보를 addField() 메소드로 추가하는 방식으로 진행된다. 또한 getLevelColor()로 로깅 레벨에 따라 색상을 다르게 줄 수도 있다.


모든 정보가 처리가 됐으면, 최종적으로 discordWebhook의 execute() 메소드를 호출한다.

public void execute() throws IOException {
        if (this.embeds.isEmpty()) {
            throw new RuntimeException("컨텐츠를 설정하거나 하나 이상의 Embed Object를 추가해야 합니다.");
        }

        try {
            ApiCallUtil.callDiscordAppenderPostAPI(
                    this.urlString, createDiscordEmbedObject(
                            this.embeds, initializerDiscordSendForJsonObject(new JsonObject())
                    ));

        } catch (IOException ioException) {
            throw ioException;
        }
    }

여기선 ApiCallUtil 클래스를 통해 Discord에 POST API를 호출하게 된다.

public static void callDiscordAppenderPostAPI(String urlString, JsonObject json) throws IOException {
        URL url = new URL(urlString);
        HttpsURLConnection connection = (HttpsURLConnection)url.openConnection();
        connection.addRequestProperty("Content-Type", "application/json");
        connection.addRequestProperty("User-Agent", "Java-DiscordWebhook-BY-Gelox_");
        connection.setDoOutput(true);
        connection.setRequestMethod("POST");

        try (OutputStream stream = connection.getOutputStream()) {
            stream.write(json.toString().getBytes());
            stream.flush();

            connection.getInputStream().close();
            connection.disconnect();

        } catch (IOException ioException) {
            throw ioException;
        }
    }

이렇게 로직이 진행되는데 일반적인 REST API call이다. User-Agent 부분이 약간 특이한데 참고하길 바란다.

이렇게 모두 다 구현하고 나게 되면 다음과 같은 예쁜 결과를 얻을 수 있게 된다!

모두 편한 에러 처리를 위해 디코 알림을 추천한다 !! (너무 편함)

2개의 댓글

comment-user-thumbnail
2024년 5월 10일

해당 소스를 볼 수 있는 깃허브가 있을까요?

1개의 답글