Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package me.gg.pinit.pinittask.infrastructure.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 = "pinit-task-log";

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

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

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

@Override
public void start() {
if (this.webhookUrl == null || this.webhookUrl.isEmpty()) {
addWarn("디스코드 웹훅 URL이 설정되지 않았습니다. DiscordWebhookAppender가 시작되지 않습니다.");
return;
}
Comment on lines +41 to +44
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: Discord 웹훅 URL이 민감한 정보임에도 불구하고 유효성 검증이 부족합니다. 빈 문자열만 체크하고 URL 형식이나 도메인 검증이 없어 잘못된 URL로 요청이 전송될 수 있습니다.

영향: 잘못된 URL 설정 시 런타임에 예외가 발생하거나, 의도하지 않은 엔드포인트로 로그가 전송될 수 있습니다.

수정 제안: URL 형식 검증과 Discord 도메인 검증을 추가하세요. 예: webhookUrl이 "https://discord.com/api/webhooks/" 또는 "https://discordapp.com/api/webhooks/"로 시작하는지 확인하세요.

Copilot uses AI. Check for mistakes.
client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(connectTimeoutMillis))
.build();

super.start();
}
Comment on lines +40 to +50
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: HttpClient 인스턴스가 start() 메서드에서 생성되지만 stop() 메서드에서 정리되지 않습니다. HttpClient는 연결 풀과 스레드를 관리하므로 적절히 종료하지 않으면 리소스 누수가 발생할 수 있습니다.

영향: 애플리케이션 종료 시 또는 설정 리로드 시 HTTP 연결 풀과 내부 스레드가 정리되지 않아 메모리 누수가 발생할 수 있습니다.

수정 제안: stop() 메서드를 오버라이드하여 HttpClient의 executor를 종료하거나, 애플리케이션 종료 시 정리될 수 있도록 처리하세요.

Copilot uses AI. Check for mistakes.

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

try {
String content = format(event);
content = truncate(content, maxContentLength);
Comment on lines +57 to +58
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 매우 긴 스택 트레이스가 있는 경우 truncate 처리가 Markdown 코드 블록 구문(```)을 깨뜨릴 수 있습니다. 잘린 위치가 코드 블록 내부일 경우 Discord 메시지 렌더링이 깨질 수 있습니다.

영향: 긴 에러 로그 발생 시 Discord 메시지가 올바르게 표시되지 않을 수 있습니다.

수정 제안: truncate 전에 예상 메시지 길이를 계산하거나, truncate 후 코드 블록이 올바르게 닫히도록 보장하는 로직을 추가하세요.

Copilot uses AI. Check for mistakes.

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);
Comment on lines +79 to +80
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: rate limit 대기 중 append() 메서드가 블로킹되어 다른 로그 이벤트 처리가 지연됩니다. AsyncAppender를 사용하더라도 워커 스레드가 블로킹되면 로그 큐가 쌓일 수 있습니다.

영향: Discord API가 429를 반환할 때 로그 처리 성능이 크게 저하되고, 심한 경우 로그 이벤트가 드롭될 수 있습니다.

수정 제안: Thread.sleep() 대신 재시도를 건너뛰고 경고 로그만 남기거나, 별도의 스케줄러를 사용하여 비블로킹 방식으로 처리하세요.

Copilot generated this review using guidance from repository custom instructions.

HttpResponse<String> retry = client.send(req, HttpResponse.BodyHandlers.ofString());
if (retry.statusCode() >= 200 && retry.statusCode() < 300) {
return; // 성공
}
addWarn("DiscordWebhookAppender: 재시도 후에도 실패, 상태 코드: " + retry.statusCode());
Comment on lines +78 to +86
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 429 응답 처리 후 재시도에서 요청이 성공해도 원본 응답 객체를 처리하지 않고 바로 return하지만, 실패 시에도 return하여 두 경로 모두 동일한 결과를 보입니다. 또한 재시도 실패 시 원본 요청의 상태 코드가 아닌 재시도 상태 코드만 로그에 남습니다.

영향: 재시도 로직의 의도가 명확하지 않고, 디버깅 시 혼란을 줄 수 있습니다.

수정 제안: 재시도 성공과 실패를 명확히 구분하여 로그를 남기고, 원본 429 응답 정보도 함께 기록하세요.

Suggested change
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());
// 원본 429 응답 정보 로그
String retryAfter = resp.headers().firstValue("Retry-After").orElse("n/a");
addWarn("DiscordWebhookAppender: 1차 요청에서 429 응답 수신, 상태 코드: " + resp.statusCode() +
", Retry-After: " + retryAfter);
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) {
addInfo("DiscordWebhookAppender: 429 응답 후 재시도 성공, 최종 상태 코드: " + retry.statusCode());
return; // 재시도 성공
}
addWarn("DiscordWebhookAppender: 429 응답 후 재시도도 실패, 원본 상태 코드: " + resp.statusCode()
+ ", 재시도 상태 코드: " + retry.statusCode());

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: InterruptedException 발생 시 스레드의 인터럽트 상태가 복원되지 않습니다. 스레드 풀 환경에서 인터럽트 상태를 무시하면 예상치 못한 동작이 발생할 수 있습니다.

영향: AsyncAppender의 워커 스레드가 정상적으로 종료되지 못하거나, 상위 레벨에서 인터럽트를 감지하지 못할 수 있습니다.

수정 제안: catch 블록에서 Thread.currentThread().interrupt()를 호출하여 인터럽트 상태를 복원하세요.

Suggested change
addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e);
addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e);
Thread.currentThread().interrupt();

Copilot uses AI. Check for mistakes.
}
}

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;
}
Comment on lines +101 to +115
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: logback-spring.xml의 layout 설정이 DiscordWebhookAppender에서 실제로 사용되지 않습니다. append() 메서드에서 format() 메서드를 통해 자체적으로 포맷팅하고 있어 설정 파일의 layout이 무시됩니다.

영향: 설정 파일에서 로그 포맷을 변경해도 실제 Discord 메시지에 반영되지 않아 혼란을 줄 수 있습니다.

수정 제안: layout 설정을 제거하거나, AppenderBase 대신 LayoutWrappingEncoder를 사용하여 설정된 layout을 실제로 활용하도록 수정하세요.

Copilot uses AI. Check for mistakes.

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;
}
}
Comment on lines +123 to +133
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: parseRetryAfterMillis() 메서드에서 Retry-After 헤더 값을 초 단위로만 파싱합니다. Discord API는 Retry-After를 밀리초 단위로 반환할 수도 있으며, HTTP 표준에서는 날짜 형식도 허용합니다.

영향: Retry-After 값이 예상과 다른 형식일 경우 rate limit 대기 시간이 잘못 계산되어 재시도가 실패할 수 있습니다.

수정 제안: Discord API 문서를 확인하여 정확한 형식을 파악하고, 밀리초/초 단위를 모두 지원하도록 수정하세요.

Copilot uses AI. Check for mistakes.
}
30 changes: 30 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<configuration>

<!-- Webhook URL은 환경변수로 주입 -->
<property name="DISCORD_WEBHOOK_URL" value="${DISCORD_WEBHOOK_URL:-}"/>

<appender name="DISCORD" class="me.gg.pinit.pinittask.infrastructure.logging.DiscordWebhookAppender">
<webhookUrl>${DISCORD_WEBHOOK_URL}</webhookUrl>
<username>backend-log</username>

<!-- 원하는 포맷으로 조정 (MDC 예: traceId) -->
<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>
</appender>

<!-- 비동기 + ERROR 이상만 -->
<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>
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: AsyncAppender 내부의 filter가 올바르게 동작하지 않습니다. AsyncAppender 자체에는 filter를 설정할 수 없으며, filter는 참조되는 appender(DISCORD)에 설정해야 합니다.

영향: 현재 구조에서는 ERROR 레벨 필터링이 의도대로 동작하지 않을 수 있으며, 모든 레벨의 로그가 Discord로 전송될 가능성이 있습니다.

수정 제안: ThresholdFilter를 ASYNC_DISCORD가 아닌 DISCORD appender로 이동시키거나, AsyncAppender 바깥에 root 레벨에서 필터를 적용하세요.

Suggested change
</appender>
<!-- 비동기 + ERROR 이상만 -->
<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>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
<!-- 비동기 + ERROR 이상만 -->
<appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="DISCORD"/>

Copilot uses AI. Check for mistakes.
</appender>

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

</configuration>