-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/discord 에러로그 알림 도입 #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/discord-\uC5D0\uB7EC\uB85C\uADF8-\uC54C\uB9BC-\uB3C4\uC785"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| client = HttpClient.newBuilder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .connectTimeout(Duration.ofMillis(connectTimeoutMillis)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| super.start(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+50
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| protected void append(ILoggingEvent event) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isStarted()) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| String content = format(event); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| content = truncate(content, maxContentLength); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+57
to
+58
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Jan 1, 2026
There was a problem hiding this comment.
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()를 호출하여 인터럽트 상태를 복원하세요.
| addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e); | |
| addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e); | |
| Thread.currentThread().interrupt(); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
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
AI
Jan 1, 2026
There was a problem hiding this comment.
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 문서를 확인하여 정확한 형식을 파악하고, 밀리초/초 단위를 모두 지원하도록 수정하세요.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <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.gg.pinit.pinittask.infrastructure.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> |
There was a problem hiding this comment.
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/"로 시작하는지 확인하세요.