BACKEND/SPRING

스프링부트 유효성 검사와 예외처리 - 예외 처리

우진하다 2023. 8. 27. 23:39

예외 처리

애플리케이션 개발 과정에서 다양한 오류와 예외 상황에 직면하게 됩니다. 이를 처리하기 위해 자바는 try/catch 및 throw 구문을 제공합니다. 스프링 부트는 이보다 진화된 예외 처리 메커니즘을 제공하여 개발자에게 더 편리하게 예외 처리를 도와줍니다.

예외 (Exception)

애플리케이션의 정상적인 동작을 방해하는 상황을 나타냅니다. 예를 들면, 잘못된 입력값, 유효하지 않은 참조 값 등이 있습니다.
예외는 개발자가 코드 내에서 직접 처리할 수 있으므로, 적절한 코드 설계와 로직을 통해 미리 예상하고 처리할 수 있습니다.

에러 (Error)

에러는 예외와 비슷한 개념으로 여겨지는 경우가 많지만, 실제로는 근본적으로 다른 현상입니다.
에러는 주로 자바의 가상머신 (JVM)에서 발생하며, 대부분의 경우 애플리케이션 레벨에서 직접 해결할 수 없는 문제입니다.
대표적인 예로는 메모리 부족 (OutOfMemoryError)이나 스택 오버플로우 (StackOverFlowError) 등이 있습니다.
이러한 에러는 발생 후에 처리하기보다는 애플리케이션 코드를 검토하고 예방하는 방식으로 접근해야 합니다.

 

예외 클래스와 그 분류

자바의 예외 처리 체계는 다양한 예외 클래스를 통해 구성되며, 이들은 특정 상속 구조를 따릅니다.

예외 클래스의 상속 구조

java.lang.Object
    └─ Throwable
        ├─ Error
        └─ Exception
            ├─ IOError
            ├─ IOException
            ├─ Checked Exception (예: IOException, SQLException)
            └─ RuntimeException (Unchecked Exception)
                ├─ NullPointerException
                ├─ IllegalArgumentException
                ├─ IndexOutOfBoundsException
                └─ ... (기타 등등)

모든 예외 클래스는 Throwable 클래스에서 파생됩니다. 
그 중 Exception 클래스는 자바에서 가장 일반적으로 발생하는 예외 유형을 포함하며, 
크게 Checked Exception과 Unchecked Exception으로 분류됩니다.

Checked Exception
컴파일 단계에서 확인 가능한 예외입니다. 즉, 이러한 예외 상황은 개발 도구나 IDE에서 확인할 수 있으며, 이를 처리하지 않으면 코드가 컴파일되지 않습니다.
대표적인 예로는 IOException, SQLException 등이 있습니다.

Unchecked Exception (또는 RuntimeException)
런타임에서 발생하는 예외입니다. 컴파일러는 이러한 예외를 감지할 수 없으며, 이는 프로그램 실행 중에만 발생합니다.
예를 들면, NullPointerException, IllegalArgumentException, IndexOutOfBoundsException 등이 있습니다.

예외 처리 방법

예외 복구

발생한 예외를 직접 처리하여 프로그램을 정상적인 상태로 돌리는 방법입니다.
이 방법은 주로 try/catch 구문을 사용합니다.
try 블록에는 예외가 발생할 가능성이 있는 코드를 작성하고, catch 블록에서는 해당 예외를 처리하는 로직을 작성합니다.

int a = 1;
String b = "a";

try {
    System.out.println(a + Integer.parseInt(b));
} catch (NumberFormatException e) {
    b = "2";
    System.out.println(a + Integer.parseInt(b));
}

예외 처리 회피

발생한 예외를 직접 처리하는 것이 아니라, 해당 예외를 호출한 메서드로 전달하여 처리하도록 합니다.
이때 throws 키워드를 사용해 메서드의 호출자에게 예외 처리의 책임을 위임합니다.

public void someMethod() throws SomeException {
    if (someCondition) {
        throw new SomeException("An error occurred!");
    }
}

예외 전환 

발생한 예외를 다른 예외로 변환하여 던지는 방법입니다.
이는 주로 의미 있는 예외 메시지나 다른 예외 유형으로 전환할 필요가 있을 때 사용됩니다.

public class CustomException extends RuntimeException {
    public CustomException(String message) {
        super(message);
    }

    public CustomException(String message, Throwable cause) {
        super(message, cause);
    }
}
public void someMethod() {
    try {
        // 이 부분에서 다른 예외가 발생할 수 있음
        externalLibraryCall();
    } catch (ExternalLibraryException e) {
        throw new CustomException("Our custom message for the error", e);
    }
}

public void externalLibraryCall() throws ExternalLibraryException {
    // 외부 라이브러리 메서드 호출
}

위의 예시에서 externalLibraryCall 메서드는 외부 라이브러리에서 제공하는 메서드라고 가정하며, 이 메서드가 ExternalLibraryException 예외를 발생시킬 수 있다고 가정합니다. 이때, 우리의 애플리케이션에서는 해당 예외를 직접 사용하기보다 CustomException으로 전환하여 처리하는 것을 선호할 수 있습니다.

someMethod에서는 try-catch 블록을 사용하여 ExternalLibraryException 예외를 잡아내고, 이를 우리의 사용자 정의 예외인 CustomException으로 전환하여 다시 던집니다. 여기서 CustomException의 두 번째 생성자를 사용하여 원래의 예외도 함께 첨부합니다. 이를 통해 스택 트레이스에서 원래의 예외 정보도 확인할 수 있게 됩니다.

 

 스프링 부트의 예외 처리 방식

@RestControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외 처리

@RestControllerAdvice는 전역 범위에서 예외를 처리하는 클래스에 지정되는 어노테이션입니다. 
그 안에서 @ExceptionHandler를 사용하여 특정 예외 유형을 처리할 수 있습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<String> handleCustomException(CustomException e) {
        return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleException(Exception e) {
        return new ResponseEntity<>("Internal Server Error", HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

이 예제에서 CustomException은 사용자 정의 예외이며, 모든 컨트롤러에서 발생할 경우 해당 핸들러 메서드로 처리됩니다. 또한, 모든 다른 예외는 Exception 클래스 핸들러 메서드에서 처리됩니다.

@ExceptionHandler를 통해 특정 컨트롤러의 예외 처리

특정 컨트롤러에서만 발생하는 예외를 처리하려면 해당 컨트롤러 내에서 @ExceptionHandler를 사용합니다.

@RestController
@RequestMapping("/api/items")
public class ItemController {

    @GetMapping("/{id}")
    public ResponseEntity<Item> getItem(@PathVariable String id) {
        // 로직 ...
        throw new ItemNotFoundException("Item not found with id: " + id);
    }

    @ExceptionHandler(ItemNotFoundException.class)
    public ResponseEntity<String> handleItemNotFoundException(ItemNotFoundException e) {
        return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND);
    }
}

이 예제에서 ItemNotFoundException은 ItemController 내부에서만 처리됩니다.

두 방식 모두 사용자에게 의미있는 오류 메시지와 적절한 HTTP 상태 코드를 반환하는 방법으로 예외를 처리합니다. 전역 예외 핸들러(@RestControllerAdvice)는 모든 컨트롤러에서 발생할 수 있는 예외를 처리하는 데 유용하며, 컨트롤러별 예외 핸들러(@ExceptionHandler)는 특정 컨트롤러와 관련된 예외만 처리하고 싶을 때 유용합니다.

 

커스텀 예외

커스텀 예외의 필요성

명시성: 예외 이름만 보고도 어떤 문제가 발생했는지 쉽게 알 수 있습니다.
유지보수성: 커스텀 예외를 사용하면 애플리케이션 내에서 발생하는 예외를 명확하게 관리할 수 있습니다.
유연성: 개발자가 예외 처리 로직을 통일하거나 다양한 예외 응답을 제공할 수 있습니다.

커스텀 예외 클래스 생성하기

커스텀 예외를 생성하는 기본적인 방법은 Java의 표준 예외 클래스를 상속받아 새로운 예외 클래스를 정의하는 것입니다. 이 때 주로 RuntimeException이나 Exception 클래스를 상속받습니다.

public class UserNotFoundException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public UserNotFoundException() {
        super("User not found.");
    }

    public UserNotFoundException(String message) {
        super(message);
    }
}
public User getUserById(Long id) {
    User user = userRepository.findById(id);
    if (user == null) {
        throw new UserNotFoundException("User with id " + id + " not found.");
    }
    return user;
}

사용자가 존재하지 않을 때 발생하는 UserNotFoundException이라는 커스텀 예외를 생성하고
이 커스텀 예외는 사용자를 찾을 수 없을 때 발생시킬 수 있으며, 기본 메시지 또는 특정 메시지를 포함하여 예외를 생성할 수 있습니다.