diff --git a/build.gradle b/build.gradle index b27df96..125b67a 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' // WebClient implementation 'org.springframework.boot:spring-boot-starter-webflux' + // MacOS Silicon 라이브러리 누락 문제 + runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.1.104.Final:osx-aarch_64' } // 스니펫이 생성되는 디렉터리 경로를 설정 diff --git a/src/main/java/kusitms/backend/chatbot/presentation/ChatbotController.java b/src/main/java/kusitms/backend/chatbot/presentation/ChatbotController.java index 9bdfed4..f7884bb 100644 --- a/src/main/java/kusitms/backend/chatbot/presentation/ChatbotController.java +++ b/src/main/java/kusitms/backend/chatbot/presentation/ChatbotController.java @@ -1,7 +1,8 @@ package kusitms.backend.chatbot.presentation; import jakarta.validation.Valid; -import jakarta.websocket.server.PathParam; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import kusitms.backend.chatbot.application.ChatbotService; import kusitms.backend.chatbot.application.ClovaService; import kusitms.backend.chatbot.dto.request.GetClovaChatbotAnswerRequest; @@ -11,11 +12,13 @@ import kusitms.backend.global.dto.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/chatbot") +@Validated public class ChatbotController { private final ChatbotService chatbotService; private final ClovaService clovaService; @@ -23,9 +26,9 @@ public class ChatbotController { // 가이드 챗봇 답변 조회 API @GetMapping("/guide") public ResponseEntity> getGuideChatbotAnswer( - @PathParam("stadiumName") String stadiumName, - @PathParam("categoryName") String categoryName, - @PathParam("orderNumber") int orderNumber){ + @RequestParam("stadiumName") @NotBlank String stadiumName, + @RequestParam("categoryName") @NotBlank String categoryName, + @RequestParam("orderNumber") @Min(1) int orderNumber){ GetGuideChatbotAnswerResponse response = chatbotService.getGuideChatbotAnswer(stadiumName, categoryName, orderNumber); diff --git a/src/main/java/kusitms/backend/global/dto/ApiResponse.java b/src/main/java/kusitms/backend/global/dto/ApiResponse.java index 20efaa0..f2001d2 100644 --- a/src/main/java/kusitms/backend/global/dto/ApiResponse.java +++ b/src/main/java/kusitms/backend/global/dto/ApiResponse.java @@ -30,4 +30,9 @@ public static ResponseEntity> onFailure(BaseErrorCode code) { ApiResponse response = new ApiResponse<>(false, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), null); return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(response); } + + public static ResponseEntity onFailure(BaseErrorCode code, String message) { + ApiResponse response = new ApiResponse<>(false, code.getReasonHttpStatus().getCode(), message, null); + return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(response); + } } \ No newline at end of file diff --git a/src/main/java/kusitms/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/kusitms/backend/global/exception/GlobalExceptionHandler.java index c4a3a7e..6599639 100644 --- a/src/main/java/kusitms/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/kusitms/backend/global/exception/GlobalExceptionHandler.java @@ -1,14 +1,28 @@ package kusitms.backend.global.exception; +import jakarta.validation.ConstraintViolationException; import kusitms.backend.global.dto.ApiResponse; import kusitms.backend.global.dto.ErrorReasonDto; import kusitms.backend.global.status.ErrorStatus; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import java.util.List; +import java.util.stream.Collectors; + @Slf4j @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @@ -16,26 +30,105 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { // 커스텀 예외 처리 @ExceptionHandler(CustomException.class) public ResponseEntity> handleCustomException(CustomException e) { - log.error("CustomException occurred: {}", e.getMessage()); + logError(e.getMessage(), e); return ApiResponse.onFailure(e.getErrorCode()); } // Security 인증 관련 처리 @ExceptionHandler(SecurityException.class) public ResponseEntity> handleSecurityException(SecurityException e) { - log.error("SecurityException: {}", e.getMessage()); + logError(e.getMessage(), e); return ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED); } - // 기타 Exception 처리 + // IllegalArgumentException 처리 (잘못된 인자가 전달된 경우) + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + String errorMessage = "잘못된 요청입니다: " + e.getMessage(); + logError("IllegalArgumentException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage); + } + + // ConstraintViolationException 처리 (쿼리 파라미터에 올바른 값이 들어오지 않은 경우) + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleValidationParameterError(ConstraintViolationException ex) { + String errorMessage = ex.getMessage(); + logError("ConstraintViolationException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage); + } + + // MissingServletRequestParameterException 처리 (필수 쿼리 파라미터가 입력되지 않은 경우) + @Override + protected ResponseEntity handleMissingServletRequestParameter(MissingServletRequestParameterException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "필수 파라미터 '" + ex.getParameterName() + "'가 없습니다."; + logError("MissingServletRequestParameterException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, errorMessage); + } + + // MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우) + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String combinedErrors = extractFieldErrors(ex.getBindingResult().getFieldErrors()); + logError("Validation error", combinedErrors); + return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST, combinedErrors); + } + + // NoHandlerFoundException 처리 (요청 경로에 매핑된 핸들러가 없는 경우) + @Override + protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "해당 경로에 대한 핸들러를 찾을 수 없습니다: " + ex.getRequestURL(); + logError("NoHandlerFoundException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._NOT_FOUND_HANDLER, errorMessage); + } + + // HttpRequestMethodNotSupportedException 처리 (지원하지 않는 HTTP 메소드 요청이 들어온 경우) + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "지원하지 않는 HTTP 메소드 요청입니다: " + ex.getMethod(); + logError("HttpRequestMethodNotSupportedException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._METHOD_NOT_ALLOWED, errorMessage); + } + + // HttpMediaTypeNotSupportedException 처리 (지원하지 않는 미디어 타입 요청이 들어온 경우) + @Override + protected ResponseEntity handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "지원하지 않는 미디어 타입입니다: " + ex.getContentType(); + logError("HttpMediaTypeNotSupportedException", errorMessage); + return ApiResponse.onFailure(ErrorStatus._UNSUPPORTED_MEDIA_TYPE, errorMessage); + } + + // 내부 서버 에러 처리 (500) @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { - log.error("Exception: {}", e.getMessage()); - - if (e instanceof IllegalArgumentException) { - return ApiResponse.onFailure(ErrorStatus._BAD_REQUEST); - } - // 그 외 내부 서버 오류로 처리 + // 서버 내부 에러 발생 시 로그에 예외 내용 기록 + logError(e.getMessage(), e); return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR); } + + // 유효성 검증 오류 메시지 추출 메서드 (FieldErrors) + private String extractFieldErrors(List fieldErrors) { + return fieldErrors.stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + } + + // 로그 기록 메서드 + private void logError(String message, Object errorDetails) { + log.error("{}: {}", message, errorDetails); + } } \ No newline at end of file diff --git a/src/main/java/kusitms/backend/global/status/ErrorStatus.java b/src/main/java/kusitms/backend/global/status/ErrorStatus.java index fc9fbeb..284f494 100644 --- a/src/main/java/kusitms/backend/global/status/ErrorStatus.java +++ b/src/main/java/kusitms/backend/global/status/ErrorStatus.java @@ -10,11 +10,13 @@ @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { // Global Error - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"500", "서버에서 요청을 처리 하는 동안 오류가 발생했습니다."), + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"500", "서버 내부 오류가 발생했습니다. 자세한 사항은 백엔드 팀에 문의하세요."), _BAD_REQUEST(HttpStatus.BAD_REQUEST,"400", "입력 값이 잘못된 요청 입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"401", "인증이 필요 합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "403", "금지된 요청 입니다."), - _METHOD_NOT_ALLOWED(HttpStatus.FORBIDDEN, "403", "금지된 요청 입니다."), + _METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "405", "허용되지 않은 요청 메소드입니다."), + _UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "415", "지원되지 않는 미디어 타입입니다."), + _NOT_FOUND_HANDLER(HttpStatus.NOT_FOUND, "404", "해당 경로에 대한 핸들러를 찾을 수 없습니다."), _FAILED_SAVE_REDIS(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-001", "Redis 저장에 실패하였습니다."), _FAILED_SERIALIZING_JSON(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-002", "JSON으로의 직렬화에 실패했습니다."), _FAILED_DESERIALIZING_JSON(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-003", "JSON에서 역직렬화에 실패했습니다.")