Exception과 Exception handler (+테스트 코드)
에러는 3가지 종류가 있습니다.
컴파일 에러: 컴파일 시에 컴파일러가 체크하는 예외로, 문법 문제, 소스코드 문제 등으로 발생합니다. (보통 IDE에서 빨간 줄로 알려줍니다.)
런타임 에러 : 컴파일은 성공했지만 프로그램 실행 중, 발생하는 문제입니다.
논리적 에러 : 컴파일도 되고, 실행도 되었지만 의도한 동작이 아닌 다른 동작을 하는 경우입니다. 만약 게임에서 목숨을 다썼는데 죽지않는 경우를 논리적 에러라고 할 수 있습니다.
런타임 에러는 다시 에러(Error)와 예외(Exception)로 나뉘어집니다.
에러는 OutOfMemoryError, StackOverFlowError 와 같은 발생 시 복구할 수 없는 심각한 문제를 의미합니다.
예외는 발생하더라도 수습할 수 있는 비교적 덜 심각한 문제를 말합니다.
그래서 개발자들은 예외에 대비하기 위해 미리 예외 처리를 해주어야합니다.
이 예외는 또 다시 Checked Exception과 Unchecked Exception으로 나뉘어지는데,
Error 또한 Unchecked Exception에 포함됩니다.
- Checked Exception (Compiletime Exception, 컴파일 예외)
예) Exception 클래스를 상속받는 RuntimeException 클래스를 제외한 클래스들(SQLException, FileNotFoundException) - Unchecked Exception (Runtime Exception, 런타임 예외)
예) Error 클래스의 하위 클래스들 + RuntimeException의 하위 클래스들(NullPointerException, ArrayIndexOutOfBoundException)
Checked Exception
Checked Exception은 명시적으로 예외 처리를 하도록 강제하는 Exception입니다.
명시적인 예외 처리란 반드시 try ~ catch로 예외를 잡거나, 'throw 키워드 + 예외 클래스' 를 이용해 호출한 메소드에게 예외를 던져야 하는 것입니다.
Checked Exception으로는 Exception 클래스를 상속받는 RuntimeException 클래스를 제외한 클래스들(IOException, SQLException, FileNotFoundException)이 있습니다.
백준 문제를 풀어봤다면 모두 다 봤을 Checked Exception 클래스인 IOException 클래스가 있습니다.
Unchecked Exception
반면 명시적인 예외 처리를 강제하지 않는 Uncheked Exception이 있습니다.
메소드는 호출하게 되면 메모리의 stack 영역에 stack frame이 쌓입니다. 그리고 예외가 발생하면 JVM은 stack에 쌓여있는 stack frame들을 하나씩 pop 하여 출력하게 됩니다.
만약 예외를 처리해주지 않으면 어떻게 될까요?
일단 Exception이 발생하면 Exception Handler가 처리를 합니다.
Exception Handler를 찾는 방식은 아래와 같습니다.
메소드는 호출할떄마다 Call Stack에 쌓이게 되고
에러가 발생하면 역순으로 내려가면서 Exception Handler를 찾게된다.
Exception Handler 가 처리할수 있는 예외타입이면 처리하고 아니면 상위로 전달하게 됩니다.
만약 적절한 Exception Handler를 찾지 못하면 JVM 까지 전달되어 최종적으로 JVM이 Exceptiond을 처리하게 됩니다.
자바에서는 클래스로 예외를 관리하는데 JVM이 프로그램 실행 도중 예외가 발생하면 해당하는 예외 클래스를 찾아서 객체를 생성하는 것입니다.
위에서 말했듯 RuntimeException의 하위 클래스(실행 예외 클래스)는 자바 컴파일러가 예외 처리 코드를 체크하지 않기 때문에 개발자의 경험에 의해 예외처리 코드를 작성해야 한다.
빈번히 발생하는 실행 예외 4가지
- NullPointerException.java : 객체가 없는 상태에서 객체를 사용할 때 ex) null.toString()
- ArrayIndexOutOfBoundsException.java : 배열에서 인덱스 범위를 초과할 때
- NumberFormatException.java : 문자열을 숫자로 변환 시, 숫자로 변환할 수 없는 문자가 포함되어 있을 때
- ClassCastException.java : 상위 클래스와 하위 클래스 간, 구현 클래스와 인터페이스 간의 관계 외의 관계에서 타입 변환할 때
- 타입 변환이 가능한지 instanceof 연산자로 확인해보는 것 추천!
- ex) animal instanceof Dog : 좌항 객체를 우항 타입으로 변환이 가능하면 true
예제
현재 진행하고 있는 프로젝트에서 회원가입 시 사용자가 이미 존재할 때의 에러를 처리하는 예제를 봅시다.
먼저 위에서 본 실행 예외 클래스처럼 예외 클래스를 생성해야 합니다.
그리고
Checked Exception을 구현하기 위해선 해당하는 Exception 클래스, 혹은 더 구체적으로 해당되는 하위 클래스를 상속 받아야하고, Unchecekd Exception을 구현하기 위해선 RuntimeException 클래스를 상속받아야 합니다.
RuntimeException 클래스 내부를 살펴보면 여러 매개변수 갯수와 종류에 따라 생성자가 5개 정의되어 있다. (public 4개, protected 1개) 아래와 같이 직접 생성하는 예외 클래스에서도 생성자를 통해 발생한 예외에 대한 정보를 지정하면 된다.
커스텀한 예외 클래스의 생성자에서는
발생한 예외의 코드정보를 아래와 같이 설정하고 이를 응답 객체 ResponseJsonObject로 build하였습니다.
"statusCode": 411,
"errorType": "Person already exists",
"errorMsg": "이미 존재하는 사용자입니다."
비즈니스 로직에서 발생하는 예외들을 처리하기 위해서 여러 예외 클래스들을 생성해야 합니다.
제가 현재 적용 중인 프로젝트에서 생성한 예외 클래스는 아래와 같습니다.
- ContentNotFoundException.java
- ImageNotFoundException.java
- ParamValidationException.java
- PersonAlreadyExistsException.java
- PersonIdNotFoundException.java
그리고 해당 클래스들은 아래 클래스를 상속받습니다.
exception/ReviewServiceException.class
비즈니스 로직에서 발생하는 예외 클래스들의 부모 클래스 역할입니다.
그리고 RuntimeException 클래스를 상속받아서 handler에서 해당 예외를 잡을 수 있도록 합니다. check필요!
public class ReviewServiceException extends RuntimeException{
protected ApiStatusCode errorStatusCode ;
protected ResponseJsonObject responseJsonObject;
public ResponseJsonObject getResponseJsonObject(){
return responseJsonObject;
}
public ReviewServiceException() {
}
public ReviewServiceException(ApiStatusCode errorStatusCode) {
this.errorStatusCode = errorStatusCode;
responseJsonObject = ResponseJsonObject.withError(errorStatusCode.getCode(), errorStatusCode.getType(), errorStatusCode.getMessage());
}
}
ResponseJsonObject는 api 호출에 대한 최종 응답 시 사용되는 DTO로, ResponseEntity 클래스로 감싸져서 응답합니다. (해당 부분은 Handler에서 처리하도록 되어있습니다.)
여러 예외 클래스 중 PersonAlreadyExistsException.java 클래스 내부를 봅시다.
exception/PersonAlreadyExistsException.java
// 회원가입 시 이미 회원이 존재하는 경우 발생하는 에러
public class PersonAlreadyExistsException extends ReviewServiceException {
public PersonAlreadyExistsException() {
super(ApiStatusCode.PERSON_ALREADY_EXISTS);
}
}
ApiStatusCode는 enum 클래스로 되어있어 서비스 중 발생할 수 있는 에러 코드를 상태 코드, 에러 타입, 에러 메시지 등으로 정의해놓은 클래스입니다.
필드로는 code, type, message가 있어 각 enum 상수들은 상태 코드, 타입, 메시지값을 가지고 있습니다.
,PERSON_NOT_FOUND(409,"Person Not Found", "사용자 정보를 찾을 수 없습니다.")
이제 위 에러가 발생했을 때 이를 잡아서 최종적으로 던져주는 handler 클래스를 보겠습니다.
@ControllerAdvice
public class GlobalExceptionHandler {
// 사용자 정의 예외
@ExceptionHandler(PersonAlreadyExistsException.class) // 회원가입 시 이미 존재하는 회원이 있을 경우 호출
public ResponseEntity<ResponseJsonObject> handlePersonAlreadyExistsException
(PersonAlreadyExistsException ex) {
System.out.println("GlobalExceptionHandler.handlePersonAlreadyExistsException 호출됨");
return new ResponseEntity<>(ex.getResponseJsonObject(), HttpStatus.CONFLICT);
}
//.. 생략
}
테스트 코드
JUnit5부터는 assertThrows()를 통해 매개변수로 발생할 것이라 예상되는 예외 클래스와 예외가 발생할 상황을 전달한다.
같은 회원정보인 userDto를 두번 연속 회원가입시켜 에러 처리 클래스로 PersonAlreadyExistException.java가 호출되면 해당 테스트는 통과된다.
@SpringBootTest // 해당 어노테이션을통해 Spring 동작이 가능해짐
class GlobalExceptionHandlerTest {
@Autowired
private UserServiceImpl userService;
@Test
@DisplayName("이미 있는 ID로 회원가입시 실패")
public void 중복회원생성_예외() throws RuntimeException{
// userDto 생성
LocalDateTime birthDate = LocalDateTime.now();
UserSaveRequestDto userDto = UserSaveRequestDto.builder().id("banan99").name("뭉지").password("123456")
.build();
// 예상되는 예외 체크
assertThrows(PersonAlreadyExistsException.class,
() -> {
userService.join(userDto);
userService.join(userDto);
});
}
}
테스트 통과 후 테이블을 확인해보면 회원 정보가 한 번만 등록된 것을 확인할 수 있다.
실제 Postman으로 테스트하면 아래와 같이 응답됩니다.
리팩토링
public class PersonAlreadyExistsException extends ReviewServiceException{
public PersonAlreadyExistsException() {
super(ApiStatusCode.PERSON_ALREADY_EXISTS);
}
}
커스텀 예외 클래스의 생성자에서 super(message); 를 사용한다.
따라 올라가보면 Throwable 클래스의 생성자를 볼 수 있다.
Throwable 클래스에서 예외 메시지 처리를 하고 있는 것이다.