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 @@ -11,11 +11,11 @@

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) // 키의 이름과 순서 보장
@Schema(description = "공통 응답 포맷")
public class ApiResponse<T> {

@JsonProperty("isSuccess")
@JsonProperty("isSuccess") // JSON 키를 항상 isSuccess로 유지
@Schema(description = "성공 여부", example = "true")
private final Boolean isSuccess;

Expand All @@ -25,7 +25,7 @@ public class ApiResponse<T> {
@Schema(description = "상태 메시지", example = "성공입니다.")
private final String message;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonInclude(JsonInclude.Include.NON_NULL) // result가 없을 때 필드를 아예 생략
@Schema(description = "결과 데이터")
private T result;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Object> validation(ConstraintViolationException e, WebRequest request) {
// 여러 에러 메세지 중 첫번째 메세지만
String errorMessage = e.getConstraintViolations().stream()
.map(constraintViolation -> constraintViolation.getMessage())
.findFirst()
Expand All @@ -39,62 +47,105 @@ public ResponseEntity<Object> validation(ConstraintViolationException e, WebRequ
return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request);
}

/**
* @Valid 어노테이션으로 인한 유효성 검사 실패 시 (주로 @RequestBody DTO) 이 핸들러가 호출
* Spring의 기본 핸들러를 오버라이드
*/
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {

// 유효성 검사에 실패한 필드와 에러 메시지를 저장할 Map
Map<String, String> 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<Object> 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<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason,
HttpHeaders headers, HttpServletRequest request) {

// ApiResponse의 정적 메소드 onFailure를 호출하여, 표준 실패 응답 본문(body)을 생성
// data 필드는 null로 설정
ApiResponse<Object> 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<Object> handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus,
HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) {
// ApiResponse.onFailure를 호출하여 표준 실패 응답 본문(body)을 생성
// data 필드에 어떤 에러인지 식별 가능한 메시지(errorPoint)를 담음
ApiResponse<Object> 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<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus,
WebRequest request, Map<String, String> errorArgs) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs);
Expand All @@ -107,6 +158,9 @@ private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHead
);
}

/**
* [Helper 메소드] ConstraintViolationException (파라미터 유효성 검사) 처리를 위한 표준 응답 ResponseEntity를 생성합니다.
*/
private ResponseEntity<Object> handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus,
HttpHeaders headers, WebRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> payload = Map.of("content", content);

// 6. HttpEntity로 헤더와 본문을 묶어서 하나의 HTTP 요청 객체로 만듦
HttpEntity<Map<String, String>> 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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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 에러 발생 테스트");
}
}