Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -39,7 +39,7 @@ public void sendPushMessage(String token, Notification notification) {
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
log.error(e.getMessage(), e);
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED || e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
// Todo 토큰 삭제 방식 변경 필요
pushSubscriptionRepository.deleteByToken(token);
}
Comment on lines +42 to 45
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package me.pinitnotification.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-notification-log";

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

@Setter
private int connectTimeoutMillis = 2000;
@Setter
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.

@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
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.

@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;
}
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);
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.
} 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.
addWarn("DiscordWebhookAppender: HTTP 요청이 중단됨", e);
}
Comment on lines +53 to +98
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.
}

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 +102 to +116
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.
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;
}
}
}
36 changes: 36 additions & 0 deletions src/main/resources/logback-spring.xml
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.pinitnotification.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>