-
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"
Conversation
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.
Pull request overview
이 PR은 애플리케이션에서 발생하는 에러 로그를 Discord 웹훅을 통해 실시간으로 알림받을 수 있는 기능을 도입합니다.
주요 변경사항:
- Discord 웹훅을 통해 ERROR 레벨 이상의 로그를 전송하는 커스텀 Logback Appender 구현
- 비동기 처리 및 rate limiting 대응을 포함한 안정적인 로그 전송 메커니즘
- 환경 변수를 통한 웹훅 URL 설정으로 보안성 향상
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| src/main/resources/logback-spring.xml | Discord 웹훅 appender와 비동기 처리를 위한 Logback 설정 추가 |
| src/main/java/me/gg/pinit/pinittask/infrastructure/logging/DiscordWebhookAppender.java | Discord API로 로그를 전송하는 커스텀 Appender 구현 (HTTP 클라이언트, rate limiting 처리, 메시지 포맷팅 포함) |
| if (waitMs > 0) | ||
| Thread.sleep(waitMs); |
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.
문제점: rate limit 대기 중 append() 메서드가 블로킹되어 다른 로그 이벤트 처리가 지연됩니다. AsyncAppender를 사용하더라도 워커 스레드가 블로킹되면 로그 큐가 쌓일 수 있습니다.
영향: Discord API가 429를 반환할 때 로그 처리 성능이 크게 저하되고, 심한 경우 로그 이벤트가 드롭될 수 있습니다.
수정 제안: Thread.sleep() 대신 재시도를 건너뛰고 경고 로그만 남기거나, 별도의 스케줄러를 사용하여 비블로킹 방식으로 처리하세요.
| } catch (IOException e) { | ||
| addWarn("DiscordWebhookAppender: HTTP 요청 실패", e); | ||
| } catch (InterruptedException e) { | ||
| addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e); |
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(); |
| public void start() { | ||
| if (this.webhookUrl == null || this.webhookUrl.isEmpty()) { | ||
| addWarn("디스코드 웹훅 URL이 설정되지 않았습니다. DiscordWebhookAppender가 시작되지 않습니다."); | ||
| return; | ||
| } | ||
| client = HttpClient.newBuilder() | ||
| .connectTimeout(Duration.ofMillis(connectTimeoutMillis)) | ||
| .build(); | ||
|
|
||
| super.start(); | ||
| } |
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.
문제점: HttpClient 인스턴스가 start() 메서드에서 생성되지만 stop() 메서드에서 정리되지 않습니다. HttpClient는 연결 풀과 스레드를 관리하므로 적절히 종료하지 않으면 리소스 누수가 발생할 수 있습니다.
영향: 애플리케이션 종료 시 또는 설정 리로드 시 HTTP 연결 풀과 내부 스레드가 정리되지 않아 메모리 누수가 발생할 수 있습니다.
수정 제안: stop() 메서드를 오버라이드하여 HttpClient의 executor를 종료하거나, 애플리케이션 종료 시 정리될 수 있도록 처리하세요.
| 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()); |
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.
문제점: 429 응답 처리 후 재시도에서 요청이 성공해도 원본 응답 객체를 처리하지 않고 바로 return하지만, 실패 시에도 return하여 두 경로 모두 동일한 결과를 보입니다. 또한 재시도 실패 시 원본 요청의 상태 코드가 아닌 재시도 상태 코드만 로그에 남습니다.
영향: 재시도 로직의 의도가 명확하지 않고, 디버깅 시 혼란을 줄 수 있습니다.
수정 제안: 재시도 성공과 실패를 명확히 구분하여 로그를 남기고, 원본 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()); | |
| // 원본 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()); |
| </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> |
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.
문제점: AsyncAppender 내부의 filter가 올바르게 동작하지 않습니다. AsyncAppender 자체에는 filter를 설정할 수 없으며, filter는 참조되는 appender(DISCORD)에 설정해야 합니다.
영향: 현재 구조에서는 ERROR 레벨 필터링이 의도대로 동작하지 않을 수 있으며, 모든 레벨의 로그가 Discord로 전송될 가능성이 있습니다.
수정 제안: ThresholdFilter를 ASYNC_DISCORD가 아닌 DISCORD appender로 이동시키거나, AsyncAppender 바깥에 root 레벨에서 필터를 적용하세요.
| </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"/> |
| String content = format(event); | ||
| content = truncate(content, maxContentLength); |
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.
문제점: 매우 긴 스택 트레이스가 있는 경우 truncate 처리가 Markdown 코드 블록 구문(```)을 깨뜨릴 수 있습니다. 잘린 위치가 코드 블록 내부일 경우 Discord 메시지 렌더링이 깨질 수 있습니다.
영향: 긴 에러 로그 발생 시 Discord 메시지가 올바르게 표시되지 않을 수 있습니다.
수정 제안: truncate 전에 예상 메시지 길이를 계산하거나, truncate 후 코드 블록이 올바르게 닫히도록 보장하는 로직을 추가하세요.
| if (this.webhookUrl == null || this.webhookUrl.isEmpty()) { | ||
| addWarn("디스코드 웹훅 URL이 설정되지 않았습니다. DiscordWebhookAppender가 시작되지 않습니다."); | ||
| return; | ||
| } |
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.
문제점: Discord 웹훅 URL이 민감한 정보임에도 불구하고 유효성 검증이 부족합니다. 빈 문자열만 체크하고 URL 형식이나 도메인 검증이 없어 잘못된 URL로 요청이 전송될 수 있습니다.
영향: 잘못된 URL 설정 시 런타임에 예외가 발생하거나, 의도하지 않은 엔드포인트로 로그가 전송될 수 있습니다.
수정 제안: URL 형식 검증과 Discord 도메인 검증을 추가하세요. 예: webhookUrl이 "https://discord.com/api/webhooks/" 또는 "https://discordapp.com/api/webhooks/"로 시작하는지 확인하세요.
| 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; | ||
| } |
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을 실제로 활용하도록 수정하세요.
| 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; | ||
| } | ||
| } |
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 문서를 확인하여 정확한 형식을 파악하고, 밀리초/초 단위를 모두 지원하도록 수정하세요.
변경된 점