From 498b9ba7f75ce95c628e8a21580822a952624b49 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 27 May 2026 21:48:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?,=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../global/exception/BootSignalException.java | 20 +++ .../global/exception/ErrorCode.java | 34 +++++ .../exception/GlobalExceptionHandler.java | 135 ++++++++++++++++++ .../global/response/ApiResponse.java | 16 +++ .../global/response/ApiResponseAdvice.java | 81 +++++++++++ .../global/response/ErrorResponse.java | 33 +++++ .../Work24CrawlerControllerTest.java | 70 +++++++++ .../exception/GlobalExceptionHandlerTest.java | 34 +++++ .../exception/ValidationTestController.java | 27 ++++ 10 files changed, 454 insertions(+) create mode 100644 src/main/java/com/bootsignal/global/exception/BootSignalException.java create mode 100644 src/main/java/com/bootsignal/global/exception/ErrorCode.java create mode 100644 src/main/java/com/bootsignal/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/bootsignal/global/response/ApiResponse.java create mode 100644 src/main/java/com/bootsignal/global/response/ApiResponseAdvice.java create mode 100644 src/main/java/com/bootsignal/global/response/ErrorResponse.java create mode 100644 src/test/java/com/bootsignal/domain/work24/controller/Work24CrawlerControllerTest.java create mode 100644 src/test/java/com/bootsignal/global/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/bootsignal/global/exception/ValidationTestController.java diff --git a/build.gradle b/build.gradle index 315b896..82d9f8d 100644 --- a/build.gradle +++ b/build.gradle @@ -57,3 +57,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/src/main/java/com/bootsignal/global/exception/BootSignalException.java b/src/main/java/com/bootsignal/global/exception/BootSignalException.java new file mode 100644 index 0000000..3e03c60 --- /dev/null +++ b/src/main/java/com/bootsignal/global/exception/BootSignalException.java @@ -0,0 +1,20 @@ +package com.bootsignal.global.exception; + +public class BootSignalException extends RuntimeException { + + private final ErrorCode errorCode; + + public BootSignalException(ErrorCode errorCode) { + super(errorCode.message()); + this.errorCode = errorCode; + } + + public BootSignalException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public ErrorCode errorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/bootsignal/global/exception/ErrorCode.java b/src/main/java/com/bootsignal/global/exception/ErrorCode.java new file mode 100644 index 0000000..983795f --- /dev/null +++ b/src/main/java/com/bootsignal/global/exception/ErrorCode.java @@ -0,0 +1,34 @@ +package com.bootsignal.global.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "요청 값이 올바르지 않습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "지원하지 않는 미디어 타입입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + ErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } + + public HttpStatus status() { + return status; + } + + public String code() { + return code; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/com/bootsignal/global/exception/GlobalExceptionHandler.java b/src/main/java/com/bootsignal/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1387ead --- /dev/null +++ b/src/main/java/com/bootsignal/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,135 @@ +package com.bootsignal.global.exception; + +import com.bootsignal.global.response.ApiResponse; +import com.bootsignal.global.response.ErrorResponse; +import jakarta.validation.ConstraintViolationException; +import java.io.IOException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +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.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BootSignalException.class) + public ResponseEntity> handleBootSignalException(BootSignalException exception) { + ErrorCode errorCode = exception.errorCode(); + return toResponse(errorCode, exception.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException( + MethodArgumentNotValidException exception + ) { + return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception)); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBindException(BindException exception) { + return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + ConstraintViolationException exception + ) { + // 메서드 파라미터 검증 실패를 필드 단위 오류로 변환한다. + List fieldErrors = exception.getConstraintViolations().stream() + .map(violation -> new ErrorResponse.FieldError( + violation.getPropertyPath().toString(), + violation.getMessage() + )) + .toList(); + return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class, + IllegalArgumentException.class + }) + public ResponseEntity> handleBadRequestException(Exception exception) { + return toResponse(ErrorCode.BAD_REQUEST, resolveMessage(exception, ErrorCode.BAD_REQUEST.message())); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFoundException(NoResourceFoundException exception) { + return toResponse(ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND.message()); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupportedException( + HttpRequestMethodNotSupportedException exception + ) { + return toResponse(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.message()); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException exception + ) { + return toResponse(ErrorCode.UNSUPPORTED_MEDIA_TYPE, ErrorCode.UNSUPPORTED_MEDIA_TYPE.message()); + } + + @ExceptionHandler(IOException.class) + public ResponseEntity> handleIOException(IOException exception) { + // 내부 상세 경로나 시스템 메시지가 응답에 노출되지 않도록 고정 메시지를 사용한다. + log.warn("I/O exception occurred", exception); + return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, "입출력 처리 중 오류가 발생했습니다."); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + log.error("Unexpected exception occurred", exception); + return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR.message()); + } + + private ResponseEntity> toResponse(ErrorCode errorCode, String message) { + return toResponse(errorCode, message, List.of()); + } + + private ResponseEntity> toResponse( + ErrorCode errorCode, + String message, + List fieldErrors + ) { + ErrorResponse errorResponse = ErrorResponse.of(errorCode, message, fieldErrors); + return ResponseEntity.status(errorCode.status()).body(ApiResponse.failure(errorResponse)); + } + + private List fieldErrors(BindException exception) { + return exception.getBindingResult().getFieldErrors().stream() + .map(this::toFieldError) + .toList(); + } + + private ErrorResponse.FieldError toFieldError(FieldError fieldError) { + return new ErrorResponse.FieldError(fieldError.getField(), resolveMessage(fieldError)); + } + + private String resolveMessage(ObjectError objectError) { + return resolveMessage(objectError.getDefaultMessage(), "유효하지 않은 값입니다."); + } + + private String resolveMessage(Exception exception, String defaultMessage) { + return resolveMessage(exception.getMessage(), defaultMessage); + } + + private String resolveMessage(String message, String defaultMessage) { + return message == null || message.isBlank() ? defaultMessage : message; + } +} diff --git a/src/main/java/com/bootsignal/global/response/ApiResponse.java b/src/main/java/com/bootsignal/global/response/ApiResponse.java new file mode 100644 index 0000000..b9dba2c --- /dev/null +++ b/src/main/java/com/bootsignal/global/response/ApiResponse.java @@ -0,0 +1,16 @@ +package com.bootsignal.global.response; + +public record ApiResponse( + boolean success, + T data, + ErrorResponse error +) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse failure(ErrorResponse error) { + return new ApiResponse<>(false, null, error); + } +} diff --git a/src/main/java/com/bootsignal/global/response/ApiResponseAdvice.java b/src/main/java/com/bootsignal/global/response/ApiResponseAdvice.java new file mode 100644 index 0000000..8d0baaa --- /dev/null +++ b/src/main/java/com/bootsignal/global/response/ApiResponseAdvice.java @@ -0,0 +1,81 @@ +package com.bootsignal.global.response; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +@RestControllerAdvice(basePackages = "com.bootsignal") +@RequiredArgsConstructor +public class ApiResponseAdvice implements ResponseBodyAdvice { + + private final ObjectMapper objectMapper; + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + Class parameterType = returnType.getParameterType(); + // 파일, 바이트, 스트리밍 응답은 원본 응답 형식을 유지한다. + return !ApiResponse.class.isAssignableFrom(parameterType) + && !ErrorResponse.class.isAssignableFrom(parameterType) + && !Resource.class.isAssignableFrom(parameterType) + && !byte[].class.isAssignableFrom(parameterType) + && !StreamingResponseBody.class.isAssignableFrom(parameterType); + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response + ) { + if (body instanceof ApiResponse || isNoContent(response)) { + return body; + } + + if (body instanceof String) { + // String 응답은 전용 컨버터가 처리하므로 JSON 문자열로 직접 직렬화한다. + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return writeAsString(ApiResponse.success(body)); + } + + if (!isJsonCompatible(selectedContentType) && body != null) { + return body; + } + + return ApiResponse.success(body); + } + + private boolean isNoContent(ServerHttpResponse response) { + if (response instanceof ServletServerHttpResponse servletResponse) { + return servletResponse.getServletResponse().getStatus() == HttpStatus.NO_CONTENT.value(); + } + return false; + } + + private boolean isJsonCompatible(MediaType mediaType) { + return mediaType == null + || MediaType.APPLICATION_JSON.includes(mediaType) + || mediaType.includes(MediaType.APPLICATION_JSON); + } + + private String writeAsString(ApiResponse response) { + try { + return objectMapper.writeValueAsString(response); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("공통 응답 직렬화에 실패했습니다.", exception); + } + } +} diff --git a/src/main/java/com/bootsignal/global/response/ErrorResponse.java b/src/main/java/com/bootsignal/global/response/ErrorResponse.java new file mode 100644 index 0000000..92ede52 --- /dev/null +++ b/src/main/java/com/bootsignal/global/response/ErrorResponse.java @@ -0,0 +1,33 @@ +package com.bootsignal.global.response; + +import com.bootsignal.global.exception.ErrorCode; +import java.util.List; + +public record ErrorResponse( + String code, + String message, + List fieldErrors +) { + + public ErrorResponse { + fieldErrors = fieldErrors == null ? List.of() : List.copyOf(fieldErrors); + } + + public static ErrorResponse of(ErrorCode errorCode) { + return of(errorCode, errorCode.message()); + } + + public static ErrorResponse of(ErrorCode errorCode, String message) { + return of(errorCode, message, List.of()); + } + + public static ErrorResponse of(ErrorCode errorCode, String message, List fieldErrors) { + return new ErrorResponse(errorCode.code(), message, fieldErrors); + } + + public record FieldError( + String field, + String message + ) { + } +} diff --git a/src/test/java/com/bootsignal/domain/work24/controller/Work24CrawlerControllerTest.java b/src/test/java/com/bootsignal/domain/work24/controller/Work24CrawlerControllerTest.java new file mode 100644 index 0000000..31d6a0c --- /dev/null +++ b/src/test/java/com/bootsignal/domain/work24/controller/Work24CrawlerControllerTest.java @@ -0,0 +1,70 @@ +package com.bootsignal.domain.work24.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bootsignal.domain.work24.dto.Work24TrainingCourseOverview; +import com.bootsignal.domain.work24.dto.Work24TrainingCourseOverviewSaveResult; +import com.bootsignal.domain.work24.service.Work24CrawlerService; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = Work24CrawlerController.class) +@AutoConfigureMockMvc(addFilters = false) +class Work24CrawlerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private Work24CrawlerService work24CrawlerService; + + @Test + void crawlAndSaveWrapsSuccessResponse() throws Exception { + Work24TrainingCourseOverview overview = new Work24TrainingCourseOverview( + "https://example.com/course", + "[훈련대상자]\n- 취업 준비생", + "[인재상]\n- 데이터 처리 역량 확보", + Instant.parse("2026-05-27T00:00:00Z") + ); + given(work24CrawlerService.crawlAndSave(any())) + .willReturn(new Work24TrainingCourseOverviewSaveResult(overview, "build/crawled/test.json")); + + mockMvc.perform(post("/api/work24/training-course-overview/crawl") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.sourceUrl").value("https://example.com/course")) + .andExpect(jsonPath("$.data.savedPath").value("build/crawled/test.json")) + .andExpect(jsonPath("$.data.trainingTargetRequirements").value("[훈련대상자]\n- 취업 준비생")) + .andExpect(jsonPath("$.data.trainingGoal").value("[인재상]\n- 데이터 처리 역량 확보")) + .andExpect(jsonPath("$.data.crawledAt").value("2026-05-27T00:00:00Z")) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void crawlAndSaveReturnsBadRequestWhenServiceThrowsIllegalArgumentException() throws Exception { + given(work24CrawlerService.crawlAndSave(any())) + .willThrow(new IllegalArgumentException("크롤링 URL 형식이 올바르지 않습니다.")); + + mockMvc.perform(post("/api/work24/training-course-overview/crawl") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"url\":\"invalid-url\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error.message").value("크롤링 URL 형식이 올바르지 않습니다.")) + .andExpect(jsonPath("$.error.fieldErrors").isArray()); + } +} diff --git a/src/test/java/com/bootsignal/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/bootsignal/global/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..dab9dae --- /dev/null +++ b/src/test/java/com/bootsignal/global/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,34 @@ +package com.bootsignal.global.exception; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = ValidationTestController.class) +@AutoConfigureMockMvc(addFilters = false) +class GlobalExceptionHandlerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void validationExceptionReturnsFieldErrors() throws Exception { + mockMvc.perform(post("/test/validate") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.error.message").value("요청 값이 올바르지 않습니다.")) + .andExpect(jsonPath("$.error.fieldErrors[0].field").value("name")) + .andExpect(jsonPath("$.error.fieldErrors[0].message").value("이름은 필수입니다.")); + } +} diff --git a/src/test/java/com/bootsignal/global/exception/ValidationTestController.java b/src/test/java/com/bootsignal/global/exception/ValidationTestController.java new file mode 100644 index 0000000..33546a1 --- /dev/null +++ b/src/test/java/com/bootsignal/global/exception/ValidationTestController.java @@ -0,0 +1,27 @@ +package com.bootsignal.global.exception; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ValidationTestController { + + @PostMapping("/test/validate") + ValidationResponse validate(@Valid @RequestBody ValidationRequest request) { + return new ValidationResponse(request.name()); + } + + record ValidationRequest( + @NotBlank(message = "이름은 필수입니다.") + String name + ) { + } + + record ValidationResponse( + String name + ) { + } +}