Skip to content
Open
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 @@ -2,6 +2,7 @@

import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import com.devoops.domain.notifier.ErrorNotifier;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,6 +22,8 @@
@RequiredArgsConstructor
public class GlobalExceptionHandler {

private final ErrorNotifier errorNotifier;

@ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindingException(BindException exception) {
log.warn("Binding exception occurred", exception);
Expand Down Expand Up @@ -69,6 +72,7 @@ public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFo
@ExceptionHandler(GssException.class)
public ResponseEntity<ErrorResponse> handleGssException(GssException exception) {
log.error("Custom GssException occurred: {}", exception.getMessage(), exception);
errorNotifier.notify(exception);
return toResponse(exception.getErrorCode());
}
Comment on lines 72 to 77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

500 에러에 대한 알림 범위 검증 필요

현재 GssException에 대해서만 알림이 발송되지만, PR 목표는 "500 에러에 대한 디스코드 알림"입니다. GssException 중에서도 4xx 상태 코드를 반환하는 경우가 있는지 확인이 필요합니다.

다음 스크립트로 GssException이 사용되는 모든 위치와 해당 ErrorCode의 상태 코드를 확인하세요:


🏁 Script executed:

#!/bin/bash
# GssException 사용처 검색
echo "=== GssException 사용 위치 ==="
rg -n "throw new GssException" -A 1 -B 1

echo -e "\n=== ErrorCode 4xx vs 5xx 분포 ==="
rg -n "^\s+\w+\([45]\d{2}," gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java

Length of output: 10618


5xx 에러만 디스코드 알림 전송하도록 필터링 추가 필요

GlobalExceptionHandler.java(handleGssException 메서드 72-77)에서 exception.getErrorCode().getStatus()가 500 이상일 때만 errorNotifier.notify 호출하도록 분기 처리하세요.

🤖 Prompt for AI Agents
In gss-api-app/src/main/java/com/devoops/exception/GlobalExceptionHandler.java
around lines 72 to 77, the handler currently always calls errorNotifier.notify
for GssException; change it to only call errorNotifier.notify when
exception.getErrorCode().getStatus() is 500 or greater. Keep the existing log
and response behavior, but wrap the errorNotifier.notify call in a conditional
that checks exception.getErrorCode().getStatus() >= 500 so only server (5xx)
errors trigger Discord notifications.


Expand All @@ -80,6 +84,7 @@ public ResponseEntity<ErrorResponse> handleException(ExpiredJwtException excepti
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception exception) {
log.error("Unhandled exception occurred", exception);
errorNotifier.notify(exception);
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ public JwtProperties(String secretKey, Duration accessTokenExpiration, Duration
this.refreshTokenExpiration = refreshTokenExpiration;
}


public Duration getExpirationByTokenType(TokenType type) {
if(type == TokenType.ACCESS_TOKEN) {
return accessTokenExpiration;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.devoops.notifier.adapter;

import com.devoops.BaseControllerTest;
import com.devoops.domain.notifier.ErrorNotifier;
import com.devoops.exception.GithubNotFoundException;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

@Disabled
class ErrorNotifierTest extends BaseControllerTest {

@Autowired
private ErrorNotifier errorNotifier;

@Test
void sendMessage() {
errorNotifier.notify(new GithubNotFoundException("Github Not Found"));
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,4 @@
spring:
profiles:
active: test

jwt:
secret_key: testtesttesttesttesttesttesttest
access_token_expiration: 1h
refresh_token_expiration: 1d

cors:
origin: http://localhost:8080

---

spring:
config:
activate:
on-profile: test
data:
redis:
host: localhost
Expand All @@ -36,6 +19,14 @@ spring:
flyway:
enabled: false

jwt:
secret_key: testtesttesttesttesttesttesttest
access_token_expiration: 1h
refresh_token_expiration: 1d

cors:
origin: http://localhost:8080

dev-oops:
mcp:
webhook-url: https://test.dev-oops.kr/api/webhooks
Expand All @@ -44,3 +35,7 @@ dev-oops:
url: https://api.github.com
connect-timeout: 3000
read-timeout: 3000

discord:
token: testToken
channel-id: testChannel
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ public enum ErrorCode {
GITHUB_CLIENT_ERROR(500, "깃허브 클라이언트 소통과정에 문제가 발생했습니다"),
AI_RESPONSE_PARSING_ERROR(500, "AI로부터 온 질문 생성을 파싱하는 과정에 오류가 발생했습니다"),
AI_CREATE_QUESTION_ERROR(500, "AI 질문 생성과정에 오류가 발생했습니다"),
AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 없습니다.")
AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 수 없습니다."),
DISCORD_PROPERTIES_EMPTY(500, "디스코드 채널 id와 키를 찾을 수 없습니다"),
DISCORD_JDA_EXCEPTION(500, "디스코드 JDA 초기화에 실패하였습니다"),
;

private final int statusCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.devoops.exception.message;

import java.util.Arrays;
import java.util.stream.Collectors;

public class ErrorMessageResolver {

private static final String NOTIFICATION_PREFIX = ":rotating_light: [**Error 발생!**]\n";
private static final String STACK_TRACE_AFFIX = "\n```\n";
private static final String DISCORD_LINE_SEPARATOR = "\n";
private static final int STACK_TRACE_LENGTH = 10;

public static String resolve(Throwable throwable) {
String errorMessage = throwable.toString();
String stackTrace = getStackTraceAsString(throwable);

return NOTIFICATION_PREFIX
+ errorMessage
+ STACK_TRACE_AFFIX
+ stackTrace
+ STACK_TRACE_AFFIX;
}

private static String getStackTraceAsString(Throwable throwable) {
return Arrays.stream(throwable.getStackTrace())
.map(StackTraceElement::toString)
.limit(STACK_TRACE_LENGTH)
.collect(Collectors.joining(DISCORD_LINE_SEPARATOR));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.devoops.exception.notifier;

public interface NotifyPort {

void sendMessage(String message);
}
8 changes: 8 additions & 0 deletions gss-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")

//Discord
implementation 'net.dv8tion:JDA:5.0.0-beta.24'
Comment on lines +23 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

베타 버전 사용에 대한 검증 필요

JDA 5.0.0-beta.24는 베타 버전으로 프로덕션 환경에서 안정성 이슈가 발생할 수 있습니다. 최신 안정 버전(GA) 사용을 권장합니다.

다음 스크립트를 실행하여 최신 안정 버전을 확인하세요:


🏁 Script executed:

#!/bin/bash
# JDA 최신 버전 및 보안 권고사항 확인
curl -s https://repo1.maven.org/maven2/net/dv8tion/JDA/maven-metadata.xml | grep -A 1 "<release>"

# GitHub에서 최신 릴리즈 확인
gh api repos/discord-jda/JDA/releases/latest --jq '.tag_name'

Length of output: 62


JDA 베타 버전 대신 최신 안정 GA(6.0.0) 버전 사용
gss-domain/build.gradle:23에서 5.0.0-beta.246.0.0으로 변경하세요.

🤖 Prompt for AI Agents
In gss-domain/build.gradle around lines 23 to 24, the dependency uses the JDA
beta version '5.0.0-beta.24'; update this to the stable GA release by changing
the implementation line to use 'net.dv8tion:JDA:6.0.0'. Ensure you update any
transitive or repository constraints if needed and run a Gradle refresh to
verify compatibility.


// testFixtures
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-data-jpa' // includes EntityManager, TX
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-test' // includes SpringExtension, DataJpaTest, etc.
Expand All @@ -33,3 +36,8 @@ dependencies {
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
}

tasks.withType(JavaCompile) {
options.compilerArgs += "-parameters"
}

40 changes: 40 additions & 0 deletions gss-domain/src/main/java/com/devoops/config/NotifierConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.devoops.config;

import com.devoops.exception.notifier.NotifyPort;
import com.devoops.domain.notifier.ConsoleNotifier;
import com.devoops.domain.notifier.DiscordNotifier;
import com.devoops.domain.notifier.DiscordProperties;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class NotifierConfig {

@Profile({"dev", "prod"})
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(DiscordProperties.class)
public static class DiscordNotifierConfig {

private final DiscordProperties discordProperties;

@Bean
public NotifyPort discordNotifier() {
return new DiscordNotifier(discordProperties);
}
}

@Profile({"test", "local"})
@Configuration
public static class ConsoleNotifierConfig {

@Bean
public NotifyPort consoleNotifier() {
return new ConsoleNotifier();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devoops.domain.notifier;


import com.devoops.exception.notifier.NotifyPort;

public class ConsoleNotifier implements NotifyPort {

@Override
public void sendMessage(String message) {
System.out.println("[메시지 발송] : " + message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.devoops.domain.notifier;

import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import com.devoops.exception.notifier.NotifyPort;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;

@Slf4j
public class DiscordNotifier implements NotifyPort {

private final DiscordProperties properties;
private final JDA jda;

public DiscordNotifier(DiscordProperties discordProperties) {
this.properties = discordProperties;
this.jda = initializeJda(properties.getToken());
}

private JDA initializeJda(String token) {
try {
return JDABuilder.createDefault(token).build().awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GssException(ErrorCode.DISCORD_JDA_EXCEPTION);
}
}

@Override
public void sendMessage(String message) {
TextChannel channel = jda.getTextChannelById(properties.getChannelId());
channel.sendMessage(message).queue();
}
Comment on lines +31 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

채널 null 시 NPE 발생 가능. null 체크 및 전송 실패 콜백 추가하세요.

getTextChannelById가 null일 수 있어 NPE 위험이 있습니다. 실패 콜백도 등록해 전송 오류를 로깅하세요.

-    public void sendMessage(String message) {
-        TextChannel channel = jda.getTextChannelById(properties.getChannelId());
-        channel.sendMessage(message).queue();
-    }
+    public void sendMessage(String message) {
+        TextChannel channel = jda.getTextChannelById(properties.getChannelId());
+        if (channel == null) {
+            log.error("Discord TextChannel not found. channelId={}", properties.getChannelId());
+            return;
+        }
+        channel.sendMessage(message).queue(
+            success -> { /* no-op */ },
+            throwable -> log.error("Failed to send Discord message", throwable)
+        );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void sendMessage(String message) {
TextChannel channel = jda.getTextChannelById(properties.getChannelId());
channel.sendMessage(message).queue();
}
@Override
public void sendMessage(String message) {
TextChannel channel = jda.getTextChannelById(properties.getChannelId());
if (channel == null) {
log.error("Discord TextChannel not found. channelId={}", properties.getChannelId());
return;
}
channel.sendMessage(message).queue(
success -> { /* no-op */ },
throwable -> log.error("Failed to send Discord message", throwable)
);
}
🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/domain/notifier/DiscordNotifier.java
around lines 31 to 35, the code calls jda.getTextChannelById(...) and
immediately uses the result, which can be null and cause an NPE; update the
method to check for a null TextChannel and log an error (and return) if not
found, and when calling channel.sendMessage(message) add a failure callback
(e.g., queue(success -> {}, failure -> log the exception and context)) so any
send errors are logged with details.

}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

애플리케이션 종료 시 JDA 종료 누락(스레드/리소스 릭 위험). @PreDestroy 추가 권장.

종료 훅에서 JDA를 내려 안전하게 스레드를 정리하세요.

 }
+    @jakarta.annotation.PreDestroy
+    void shutdown() {
+        try {
+            jda.shutdown();
+        } catch (Exception e) {
+            log.warn("Error while shutting down JDA", e);
+        }
+    }

추가 import:

import jakarta.annotation.PreDestroy;

Spring Boot 3/Spring 6는 jakarta.* 네임스페이스를 사용합니다. Based on learnings

🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/domain/notifier/DiscordNotifier.java
around line 36, the class currently does not shut down the JDA client on
application exit, risking thread/resource leaks; add a method annotated with
jakarta.annotation.PreDestroy that calls jda.shutdownNow() (or the appropriate
JDA shutdown method) to cleanly stop JDA, and add the import
jakarta.annotation.PreDestroy; ensure the method checks for null and handles
exceptions so shutdown is attempted safely during application teardown.


Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.devoops.domain.notifier;

import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@ConfigurationProperties(prefix = "discord")
public class DiscordProperties {

private final String token;
private final String channelId;

public DiscordProperties(String token, String channelId) {
validate(token);
validate(channelId);
this.token = token;
this.channelId = channelId;
}

private void validate(String element) {
if (element == null || element.isBlank()) {
throw new GssException(ErrorCode.DISCORD_PROPERTIES_EMPTY);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devoops.domain.notifier;

import com.devoops.exception.message.ErrorMessageResolver;
import com.devoops.exception.notifier.NotifyPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ErrorNotifier {

private final NotifyPort notifyPort;

public void notify(Throwable throwable) {
String message = ErrorMessageResolver.resolve(throwable);
notifyPort.sendMessage(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.devoops.exception;

import com.devoops.exception.errorcode.ErrorCode;
import org.springframework.http.HttpStatus;

public record ErrorResponse(
String code,
String status,
String message
) {

public ErrorResponse(ErrorCode errorCode) {
this(
errorCode.name(),
HttpStatus.valueOf(errorCode.getStatusCode()).name(),
errorCode.getMessage()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.devoops.exception;

import com.devoops.domain.notifier.ErrorNotifier;
import com.devoops.exception.custom.GssException;
import com.devoops.exception.errorcode.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

private final ErrorNotifier errorNotifier;

@ExceptionHandler(GssException.class)
public ResponseEntity<ErrorResponse> handleGssException(GssException exception) {
log.error("Custom GssException occurred: {}", exception.getMessage(), exception);
errorNotifier.notify(exception);
return toResponse(exception.getErrorCode());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception exception) {
log.error("Unhandled exception occurred", exception);
errorNotifier.notify(exception);
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR);
}

private ResponseEntity<ErrorResponse> toResponse(ErrorCode errorCode) {
ErrorResponse errorResponse = new ErrorResponse(errorCode);
return ResponseEntity.status(errorCode.getStatusCode())
.body(errorResponse);
}
}
17 changes: 0 additions & 17 deletions src/test/resources/application-ci.yml

This file was deleted.

Loading
Loading