Skip to content

Conversation

@GoGradually
Copy link
Collaborator

변경된 점

  • 디스코드 에러로그 알림 메시지 도입

Copilot AI review requested due to automatic review settings January 1, 2026 13:02
Copy link

Copilot AI left a 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 웹훅을 통한 에러 로그 알림 시스템을 도입합니다. Logback 커스텀 Appender를 구현하여 ERROR 레벨 이상의 로그를 Discord 채널로 실시간 전송하며, FCM 서비스의 에러 처리 로직을 개선했습니다.

  • Discord 웹훅 기반 에러 로그 알림을 위한 커스텀 Logback Appender 구현
  • 비동기 처리 및 rate limiting 대응을 포함한 로깅 설정 추가
  • FCM INVALID_ARGUMENT 에러 코드에 대한 토큰 삭제 처리 추가

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 10 comments.

File Description
src/main/resources/logback-spring.xml Discord appender와 비동기 처리를 위한 Logback 설정 추가
src/main/java/me/pinitnotification/infrastructure/logging/DiscordWebhookAppender.java Discord 웹훅으로 로그를 전송하는 커스텀 Appender 구현
src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java INVALID_ARGUMENT 에러 코드 처리 추가

} catch (JsonProcessingException e) {
addWarn("DiscordWebhookAppender: JSON 직렬화 실패", e);
} catch (IOException 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.

문제점: 예외 발생 시 에러 메시지가 구체적이지 않아 문제 원인 파악이 어렵습니다. 특히 IOException(94-95라인)은 네트워크 연결 실패, 타임아웃, DNS 실패 등 다양한 원인이 있을 수 있는데 구분되지 않습니다.

영향: 디버깅 및 모니터링 시 문제 원인 파악 지연

수정 제안: 예외 메시지에 webhookUrl(도메인 부분만), 타임아웃 설정값 등 디버깅에 유용한 컨텍스트 정보 추가

Suggested change
addWarn("DiscordWebhookAppender: HTTP 요청 실패", e);
String domain = null;
try {
if (webhookUrl != null) {
URI uri = URI.create(webhookUrl);
domain = uri.getHost();
}
} catch (IllegalArgumentException ignore) {
// webhookUrl 이 잘못된 경우 도메인 정보는 생략
}
StringBuilder msg = new StringBuilder("DiscordWebhookAppender: HTTP 요청 실패");
if (domain != null && !domain.isBlank()) {
msg.append(" (domain=").append(domain).append(")");
}
msg.append(" [connectTimeoutMillis=")
.append(connectTimeoutMillis)
.append(", requestTimeoutMillis=")
.append(requestTimeoutMillis)
.append("]");
addWarn(msg.toString(), e);

Copilot uses AI. Check for mistakes.
Comment on lines +42 to 45
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED || e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
// Todo 토큰 삭제 방식 변경 필요
pushSubscriptionRepository.deleteByToken(token);
}
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.

문제점: INVALID_ARGUMENT 에러 코드에 대해 무조건 토큰을 삭제하는 로직이 추가되었습니다. INVALID_ARGUMENT는 토큰 형식이 잘못되었을 때뿐만 아니라, 메시지 페이로드가 잘못되었거나 기타 입력 검증 실패 시에도 발생할 수 있습니다. 이 경우 유효한 토큰이 잘못 삭제될 수 있습니다.

영향:

  • 일시적인 페이로드 오류로 인해 정상 토큰이 삭제되어 사용자가 푸시 알림을 받지 못함
  • 재구독이 필요하여 사용자 경험 저하

수정 제안:

  1. FirebaseMessagingException의 세부 메시지를 확인하여 토큰 관련 오류인지 검증
  2. 또는 INVALID_ARGUMENT 중에서도 특정 에러 메시지 패턴만 처리
  3. 로그에 삭제 사유를 명확히 기록하여 모니터링 가능하도록 개선

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +98
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);
}
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.

문제점: append() 메서드에서 동기적으로 HTTP 요청을 수행하고 있어, Discord API가 느리거나 응답하지 않을 경우 애플리케이션의 로깅 스레드가 차단될 수 있습니다. 특히 429 응답 시 Thread.sleep()을 사용하여 대기하는 부분(79-80라인)은 로깅 작업을 블로킹시킵니다.

영향:

  • Discord 웹훅 서버가 느리거나 장애 상황일 때 애플리케이션 성능 저하
  • AsyncAppender를 사용하더라도, 큐가 가득 찰 경우 애플리케이션 스레드 블로킹 가능
  • 대량의 에러 발생 시 Discord rate limit으로 인한 애플리케이션 전체 응답 지연

수정 제안:

  1. HTTP 요청을 별도의 스레드풀에서 비동기로 처리 (ExecutorService 활용)
  2. 429 응답 시 재시도를 큐에 넣거나 포기하는 방식으로 변경하여 Thread.sleep() 제거
  3. 타임아웃 설정을 더 짧게 조정 (현재 3000ms는 로깅에 너무 김)

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 9 to 15

<!-- 원하는 포맷으로 조정 (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>
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.

문제점: layout 설정이 DiscordWebhookAppender에서는 사용되지 않습니다. appender의 format() 메서드(101-114라인)에서 자체적으로 포맷팅을 수행하고 있어, 이 layout 설정은 무시됩니다.

영향: 설정 파일의 의도와 실제 동작이 다르며, 혼동을 야기할 수 있습니다.

수정 제안: layout 설정을 제거하거나, DiscordWebhookAppender가 layout을 사용하도록 구현 변경 (Layout<ILoggingEvent> 필드 추가 및 활용)

Suggested change
<!-- 원하는 포맷으로 조정 (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>

Copilot uses AI. Check for mistakes.
addWarn("DiscordWebhookAppender: JSON 직렬화 실패", e);
} catch (IOException e) {
addWarn("DiscordWebhookAppender: HTTP 요청 실패", e);
} catch (InterruptedException 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을 catch한 후 스레드의 interrupted 상태를 복원하지 않고 있습니다. 이는 Java 동시성 베스트 프랙티스를 위반합니다.

영향:

  • 상위 레벨 코드가 인터럽트를 감지하지 못할 수 있음
  • 스레드풀 환경에서 예상치 못한 동작 가능

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

Suggested change
} catch (InterruptedException e) {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();

Copilot uses AI. Check for mistakes.
private int requestTimeoutMillis = 3000;

private HttpClient client;
private ObjectMapper om = new ObjectMapper();
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.

문제점: ObjectMapper 인스턴스가 각 appender 인스턴스마다 생성되지만(37라인), ObjectMapper는 thread-safe하므로 static final로 선언하여 재사용하는 것이 권장됩니다.

영향: 불필요한 메모리 사용 및 초기화 오버헤드

수정 제안: ObjectMapper를 static final 필드로 변경하여 클래스 레벨에서 공유

Suggested change
private ObjectMapper om = new ObjectMapper();
private static final ObjectMapper om = new ObjectMapper();

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +116
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;
}

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 서버로 전송하고 있습니다. 특히 스택 트레이스에 민감한 정보가 노출될 위험이 있습니다.

영향:

  • 개인정보 보호 법규(GDPR, 개인정보보호법) 위반 가능성
  • 보안 자격증명 노출로 인한 보안 사고 위험

수정 제안:

  1. 민감한 정보를 마스킹하는 필터 로직 추가 (예: 이메일, 전화번호, 토큰 등)
  2. 환경변수, 설정값 등이 스택 트레이스에 노출되지 않도록 검증
  3. 프로덕션 환경에서만 사용하도록 프로파일 제한 고려
Suggested change
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;
}
String message = sanitizeLogContent(event.getFormattedMessage());
String base = String.format(
"[%s] %-5s %s - %s",
java.time.Instant.ofEpochMilli(event.getTimeStamp()),
event.getLevel(),
event.getLoggerName(),
message
);
if (event.getThrowableProxy() != null) {
String stack = ThrowableProxyUtil.asString(event.getThrowableProxy());
stack = sanitizeLogContent(stack);
return base + "\n```" + stack + "```";
}
return base;
}
/**
* 로그 메시지와 스택 트레이스 내의 민감 정보를 마스킹한다.
* - 이메일, 전화번호, 토큰/JWT, hex 문자열 등을 단순 패턴 기반으로 치환한다.
*/
private String sanitizeLogContent(String input) {
if (input == null || input.isEmpty()) {
return input;
}
String result = input;
// 이메일 주소 마스킹
result = result.replaceAll("(?i)[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "[EMAIL]");
// 전화번호(간단 패턴) 마스킹
result = result.replaceAll("\\+?\\d[\\d\\s\\-]{7,}\\d", "[PHONE]");
// JWT 토큰 (header.payload.signature) 형태 마스킹
result = result.replaceAll("[A-Za-z0-9\\-_]{20,}\\.[A-Za-z0-9\\-_]{10,}\\.[A-Za-z0-9\\-_]{10,}", "[TOKEN]");
// 긴 hex 문자열(예: 액세스 키, 토큰 등) 마스킹
result = result.replaceAll("\\b[0-9a-fA-F]{32,}\\b", "[TOKEN]");
return result;
}

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +50
public void start() {
if (this.webhookUrl == null || this.webhookUrl.isEmpty()) {
addWarn("디스코드 웹훅 URL이 설정되지 않았습니다. DiscordWebhookAppender가 시작되지 않습니다.");
return;
}
client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(connectTimeoutMillis))
.build();

super.start();
}
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 인스턴스가 36라인에서 선언되지만 초기화되지 않은 상태로 남아있다가, start() 메서드에서만 초기화됩니다. 만약 webhookUrl이 비어있어서 start()가 조기 반환되면(43라인), client는 null 상태로 남게 되고, append() 메서드의 54라인 isStarted() 체크가 제대로 작동하지 않으면 NPE가 발생할 수 있습니다.

영향: NullPointerException 발생 가능성

수정 제안: start() 메서드에서 webhookUrl이 없을 때 super.start()를 호출하지 않도록 하여 isStarted()가 false를 반환하도록 보장하거나, client null 체크를 append()에 추가

Copilot uses AI. Check for mistakes.
Comment on lines 19 to 24
<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>
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의 필터가 올바르게 적용되지 않습니다. Logback에서 AsyncAppender에 filter를 직접 적용하면, 필터가 비동기 큐에 들어가기 전이 아니라 후에 적용될 수 있습니다. 이는 ERROR가 아닌 로그도 큐에 쌓이게 만들어 비효율적입니다.

영향:

  • INFO, WARN 레벨 로그도 비동기 큐에 적재되어 메모리 낭비
  • 불필요한 필터링 오버헤드

수정 제안: ThresholdFilter를 DISCORD appender(6-16라인)에 직접 적용하거나, root logger 레벨을 ERROR로 설정하고 다른 appender를 별도로 구성

Copilot uses AI. Check for mistakes.
Comment on lines 26 to 28
<root level="INFO">
<appender-ref ref="ASYNC_DISCORD"/>
</root>
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.

문제점: root logger에 ASYNC_DISCORD appender만 연결되어 있어, 콘솔이나 파일로의 일반적인 로그 출력이 모두 사라집니다. 이는 개발 환경이나 디버깅 시 문제가 될 수 있습니다.

영향:

  • 로컬 개발 환경에서 로그 확인 불가
  • 파일 기반 로그 보관 불가로 인한 감사 추적 어려움
  • Discord 장애 시 모든 로그 손실

수정 제안: CONSOLE 또는 FILE appender를 추가로 설정하여 기본 로깅 기능 유지

Copilot uses AI. Check for mistakes.
@GoGradually GoGradually merged commit 57146b5 into master Jan 1, 2026
1 check passed
@GoGradually GoGradually deleted the feat/discord-에러로그-알림-도입 branch January 1, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants