Article - 깊게 탐구하기/시간 기록, 관리 서비스 Pinit

[Pinit] 디스코드 메시지로 예외 알림 받기

조금씩 차근차근 2026. 1. 2. 19:00

실제로 k3s를 이용해 배포한 뒤, 발생한 예외를 바로바로 확인하기 어려운 문제가 있었다.

 

따라서, 디스코드 웹훅을 이용해 예외를 메시지로 받아보려 한다.

목차

  1. 에러 로그를 디스코드 메시지로 전달할 Appender에 대해 알아보기
  2. DiscordWebhookAppender 구현하기
  3. logback-spring.xml 정의하기

Appender

Logback에서 Appender는 “로그 이벤트(ILoggingEvent)를 받아서, 특정 출력 대상(sink)으로 내보내는 출력 어댑터”이다.

 

간단하게 Appender가 동작하기까지의 동작 과정을 살펴보면 다음과 같다.

  1. SLF4J API: log.info(...), log.error(...) 호출
  2. Logback Logger: 로그 이벤트(ILoggingEvent) 생성
  3. (선택) Filter / Level 판단: 이 이벤트를 처리할지 말지 결정
  4. Appender: 처리하기로 한 이벤트를 “출력 대상”으로 전송

그럼 이제 이 Appender(이후 '어펜더'로 표기)를 어떻게 생성하고, 어떻게 관리하는지를 라이프사이클을 살펴보며 이해해보자.


1) Appender 라이프사이클 상태 전이

상태(State)

  1. NEW (생성됨, 미초기화)
  2. CONFIGURING (설정 주입 중)
  3. STARTED (동작 가능)
  4. STOPPED (종료됨)

전이(Transition)

  • NEW → CONFIGURING : XML 파서(Joran)가 Appender 인스턴스를 만들고, name/context 및 각종 setter로 프로퍼티를 주입
  • CONFIGURING → STARTED : start() 호출이 정상 완료되면 started 플래그가 true가 됨
  • STARTED → STOPPED : stop() 호출(리로드/리셋/종료 시점)
  • CONFIGURING → (FAILED) 또는 STARTED로 못 감 : start()에서 필수값(webhookUrl 등) 검증 실패 시

 


2) 실제 호출 순서(어떤 메소드들이 언제 호출되나)

Logback이 Appender를 올리는 순서는 대략 다음과 같다.

(A) 부팅/설정 로딩 시점

  1. 인스턴스 생성
    • new YourAppender()
  2. Context 주입
    • setContext(LoggerContext)
  3. 이름/프로퍼티 주입
    • setName("DISCORD")
    • <webhookUrl>...</webhookUrl> 같은 설정들이 setter로 주입
    • <layout>, <encoder> 같은 하위 컴포넌트도 함께 생성/주입됨
  4. start() 호출
    • start()에서 리소스 초기화(HTTP client 생성, 파일/소켓 오픈 등)
    • 필수값 검증 실패 시 start를 호출하더라도 STARTED가 되지 않게 구성하는 것이 일반적

(B) 런타임(로그가 찍힐 때)

  • Logback은 보통 Appender에 대해 doAppend(event)를 호출한다.
    • 내부에서 (구현/상속에 따라) started 여부를 확인하고,
    • 실제 구현 메소드인 append(event)로 넘기게 된다.
  • 사용자 구현 Appender에서는 보통 append(event)만 구현한다.

(C) 종료/리로드 시점

  • 설정 리로드(reconfigure), context reset, 애플리케이션 종료 시에
    • stop() 호출
    • 열어둔 리소스 정리(스레드 종료, 커넥션/파일 close 등)

 


이 내용들을 바탕으로 대강 동작을 유추해보면, 다음과 같을 것이다.


3) 개발자가 start()/stop()에 넣어야 하는 것

start()에 넣는 것 (초기화)

  • 필수 설정값 검증: webhookUrl
  • 네트워크/파일 리소스 준비: HttpClient, socket, file handle
  • 하위 컴포넌트 start: layout/encoder 등을 직접 들고 있다면 시작 처리
  • 내부 버퍼/큐를 만든다면 여기서 생성(단, 이번 설계에서는 AsyncAppender가 큐 담당)

stop()에 넣는 것 (정리)

  • 열린 리소스 close
  • 내부 스레드가 있으면 안전 종료 (interrupt + join)
  • flush가 필요하면 flush 수행

DiscordWebhookAppender 구현하기

package me.gogradually.discordlog.logging;  

import ch.qos.logback.classic.spi.ILoggingEvent;  
import ch.qos.logback.classic.spi.ThrowableProxyUtil;  
import ch.qos.logback.core.AppenderBase;  
import com.fasterxml.jackson.core.JsonProcessingException;  
import com.fasterxml.jackson.databind.ObjectMapper;  
import lombok.Setter;  

import java.io.IOException;  
import java.net.URI;  
import java.net.http.HttpClient;  
import java.net.http.HttpRequest;  
import java.net.http.HttpResponse;  
import java.time.Duration;  
import java.util.List;  
import java.util.Map;  

public class DiscordWebhookAppender extends AppenderBase<ILoggingEvent> {  

    // logback-spring.xml 에서 주입  
    @Setter  
    private String webhookUrl;  
    @Setter  
    private String username = "backend-log";  

    // Discord content 제한 (2000자)  
    @Setter  
    private int maxContentLength = 1900;  

    @Setter  
    private int connectTimeoutMillis = 2000;  
    @Setter  
    private int requestTimeoutMillis = 3000;  

    private HttpClient client;  
    private ObjectMapper om = new ObjectMapper();  

    @Override  
    public void start() {  
        if (this.webhookUrl == null || this.webhookUrl.isEmpty()) {  
            addWarn("디스코드 웹훅 URL이 설정되지 않았습니다. DiscordWebhookAppender가 시작되지 않습니다.");  
            return;  
        }  
        client = HttpClient.newBuilder()  
                .connectTimeout(java.time.Duration.ofMillis(connectTimeoutMillis))  
                .build();  

        super.start();  
    }  

    @Override  
    protected void append(ILoggingEvent event) {  
        if (!isStarted()) return;  

        try {  
            String content = format(event);  
            content = truncate(content, maxContentLength);  

            Map<String, Object> payload = Map.of(  
                    "username", username,  
                    "content", content,  
                    "allowed_mentions", Map.of("parse", List.of())  
            );  

            String json = om.writeValueAsString(payload);  

            HttpRequest req = HttpRequest.newBuilder()  
                    .uri(URI.create(webhookUrl))  
                    .timeout(Duration.ofMillis(requestTimeoutMillis))  
                    .header("Content-Type", "application/json")  
                    .POST(HttpRequest.BodyPublishers.ofString(json))  
                    .build();  

            HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());  

            if (resp.statusCode() == 429) {  
                long waitMs = parseRetryAfterMillis(resp);  
                if (waitMs > 0)  
                    Thread.sleep(waitMs);  

                HttpResponse<String> retry = client.send(req, HttpResponse.BodyHandlers.ofString());  
                if(retry.statusCode() >= 200 && retry.statusCode() < 300) {  
                    return; // 성공  
                }  
                addWarn("DiscordWebhookAppender: 재시도 후에도 실패, 상태 코드: " + retry.statusCode());  
                return;  
            }  
            if(resp.statusCode() < 200 || resp.statusCode() >= 300) {  
                addWarn("DiscordWebhookAppender: HTTP 요청 실패, 상태 코드: " + resp.statusCode());  
            }  
        } catch (JsonProcessingException e) {  
            addWarn("DiscordWebhookAppender: JSON 직렬화 실패", e);  
        } catch (IOException e) {  
            addWarn("DiscordWebhookAppender: HTTP 요청 실패", e);  
        } catch (InterruptedException e) {  
            addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e);  
        }  
    }  

    private String format(ILoggingEvent event) {  
        String base = String.format(  
                "[%s] %-5s %s - %s",  
                java.time.Instant.ofEpochMilli(event.getTimeStamp()),  
                event.getLevel(),  
                event.getLoggerName(),  
                event.getFormattedMessage()  
        );  

        if (event.getThrowableProxy() != null) {  
            String stack = ThrowableProxyUtil.asString(event.getThrowableProxy());  
            return base + "\n```" + stack + "```";  
        }  
        return base;  
    }  

    private String truncate(String s, int max) {  
        if (s == null) return "";  
        if (s.length() <= max) return s;  
        return s.substring(0, max) + "\n...(truncated)";  
    }  

    private long parseRetryAfterMillis(HttpResponse<String> resp) {  
        String ra = resp.headers().firstValue("Retry-After").orElse(null);  
        if (ra == null || ra.isBlank()) return 0;  

        try {  
            double v = Double.parseDouble(ra.trim());  
            return (long) (v * 1000);  
        } catch (NumberFormatException ignore) {  
            return 0;  
        }  
    }  
}

Appender의 구조에 대한 이해가 완료되었다면, 그 이후는 사실상 API 규약 & HTTP 송수신 로직에 가깝다.

위 payload 맵에 작성된 것처럼, username/content/allowed_mentions 부분을 원하는 대로 채워넣으면 된다.

logback-spring.xml 설정

src/main/resources/logback-spring.xml 경로 내에 넣어둔다.

<configuration>

  <property name="DISCORD_WEBHOOK_URL" value="${DISCORD_WEBHOOK_URL:-}" />

  <!-- 콘솔 appender -->
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] %-5level [%X{traceId:-}] %logger{36} - %msg%n%ex{full}</pattern>
    </encoder>
  </appender>

  <!-- Discord appender -->
  <appender name="DISCORD" class="me.gogradually.discordlog.logging.DiscordWebhookAppender">
    <webhookUrl>${DISCORD_WEBHOOK_URL}</webhookUrl>
    <username>backend-log</username>
    <layout class="ch.qos.logback.classic.PatternLayout">
      <pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] %-5level [%X{traceId:-}] %logger{36} - %msg%n```%ex{full}```</pattern>
    </layout>

    <!-- 최종 전송 지점에 필터 -->
    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
      <level>ERROR</level>
    </filter>
  </appender>

  <appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="DISCORD" />
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="ASYNC_DISCORD" />
  </root>

</configuration>

패키지 이름에 주의하자.

환경 변수 설정

이제 디스코드에서 해당 웹훅을 받아올 차례이다.


서버 설정에서 다음과 같이 웹훅 URL을 받아오고,

아래처럼 환경변수를 추가해주자.

그럼 아래와 같이 메시지를 받아볼 수 있다.