서론
스프링이 제공하는 예외 추상화를 이해하기 위해선, 먼저 자바 기본 예외에 대한 이해가 필요합니다. 실무에 필요한 체크 예외와 언체크 예외의 차이점과 활용 방안에 대해서도 알아보도록 하겠습니다.
예외 계층
- Object : 예외도 객체입니다. 모든 객체의 최상위 부모는 Object입니다.
- Throwable : 최상위 예외입니다.
- Error : 애플리케이션에서 복구 불가능한 시스템 예외입니다. 메모리 부족과 같은 심각한 시스템 오류를 포함합니다.
- Exception : 체크 예외
- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외입니다.
- Exception과 그 하위 예외는 모두 컴파일러가 체크하는 예외입니다. 따라서 catch 나 throw 로 해당 예외를 처리해야 컴파일이 됩니다.
- 단, RuntimeException은 예외입니다.
- RuntimeException : 언체크 예외, 런타임 시 체크되는 예외
- 컴파일러가 체크하지 않는 언체크예외입니다. 따라서 catch나 throw로 해당 예외를 처리하지 않아도 컴파일 오류를 발생시키지 않습니다. (컴파일 오류만 발생시키지 않고, 실행하면 런타임 에러가 발생합니다.)
- RuntimeException과 그 자식 예외는 모두 언체크 예외입니다. 해당 예외들을 런타임 예외라고 많이 부릅니다. 여기서도 런타임 예외라 지칭하겠습니다.
✅참고 Throwable 예외를 잡으면 안 된다.
상위 예외를 catch 로 잡으면, 그 하위 예외도 함께 잡힙니다. 따라서 애플리케이션 로직에서는 Throwable 예외를 잡으면 안 되는데, 복구 불가능한 시스템 예외 Error도 함께 잡을 수 있기 때문입니다.
따라서 애플리케이션 로직은 이러한 이유로 Exception부터 필요한 예외로 생각하고 잡으면 됩니다.
참고로, Error도 언체크 예외입니다.
체크 예외(Checked Exception)
- 컴파일러가 강제로 처리를 요구하는 예외입니다.
- 주로 외부 리소스와의 상호 작용에서 발생합니다. (ex) 'IOException', 'SQLException' ...)
- 메서드에서 체크 예외가 발생할 가능성이 있는 경우, 컴파일러는 해당 예외를 체크하여 개발자에게 해당 예외를 처리하거나 메서드 선언부에 throws를 추가해 예외를 던지도록 강제합니다.
import java.io.IOException;
public class Example {
public void readFile() throws IOException {
// 파일을 읽는 코드
}
}
언체크 예외(Unchecked Exception)
- 컴파일러가 강제로 처리를 요구하지 않습니다.
- 주로 프로그램의 논리 오류나 예측 불가능한 상황에서 런타임 시 발생합니다. (ex) 'NullPointerException', 'ArrayIndexOutOfBoundsException' ...)
- 언체크 예외는 컴파일러가 강제로 처리를 요구하지 않기 때문에 예외가 발생하면 프로그램이 비정상적으로 종료될 수 있습니다.
public class Example {
public static void main(String[] args) {
// 언체크 예외인 ArithmeticException
int result = 5 / 0; // 이 부분에서 예외 발생
}
}
예외 처리 2가지 방법
예외는 폭탄 돌리기와 같습니다. 예외를 처리하는 방법에는 자신이 예외를 처리하거나, 처리할 수 없다면 밖으로 던지는 2가지 방법이 있습니다.
예외 처리(catch)
Repository에서 발생한 예외를 Service에서 처리하면, Controller 이후 부터의 로직은 정상 흐름으로 동작합니다.
예외 던짐(throws)
예외를 처리하지 못하겠으면, 호출한 곳으로 예외를 던질 수 있습니다.
참고 예외를 처리하거나, 던질 때는 해당 예외 뿐만 아니라 자식 예외들도 함께 처리되거나 던져집니다.
❔예외를 처리하지 않고, 계속해서 던진다면?
- 자바의 경우 main() 쓰레드가 예외 로그를 출력하고 시스템이 종료됩니다.
- 웹 애플리케이션의 경우에는 여러 사용자의 요청을 멀티 쓰레드로 처리하기 때문에, 하나의 예외 때문에 시스템이 종료가 되면 안 됩니다. 따라서 WAS가 예외를 받아 처리하는데, 주로 개발자가 지정한 오류 페이지를 보여줍니다.
체크 예외와 언체크 예외의 중요한 차이점
- 체크 예외는 예외가 발생하거나, 넘어오면 잡거나 던짐으로써 반드시 예외를 처리하지 않으면 컴파일 오류가 발생합니다.
- 언체크 예외는 개발자가 catch로 처리하지 않으면, 자동으로 던져집니다. 즉 throws 구문을 생략해도 컴파일 오류가 발생하지 않습니다.
체크 예외 테스트
체크 예외는 catch로 해결하거나, throws 로 던지지 않으면 컴파일 오류가 발생합니다.
실행 순서
- test → service → repository[예외 발생]
package hello.jdbc.exception.basic;
...
@Slf4j
public class CheckedTest {
/**
* service에서 예외를 잡는 테스트
* 예외가 올라오지 않는다.
*/
@Test
void checked_catch() {
Service service = new Service();
service.callCatch();
}
/**
* service에서 예외를 던지는 테스트
* repository에서 발생한 예외가 최상위까지 올라온다.
*/
@Test
void checked_throw() {
Service service = new Service();
Assertions.assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyCheckedException.class);
}
/**
* Exception 을 상속받은 예외는 체크 예외가 된다.
*/
static class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
/**
* Checked 예외는 예외를 잡아서 처리하거나, 던지거나 둘 중 하나를 필수로 선택해야 한다.
*/
static class Service{
Repository repository = new Repository();
/**
* 예외를 잡아서 처리
*/
public void callCatch() {
try {
repository.call();
} catch (MyCheckedException e) {
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
/**
* 체크 예외를 밖으로 던짐
* 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야 한다.
*/
public void callThrow() throws MyCheckedException {
repository.call();
}
}
/**
* 체크 예외 발생
*/
static class Repository {
public void call() throws MyCheckedException{
throw new MyCheckedException("ex");
}
}
}
- 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전 장치입니다. 가장 좋은 오류는 컴파일 단계에서 발생하는 오류입니다.
- 단점 : 개발자가 반드시 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에 너무 번거롭습니다. 또한 의존관계 문제점도 발생합니다. 예외를 throw하면 거쳐가는 객체마다 해당 예외가 넘어가기 때문에. 해당 예외를 의존하여 만약 다른 기술로 변경한다면 일일이 코드를 수정해야 합니다.
언체크 예외 테스트
- 언체크 예외는 체크 예외와 달리 예외를 던지는 throws를 선언하지 않고 생략할 수 있습니다.
- catch로 처리하지 않은 예외는 자동으로 예외를 던집니다.
- throws 구문을 생략해도 되기 때문에, 특정한 객체에 의존하지 않아 다른 기술로의 변경이 용이합니다.
참고 언체크 예외는 주로 생략하지만, 중요한 예외의 경우에는 throws를 선언하는 것이 개발자가 이런 예외가 발생한다는 것을 좀 더 편리하게 인지할 수 있습니다.
실행 순서
- test → service → repository[예외 발생]
package hello.jdbc.exception.basic;
...
@Slf4j
public class UncheckedTest {
@Test
void unchecked_catch() {
Service service = new Service();
service.callCatch();
}
@Test
void unchecked_throw() {
Service service = new Service();
Assertions.assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyUncheckedException.class);
}
/**
* RuntimeException 을 상속받은 예외는 언체크 예외가 된다.
*/
static class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
/**
* Unchecked 예외는 예외를 잡거나, 던지지 않아도 된다.
* 예외를 잡지 않으면 자동으로 밖으로 던진다.
*/
static class Service{
Repository repository = new Repository();
/**
* 필요할 경우 예외를 잡아서 처리하면 된다.
*/
public void callCatch() {
try {
repository.call();
} catch (MyUncheckedException e) {
//예외 처리 로직
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
/**
* 예외를 잡지 않아도 된다. 자연스럽게 상위로 넘어간다.
* 체크 예외와 다르게 throws 예외 선언을 하지 않아도 된다.
*/
public void callThrow() {
repository.call();
}
}
static class Repository {
public void call() throws MyUncheckedException {
throw new MyUncheckedException("ex");
}
}
}
- 장점 : 신경쓰고 싶지 않은 예외는 무시할 수 있습니다.
- 단점 : 개발자가 실수로 예외 처리를 누락할 수도 있습니다.
예외 전환
개발자는 필요에 따라 체크 예외를 언체크 예외로 바꿔서 던지거나, 언체크 예외를 체크 예외로 바꿔서 던질 수 있습니다.
예를 들어, SQLException은 체크 예외입니다. 만약, SQLException이 발생했다면, catch로 해당 예외를 잡고, 언체크 예외를 생성하여 던질 수 있습니다. 따라서, 체크 예외를 언체크 예외로 변경하여 ControllerAdvice에서 공통으로 처리하게 할 수 있습니다.
언체크 예외 클래스 생성 - RuntimeException 상속
class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
체크 예외를 언체크 예외로 전환
class Repository {
public void call() {
try {
throw new SQLException("ex"); //체크 예외 발생
} catch (SQLException e) {
throw new RuntimeSQLException(e); //체크 예외 잡은 후, 언체크 예외 던짐
}
}
}
💡주의: 예외 포함과 스택 트레이스
예외를 전환할 때는 꼭! 기존 예외를 포함해야 합니다. 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생합니다.
void printEx() {
try {
...
} catch (Exception e) {
//e.printStackTrace();
log.info("ex", e);
}
}
- log.info("ex", e) 처럼, 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있습니다.
- 콘솔에 스택 트레이스를 출력하려면 e.printStackTrace();를 사용하면 됩니다.
- 하지만, 실무에서는 항상 로그를 사용해야 함을 기억해야 합니다.
기존 예외 포함
예외를 변경할 때, 변경 전의 예외를 스택 트레이스에 출력하기 위해서 반드시 파라미터에 포함시켜야 합니다.
public void call() {
try {
throw new SQLException("ex");
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
실행 로그
13:10:45.626 [Test worker] INFO hello.jdbc.exception.basic.UncheckedAppTest - ex
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex
at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:61)
at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java:45)
at hello.jdbc.exception.basic.UncheckedAppTest$Controller.request(UncheckedAppTest.java:35)
at hello.jdbc.exception.basic.UncheckedAppTest.printEx(UncheckedAppTest.java:24)
Caused by: java.sql.SQLException: ex
at hello.jdbc.exception.basic.UncheckedAppTest$Repository.runSQL(UncheckedAppTest.java:66)
at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:59)
새로 생성한 RuntimeSQLException과 함께, 기존에 발생한 SQLExcepion도 로그에 남는 것을 확인할 수 있습니다.
기존 예외 미포함
public void call() {
try {
throws SQLException
} catch (SQLException e) {
throw new RuntimeSQLException();
}
}
실행 로그
[Test worker] INFO hello.jdbc.exception.basic.UncheckedAppTest - ex
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: null
at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:61)
at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java:45)
기존에 발생한 SQLException을 스택 트레이스에서 확인할 수 없습니다. 변환한 RuntimeSQLException부터 예외를 확인할 수 있습니다. 만약, 실제 DB와 연동했다면 DB에서 발생한 예외를 확인할 수 없는 심각한 문제가 발생합니다.
예외를 전환할 떄는 꼭! 기존 예외를 포함시켜야 합니다.
체크 예외 VS 언체크 예외
따라서 이와 같이 체크 예외를 사용할지, 언체크 예외를 사용할지는 개발자가 조절할 수 있습니다.
그렇다면, 언제 체크 예외를 사용하고 언제 언체크 예외를 사용하는 것이 좋을까요?
기본적으론 언체크 예외를 사용하자!
추세는 언체크 예외를 사용하는 것 입니다. 발생하는 대부분의 예외들은 처리할 수 있는 예외들도 있겠지만 아주 적습니다. 예를 들어, SQLException이 발생했다면, 데이터베이스 서버가 중간에 다운되었을 수도 있습니다.
즉, 컨트롤러나 서비스 계층에서 개발자가 처리할 수 없는 게 대부분입니다.
따라서, 이런 문제들은 일관성있게 서블릿 필터, 스프링 인터셉터, ControllerAdvice 같은 컨트롤러에서 모두 모아 공통으로 처리하는 것이 깔끔합니다.
예외 공통 처리
대부분 서버에서 발생한 문제를 사용자에게 자세히 알려주기 보다 "서비스에 문제가 있습니다." 정도로만 알려줍니다.
"데이터베이스 어느 부분에서 문제가 발생했다" 고 자세히 알려주는 것은 사용자가 이해하지 못할 뿐더러, 보안에도 문제가 있습니다.
보통 오류 페이지나 API의 경우 HTTP 상태코드 500(내부 서버 오류)를 사용하여 응답합니다.
해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자에게 오류를 빨리 인지할 수 있도록 메일이나 알림(문자, 슬랙) 등을 통해서 전달받아야 합니다.
예를 들어, SQLExcpeption과 같이 잘못된 SQL을 작성해서 오류가 발생했다면 (그럴 일은 많이 없겠지만..) 개발자가 해당 SQL을 수정하기 전까진 사용자는 계속 같은 문제를 겪게 되기 때문입니다.
따라서, throws 구문을 작성할 필요가 없는 언체크 예외를 사용하여 앞단에서 공통으로 처리하는 것이 좋습니다.
또한 throws 를 작성할 필요가 없으므로 특정 예외에 의존적이지 않기 때문에 다른 기술의 변경도 용이합니다.
단, 비지니스적으로 꼭 체크 해야한다면 체크 예외 사용
단, 체크 예외는 비지니스 로직 상 의도적으로 던져야 하는 예외에 사용할 수 있습니다.
체크 예외 예)
- 계좌 이체 실패
- 결제 시 포인트 부족
- 로그인 ID, PW 불일치
물론 이 경우에도 100% 체크 예외로 만들어야 하는 것은 아닙니다.
다만, 계좌 이체 실패처럼 매우 심각한 문제는 개발자가 실수로 예외를 놓치면 안 된다고 판단할 수 있기 때문에 이 경우 체크 예외로 만들어두면 컴파일러를 통해 놓친 예외를 인식할 수 있습니다.
런타임 예외의 문서화
런타임 예외는 예외 처리를 반드시 하지 않아도 되기 때문에 놓칠 수 있어 문서화가 중요합니다.
다음과 같이 주석이나 코드에 throws 런타임 예외를 남겨 중요한 예외를 인지할 수 있도록 해야 합니다.
ex) JPA EntityManager
주석에 명시
/**
* Make an instance managed and persistent.
* @param entity entity instance
* @throws EntityExistsException if the entity already exists.
* @throws IllegalArgumentException if the instance is not an
* entity
* @throws TransactionRequiredException if there is no transaction when
* invoked on a container-managed entity manager of that is of type
* <code>PersistenceContextType.TRANSACTION</code>
*/
public void persist(Object entity);
ex) 스프링 JdbcTemplate
주석 + 코드에 명시
- 던지는 예외가 명확하고 중요하다면, 코드에 어떤 예외를 던지는지 명시하는 것이 개발자가 IDE를 통해 예외를 확인하기에 편리합니다.
/**
* Issue a single SQL execute, typically a DDL statement.
* @param sql static SQL to execute
* @throws DataAccessException if there is any problem
*/
void execute(String sql) throws DataAccessException;
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard