diff --git a/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java index ee7183d..a15492f 100644 --- a/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java @@ -11,11 +11,11 @@ @Getter @AllArgsConstructor -@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) // 키의 이름과 순서 보장 @Schema(description = "공통 응답 포맷") public class ApiResponse { - @JsonProperty("isSuccess") + @JsonProperty("isSuccess") // JSON 키를 항상 isSuccess로 유지 @Schema(description = "성공 여부", example = "true") private final Boolean isSuccess; @@ -25,7 +25,7 @@ public class ApiResponse { @Schema(description = "상태 메시지", example = "성공입니다.") private final String message; - @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonInclude(JsonInclude.Include.NON_NULL) // result가 없을 때 필드를 아예 생략 @Schema(description = "결과 데이터") private T result; diff --git a/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java b/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java index c37f443..fc95d69 100644 --- a/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java @@ -4,6 +4,7 @@ import com.example.umc9th.global.apiPayload.ApiResponse; import com.example.umc9th.global.apiPayload.code.ErrorReasonDTO; import com.example.umc9th.global.apiPayload.code.status.ErrorStatus; +import com.example.umc9th.global.notifier.DiscordNotifier; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.RequiredArgsConstructor; @@ -26,11 +27,18 @@ @RequiredArgsConstructor @Slf4j -@RestControllerAdvice(annotations = {RestController.class}) +@RestControllerAdvice(annotations = {RestController.class}) // 전역 예외 처리기임을 선언하고, @RestController가 붙은 클래스들만 대상으로 +// ResponseEntityExceptionHandler: Spring MVC의 기본 예외 처리 public class ExceptionAdvice extends ResponseEntityExceptionHandler { + private final DiscordNotifier discordNotifier; + + /** + * @Validated 어노테이션으로 인한 유효성 검사 실패 시 (주로 @RequestParam, @PathVariable) 이 핸들러가 호출 + */ @ExceptionHandler public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + // 여러 에러 메세지 중 첫번째 메세지만 String errorMessage = e.getConstraintViolations().stream() .map(constraintViolation -> constraintViolation.getMessage()) .findFirst() @@ -39,62 +47,105 @@ public ResponseEntity validation(ConstraintViolationException e, WebRequ return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request); } + /** + * @Valid 어노테이션으로 인한 유효성 검사 실패 시 (주로 @RequestBody DTO) 이 핸들러가 호출 + * Spring의 기본 핸들러를 오버라이드 + */ @Override public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + // 유효성 검사에 실패한 필드와 에러 메시지를 저장할 Map Map errors = new LinkedHashMap<>(); - + // 예외 객체(e)의 BindingResult에서 모든 필드 에러(FieldErrors)를 가져와 스트림으로 처리 e.getBindingResult().getFieldErrors().stream() .forEach(fieldError -> { - String fieldName = fieldError.getField(); - String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + String fieldName = fieldError.getField(); // 에러가 발생한 필드 이름 + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); // 에러에 설정된 기본 메세지 + // Map에 필드 이름(key)과 에러 메시지(value)를 저장 + // 만약 이미 동일한 필드 이름(key)이 존재하면, 기존 메시지 뒤에 새 메시지 errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); }); return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors); } + /** + * 위에서 명시적으로 처리되지 않은 모든 Exception을 처리하는 핸들러 + */ @ExceptionHandler public ResponseEntity exception(Exception e, WebRequest request) { + // 발생한 예외의 전체 스택 트레이스(호출 경로)를 서버 로그에 출력 (디버깅 용도) e.printStackTrace(); + // 요청 URI 추출 + String uri = request.getDescription(false).replace("uri=", ""); + + // Discord 알림 전송 + discordNotifier.sendErrorNotification(e, uri); + + // _INTERNAL_SERVER_ERROR (HTTP 500) 상태를 기반으로 표준 응답생성 return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(),request, e.getMessage()); } + + /** + * 개발자가 직접 정의한 'GeneralException' (커스텀 비즈니스 예외)을 처리하는 핸들러 + */ @ExceptionHandler(value = GeneralException.class) public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { + // 예외 객체(generalException)로부터 미리 정의된 에러 이유(ErrorReasonDTO)를 가져옴 ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + + // 가져온 에러 이유를 기반으로 표준 응답(ResponseEntity)을 생성하여 반환 return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request); } + /** + * [Helper 메소드] GeneralException 처리를 위한 표준 응답 ResponseEntity를 생성 + */ private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { + // ApiResponse의 정적 메소드 onFailure를 호출하여, 표준 실패 응답 본문(body)을 생성 + // data 필드는 null로 설정 ApiResponse body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null); // e.printStackTrace(); + // HttpServletRequest를 WebRequest로 래핑(Wrapping) WebRequest webRequest = new ServletWebRequest(request); + + // 부모 클래스(ResponseEntityExceptionHandler)의 handleExceptionInternal 메소드를 호출 return super.handleExceptionInternal( - e, - body, - headers, - reason.getHttpStatus(), - webRequest + e, // 원본 예외 객체 + body, // 표준 응답 본문 (ApiResponse) + headers, // HTTP 헤더 (이 경우 null이 전달됨) + reason.getHttpStatus(), // ErrorReasonDTO에 정의된 HTTP 상태 코드 + webRequest // 래핑된 WebRequest 객체 ); } + /** + * [Helper 메소드] 예측하지 못한 'Exception' 처리를 위한 표준 응답 ResponseEntity를 생성 + */ private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + // ApiResponse.onFailure를 호출하여 표준 실패 응답 본문(body)을 생성 + // data 필드에 어떤 에러인지 식별 가능한 메시지(errorPoint)를 담음 ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint); + + // 부모 클래스의 handleExceptionInternal 메소드를 호출 return super.handleExceptionInternal( - e, - body, - headers, - status, - request + e, // 원본 예외 객체 + body, // 표준 응답 본문 (ApiResponse) + headers, // HTTP 헤더 + status, // HTTP 상태 코드 (e.g., 500) + request // WebRequest 객체 ); } + /** + * [Helper 메소드] MethodArgumentNotValidException (DTO 유효성 검사) 처리를 위한 표준 응답 ResponseEntity를 생성합니다. + */ private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, WebRequest request, Map errorArgs) { ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs); @@ -107,6 +158,9 @@ private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHead ); } + /** + * [Helper 메소드] ConstraintViolationException (파라미터 유효성 검사) 처리를 위한 표준 응답 ResponseEntity를 생성합니다. + */ private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, HttpHeaders headers, WebRequest request) { ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); diff --git a/src/main/java/com/example/umc9th/global/notifier/DiscordNotifier.java b/src/main/java/com/example/umc9th/global/notifier/DiscordNotifier.java new file mode 100644 index 0000000..2c95ee2 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notifier/DiscordNotifier.java @@ -0,0 +1,87 @@ +package com.example.umc9th.global.notifier; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +/** + * 서버에서 발생한 예외(500 Internal Server Error)를 Discord Webhook으로 전송하는 역할 + * ExceptionAdvice에서 예외 발생 시 이 클래스를 호출하여 메시지를 전송 + */ +@Slf4j +@Component +public class DiscordNotifier { + + @Value("${webhook.discord-url:}") + private String discordWebhookUrl; // Discord로 메시지를 보낼 Webhook 주소 + + @Value("${spring.profiles.active:local}") + private String activeProfile; // 현재 실행 중인 환경 + + // REST API 요청을 보내기 위한 Spring의 HTTP 클라이언트 객체 + private final RestTemplate restTemplate = new RestTemplate(); + + /** + * 예외 발생 시 호출되는 핵심 메서드 + * @param e 발생한 예외 객체 + * @param requestUri 에러가 발생한 요청 URI + */ + public void sendErrorNotification(Exception e, String requestUri) { + // 1. 환경 분기 - 로컬 환경에서는 알림 보내지 않음 + if (!"prod".equalsIgnoreCase(activeProfile) && !"dev".equalsIgnoreCase(activeProfile)) { + log.info("[DISCORD-NOTIFY] Skip sending message in {} environment", activeProfile); + return; + } + + // 2. 에러 발생 시각을 문자열로 포맷팅 + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + // 3. Discord 메시지 포맷 생성 + String content = String.format( + "🚨 **서버 에러 발생** 🚨\n" + + "```%s```\n" + + "📅 발생 시간: %s\n" + + "🌍 환경: %s\n" + + "📡 요청 URL: %s\n" + + "🧩 예외: %s\n" + + "💬 메시지: %s", + e.getClass().getSimpleName(), // 간단한 클래스명 + timestamp, // 발생 시간 + activeProfile, // 현재 환경 + requestUri, // 요청 URL + e.getClass().getName(), // 예외 클래스 전체 이름 + e.getMessage() // 예외 메시지 (개발자에게 디버깅 정보 제공) + ); + + try { + // 4. HTTP 요청 헤더 생성 + // Content-Type: application/json 설정 (Discord Webhook은 JSON 형식으로만 메시지를 받음) + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // 5. Discord Webhook으로 전송할 JSON 본문 생성 + // Discord Webhook은 {"content": "메시지내용"} 형태의 JSON만 인식함 + Map payload = Map.of("content", content); + + // 6. HttpEntity로 헤더와 본문을 묶어서 하나의 HTTP 요청 객체로 만듦 + HttpEntity> entity = new HttpEntity<>(payload, headers); + + // 7. POST 요청 전송 - Discord Webhook에 메시지를 전송함 + // restTemplate.postForEntity(요청URL, 요청본문, 응답타입) + restTemplate.postForEntity(discordWebhookUrl, entity, String.class); + + log.info("[DISCORD-NOTIFY] Error sent to Discord channel successfully."); + + } catch (Exception ex) { + log.error("[DISCORD-NOTIFY] Failed to send message to Discord: {}", ex.getMessage()); + } + } +} diff --git a/src/main/java/com/example/umc9th/global/notifier/TestController.java b/src/main/java/com/example/umc9th/global/notifier/TestController.java new file mode 100644 index 0000000..c3781b6 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notifier/TestController.java @@ -0,0 +1,27 @@ +package com.example.umc9th.global.notifier; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 서버의 500 Internal Server Error 발생 시 + * ExceptionAdvice가 정상적으로 에러를 잡고 + * DiscordNotifier를 통해 알림이 전송되는지 확인하기 위한 테스트용 컨트롤러 + */ +@RestController +@RequestMapping("/test") +public class TestController { + + /** + * 강제로 500 Internal Server Error를 발생시키는 테스트용 API 엔드포인트 + * GET /test/error 요청 시 RuntimeException을 던져 서버 예외 상황을 의도적으로 유발 + * → ExceptionAdvice의 @ExceptionHandler(Exception.class)가 이 예외를 잡아 처리함 + * → DiscordNotifier가 자동으로 Webhook POST 요청을 전송 + */ + @GetMapping("/error") + public String triggerError() { + // 강제로 500 에러 발생시키기 + throw new RuntimeException("강제 500 에러 발생 테스트"); + } +}