RequestExceptionHandler.java

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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
        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 → NO_COVERAGE
2. handleErrorResponseException : negated conditional → NO_COVERAGE
        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 → NO_COVERAGE
            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 → NO_COVERAGE
        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
Location : handleMessageNotReadableException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMessageNotReadableException → NO_COVERAGE

70

1.1
Location : handleMethodNotSupportedException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMethodNotSupportedException → NO_COVERAGE

88

1.1
Location : handleMissingParameterException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMissingParameterException → NO_COVERAGE

106

1.1
Location : handleTypeMismatchException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleTypeMismatchException → NO_COVERAGE

124

1.1
Location : handleMediaTypeNotSupportedException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMediaTypeNotSupportedException → NO_COVERAGE

141

1.1
Location : handleMediaTypeNotAcceptableException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMediaTypeNotAcceptableException → NO_COVERAGE

159

1.1
Location : handleMissingRequestHeaderException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMissingRequestHeaderException → NO_COVERAGE

176

1.1
Location : handleServletRequestBindingException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleServletRequestBindingException → NO_COVERAGE

194

1.1
Location : handleNoResourceFoundException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleNoResourceFoundException → NO_COVERAGE

212

1.1
Location : handleMaxUploadSizeExceededException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleMaxUploadSizeExceededException → NO_COVERAGE

240

1.1
Location : handleErrorResponseException
Killed by : none
changed conditional boundary → NO_COVERAGE

2.2
Location : handleErrorResponseException
Killed by : none
negated conditional → NO_COVERAGE

246

1.1
Location : handleErrorResponseException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleErrorResponseException → NO_COVERAGE

258

1.1
Location : handleErrorResponseException
Killed by : none
replaced return value with null for com/project/auth/presentation/support/exception/RequestExceptionHandler::handleErrorResponseException → NO_COVERAGE

Active mutators

Tests examined


Report generated by PIT 1.19.1