Skip to content

Commit 9db3691

Browse files
authored
feat: 공통 응답, 예외 처리 구현
[FEAT] 공통 응답, 예외 처리 구현
2 parents 08eb665 + 498b9ba commit 9db3691

10 files changed

Lines changed: 454 additions & 0 deletions

File tree

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,7 @@ dependencies {
5757
tasks.named('test') {
5858
useJUnitPlatform()
5959
}
60+
61+
tasks.withType(JavaCompile).configureEach {
62+
options.encoding = 'UTF-8'
63+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.bootsignal.global.exception;
2+
3+
public class BootSignalException extends RuntimeException {
4+
5+
private final ErrorCode errorCode;
6+
7+
public BootSignalException(ErrorCode errorCode) {
8+
super(errorCode.message());
9+
this.errorCode = errorCode;
10+
}
11+
12+
public BootSignalException(ErrorCode errorCode, String message) {
13+
super(message);
14+
this.errorCode = errorCode;
15+
}
16+
17+
public ErrorCode errorCode() {
18+
return errorCode;
19+
}
20+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.bootsignal.global.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
public enum ErrorCode {
6+
BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다."),
7+
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "요청 값이 올바르지 않습니다."),
8+
NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
9+
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."),
10+
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "지원하지 않는 미디어 타입입니다."),
11+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.");
12+
13+
private final HttpStatus status;
14+
private final String code;
15+
private final String message;
16+
17+
ErrorCode(HttpStatus status, String code, String message) {
18+
this.status = status;
19+
this.code = code;
20+
this.message = message;
21+
}
22+
23+
public HttpStatus status() {
24+
return status;
25+
}
26+
27+
public String code() {
28+
return code;
29+
}
30+
31+
public String message() {
32+
return message;
33+
}
34+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.bootsignal.global.exception;
2+
3+
import com.bootsignal.global.response.ApiResponse;
4+
import com.bootsignal.global.response.ErrorResponse;
5+
import jakarta.validation.ConstraintViolationException;
6+
import java.io.IOException;
7+
import java.util.List;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.http.converter.HttpMessageNotReadableException;
11+
import org.springframework.validation.BindException;
12+
import org.springframework.validation.FieldError;
13+
import org.springframework.validation.ObjectError;
14+
import org.springframework.web.HttpMediaTypeNotSupportedException;
15+
import org.springframework.web.HttpRequestMethodNotSupportedException;
16+
import org.springframework.web.bind.MethodArgumentNotValidException;
17+
import org.springframework.web.bind.MissingServletRequestParameterException;
18+
import org.springframework.web.bind.annotation.ExceptionHandler;
19+
import org.springframework.web.bind.annotation.RestControllerAdvice;
20+
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
21+
import org.springframework.web.servlet.resource.NoResourceFoundException;
22+
23+
@Slf4j
24+
@RestControllerAdvice
25+
public class GlobalExceptionHandler {
26+
27+
@ExceptionHandler(BootSignalException.class)
28+
public ResponseEntity<ApiResponse<Void>> handleBootSignalException(BootSignalException exception) {
29+
ErrorCode errorCode = exception.errorCode();
30+
return toResponse(errorCode, exception.getMessage());
31+
}
32+
33+
@ExceptionHandler(MethodArgumentNotValidException.class)
34+
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValidException(
35+
MethodArgumentNotValidException exception
36+
) {
37+
return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception));
38+
}
39+
40+
@ExceptionHandler(BindException.class)
41+
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException exception) {
42+
return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception));
43+
}
44+
45+
@ExceptionHandler(ConstraintViolationException.class)
46+
public ResponseEntity<ApiResponse<Void>> handleConstraintViolationException(
47+
ConstraintViolationException exception
48+
) {
49+
// 메서드 파라미터 검증 실패를 필드 단위 오류로 변환한다.
50+
List<ErrorResponse.FieldError> fieldErrors = exception.getConstraintViolations().stream()
51+
.map(violation -> new ErrorResponse.FieldError(
52+
violation.getPropertyPath().toString(),
53+
violation.getMessage()
54+
))
55+
.toList();
56+
return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors);
57+
}
58+
59+
@ExceptionHandler({
60+
HttpMessageNotReadableException.class,
61+
MissingServletRequestParameterException.class,
62+
MethodArgumentTypeMismatchException.class,
63+
IllegalArgumentException.class
64+
})
65+
public ResponseEntity<ApiResponse<Void>> handleBadRequestException(Exception exception) {
66+
return toResponse(ErrorCode.BAD_REQUEST, resolveMessage(exception, ErrorCode.BAD_REQUEST.message()));
67+
}
68+
69+
@ExceptionHandler(NoResourceFoundException.class)
70+
public ResponseEntity<ApiResponse<Void>> handleNoResourceFoundException(NoResourceFoundException exception) {
71+
return toResponse(ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND.message());
72+
}
73+
74+
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
75+
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(
76+
HttpRequestMethodNotSupportedException exception
77+
) {
78+
return toResponse(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.message());
79+
}
80+
81+
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
82+
public ResponseEntity<ApiResponse<Void>> handleMediaTypeNotSupportedException(
83+
HttpMediaTypeNotSupportedException exception
84+
) {
85+
return toResponse(ErrorCode.UNSUPPORTED_MEDIA_TYPE, ErrorCode.UNSUPPORTED_MEDIA_TYPE.message());
86+
}
87+
88+
@ExceptionHandler(IOException.class)
89+
public ResponseEntity<ApiResponse<Void>> handleIOException(IOException exception) {
90+
// 내부 상세 경로나 시스템 메시지가 응답에 노출되지 않도록 고정 메시지를 사용한다.
91+
log.warn("I/O exception occurred", exception);
92+
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, "입출력 처리 중 오류가 발생했습니다.");
93+
}
94+
95+
@ExceptionHandler(Exception.class)
96+
public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
97+
log.error("Unexpected exception occurred", exception);
98+
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR.message());
99+
}
100+
101+
private ResponseEntity<ApiResponse<Void>> toResponse(ErrorCode errorCode, String message) {
102+
return toResponse(errorCode, message, List.of());
103+
}
104+
105+
private ResponseEntity<ApiResponse<Void>> toResponse(
106+
ErrorCode errorCode,
107+
String message,
108+
List<ErrorResponse.FieldError> fieldErrors
109+
) {
110+
ErrorResponse errorResponse = ErrorResponse.of(errorCode, message, fieldErrors);
111+
return ResponseEntity.status(errorCode.status()).body(ApiResponse.failure(errorResponse));
112+
}
113+
114+
private List<ErrorResponse.FieldError> fieldErrors(BindException exception) {
115+
return exception.getBindingResult().getFieldErrors().stream()
116+
.map(this::toFieldError)
117+
.toList();
118+
}
119+
120+
private ErrorResponse.FieldError toFieldError(FieldError fieldError) {
121+
return new ErrorResponse.FieldError(fieldError.getField(), resolveMessage(fieldError));
122+
}
123+
124+
private String resolveMessage(ObjectError objectError) {
125+
return resolveMessage(objectError.getDefaultMessage(), "유효하지 않은 값입니다.");
126+
}
127+
128+
private String resolveMessage(Exception exception, String defaultMessage) {
129+
return resolveMessage(exception.getMessage(), defaultMessage);
130+
}
131+
132+
private String resolveMessage(String message, String defaultMessage) {
133+
return message == null || message.isBlank() ? defaultMessage : message;
134+
}
135+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.bootsignal.global.response;
2+
3+
public record ApiResponse<T>(
4+
boolean success,
5+
T data,
6+
ErrorResponse error
7+
) {
8+
9+
public static <T> ApiResponse<T> success(T data) {
10+
return new ApiResponse<>(true, data, null);
11+
}
12+
13+
public static ApiResponse<Void> failure(ErrorResponse error) {
14+
return new ApiResponse<>(false, null, error);
15+
}
16+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.bootsignal.global.response;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.core.MethodParameter;
7+
import org.springframework.core.io.Resource;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.http.converter.HttpMessageConverter;
11+
import org.springframework.http.server.ServerHttpRequest;
12+
import org.springframework.http.server.ServerHttpResponse;
13+
import org.springframework.http.server.ServletServerHttpResponse;
14+
import org.springframework.web.bind.annotation.RestControllerAdvice;
15+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
16+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
17+
18+
@RestControllerAdvice(basePackages = "com.bootsignal")
19+
@RequiredArgsConstructor
20+
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
21+
22+
private final ObjectMapper objectMapper;
23+
24+
@Override
25+
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
26+
Class<?> parameterType = returnType.getParameterType();
27+
// 파일, 바이트, 스트리밍 응답은 원본 응답 형식을 유지한다.
28+
return !ApiResponse.class.isAssignableFrom(parameterType)
29+
&& !ErrorResponse.class.isAssignableFrom(parameterType)
30+
&& !Resource.class.isAssignableFrom(parameterType)
31+
&& !byte[].class.isAssignableFrom(parameterType)
32+
&& !StreamingResponseBody.class.isAssignableFrom(parameterType);
33+
}
34+
35+
@Override
36+
public Object beforeBodyWrite(
37+
Object body,
38+
MethodParameter returnType,
39+
MediaType selectedContentType,
40+
Class<? extends HttpMessageConverter<?>> selectedConverterType,
41+
ServerHttpRequest request,
42+
ServerHttpResponse response
43+
) {
44+
if (body instanceof ApiResponse<?> || isNoContent(response)) {
45+
return body;
46+
}
47+
48+
if (body instanceof String) {
49+
// String 응답은 전용 컨버터가 처리하므로 JSON 문자열로 직접 직렬화한다.
50+
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
51+
return writeAsString(ApiResponse.success(body));
52+
}
53+
54+
if (!isJsonCompatible(selectedContentType) && body != null) {
55+
return body;
56+
}
57+
58+
return ApiResponse.success(body);
59+
}
60+
61+
private boolean isNoContent(ServerHttpResponse response) {
62+
if (response instanceof ServletServerHttpResponse servletResponse) {
63+
return servletResponse.getServletResponse().getStatus() == HttpStatus.NO_CONTENT.value();
64+
}
65+
return false;
66+
}
67+
68+
private boolean isJsonCompatible(MediaType mediaType) {
69+
return mediaType == null
70+
|| MediaType.APPLICATION_JSON.includes(mediaType)
71+
|| mediaType.includes(MediaType.APPLICATION_JSON);
72+
}
73+
74+
private String writeAsString(ApiResponse<?> response) {
75+
try {
76+
return objectMapper.writeValueAsString(response);
77+
} catch (JsonProcessingException exception) {
78+
throw new IllegalStateException("공통 응답 직렬화에 실패했습니다.", exception);
79+
}
80+
}
81+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.bootsignal.global.response;
2+
3+
import com.bootsignal.global.exception.ErrorCode;
4+
import java.util.List;
5+
6+
public record ErrorResponse(
7+
String code,
8+
String message,
9+
List<FieldError> fieldErrors
10+
) {
11+
12+
public ErrorResponse {
13+
fieldErrors = fieldErrors == null ? List.of() : List.copyOf(fieldErrors);
14+
}
15+
16+
public static ErrorResponse of(ErrorCode errorCode) {
17+
return of(errorCode, errorCode.message());
18+
}
19+
20+
public static ErrorResponse of(ErrorCode errorCode, String message) {
21+
return of(errorCode, message, List.of());
22+
}
23+
24+
public static ErrorResponse of(ErrorCode errorCode, String message, List<FieldError> fieldErrors) {
25+
return new ErrorResponse(errorCode.code(), message, fieldErrors);
26+
}
27+
28+
public record FieldError(
29+
String field,
30+
String message
31+
) {
32+
}
33+
}

0 commit comments

Comments
 (0)