Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 34 additions & 0 deletions src/main/java/com/bootsignal/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Void>> handleBootSignalException(BootSignalException exception) {
ErrorCode errorCode = exception.errorCode();
return toResponse(errorCode, exception.getMessage());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException exception
) {
return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception));
}

@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException exception) {
return toResponse(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.message(), fieldErrors(exception));
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolationException(
ConstraintViolationException exception
) {
// 메서드 파라미터 검증 실패를 필드 단위 오류로 변환한다.
List<ErrorResponse.FieldError> 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<ApiResponse<Void>> handleBadRequestException(Exception exception) {
return toResponse(ErrorCode.BAD_REQUEST, resolveMessage(exception, ErrorCode.BAD_REQUEST.message()));
}

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFoundException(NoResourceFoundException exception) {
return toResponse(ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND.message());
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(
HttpRequestMethodNotSupportedException exception
) {
return toResponse(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.message());
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMediaTypeNotSupportedException(
HttpMediaTypeNotSupportedException exception
) {
return toResponse(ErrorCode.UNSUPPORTED_MEDIA_TYPE, ErrorCode.UNSUPPORTED_MEDIA_TYPE.message());
}

@ExceptionHandler(IOException.class)
public ResponseEntity<ApiResponse<Void>> handleIOException(IOException exception) {
// 내부 상세 경로나 시스템 메시지가 응답에 노출되지 않도록 고정 메시지를 사용한다.
log.warn("I/O exception occurred", exception);
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, "입출력 처리 중 오류가 발생했습니다.");
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
log.error("Unexpected exception occurred", exception);
return toResponse(ErrorCode.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR.message());
}

private ResponseEntity<ApiResponse<Void>> toResponse(ErrorCode errorCode, String message) {
return toResponse(errorCode, message, List.of());
}

private ResponseEntity<ApiResponse<Void>> toResponse(
ErrorCode errorCode,
String message,
List<ErrorResponse.FieldError> fieldErrors
) {
ErrorResponse errorResponse = ErrorResponse.of(errorCode, message, fieldErrors);
return ResponseEntity.status(errorCode.status()).body(ApiResponse.failure(errorResponse));
}

private List<ErrorResponse.FieldError> 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;
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/bootsignal/global/response/ApiResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bootsignal.global.response;

public record ApiResponse<T>(
boolean success,
T data,
ErrorResponse error
) {

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}

public static ApiResponse<Void> failure(ErrorResponse error) {
return new ApiResponse<>(false, null, error);
}
}
Original file line number Diff line number Diff line change
@@ -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<Object> {

private final ObjectMapper objectMapper;

@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> 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<? extends HttpMessageConverter<?>> 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);
}
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/bootsignal/global/response/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -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<FieldError> 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<FieldError> fieldErrors) {
return new ErrorResponse(errorCode.code(), message, fieldErrors);
}

public record FieldError(
String field,
String message
) {
}
}
Loading
Loading