본문 바로가기

Spring/이론

Spring API 예외 처리 시스템 정리

목표: 깔끔하고 일관된 예외 처리 시스템 구축

핵심 아이디어: ErrorCode Enum + BusinessException + ApiResponse + @ControllerAdvice 조합


1단계: ErrorCode Enum 정의

public enum ErrorCode {
    // 사용자 관련 (4xx)
    USER_NOT_FOUND(404, "U001", "사용자를 찾을 수 없습니다"),
    USER_ALREADY_EXISTS(409, "U002", "이미 존재하는 사용자입니다"),
    USER_UNAUTHORIZED(401, "U003", "권한이 없습니다"),
    
    // 상품 관련 (4xx)
    PRODUCT_NOT_FOUND(404, "P001", "상품을 찾을 수 없습니다"),
    PRODUCT_OUT_OF_STOCK(409, "P002", "재고가 부족합니다"),
    
    // 주문 관련 (4xx)
    ORDER_NOT_FOUND(404, "O001", "주문을 찾을 수 없습니다"),
    ORDER_ALREADY_CANCELLED(409, "O002", "이미 취소된 주문입니다"),
    
    // 입력값 검증 (4xx)
    INVALID_INPUT(400, "C001", "잘못된 입력값입니다"),
    VALIDATION_FAILED(400, "C002", "유효성 검사에 실패했습니다"),
    
    // 서버 오류 (5xx)
    INTERNAL_SERVER_ERROR(500, "S001", "서버 내부 오류가 발생했습니다"),
    DATABASE_ERROR(500, "S002", "데이터베이스 오류가 발생했습니다"),
    EXTERNAL_API_ERROR(500, "S003", "외부 API 호출에 실패했습니다");
    
    private final int status;
    private final String code;
    private final String message;
    
    ErrorCode(int status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
    
    public int getStatus() { return status; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
}

 


2단계: 공통 예외 클래스 정의

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;
    }
}

 

사용법:

// 서비스에서 예외 발생시키기
public User findUserById(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}

// 커스텀 메시지와 함께
public void validateUserAge(int age) {
    if (age < 0) {
        throw new BusinessException(ErrorCode.INVALID_INPUT, "나이는 0 이상이어야 합니다");
    }
}

 


3단계: 통일된 응답 형식 (ApiResponse)

public class ApiResponse<T> {
    private boolean success;
    private T data;
    private String message;
    private String errorCode;
    private LocalDateTime timestamp;
    
    // 생성자
    private ApiResponse(boolean success, T data, String message, String errorCode) {
        this.success = success;
        this.data = data;
        this.message = message;
        this.errorCode = errorCode;
        this.timestamp = LocalDateTime.now();
    }
    
    // 성공 응답 생성
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, "요청이 성공했습니다", null);
    }
    
    public static <T> ApiResponse<T> success(T data, String message) {
        return new ApiResponse<>(true, data, message, null);
    }
    
    public static ApiResponse<Void> success() {
        return new ApiResponse<>(true, null, "요청이 성공했습니다", null);
    }
    
    // 실패 응답 생성
    public static <T> ApiResponse<T> failure(String message, String errorCode) {
        return new ApiResponse<>(false, null, message, errorCode);
    }
    
    public static <T> ApiResponse<T> failure(ErrorCode errorCode) {
        return new ApiResponse<>(false, null, errorCode.getMessage(), errorCode.getCode());
    }
    
    // Getter 메서드들
    public boolean isSuccess() { return success; }
    public T getData() { return data; }
    public String getMessage() { return message; }
    public String getErrorCode() { return errorCode; }
    public LocalDateTime getTimestamp() { return timestamp; }
}

 


4단계: 전역 예외 처리 (@ControllerAdvice)

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
        log.warn("Business Exception: {}", e.getMessage());
        
        ApiResponse<Void> response = ApiResponse.failure(e.getErrorCode());
        return ResponseEntity
            .status(e.getErrorCode().getStatus())
            .body(response);
    }
    
    // 유효성 검사 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
        log.warn("Validation Exception: {}", e.getMessage());
        
        String errorMessage = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.joining(", "));
            
        ApiResponse<Void> response = ApiResponse.failure(errorMessage, ErrorCode.VALIDATION_FAILED.getCode());
        return ResponseEntity.badRequest().body(response);
    }
    
    // 요청 파라미터 타입 오류
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException e) {
        log.warn("Type Mismatch Exception: {}", e.getMessage());
        
        ApiResponse<Void> response = ApiResponse.failure(ErrorCode.INVALID_INPUT);
        return ResponseEntity.badRequest().body(response);
    }
    
    // 일반적인 예외 (예상치 못한 오류)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception e) {
        log.error("Unexpected Exception: ", e);
        
        ApiResponse<Void> response = ApiResponse.failure(ErrorCode.INTERNAL_SERVER_ERROR);
        return ResponseEntity.internalServerError().body(response);
    }
}

 


5단계: 컨트롤러에서 활용

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    // 사용자 목록 조회
    @GetMapping
    public ApiResponse<List<UserDto>> getUsers() {
        List<UserDto> users = userService.findAllUsers();
        return ApiResponse.success(users, "사용자 목록을 성공적으로 조회했습니다");
    }
    
    // 사용자 단건 조회
    @GetMapping("/{id}")
    public ApiResponse<UserDto> getUser(@PathVariable Long id) {
        UserDto user = userService.findUserById(id);  // 없으면 BusinessException 발생
        return ApiResponse.success(user);
    }
    
    // 사용자 생성
    @PostMapping
    public ApiResponse<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDto createdUser = userService.createUser(request);
        return ApiResponse.success(createdUser, "사용자가 성공적으로 생성되었습니다");
    }
    
    // 사용자 수정
    @PutMapping("/{id}")
    public ApiResponse<UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) {
        UserDto updatedUser = userService.updateUser(id, request);
        return ApiResponse.success(updatedUser, "사용자 정보가 성공적으로 수정되었습니다");
    }
    
    // 사용자 삭제
    @DeleteMapping("/{id}")
    public ApiResponse<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ApiResponse.success();
    }
}

 


6단계: 실제 응답 예시

성공 응답

{
  "success": true,
  "data": {
    "id": 1,
    "name": "김철수",
    "email": "kim@example.com"
  },
  "message": "요청이 성공했습니다",
  "errorCode": null,
  "timestamp": "2024-12-25T14:30:00"
}

 

실패 응답 (비즈니스 예외)

{
  "success": false,
  "data": null,
  "message": "사용자를 찾을 수 없습니다",
  "errorCode": "U001",
  "timestamp": "2024-12-25T14:30:00"
}

 

실패 응답 (유효성 검사)

{
  "success": false,
  "data": null,
  "message": "이름은 필수입니다, 이메일 형식이 올바르지 않습니다",
  "errorCode": "C002",
  "timestamp": "2024-12-25T14:30:00"
}

 


장점 정리

  1. 일관성: 모든 API가 동일한 응답 구조
  2. 중앙 집중: 에러 코드와 메시지를 한 곳에서 관리
  3. 유지보수성: 응답 형식 변경시 ApiResponse만 수정
  4. 클라이언트 친화적: 프론트엔드에서 처리하기 쉬운 구조
  5. 확장성: 새로운 에러 타입 추가가 용이
  6. 추적 가능: 에러 코드로 문제 원인 파악 용이

팀프로젝트 팁

  • ErrorCode는 한번 정의하면 변경하지 말 것 (API 호환성)
  • 에러 메시지는 사용자 친화적으로 작성
  • 로깅을 적절히 활용하여 디버깅 지원
  • 팀 컨벤션에 맞춰 에러 코드 체계 정립
  • 문서화를 통해 팀원들과 공유