Home 예외 이해하기
Post
Cancel

예외 이해하기

스프링이 제공하는 예외 추상화를 이해하기 위해 예외에 대해 실무에서의 활용 등과 연관지어 정리해본다.

예외 계층

  • Object: 예외도 하나의 객체이기 때문에 Object가 최상위 부모이다.
  • Throwable: 최상위 예외이다. 하위에 Exception과 Error가 있다.
  • Error: 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다.
    • 상위 예외를 catch로 잡으면 그 하위 예외까지 함께 잡게 된다. throwable을 잡으면 Exception과 Error를 모두 잡는다는 것이다. Error는 애플리케이션 로직에서 잡으면 안되기 때문에 개발자는 Exception 부터 필요한 예외로 생각하고 잡으면 된다.
  • Exception: 체크 예외
    • 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외
    • Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크예외이다.
      • 단 RuntimeException은 예외로 한다.
  • RuntimeException: 언체크 예외, 런타임 예외
    • 컴파일러가 체크하지 않는다.
    • RuntimeException 자식 예외는 모두 언체크 예외이다.

예외 기본 규칙

예외는 폭탄 돌리기와 같다. 처리할 수 없다면 밖으로 던져야 한다.

우선 처리과정을 보자.

컨트롤러에서 서비스를 호출하고 그 서비스에서 리포지토리를 활용해야하는데, 리포지토리에서 Exception이 발생했다고 가정하자.

리포지토리에서는 예외를 처리하지 못해 서비스 계층으로 예외를 던지고, 그 예외를 서비스 계층에서 처리하면 이후 애플리케이션 로직이 정상 흐름으로 작동되는 것이다.

예외를 처리하지 못한다면?

계속 밖으로 던지다가 끝까지 가게 된다.

자바의 경우 main() 쓰레드에서 예외 로그를 출력하며 시스템이 종료되며 끝나고, 웹 애플리케이션의 경우 WAS가 해당 예외를 받아 처리한다. 주로 사용자에게 개발자가 지정한 오류페이지를 보여주는 것이다.

웹 애플리케이션에 경우는 여러 사용자의 요청을 처리해야 하기 때문에 하나의 예외때문에 시스템 자체가 종료되면 안된다. 그래서 위와 같이 WAS가 오류페이지를 보여주는 것으로 처리된다.

예외 기본 규칙

  • 예외는 잡아서 처리하거나 던져야 한다.
  • 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다.
    • Exception을 catch로 잡으면 그 하위 예외도 모두 잡을 수 있다.
    • Exception을 throws로 던지면 그 하위 예외들도 모두 던질 수 있다.

체크 예외와 언체크 예외 기본

체크 예외와 언체크 예외의 기본 개념에 대해 코드와 함께 알아본다.

체크 예외

  • Exception과 그 하위 예외는 모두 컴파일러가 체크한다. 체크 예외라고 한다.
    • 단 RuntimeException은 예외이다. (언체크 예외)
  • 체크 예외는 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야 한다. (throws) 그렇지 않으면 컴파일 오류가 발생한다.

예시 코드로 체크 예외를 알아본다.

1
2
3
4
5
static class MyCheckedException extends Exception {  
	public MyCheckedException(String message) {  
		super(message);  
	}  
}

이와 같은 예외를 만들었다고 가정해보자. Exception을 상속받기 때문에 체크 예외이다.

임의의 리포지토리를 만들고 거기서 call() 메서드를 호출하면 MyCheckedException이 발생하도록 했다.

그런데 컴파일러가 오류로 잡아준다. 체크 예외이기 때문에 잡아서 해결하든, 밖으로 던져주든 해야한다.

throws를 이용해 밖으로 던지면 오류가 사라진다.

서비스 계층에서 리포지토리의 call() 메서드를 이용한다고 해보자. call() 메서드를 호출하면 오류가 발생했고, 그 오류가 던져졌기 때문에 오류를 서비스에서 받게 된다.

그러면 컴파일러가 또다시 처리해야 한다고 오류로 잡아준다.

이번에는 오류를 처리해보자.

try - catch를 통해 처리해주었기 때문에 컴파일러가 오류로 잡아주지 않는다. 참고로 log.info에서 e로 printStackTrace()를 호출하는 것을 대체한다. 자동으로 해준다.

catch () 에서 MyCheckedException을 Exception으로 바꾸어도 동작한다. Exception의 하위 클래스를 모두 잡아주기 때문이다. 하지만 보통 자세하게 하위 클래스를 예외로 잡아주는 것이 더 좋다.

참고

서비스 계층에서도 예외를 던졌을 경우 테스트.

1
2
3
4
5
void checkedThrow() {  
	Service service = new Service();  
	Assertions.assertThatThrownBy(() -> service.callThrow())  
			.isInstanceOf(MyCheckedException.class);  
}

체크 예외의 장단점

체크 예외는 예외를 잡아 처리할 수 없을 때, throws 예외를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생하는 것을 볼 수 있었다. 이러한 부분 때문에 장단점이 존재한다.

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전장치이다.
  • 단점: 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 처리해야 하기 때문에, 너무 번거로운 일이 된다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다.
    • 의존관계에 따른 단점도 존재한다.

언체크 예외

RuntimeException과 그 하위 예외에 포함되는 예외들은 언체크 예외로 분류된다.

언체크 예외는 말 그대로 컴파일러가 예외를 체크하지 않는다.

  • 체크 예외: 예외를 잡아 처리하지 않으면 항상 throws를 통해 예외를 던지는 선언을 해야한다.
  • 언체크 예외: 예외를 잡아서 처리하지 않아도 throws를 생략할 수 있다.

예시 코드로 언체크 예외를 알아보자.

1
2
3
4
5
static class MyUnCheckedException extends RuntimeException {  
	public MyUnCheckedException(String message) {  
		super(message);  
	}  
}

RuntimeException을 상속받아 언체크 예외를 만든다.

1
2
3
4
5
static class Repository {  
	public void call() {  
		throw new MyUnCheckedException("ex");  
	}  
}

체크예외때와 동일하게 리포지토리에서 언체크예외를 던진다. 단, 이때 언체크예외이기 때문에 throws를 하지 않아도 컴파일러에서 오류로 잡지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class Service {  
	Repository repository = new Repository();  
	  
	public void callCatch() {  
		try {  
			repository.call();  
		} catch (MyUnCheckedException e) {  
			log.info("예외 처리 message={}",e.getMessage(), e);  
		}  
	}  
	  
	public void callThrow() {  
		repository.call();  
	}  
}

이제 예외를 잡아서 처리해도 되고, 그냥 냅둬도 컴파일러에서 잡지 않는다. throws를 명시적으로 선언하지 않아도 되고, try-catch로 잡지 않아도 된다.

단 callThrow()와 같이 예외를 처리하지 않고, throws를 안해도 예외를 자동으로 던지기 때문에 예외가 끝까지 던져지게 된다.

물론 언체크 예외도 throws를 선언해 던져도 무방하다.

언체크 예외는 주로 생략하지만, 중요한 예외의 경우 throws를 통해 선언해두면 해당 코드를 호출하는 개발자가 이런 예외가 발생한다는 부분을 IDE를 통해 조금 더 편리하게 인지할 수 있다. (컴파일 시점에 막진 않는다.)

언체크 예외의 장단점

언체크 예외는 예외를 잡아 처리할 수 없을 때 예외를 밖으로 던지는 throws를 생략할 수 있다고 했다. 이 부분이 장단점을 만든다.

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 throws를 선언해야 하지만 언체크 예외는 생략할 수 있다.
    • 신경쓰고 싶지 않은 예외의 의존관계를 생략할 수 있다.
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다.

체크 예외와 언체크 예외의 활용

언제 체크 예외를 사용하고 언제 언체크 예외를 사용하면 좋을까?

기본적으로 아래 두가지를 기억하면 된다.

  • 기본적으로는 언체크(런타임) 예외를 사용한다.
  • 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용한다.

체크 예외는 해당 예외를 잡아 반드시 처리해야 하는 문제일 때만 사용하라는 것이다. 예를 들어보자.

  • 체크 예외 사용 예시
    • 계좌 이체 실패 예외
    • 결제 시 포인트 부족 예외
    • 로그인 ID, PW 불일치 예외

이러한 경우들에도 반드시 체크 예외를 사용해야 하는 것은 아니지만 계좌 이체 실패처럼 매우 심각한 문제는 개발자가 실수로라도 예외를 놓치면 안되기 때문에 이러한 경우 체크 예외로 만들어두면 컴파일러를 통해 놓친 예외를 인지할 수 있다.

그렇다면 왜 언체크 예외를 기본으로 둘까? 체크 예외에서의 문제 때문에 그렇다.

체크 예외의 문제점

체크 예외는 컴파일러가 잡아줌으로써 모든 예외를 처리하고, 더 안전해보인다. 그런데 왜 언체크 예외가 기본일까?

위의 그림을 보자. 컨트롤러에서 서비스를 호출하고 그 서비스가 Repository와 NetworkClient를 사용한다.

그런데 Repository와 NetworkClient에서 각기 다른 Exception을 던진다는 상황을 가정해보자.

  • 리포지토리는 DB에 접근해 데이터를 저장하고 관리한다. 여기서 SQLException을 던진다.
  • NetWorkClient는 외부 네트워크에 접속해 어떤 기능을 처리하는 객체이다. 여기서 ConnectException을 던진다.
  • 서비스는 이 둘을 모두 호출한다.
    • 두 곳에서 올라오는 체크 예외를 모두 처리해야 한다.
    • 그런데 서비스는 이 둘을 처리할 방법을 모른다. ConnectionException처럼 연결 문제를 해결하거나, SQLException처럼 데이터베이스에서 발생하는 문제를 서비스 단계, 애플리케이션 로직에서 처리할 방법이 없다.
  • 서비스는 처리 방법을 모르기 때문에 두 예외를 모두 밖으로 던지게 된다.
    • throws SQLException, ConnectExcpetion
  • 하지만 컨트롤러도 마찬가지다. 처리할 수 없다. 예외를 또 던진다.
  • 웹 애플리케이션이라면 서블릿의 오류 페이지나, 또는 스프링 MVC가 제공하는 ControllerAdvice 에서 이러한 예외를 공통으로 처리한다.
    • 이런 문제들은 보통 사용자에게 어떤 문제가 발생한 것인지 자세히 설명하기 어렵다. 사용자에게는 “서비스에 문제가 발생했다” 정도로 전달한다.
    • API라면 보통 HTTP 상태코드 500을 사용해 응답을 내려준다.
    • 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자가 오류를 빨리 인지할 수 있도록 메일, 알림(문자, 슬랙) 등을 통해 전달받아야 한다.
      • 예를 들어 SQLException이 잘못된 SQL을 작성해 발생했다면, 개발자가 해당 SQL을 수정해 배포하기 전까지 같은 문제를 겪게 된다.

이렇게 체크 예외를 처리하지 못해 끝까지 던지게 된다. 여기에는 2가지 문제를 알 수 있다.

  • 복구 불가능한 예외
  • 의존 관계에 대한 문제

복구 불가능한 예외

대부분의 예외는 복구가 불가능하다. 일부 복구가 가능한 예외도 있지만 아주 적다.

SQLException을 예로 들면 데이터베이스에 무언가 문제가 생겨 발생하는 예외이다. SQL 문법에 문제가 있거나 DB 자체에 문제가 발생한 경우이다. 서버가 중간에 다운되었을 수도 있다.

이런 복구 불가능한 문제를 서비스나 컨트롤러 단계에서 해결할 수 없다.

따라서 이런 문제는 일관성있게 공통으로 처리한다. 로그를 남기고 알림을 받아 빠르게 오류를 인지해 해결해야 한다.

서블릿 필터, 스프링 인터셉터, 스프링 ControllAdvice를 사용하면 이런 부분을 깔끔하게 공통으로 해결할 수 있다.

의존 관계에 대한 문제

컨트롤러나 서비스 입장에서 본인이 처리할 수 없음에도 어쩔수 없이 throws를 통해 던지는 예외를 선언해야 한다.

서비스와 컨트롤러에서 java.sql.SQLException을 의존하는 것이다.

이전 포스트 에서 아직 해결하지 못했던 문제이다. @Transactional을 써서 다른 문제를 해결했음에도 서비스 단계에 throws SQLException을 해주는 것을 볼 수 있다.

이것이 왜 문제가 되느냐.

향후 리포지토리를 JDBC 기술이 아닌 다른 기술로 변경하게 된다면 SQLException이 아니라 예를 들면 JPAException으로 바꿔야 한다. 이러면 SQLException에 의존하던 모든 서비스와 컨트롤러 코드들을 수정해야 한다.

서비스나 컨트롤러 입장에서 보면 본인이 처리할 수도 없는 예외를 의존해야 하는 큰 단점이 생기고, 이는 OCP, DI를 통해 클라이언트 코드의 변경 없이 대상 구현체를 변경할 수 있다는 장점이 있던 것을 체크 예외가 방해하는 것과 같다.

throws Exception으로 던지면 되지 않나?

물론 코드를 수정할 필요는 없을 수 있다. 다만 최상위 타입을 던지면 모든 체크예외를 다 밖으로 던지게 되고, 이러면 중간에 다른 중요한 체크 예외가 발생해도 컴파일 오류가 발생하지 않게 된다.

언체크 예외 활용

런타임 예외를 사용해 위 문제를 해결하는 것을 보자.

  • SQLException을 런타임 예외인 RuntimeSQLException으로 바꾼다.
  • ConnectException 대신 런타임 예외인 RuntimeConnectException으로 바꾼다.
  • 런타임 예외이기 때문에 이제는 서비스, 컨트롤러에서 해당 예외를 처리할 수 없다면 별도의 선언없이 그냥 두면 된다.

코드로 보자.

1
2
3
4
5
6
7
8
9
10
11
static class RuntimeConnectException extends RuntimeException {  
	public RuntimeConnectException(String message) {  
		super(message);  
	}  
}  
  
static class RuntimeSQLException extends RuntimeException {  
	public RuntimeSQLException(Throwable cause) {  
		super(cause);  
	}  
}

각각 런타임 예외로 만든다. 여기서 Throwable cause는 이전 예외 객체의 정보들을 전달하는 역할이다. stackTrace()나 메시지를 가져다 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static class NetworkClient {  
	public void call() {  
		throw new RuntimeConnectException("연결 실패");  
	}  
}  
	  
static class Repository {  
	public void call() {  
		try {  
			runSQL();  
		} catch (SQLException e) {  
			throw new RuntimeSQLException(e);  
		}  
	}  
  
	private void runSQL() throws SQLException {  
		throw new SQLException("ex");  
	}  
}

리포지토리에서는 이제 SQLException이 발생되는 상황이 있으면 해당 exception을 담아 RuntimeSQLException 으로 전환해 예외를 발생시킨다. throws 해줄 필요 없다.

문제 해결

  • 복구 불가능한 예외
    • 런타임 예외를 사용해 서비스나 컨트롤러가 본인이 복구 불가능한 예외를 신경쓰지 않아도 된다. 물론 이렇게 복구 불가능한 예외는 일관성있게 공통으로 처리해야 한다.
  • 의존 관계에 대한 문제
    • 런타임 예외의 경우는 해당 객체가 처리할 수 없는 예외라면 무시하면 된다. 따라서 체크 예외처럼 강제로 의존하지 않아도 된다.

이렇게 함으로써 만약 기술을 바꿔도 서비스나 컨트롤러 단계에서 throws ~ 코드를 바꾸지 않아도 된다. 단, 공통으로 처리하는 한 곳은 변경해야 한다.

정리

처음 자바 설계 당시에는 체크 예외가 더 나은 선택이라고 생각되었다. 그래서 자바가 기본으로 제공하는 기능들에는 체크 예외가 많은 것이다.

하지만 시간이 흐르면서 복구할 수 없는 예외가 많아졌다. 특히 라이브러리를 점점 더 많이 사용하면서 처리해야 하는 예외도 더 늘어났다. 체크 예외는 해당 라이브러리들이 제공하는 모든 예외를 처리할 수 없을 때마다 throws에 예외를 덕지덕지 붙여야 했다. 그래서 개발자들은 극단적으로 Exception을 던지는 경우까지 발생했다.

체크 예외의 이러한 문제때문에 최근 라이브러리들은 대부분 런타임 예외를 기본으로 제공한다. JPA기술도 런타임 예외를 사용한다. 스프링도 대부분 런타임 예외를 제공한다.

런타임 예외도 필요하면 잡을 수 있기 때문에 경우에 따라 잡아서 처리하고, 그렇지 못하면 자연스럽게 던지도록 두면 된다. 그리고 최종적으로 던져지는 예외들을 공통으로 처리하는 부분을 만들어 처리하면 된다.

단, 이러한 런타임 예외는 컴파일러가 잡아주지 않고, 명시적으로도 throws를 통해 표시하지 않기 때문에 놓칠 수 있어 문서화가 중요하다.

런타임 예외의 문서화

런타임 예외는 문서화를 잘 해놓거나 또는 코드에 throws를 남겨 중요한 예외를 인지할 수 있도록 해주어야 한다.

JPA EntityManager 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
/** 
* 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 
* `PersistenceContextType.TRANSACTION` 
*/ 

public void persist(Object entity);

해당 메서드에 IllegalArgumentException 등등이 발생할 수 있다. 라고 명시해 둔 것이다.

다음은 스프링의 JdbcTemplate 예시를 보자.

1
2
3
4
5
6
7
/** 
* 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;

똑같이 명시도 하고, throws로 코드에 까지 명시하도록 했다.

  • 던지는 예외가 명확하고 중요하다면 코드에 어떤 예외를 던지는 지 명시해 개발자가 IDE를 통해 예외를 확인하기 편리하도록 한다.
  • 이를 쓰는 컨트롤러나 서비스에서 DataAccessException을 사용하지 않는다면 런타임 예외이기 때문에 그냥 무시하면 된다.

예외 포함과 스택 트레이스

실무에서 정말 중요한 부분이다.

예외를 전환할 때는 기존 예외를 반드시! 포함해야 한다.

그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생한다.

우선 일반 Exception 상황을 보자.

1
2
3
4
5
6
7
8
9
@Test  
void printEx() {  
	Controller controller = new Controller();  
	try {  
		controller.request();  
	} catch (Exception e) {  
		log.info("ex", e);  
	}  
}
  • 로그를 출력할 때 마지막 파라미터에 예외 (e) 를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.
  • System.out에 스택 트레이스를 출력하려면 e.printStackTrace()를 사용한다.
    • 실무에서는 항상 로그를 사용한다.

이제 문제가 되는 기존의 예외를 포함하는 케이스를 보자.

1
2
3
4
5
6
7
public void call() {
	try {
		runSQL();
	} catch (SQLException e) {
		throw new RuntimeSQLException(e);
	}
}

위의 코드에서 SQLException을 RuntimeException으로 전환해주었을 때 이전 예외인SQLException을 Throwable 객체로 받아서 던졌었다.

Exception이 기존 Exception을 가지게 되는 것이다.

1
2
3
4
5
6
7
8
[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.ja va: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.ja va:59)

이렇게 하면 기존 발생한 java.sql.SQLException과 스택 트레이스를 확인할 수 있다.

기존 예외를 포함하지 않는다면?

1
2
3
catch (SQLExcpetion e) {
	throw new RuntimeSQLException();
}
1
2
3
4
[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.ja va:61) 
at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java :45)

막상 RuntimeSQLException은 SQLException 때문에 발생한 예외인데, 왜 발생했는 지 알 수가 없다.

이것이 왜 문제가 되느냐.

만약 실제의 상황이라면 SQLException 스택 트레이스에 해당 DB에 문제가 생긴 부분의 정보가 담겨있을텐데 그런 정보를 전혀 받을 수 없게 된다.

예외를 전환할 때는 반드시 기존 예외를 포함하도록 한다.

This post is licensed under CC BY 4.0 by the author.