Article - 깊게 탐구하기/시간 기록, 관리 서비스 Pinit
[Pinit] 디스코드 메시지로 예외 알림 받기
조금씩 차근차근
2026. 1. 2. 19:00
실제로 k3s를 이용해 배포한 뒤, 발생한 예외를 바로바로 확인하기 어려운 문제가 있었다.
따라서, 디스코드 웹훅을 이용해 예외를 메시지로 받아보려 한다.
목차
- 에러 로그를 디스코드 메시지로 전달할 Appender에 대해 알아보기
- DiscordWebhookAppender 구현하기
- logback-spring.xml 정의하기
Appender
Logback에서 Appender는 “로그 이벤트(ILoggingEvent)를 받아서, 특정 출력 대상(sink)으로 내보내는 출력 어댑터”이다.
간단하게 Appender가 동작하기까지의 동작 과정을 살펴보면 다음과 같다.
- SLF4J API:
log.info(...),log.error(...)호출 - Logback Logger: 로그 이벤트(
ILoggingEvent) 생성 - (선택) Filter / Level 판단: 이 이벤트를 처리할지 말지 결정
- Appender: 처리하기로 한 이벤트를 “출력 대상”으로 전송
그럼 이제 이 Appender(이후 '어펜더'로 표기)를 어떻게 생성하고, 어떻게 관리하는지를 라이프사이클을 살펴보며 이해해보자.
1) Appender 라이프사이클 상태 전이
상태(State)
- NEW (생성됨, 미초기화)
- CONFIGURING (설정 주입 중)
- STARTED (동작 가능)
- 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) 부팅/설정 로딩 시점
- 인스턴스 생성
new YourAppender()
- Context 주입
setContext(LoggerContext)
- 이름/프로퍼티 주입
setName("DISCORD")<webhookUrl>...</webhookUrl>같은 설정들이 setter로 주입<layout>,<encoder>같은 하위 컴포넌트도 함께 생성/주입됨
- 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을 받아오고,

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

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