-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/discord 에러로그 알림 도입 #12
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 2 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.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-auth-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
|
||||||||||||||||||||||||||||||||||||||||||||||||
| client = HttpClient.newBuilder() | ||||||||||||||||||||||||||||||||||||||||||||||||
| .connectTimeout(Duration.ofMillis(connectTimeoutMillis)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| super.start(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+39
to
+50
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @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; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+77
to
+88
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
+98
|
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| private String truncate(String s, int max) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (s == null) return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (s.length() <= max) return s; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return s.substring(0, max) + "\n...(truncated)"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+119
to
+120
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (s.length() <= max) return s; | |
| return s.substring(0, max) + "\n...(truncated)"; | |
| // 최종 문자열 길이가 max를 넘지 않도록, 잘림 표시 문자열 길이를 포함해 계산 | |
| String suffix = "\n...(truncated)"; | |
| int suffixLen = suffix.length(); | |
| // 이미 max 이하이면 그대로 반환 | |
| if (s.length() <= max) return s; | |
| // max가 suffix 길이보다 작거나 같은 극단적인 설정에 대한 방어 코드 | |
| if (max <= suffixLen) { | |
| // 최소한 0 이상 max 이하 범위에서 substring 수행 | |
| int end = Math.max(0, max); | |
| return s.substring(0, end); | |
| } | |
| int end = max - suffixLen; | |
| if (end < 0) { | |
| end = 0; | |
| } | |
| return s.substring(0, end) + suffix; |
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.
(1) 문제점: DiscordWebhookAppender 클래스에 대한 단위 테스트가 없습니다. HTTP 요청, 재시도 로직, 메시지 포맷팅 및 자르기, 예외 처리 등 여러 중요한 동작에 대한 테스트가 필요합니다.
(2) 영향: 향후 코드 수정 시 기존 기능이 깨질 수 있으며, 엣지 케이스(예: 긴 스택 트레이스, 특수 문자, 네트워크 타임아웃)가 제대로 처리되는지 확인할 수 없습니다.
(3) 수정 제안: MockWebServer 또는 WireMock을 사용하여 디스코드 웹훅 API를 모킹하고, 다음 시나리오에 대한 테스트를 추가하세요: 1) 정상 케이스, 2) 429 응답 및 재시도, 3) 네트워크 에러, 4) 메시지 길이 초과 시 자르기, 5) 잘못된 URL 처리.
| 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.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> | ||
| </appender> | ||
|
||
|
|
||
| <root level="INFO"> | ||
| <appender-ref ref="ASYNC_DISCORD"/> | ||
| </root> | ||
|
Comment on lines
31
to
34
|
||
|
|
||
| </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.
(1) 문제점: ObjectMapper 인스턴스가 매 로그 이벤트마다 사용되지만, 스레드 안전성이 보장되지 않습니다. ObjectMapper는 기본적으로 스레드 안전하지만, 이 코드에서는 AsyncAppender를 사용하므로 여러 스레드에서 동시에 append() 메서드를 호출할 가능성이 있습니다.
(2) 영향: 실제로는 ObjectMapper가 스레드 안전하게 동작하지만, 코드 리뷰 관점에서 명시적으로 안전성을 보장하는 것이 좋습니다. 또한 향후 ObjectMapper 설정 변경 시 동시성 문제가 발생할 수 있습니다.
(3) 수정 제안: ObjectMapper를 final로 선언하고, 필요한 경우 주석으로 스레드 안전성을 명시하세요. 또는 매번 새로운 인스턴스를 생성하는 대신 공유 인스턴스 사용을 명확히 하세요.