| 1 | package com.project.auth.presentation.support.exception; | |
| 2 | ||
| 3 | import com.project.auth.application.support.exception.CommonErrorCode; | |
| 4 | import com.project.auth.application.support.logging.LogSanitizer; | |
| 5 | import com.project.auth.presentation.support.response.ApiResult; | |
| 6 | import com.project.auth.presentation.support.response.ApiResultFactory; | |
| 7 | import jakarta.servlet.http.HttpServletRequest; | |
| 8 | import org.slf4j.Logger; | |
| 9 | import org.slf4j.LoggerFactory; | |
| 10 | import org.springframework.core.annotation.Order; | |
| 11 | import org.springframework.beans.TypeMismatchException; | |
| 12 | import org.springframework.core.Ordered; | |
| 13 | import org.springframework.http.HttpStatusCode; | |
| 14 | import org.springframework.http.ResponseEntity; | |
| 15 | import org.springframework.http.converter.HttpMessageNotReadableException; | |
| 16 | import org.springframework.web.ErrorResponse; | |
| 17 | import org.springframework.web.ErrorResponseException; | |
| 18 | import org.springframework.web.HttpMediaTypeNotAcceptableException; | |
| 19 | import org.springframework.web.HttpMediaTypeNotSupportedException; | |
| 20 | import org.springframework.web.HttpRequestMethodNotSupportedException; | |
| 21 | import org.springframework.web.bind.MissingServletRequestParameterException; | |
| 22 | import org.springframework.web.bind.MissingRequestHeaderException; | |
| 23 | import org.springframework.web.bind.ServletRequestBindingException; | |
| 24 | import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 25 | import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 26 | import org.springframework.web.multipart.MaxUploadSizeExceededException; | |
| 27 | import org.springframework.web.server.ResponseStatusException; | |
| 28 | import org.springframework.web.servlet.resource.NoResourceFoundException; | |
| 29 | ||
| 30 | @RestControllerAdvice | |
| 31 | @Order(Ordered.HIGHEST_PRECEDENCE + 20) | |
| 32 | public class RequestExceptionHandler { | |
| 33 | ||
| 34 | private static final Logger log = LoggerFactory.getLogger(RequestExceptionHandler.class); | |
| 35 | ||
| 36 | private final ApiResultFactory apiResultFactory; | |
| 37 | ||
| 38 | public RequestExceptionHandler(ApiResultFactory apiResultFactory) { | |
| 39 | this.apiResultFactory = apiResultFactory; | |
| 40 | } | |
| 41 | ||
| 42 | @ExceptionHandler(HttpMessageNotReadableException.class) | |
| 43 | public ResponseEntity<ApiResult<Void>> handleMessageNotReadableException( | |
| 44 | HttpMessageNotReadableException exception, | |
| 45 | HttpServletRequest request | |
| 46 | ) { | |
| 47 | log.warn("Request body not readable. method={} requestPath={} errorCode={}", | |
| 48 | request.getMethod(), | |
| 49 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 50 | PresentationErrorCode.INVALID_REQUEST_BODY.code()); | |
| 51 | ||
| 52 |
1
1. handleMessageNotReadableException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMessageNotReadableException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.INVALID_REQUEST_BODY)) |
| 53 | .body(apiResultFactory.failure( | |
| 54 | PresentationErrorCode.INVALID_REQUEST_BODY.code(), | |
| 55 | PresentationErrorCode.INVALID_REQUEST_BODY.message() | |
| 56 | )); | |
| 57 | } | |
| 58 | ||
| 59 | @ExceptionHandler(HttpRequestMethodNotSupportedException.class) | |
| 60 | public ResponseEntity<ApiResult<Void>> handleMethodNotSupportedException( | |
| 61 | HttpRequestMethodNotSupportedException exception, | |
| 62 | HttpServletRequest request | |
| 63 | ) { | |
| 64 | log.warn("Method not supported. method={} requestPath={} attemptedMethod={} errorCode={}", | |
| 65 | request.getMethod(), | |
| 66 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 67 | LogSanitizer.normalize(exception.getMethod()), | |
| 68 | PresentationErrorCode.METHOD_NOT_ALLOWED.code()); | |
| 69 | ||
| 70 |
1
1. handleMethodNotSupportedException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMethodNotSupportedException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.METHOD_NOT_ALLOWED)) |
| 71 | .body(apiResultFactory.failure( | |
| 72 | PresentationErrorCode.METHOD_NOT_ALLOWED.code(), | |
| 73 | PresentationErrorCode.METHOD_NOT_ALLOWED.message() | |
| 74 | )); | |
| 75 | } | |
| 76 | ||
| 77 | @ExceptionHandler(MissingServletRequestParameterException.class) | |
| 78 | public ResponseEntity<ApiResult<Void>> handleMissingParameterException( | |
| 79 | MissingServletRequestParameterException exception, | |
| 80 | HttpServletRequest request | |
| 81 | ) { | |
| 82 | log.warn("Missing request parameter. method={} requestPath={} parameter={} errorCode={}", | |
| 83 | request.getMethod(), | |
| 84 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 85 | LogSanitizer.normalize(exception.getParameterName()), | |
| 86 | PresentationErrorCode.MISSING_PARAMETER.code()); | |
| 87 | ||
| 88 |
1
1. handleMissingParameterException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMissingParameterException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.MISSING_PARAMETER)) |
| 89 | .body(apiResultFactory.failure( | |
| 90 | PresentationErrorCode.MISSING_PARAMETER.code(), | |
| 91 | PresentationErrorCode.MISSING_PARAMETER.message() | |
| 92 | )); | |
| 93 | } | |
| 94 | ||
| 95 | @ExceptionHandler(TypeMismatchException.class) | |
| 96 | public ResponseEntity<ApiResult<Void>> handleTypeMismatchException( | |
| 97 | TypeMismatchException exception, | |
| 98 | HttpServletRequest request | |
| 99 | ) { | |
| 100 | log.warn("Type mismatch for parameter. method={} requestPath={} parameter={} errorCode={}", | |
| 101 | request.getMethod(), | |
| 102 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 103 | LogSanitizer.normalize(exception.getPropertyName()), | |
| 104 | PresentationErrorCode.TYPE_MISMATCH.code()); | |
| 105 | ||
| 106 |
1
1. handleTypeMismatchException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleTypeMismatchException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.TYPE_MISMATCH)) |
| 107 | .body(apiResultFactory.failure( | |
| 108 | PresentationErrorCode.TYPE_MISMATCH.code(), | |
| 109 | PresentationErrorCode.TYPE_MISMATCH.message() | |
| 110 | )); | |
| 111 | } | |
| 112 | ||
| 113 | @ExceptionHandler(HttpMediaTypeNotSupportedException.class) | |
| 114 | public ResponseEntity<ApiResult<Void>> handleMediaTypeNotSupportedException( | |
| 115 | HttpMediaTypeNotSupportedException exception, | |
| 116 | HttpServletRequest request | |
| 117 | ) { | |
| 118 | log.warn("Unsupported media type. method={} requestPath={} contentType={} errorCode={}", | |
| 119 | request.getMethod(), | |
| 120 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 121 | LogSanitizer.normalize(String.valueOf(exception.getContentType())), | |
| 122 | PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.code()); | |
| 123 | ||
| 124 |
1
1. handleMediaTypeNotSupportedException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMediaTypeNotSupportedException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE)) |
| 125 | .body(apiResultFactory.failure( | |
| 126 | PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.code(), | |
| 127 | PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.message() | |
| 128 | )); | |
| 129 | } | |
| 130 | ||
| 131 | @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) | |
| 132 | public ResponseEntity<ApiResult<Void>> handleMediaTypeNotAcceptableException( | |
| 133 | HttpMediaTypeNotAcceptableException exception, | |
| 134 | HttpServletRequest request | |
| 135 | ) { | |
| 136 | log.warn("Not acceptable media type. method={} requestPath={} errorCode={}", | |
| 137 | request.getMethod(), | |
| 138 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 139 | PresentationErrorCode.NOT_ACCEPTABLE.code()); | |
| 140 | ||
| 141 |
1
1. handleMediaTypeNotAcceptableException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMediaTypeNotAcceptableException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.NOT_ACCEPTABLE)) |
| 142 | .body(apiResultFactory.failure( | |
| 143 | PresentationErrorCode.NOT_ACCEPTABLE.code(), | |
| 144 | PresentationErrorCode.NOT_ACCEPTABLE.message() | |
| 145 | )); | |
| 146 | } | |
| 147 | ||
| 148 | @ExceptionHandler(MissingRequestHeaderException.class) | |
| 149 | public ResponseEntity<ApiResult<Void>> handleMissingRequestHeaderException( | |
| 150 | MissingRequestHeaderException exception, | |
| 151 | HttpServletRequest request | |
| 152 | ) { | |
| 153 | log.warn("Missing request header. method={} requestPath={} header={} errorCode={}", | |
| 154 | request.getMethod(), | |
| 155 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 156 | LogSanitizer.normalize(exception.getHeaderName()), | |
| 157 | PresentationErrorCode.MISSING_HEADER.code()); | |
| 158 | ||
| 159 |
1
1. handleMissingRequestHeaderException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMissingRequestHeaderException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.MISSING_HEADER)) |
| 160 | .body(apiResultFactory.failure( | |
| 161 | PresentationErrorCode.MISSING_HEADER.code(), | |
| 162 | PresentationErrorCode.MISSING_HEADER.message() | |
| 163 | )); | |
| 164 | } | |
| 165 | ||
| 166 | @ExceptionHandler(ServletRequestBindingException.class) | |
| 167 | public ResponseEntity<ApiResult<Void>> handleServletRequestBindingException( | |
| 168 | ServletRequestBindingException exception, | |
| 169 | HttpServletRequest request | |
| 170 | ) { | |
| 171 | log.warn("Request binding failed. method={} requestPath={} errorCode={}", | |
| 172 | request.getMethod(), | |
| 173 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 174 | PresentationErrorCode.REQUEST_BINDING_FAILED.code()); | |
| 175 | ||
| 176 |
1
1. handleServletRequestBindingException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleServletRequestBindingException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.REQUEST_BINDING_FAILED)) |
| 177 | .body(apiResultFactory.failure( | |
| 178 | PresentationErrorCode.REQUEST_BINDING_FAILED.code(), | |
| 179 | PresentationErrorCode.REQUEST_BINDING_FAILED.message() | |
| 180 | )); | |
| 181 | } | |
| 182 | ||
| 183 | @ExceptionHandler(NoResourceFoundException.class) | |
| 184 | public ResponseEntity<ApiResult<Void>> handleNoResourceFoundException( | |
| 185 | NoResourceFoundException exception, | |
| 186 | HttpServletRequest request | |
| 187 | ) { | |
| 188 | log.warn("No resource found. method={} requestPath={} resourcePath={} errorCode={}", | |
| 189 | request.getMethod(), | |
| 190 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 191 | LogSanitizer.requestPath(exception.getResourcePath()), | |
| 192 | PresentationErrorCode.RESOURCE_NOT_FOUND.code()); | |
| 193 | ||
| 194 |
1
1. handleNoResourceFoundException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleNoResourceFoundException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.RESOURCE_NOT_FOUND)) |
| 195 | .body(apiResultFactory.failure( | |
| 196 | PresentationErrorCode.RESOURCE_NOT_FOUND.code(), | |
| 197 | PresentationErrorCode.RESOURCE_NOT_FOUND.message() | |
| 198 | )); | |
| 199 | } | |
| 200 | ||
| 201 | @ExceptionHandler(MaxUploadSizeExceededException.class) | |
| 202 | public ResponseEntity<ApiResult<Void>> handleMaxUploadSizeExceededException( | |
| 203 | MaxUploadSizeExceededException exception, | |
| 204 | HttpServletRequest request | |
| 205 | ) { | |
| 206 | log.warn("Upload payload too large. method={} requestPath={} maxBytes={} errorCode={}", | |
| 207 | request.getMethod(), | |
| 208 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 209 | exception.getMaxUploadSize(), | |
| 210 | PresentationErrorCode.PAYLOAD_TOO_LARGE.code()); | |
| 211 | ||
| 212 |
1
1. handleMaxUploadSizeExceededException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMaxUploadSizeExceededException → KILLED |
return ResponseEntity.status(ApiErrorHttpStatusMapper.map(PresentationErrorCode.PAYLOAD_TOO_LARGE)) |
| 213 | .body(apiResultFactory.failure( | |
| 214 | PresentationErrorCode.PAYLOAD_TOO_LARGE.code(), | |
| 215 | PresentationErrorCode.PAYLOAD_TOO_LARGE.message() | |
| 216 | )); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * 더 구체적인 핸들러로 잡히지 않은, 프레임워크가 직접 던진 상태 캐리어 예외 처리. | |
| 221 | * 상위에서 결정된 4xx vs 5xx 분기가 클라이언트까지 보존되도록 carriedStatus를 그대로 사용한다. | |
| 222 | * | |
| 223 | * 본문 분류: 5xx는 내부 분류 노출을 막기 위해 COMMON-999로 정규화하고, 4xx는 | |
| 224 | * UNHANDLED_CLIENT_ERROR로 폴백한다. 클라이언트 SDK가 "요청 잘못" vs "서버 결함"을 | |
| 225 | * 여전히 구분할 수 있게 하기 위함이다. | |
| 226 | * | |
| 227 | * ResponseStatusException과 ErrorResponseException은 둘 다 {@link ErrorResponse}를 구현하면서 | |
| 228 | * 동시에 {@link Throwable}을 상속하므로 로그 호출부의 (Throwable) 캐스트가 안전하다. | |
| 229 | * 향후 Throwable이 아닌 ErrorResponse 구현체를 이 @ExceptionHandler 목록에 추가하면 | |
| 230 | * 런타임 ClassCastException이 발생하므로, 이 핸들러는 명시한 두 예외 타입으로만 한정한다. | |
| 231 | */ | |
| 232 | @ExceptionHandler({ResponseStatusException.class, ErrorResponseException.class}) | |
| 233 | public ResponseEntity<ApiResult<Void>> handleErrorResponseException( | |
| 234 | ErrorResponse exception, | |
| 235 | HttpServletRequest request | |
| 236 | ) { | |
| 237 | HttpStatusCode carriedStatus = exception.getStatusCode(); | |
| 238 | int statusValue = carriedStatus.value(); | |
| 239 | ||
| 240 |
2
1. handleErrorResponseException : changed conditional boundary → SURVIVED 2. handleErrorResponseException : negated conditional → KILLED |
if (statusValue >= 500) { |
| 241 | log.error("Framework-thrown 5xx status. method={} requestPath={} status={}", | |
| 242 | request.getMethod(), | |
| 243 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 244 | statusValue, | |
| 245 | (Throwable) exception); | |
| 246 |
1
1. handleErrorResponseException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleErrorResponseException → KILLED |
return ResponseEntity.status(carriedStatus) |
| 247 | .body(apiResultFactory.failure( | |
| 248 | CommonErrorCode.INTERNAL_SERVER_ERROR.code(), | |
| 249 | CommonErrorCode.INTERNAL_SERVER_ERROR.message() | |
| 250 | )); | |
| 251 | } | |
| 252 | ||
| 253 | log.warn("Framework-thrown 4xx status. method={} requestPath={} status={} errorCode={}", | |
| 254 | request.getMethod(), | |
| 255 | LogSanitizer.requestPath(request.getRequestURI()), | |
| 256 | statusValue, | |
| 257 | PresentationErrorCode.UNHANDLED_CLIENT_ERROR.code()); | |
| 258 |
1
1. handleErrorResponseException : replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleErrorResponseException → KILLED |
return ResponseEntity.status(carriedStatus) |
| 259 | .body(apiResultFactory.failure( | |
| 260 | PresentationErrorCode.UNHANDLED_CLIENT_ERROR.code(), | |
| 261 | PresentationErrorCode.UNHANDLED_CLIENT_ERROR.message() | |
| 262 | )); | |
| 263 | } | |
| 264 | ||
| 265 | } | |
Mutations | ||
| 52 |
1.1 |
|
| 70 |
1.1 |
|
| 88 |
1.1 |
|
| 106 |
1.1 |
|
| 124 |
1.1 |
|
| 141 |
1.1 |
|
| 159 |
1.1 |
|
| 176 |
1.1 |
|
| 194 |
1.1 |
|
| 212 |
1.1 |
|
| 240 |
1.1 2.2 |
|
| 246 |
1.1 |
|
| 258 |
1.1 |