Spring Boot에서 @RestControllerAdvice를 사용하여 애플리케이션 전체의 예외를 한 곳에서 처리하는 방법입니다.
@RestController
public class ScheduleController {
@GetMapping("/schedules/{id}")
public ResponseEntity<?> getSchedule(@PathVariable Long id) {
try {
ScheduleResponseDto schedule = scheduleService.getSchedule(id);
return ResponseEntity.ok(schedule);
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().body("서버 오류가 발생했습니다.");
}
}
@PostMapping("/schedules")
public ResponseEntity<?> createSchedule(@RequestBody ScheduleRequestDto request) {
try {
ScheduleResponseDto schedule = scheduleService.createSchedule(request);
return ResponseEntity.ok(schedule);
} catch (ValidationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.internalServerError().body("서버 오류가 발생했습니다.");
}
}
}
문제점:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 엔티티를 찾을 수 없는 경우
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleEntityNotFoundException(EntityNotFoundException e) {
log.warn("Entity not found: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(e.getMessage()));
}
// 중복 데이터 예외
@ExceptionHandler(DuplicateException.class)
public ResponseEntity<ApiResponse<Void>> handleDuplicateException(DuplicateException e) {
log.warn("Duplicate data: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(e.getMessage()));
}
// 접근 권한 없음
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException e) {
log.warn("Access denied: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("접근 권한이 없습니다."));
}
// 잘못된 인자 예외
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
log.warn("Invalid argument: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
// 예상치 못한 모든 예외
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception e) {
log.error("Unexpected error occurred", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."));
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// @Valid 유효성 검사 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
log.warn("Validation failed: {}", e.getMessage());
List<ErrorDetail> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new ErrorDetail(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()
))
.collect(Collectors.toList());
return ResponseEntity.badRequest()
.body(ApiResponse.validationError("입력값 검증에 실패했습니다.", errors));
}
// 필수 파라미터 누락
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParameter(MissingServletRequestParameterException e) {
log.warn("Missing parameter: {}", e.getMessage());
String message = String.format("필수 파라미터가 누락되었습니다: %s", e.getParameterName());
return ResponseEntity.badRequest()
.body(ApiResponse.error(message));
}
// JSON 파싱 실패
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleJsonParsingException(HttpMessageNotReadableException e) {
log.warn("JSON parsing failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error("요청 본문의 JSON 형식이 올바르지 않습니다."));
}
}
// 기본 비즈니스 예외
public abstract class BusinessException extends RuntimeException {
protected BusinessException(String message) {
super(message);
}
}
// 엔티티 못 찾음 예외
public class EntityNotFoundException extends BusinessException {
public EntityNotFoundException(String message) {
super(message);
}
public static EntityNotFoundException schedule(Long id) {
return new EntityNotFoundException("일정을 찾을 수 없습니다. ID: " + id);
}
public static EntityNotFoundException user(Long id) {
return new EntityNotFoundException("사용자를 찾을 수 없습니다. ID: " + id);
}
}
// 중복 데이터 예외
public class DuplicateException extends BusinessException {
public DuplicateException(String message) {
super(message);
}
public static DuplicateException username(String username) {
return new DuplicateException("이미 존재하는 사용자명입니다: " + username);
}
public static DuplicateException email(String email) {
return new DuplicateException("이미 존재하는 이메일입니다: " + email);
}
}
// 접근 권한 예외
public class AccessDeniedException extends BusinessException {
public AccessDeniedException(String message) {
super(message);
}
public static AccessDeniedException scheduleModify() {
return new AccessDeniedException("해당 일정을 수정할 권한이 없습니다.");
}
public static AccessDeniedException userInfo() {
return new AccessDeniedException("다른 사용자의 정보에 접근할 수 없습니다.");
}
}
public enum ErrorCode {
// 일반적인 에러
INVALID_REQUEST("E001", "잘못된 요청입니다."),
UNAUTHORIZED("E002", "인증이 필요합니다."),
FORBIDDEN("E003", "접근 권한이 없습니다."),
// 엔티티 관련
SCHEDULE_NOT_FOUND("E101", "일정을 찾을 수 없습니다."),
USER_NOT_FOUND("E102", "사용자를 찾을 수 없습니다."),
// 중복 관련
DUPLICATE_USERNAME("E201", "이미 존재하는 사용자명입니다."),
DUPLICATE_EMAIL("E202", "이미 존재하는 이메일입니다.");
private final String code;
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
public String getCode() { return code; }
public String getMessage() { return message; }
}
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String customMessage) {
super(customMessage);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
// 전역 예외 처리에서 에러 코드 활용
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
log.warn("Business exception: {} - {}", e.getErrorCode().getCode(), e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getErrorCode().getCode(), e.getMessage()));
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// Spring Security 인증 실패
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthenticationException(AuthenticationException e) {
log.warn("Authentication failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("인증에 실패했습니다."));
}
// JWT 토큰 관련 예외
@ExceptionHandler(JwtException.class)
public ResponseEntity<ApiResponse<Void>> handleJwtException(JwtException e) {
log.warn("JWT error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("유효하지 않은 토큰입니다."));
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// 파일 크기 초과
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSizeExceeded(MaxUploadSizeExceededException e) {
log.warn("File size exceeded: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ApiResponse.error("파일 크기가 너무 큽니다. 최대 10MB까지 업로드 가능합니다."));
}
// 지원하지 않는 파일 형식
@ExceptionHandler(UnsupportedFileTypeException.class)
public ResponseEntity<ApiResponse<Void>> handleUnsupportedFileType(UnsupportedFileTypeException e) {
log.warn("Unsupported file type: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error("지원하지 않는 파일 형식입니다."));
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// 데이터 무결성 위반
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation(DataIntegrityViolationException e) {
log.warn("Data integrity violation: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("데이터 무결성 제약 조건을 위반했습니다."));
}
// 낙관적 락 예외
@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handleOptimisticLockingFailure(OptimisticLockingFailureException e) {
log.warn("Optimistic locking failure: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("데이터가 다른 사용자에 의해 수정되었습니다. 새로고침 후 다시 시도해주세요."));
}
}
@RestController
@RequestMapping("/api/schedules")
public class ScheduleController {
private final ScheduleService scheduleService;
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ScheduleResponseDto>> getSchedule(@PathVariable Long id) {
// 예외 처리 코드 없음! Global Handler가 알아서 처리
ScheduleResponseDto schedule = scheduleService.getSchedule(id);
return ResponseEntity.ok(ApiResponse.success(schedule));
}
@PostMapping
public ResponseEntity<ApiResponse<ScheduleResponseDto>> createSchedule(
@Valid @RequestBody ScheduleRequestDto request,
@AuthenticationPrincipal CustomUserDetails userDetails) {
ScheduleResponseDto schedule = scheduleService.createSchedule(request, userDetails.getId());
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("일정이 생성되었습니다.", schedule));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ScheduleResponseDto>> updateSchedule(
@PathVariable Long id,
@Valid @RequestBody ScheduleRequestDto request,
@AuthenticationPrincipal CustomUserDetails userDetails) {
ScheduleResponseDto schedule = scheduleService.updateSchedule(id, request, userDetails.getId());
return ResponseEntity.ok(ApiResponse.success("일정이 수정되었습니다.", schedule));
}
}
@Service
@Transactional
public class ScheduleService {
public ScheduleResponseDto getSchedule(Long id) {
// EntityNotFoundException 발생 시 Global Handler가 처리
Schedule schedule = scheduleRepository.findByIdOrThrow(id);
return ScheduleResponseDto.from(schedule);
}
public ScheduleResponseDto updateSchedule(Long id, ScheduleRequestDto request, Long userId) {
Schedule schedule = scheduleRepository.findByIdOrThrow(id);
// 권한 검증 - AccessDeniedException 발생 시 Global Handler가 처리
if (!schedule.isOwner(userId)) {
throw AccessDeniedException.scheduleModify();
}
schedule.update(request.getTitle(), request.getContent(), request.getScheduledDate());
return ScheduleResponseDto.from(schedule);
}
public ScheduleResponseDto createSchedule(ScheduleRequestDto request, Long userId) {
// 중복 검증 - DuplicateException 발생 시 Global Handler가 처리
if (scheduleRepository.existsByTitleAndUserId(request.getTitle(), userId)) {
throw DuplicateException.scheduleTitle(request.getTitle());
}
Schedule schedule = Schedule.createSchedule(request.getTitle(), request.getContent(), userId);
Schedule savedSchedule = scheduleRepository.save(schedule);
return ScheduleResponseDto.from(savedSchedule);
}
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@Value("${spring.profiles.active:dev}")
private String activeProfile;
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Object>> handleGenericException(Exception e) {
log.error("Unexpected error occurred", e);
// 개발 환경에서는 상세한 에러 정보 제공
if ("dev".equals(activeProfile) || "local".equals(activeProfile)) {
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("exception", e.getClass().getSimpleName());
errorDetails.put("message", e.getMessage());
errorDetails.put("stackTrace", Arrays.toString(e.getStackTrace()));
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다.", errorDetails));
}
// 운영 환경에서는 간단한 메시지만
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."));
}
}
Global Exception Handler를 도입하면:
처음에는 핵심적인 예외들만 처리하고, 점진적으로 기능을 확장해나가는 것이 좋습니다!
추천 적용 순서:
1. EntityNotFoundException, IllegalArgumentException 등 기본 예외
2. 유효성 검사 예외 (@Valid)
3. 인증/인가 예외
4. 파일 업로드, 데이터베이스 등 특수 예외