Global Exception Handler - 전역 예외 처리의 모든 것

geoson·2025년 6월 5일

Spring & 백엔드

목록 보기
12/18

Global Exception Handler란?

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("서버 오류가 발생했습니다.");
        }
    }
}

문제점:

  • 각 컨트롤러마다 중복된 예외 처리 코드
  • 일관성 없는 에러 응답 형식
  • 예외 처리 로직 변경 시 모든 컨트롤러 수정 필요

해결 방법: Global Exception Handler

1. 기본 전역 예외 처리

@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("서버 내부 오류가 발생했습니다."));
    }
}

2. 유효성 검사 예외 처리 ✅

@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 형식이 올바르지 않습니다."));
    }
}

커스텀 예외 클래스 설계

1. 기본 비즈니스 예외 🎯

// 기본 비즈니스 예외
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("다른 사용자의 정보에 접근할 수 없습니다.");
    }
}

2. 에러 코드가 포함된 예외 🏷️

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("서버 내부 오류가 발생했습니다."));
    }
}

장점

🧹 코드 중복 제거

  • 각 컨트롤러에서 반복되는 예외 처리 코드 제거
  • Try-catch 블록 없이 깔끔한 컨트롤러

📐 일관된 에러 응답

  • 모든 API에서 동일한 형식의 에러 응답
  • 클라이언트에서 에러 처리 로직 단순화

🎯 중앙 집중식 관리

  • 예외 처리 로직 변경 시 한 곳에서만 수정
  • 체계적인 로깅 및 모니터링

📊 모니터링 용이

  • 예외 발생 패턴 추적 가능
  • 에러 통계 수집 용이

단계별 적용 방법

  1. 기본 비즈니스 예외 클래스 정의
  2. 간단한 Global Exception Handler 구성
  3. 유효성 검사, 인증/인가 예외 추가
  4. 로깅 및 모니터링 기능 강화
  5. 환경별 설정 및 국제화 지원

주의사항

  • 예외 우선순위: 구체적인 예외부터 처리 (상위 클래스는 마지막에)
  • 로깅 레벨: 비즈니스 예외는 WARN, 시스템 예외는 ERROR
  • 보안: 운영 환경에서는 상세한 에러 정보 노출 금지
  • 성능: 예외는 정상적인 플로우가 아니므로 남용 금지

결론

Global Exception Handler를 도입하면:

  • 깔끔한 컨트롤러 코드
  • 일관된 에러 응답
  • 중앙 집중식 예외 관리
  • 체계적인 로깅

처음에는 핵심적인 예외들만 처리하고, 점진적으로 기능을 확장해나가는 것이 좋습니다!

추천 적용 순서:
1. EntityNotFoundException, IllegalArgumentException 등 기본 예외
2. 유효성 검사 예외 (@Valid)
3. 인증/인가 예외
4. 파일 업로드, 데이터베이스 등 특수 예외

0개의 댓글