예외 처리

김명후·2023년 1월 23일
0
post-thumbnail

애플리케이션을 개발할 때는 불가피하게 많은 오류가 발생한다. 자바에서는 이런 오류를 try/catch/throw 구문을 활용해 처리한다. 스프링 부트에서는 더 편리하게 예외 처리를 할 수 있는 기능을 제공한다.

1. 예외와 에러

프로그래밍에서 예외(Exception)는 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 말한다. 예외는 개발자가 직접 처리할 수 있으므로 미리 코드 설계를 통해 처리할 수 있다.

에러(Error)는 주로 자바의 가상머신에서 발생시키는 것으로 예외와 달리 애플리케이션 코드에서 처리할 수 있는 것이 거의 없다. 대표적인 예로 메모리 부족(OutOfMemory), 스택 오버플로(StackOverFlow)등이 있다.

이런 에러는 발생 시점에 처리하는 것이 아니라 미리 애플리케이션의 코드를 살펴보면서 문제가 발생하지 않도록 예방해서 원천적으로 차단해야 한다.

2. 예외 클래스

파란색은 Checked Excpetion, 주황색은 Unchecked Excpetion

모든 예외 클래스는 Throwable 클래스를 상속받는다. 그리고 가장 익숙하게 볼 수 있는 Exception 클래스는 다양한 자식 클래스를 가지고 있다. 크게 Checked ExceptionUnchecked Excpetion으로 구분할 수 있다.

Checked ExcpetionUnchecked Exception
처리 여부반드시 예외처리 필요명시적 처리 강제하지 않음
확인 시점컴파일 단계실행 중 단계
예외발생시 트랜잭션롤백하지 않음롤백함
대표 예외IOException, SQLExceptionNPE, IllegalArgumentException, IndexOutOfBoundException, SystemException
RuntimeException을 상속받는 Exception 클래스는 Unchecked Excpetion이고 그렇지 않는 Exception 클래스는 Checked Exception이다.

3. 예외 처리 방법

예외가 발생했을 때 이를 처리하는 방법은 크게 세 가지이다.

  • 예외 복구
  • 예외 처리 회피
  • 예외 전환

3.1 예외 복구

예외 복구는 예외 상항을 파악해서 문제를 해결하는 방식이다. 대표적으로 try/catch구문이다. try 블록에는 예외가 발생할 수 있는 코드를 작성한다. 대체로 외부 라이브러리를 사용하는 경우에는 try블록을 사용하라는 IDE 알람이 발생하지만 개발자가 직접 작성한 로직은 예외 상황을 예측해서 try블록에 포함시켜야 하낟. 그리고 catch 블록은 여러 개를 작성할 수 있다. 이 경우 예외 상황이 발생하면 애플리케이션에서는 여러 개의 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));
}

3.2 예외 처리 회피

이 방법은 예외가 발생한 시점에서 바로 처리하는 것이 아니라 예외가 발생한 메서드를 호출한 곳에서 에러를 처리할 수 있게 전가하는 방식이다. 이때 throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있다.

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

try {
	System.out.println(a + Integer.parseInt(b)); 
} catch (NumberFormatException e) {
	throw new NumberFormatException("숫자가 아닙니다.");
}

3.3 예외 전환

이 방법은 예외 복구와 예외 처리 회피 두 방식을 적절하게 섞은 방식이다. 예외가 발생했을 때 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달할 필요가 있다. 또는 애플리케이션에서 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping)해야 하는 경우도 있다. 이런 경우에는 try/catch 방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 된다.

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

웹 서비스 애플리케이션에서는 외부에서 들어오는 요청에 담긴 데이터를 처리하는 경우가 많다. 그 과정에서 예외가 발생하면 예외를 복구해서 정상으로 처리하기보다는 요청을 보낸 클라이언트에게 어떤 문제가 발생했는지 상황을 전달하는 경우가 많다.

예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 한다. 이렇게 전달받은 예외를 스프링 부트에서 처리하는 방식으로 크게 두 가지가 있다.

  • @ControllerAdvice, @RestControllerAdvice@ExceptionHandler를 통해 모든 컨트롤러의 예외 처리
  • @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
@ControllerAdvice 대신 @RestControllerAdvice를 사용하면 결과값을 JSON 형태로 반환할 수 있다.

4.1 @RestControllerAdvice를 활용

@RestControllerAdvice //(basePackages = "com.springboot.valid_exception")
public class CustomExceptionHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

@ControllerAdvice, @RestControllerAdvice는 스프링에서 제공하는 애노테이션이다. 이 애노티에션은 @Controller, @RestController에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 해주는 기능이다. 즉, 다음과 같이 별도 설정을 통해 예외를 관제하는 범위를 지정할 수 있다.

@RestControllerAdvice(basePackages = "com.springboot.valid_exception")

@ExceptionHandler@Controller@RestController가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의할 때 사용된다. 어떤 예외 클래스를 처리할지는 value속성으로 등록한다. valu속성은 배열의 형식으로도 전달받을 수 있어 여러 예외 클래스를 등록할 수도 있다. 위 코드에서는 RuntimeException이 발생하면 처리하도록 코드를 작성했으므로 RuntimeException에 포함되는 각종 예외가 발생할 경우를 포착해서 처리하게 된다.

handleExcpetion() 메서드는 클라이언트에게 오류가 발생했다는 것을 알리는 응답 메시지를 구성해서 리턴한다. 컨트롤러의 메서드에 다른 타입의 리턴이 설정돼 있어도 핸들러 메서드에서 별도의 리턴 타입을 지정할 수 있다.

이 예제를 테스트하기 위해 예외를 발생시킬 수 있는 컨트롤러를 생성하자. 이 코드는 ExceptionController를 생성한다.

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);

    @GetMapping
    public void getRuntimeException() {
        throw new RuntimeException("getRuntimeException 메소드 호출");
    }
}

위 예제의 getRuntimeException() 메서드는 컨트롤러로 요청이 들어오면 RuntimeException을 발생시킨다.

컨트롤러에서 던진 예외는 @ControllerAdvice 또는 @RestControllerAdvice가 선언돼 있는 핸들러 클래스에서 매핑된 예외 타입을 찾아 처리하게 된다. 두 애노테이션은 별도 범위 설정이 없으면 전역 범위에서 예외를 처리하기 때문에 특정 컨트롤러에서만 동작하는 @ExceptionHnadler 메서드를 생성해서 처리할 수도 있다.

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);

    ...

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.setContentType(MediaType.APPLICATION_JSON);
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("클래스 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }
}    

위처럼 컨트롤러 클래스 내에 @ExceptionHandler 애노테이션을 사용한 메서드를 선언하면 해당 클래스에 국한해서 예외 처리를 할 수 있다.

만약 @ControllerAdvice와 컨트롤러 내에 동일한 예외 타입을 처리한다면 좀 더 우선순위가 높은 클래스 내에 핸들러 메서드가 사용되는 것을 볼 수 있다.

우선순위를 비교하는 방법은 총 두 가지가 있다.

예외 타입 레벨에 따른 예외 처리 우선순위

만약 컨트롤러 또는 @ControllerAdvice 클래스 내에 동일하게 핸들러 메서드가 선언된 상태에서 Exception 클래스와 그보다 좀 더 구체적인 NullPointerException 클래스가 각각 선언된 경우에는 구체적인 클래스가 지정된 쪽이 우선순위를 갖게 된다.

핸들러 위치에 따른 예외 처리 우선순위

@ControllerAdvice의 글로벌 예외 처리와 @Controller 내에 컨트롤러 예외 처리에 동일한 타입의 예외 처리를 하게 되면 범위가 좁은 컨트롤러의 핸들러 메서드가 우선순위를 갖게 된다.

5. 커스텀 예외

5.1 왜 커스텀 예외를 사용할까?

애플리케이션을 개발하다 보면 점점 예외로 처리할 영역이 늘어나고 예외 상황이 다양해지면서 사용하는 예외 타입도 많아진다. 대부분의 상황에서는 자바에서 이미 적절한 상황에 사용할 수 있도록 제공하는 표준 예외(Standard Exception)를 사용하면 해결된다. 사실 애플리케이션의 예외 처리에는 표준 예외만 사용해도 모든 상황들을 처리할 수 있는데, 왜 커스텀 예외(Custom Exception)을 만들어서 사용할까?

첫번째. 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다.

커스텀 예외를 만들어서 사용하면 네이밍에 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다. 앞에서 언급했듯이 표준 예외에서도 다양한 예외 상황을 처리할 수 있는 클래스를 제공하고 있지만 표준 예외에서 제공하는 클래스는 해당 예외 타입의 이름만으로 이해하기 어려운 경우가 있습니다. 그래서 표준 예외를 사용할 때는 예외 메시지를 상세히 작성해야 하는 번거로움이 있다.

두번째. 개발자가 관리하기가 수월해진다.

또한 커스텀 예외를 사용하면 애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기가 수월해진다. 표준 예외를 상속받은 커스텀 예외들을 개발자가 직접 코드로 관리하기 때문에 책임 소재를 애플리케이션 내부로 가져올 수 있게 된다. 이를 통해 동일한 예외 상황이 발생할 경우 한 곳에서 처리하며 특정 상황에 맞는 예외 코드를 적용할 수 있게 된다.

세번째. 예외 상황에 대한 처리가 용이하다.

예외 상황에 대한 처리도 용이하다. 우리는 @ControllerAdvice@ExceptionHandler에 대해 살펴봤었는데, 이런 애노테이션을 사용해 애플리케이션에서 발생하는 예외 상황들을 한 곳에서 처리할 수 있었다. 예를 들어, RuntimeException에 대해 @ControllerAdvice의 내부에서 표준 예외 처리를 하는 로직을 작성한 경우 개발자가 의도한 RuntimeException 부분이 아닌 의도하지 않는 부분에서 발생하는 에러들이 존재할 수 있다.

표준 예외를 사용하면 이처럼 의도하지 않은 예외 상황도 정해진 예외 처리 코드에서 처리하기 때문에 어디에서 문제가 발생했는지 확인하기가 어렵다. 그러나 커스텀 예외로 관리하면 의도하지 않았던 부분에서 발생한 예외는 개발자가 관리하는 예외 처리 코드가 처리하지 않으므로 개발 과정에서 혼동할 여지가 줄어든다.

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

커스텀 예외는 만드는 목적에 따라 생성하는 방법이 다르다. 여기서는 스프링 환경에서 사용할 수 있는 @ControllerAdvice@ExceptionHandler의 무분별한 예외 처리를 방지하기 위한 커스텀 예외를 생성하는 과정을 보겠다.

커스텀 예외는 예외가 발생하는 상황에 해당하는 상위 예외 클래스를 상속받는다. 그래서 커스텀 예외는 상위 예외 클래스보다 좀 더 구체적인 이름을 사용하기도 한다. 그러나 여기서는 커스텀 예외의 네이밍보다는 클래스의 구조적인 설계를 통한 예외 클래스 생성 방법을 알아보자.

먼저 Exception 클래스의 커스텀 예외를 만들어보자. 예외 클래스의 상속 구조를 보면 Exception 클래스는 Throwable 클래스를 상속받는다. 아래에는 그중 필수적으로 사용되는 message 변수를 이용해 Exception 클래스의 커스텀 예외를 만들도록 하겠다. 먼저 Exception는 아래와 같다.

public class Exception extends Throwable {
	static final long serialVersionUID = -3387516993124229948L;
    
    public Exception() {
    	super();
    }
    
    public Exception(String message) {
    	super(message);
    }
    
    public Exception(String message, Throwable cause) {
    	super(message, cause);
    }
    
    public Exception(Throwable cause) {
    	super(cause);
    }
    
    protected Exception(String message, Throwable cause,
    					boolean enableSuppression,
                        boolean writeableStackTrace) {
                        
    	super(message, cause, enableSuppression, writeableStackTrace);                    
    }
}

Exception 클래스의 Exception(String message) 생성자는 String 타입의 메시지 문자열을 받고 있다. 이 생성자는 아래 Throwable 클래스의 생성자를 호출한다.

public class Throwable implements Serializable {
	
    static final long serialVersionUID = -3387516993124229948L;
    
    private transient Object backtrace;
    
    private String detailMessage;
    
    ...생략...
    
    public Throwable() {
    	fillInStackTrace();
    }
    
    public Throwable(String message) {
    	fillInStackTrace();
        detailMessage = message;
    }
    
    public String getMessage() {
    	return detailMessage;
    }
    
    public String getLocalizedMessage() {
    	return getMessage();
    }

	...생략...
}

Exception 클래스는 부모 클래스인 Throwable 클래스 생성자Throwalbe(String message)를 호출하게 되며, message 변수의 값을 detailMessage 변수로 전달받는다. 커스텀 예외를 생성하는 경우에도 이 message 변수를 사용하게 된다.

그리고 HttpStatus를 커스텀 예외 클래스에 포함시키면 핸들러 안에서 선언해서 사용하는 것이 아닌 예외 클래스만 전달받으면 그 안에 내용이 포함돼 있는 구조로 설계할 수 있다. 참고로 HttpStatus는 열거형(enum)이다. 열거형은 서로 관련 있는 상수를 모은 심볼릭한 명칭의 집합이다. 쉽게 생각해서 클래스 타입의 상수로 볼 수 있다.

public enum HttpStatus {


	BAD_REQUEST(400, Series.CLIENT_ERROR, "Bad Request"),

	PAYMENT_REQUIRED(402, Series.CLIENT_ERROR, "Payment Required"),

	FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"),
    
    NOT_FOUND(404, Series.CLIENT_ERROR, "Not Found"),
    
    METHOD_NOT_ALLOWED(405, Series.CLIENT_ERROR, "Method Not Allowed"),

	private final int value;

	private final Series series;

	private final String reasonPhrase;

	HttpStatus(int value, Series series, String reasonPhrase) {
		this.value = value;
		this.series = series;
		this.reasonPhrase = reasonPhrase;
	}

	public int value() {
		return this.value;
	}

	public Series series() {
		return this.series;
	}

	public String getReasonPhrase() {
		return this.reasonPhrase;
	}

HttpStatus는 value, series, reasonPhrase 변수로 구성된 객체를 제공한다. 흔히 볼 수 있는 Http 응답 코드와 메시지이다. 최종적으로 커스텀 예외 클래스를 생성하는 데 필요한 내용은 아래와 같다.

  • 에러 타입(error type) : HttpStatusreasonPhrase
  • 에러 코드(error code) : HttpStatusvalue
  • 메시지(message) : 상황별 상세 메시지

이와 같은 구성으로 커스텀 예외 클래스를 생성해보자. 추가로 애플리케이션에서 가지고 있는 도메인 레벨을 메시지에 표현하기 위해 ExceptionClass 열거형 타입을 생성하자. 이를 도식화하면 아래와 같은 커스텀 예외 클래스 구조가 된다.

커스텀 예외 클래스 구조

커스텀 예외 클래스를 생성하기에 앞서 도메인 레벨 표현을 위한 열거형을 아래와 같이 생성했다.

public class Constants {

    public enum ExceptionClass {

        PRODUCT("Product");

        private String exceptionClass;

        ExceptionClass(String exceptionClass) {
            this.exceptionClass = exceptionClass;
        }

        public String getExceptionClass() {
            return exceptionClass;
        }

        @Override
        public String toString() {
            return getExceptionClass() + " Exception. ";
        }
	}     
}

ExceptionClass라는 열거형은 커스텀 예외 클래스에서 메시지 내부에 어떤 도메인에서 문제가 발생했는지 보여주는 데 사용된다.

열거형을 생성하고 난 뒤 커스텀 예외 클래스를 생성하자.

public class CustomException extends Exception{

    private static final long serialVersionUID = 4300333310379239987L;

    private Constants.ExceptionClass exceptionClass;
    private HttpStatus httpStatus;

    public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus,
        String message) {
        super(exceptionClass.toString() + message);
        this.exceptionClass = exceptionClass;
        this.httpStatus = httpStatus;
    }

    public Constants.ExceptionClass getExceptionClass() {
        return exceptionClass;
    }

    public int getHttpStatusCode() {
        return httpStatus.value();
    }

    public String getHttpStatusType() {
        return httpStatus.getReasonPhrase();
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }

}

커스텀 예외 클래스는 앞에서 만든 ExceptionClassHttpStatus를 필드로 가집니다. 두 객체를 기반으로 예외 내용을 정의하며, 위 코드에서 public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus, String message)를 통해 클래스를 초기화한다.

이제 커스텀 예외를 활용해보자. 먼저 ExceptionHandler 클래스에 CustomException에 대한 예외 처리 코드를 다음과 같이 추가한다.

@ExceptionHandler(value = CustomException.class)
    public ResponseEntity<Map<String, String>> handleException(CustomException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", e.getHttpStatusType());
        map.put("code", Integer.toString(e.getHttpStatusCode()));
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
    }

위와 같이 처리하면 기존에 작성했던 핸들러 메서드와 달리 예외 발생 시점에 HttpStatus를 정의해서 전달하기 때문에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다는 장점이 있다.

이제 커스텀 예외에 대해 컨트롤러 메서드를 생성해보자.

@GetMapping("/custom")
    public void getCustomException() throws CustomException {
        throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출");
    }

이처럼 CustomExceptionthrow 키워드로 던지면 커스텀 예외가 발생한다. throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출"); 처럼 ExceptionClass에서 도메인을 비롯해 HttpStatus를 통해 어떤 응답 코드를 사용할지와 세부 메시지를 전달한다. 여기서는 세부 메시지를 간단한 문자열로 표현했지만 예외가 발생하는 상황에서 특정 값을 전달하는 구성이라면 상세한 메시지를 작성해서 전달하거나 커스텀 예외 클래스를 적절한 타입으로 변경하는 것도 좋다.

0개의 댓글