나에게 예외는 피하고 싶은 것이었다.
비즈니스 로직 상 고의로 예외를 발생 시키기도 하였지만, 대부분의 예외는 나의 실수를 콕 찌르는 거 같았다.
예외 또한 내 코드나 API를 사용하는 Client들에게는 하나의 메세지가 될 수 있다는 것을 이해 하지 못했었다.
토비의 스프링을 보고, 올바른 예외 처리가 Application Code를 작성할 때 얼마나 중요한 것인지 깨닫고 있다.
이번 글에서는 예외를 어떻게 다루어야 회피하는 것이 아니라, 책임지는 것인지에 대해서 중점적으로 다루고자 한다.
나는 인간 CCTV다.
나는 예외를 Try Catch 문으로 잡을 때 늘 작성하는 코드가 있었다.
바로 e.printStackTrace();
이다.
printStackTrace() 메서드는 예외 발생 시 예외 메시지와 함께 호출 스택 정보를 출력해주는 메서드이다.
일반적으로는 보안, 성능상 이슈로 사용을 지양하라고 한다.
로깅 라이브러리 또는 getStackTrace 사용을 추천한다.
https://dev-jwblog.tistory.com/167
https://tmdrnr96.tistory.com/43
아래 예시 코드를 봐보자.
public String XMLCreate(인자...) {
try {
XML을 만드는 코드...
} catch (IOException e) {
e.printStackTrace();
// or log.error(e);
}
return path;
}
위 예시 코드는 내가 일반적으로 늘 작성했던 Try Catch 코드이다.
해당 예시 코드는 Try 문 실행 간 Exception이 발생하면 해당 예외가 발생한 호출 스택을 log로 출력하는 코드이다.
사실 너무 평범한 예외 처리문이다.
예외가 발생하면 화면에 출력 해주는데 이게 뭐가 문제일까?
위 코드가 실행되는 환경이 로컬 환경이고, 해당 예외 로그를 IDE 콘솔에서 볼 수 있다면 우리는 예외의 발생 지점 및 발생 시점을 명확히 알 수 있을 것이다.
그러나 이는 로컬 환경일 때의 이야기이다.
실제 유저가 사용하는 운영 서버의 경우 1s에도 수 많은 로그 또는 메세지가 쌓인다.
사진의 미어캣 마냥 콘솔 로그를 계속 모니터링 하지 않는 이상 예외 코드의 발생 지점 및 시점을 아는 것은 상당히 어렵다.
그리고 이는 시한 폭탄과도 같이 운영 환경에 남겨져있을 것이다.
반드시 예외는 처리 되어야 한다.
예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 아래와 같다.
모든 예외는 적절하게 복구 되든지 아니면 작업을 중단 시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.
위 코드처럼 예외를 무시하고, 정상적으로 동작하고 있는 것처럼 모른 척 다른 코드로 실행을 이어간다는 것은 용납 되지 않는다.
책의 저자는 책임감 없는
심지어 위 코드보다 아래 코드가 낫다고 설명 하고 있다.
catch (IOException e) {
e.printStackTrace();
System.exit(1);
}
너무나 극단적인 코드이지만, 무책임하게 예외를 다룬 코드보다 낫다는 저자의 의견을 명확하게 나타내는 예시 코드이다.
만약 해당 IOException
을 catch 문으로 잡는다 하더라도, 로그를 찍는걸 제외하고, 해당 code에서 해결 할 방법이 없다면 메서드에 throw IOException
을 선언해서 메서드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가해라.
예외? 아몰랑
위에서 본인이 해결할 수 없는 예외를 throw 문을 통해 자신을 호출 하는 코드에 책임을 전가 하라고 했다.
그러나 이는 절대 예외에 대해서 무책임 해지라는 말이 아니다.
반드시 예외는 처리 되어야 한다.
public static String getHeaderInf(인자...) throws Exception {
Header 파일 가져오는 코드
}
위 예시 코드는 내가 일반적으로 늘 작성했던 throws를 사용한 코드이다.
throws는 어떠한 문제를 야기 할 수 있을까?
위 코드를 봐보자.
만약 해당 우리가 해당 메서드를 호출 하는 Client의 입장이라고 해보자.
우리에게 던져진 Exception을 놓고 어떠한 유의미한 정보를 찾을 수 없으며, 이 불안정하고 애매한 Exception을 우리도 throws Exception을 통해 던져버릴 수 밖에 없을 것이다.
이를 통해 적절한 처리를 통해 복구될 수 있는 예외 상황도 제대로 다룰 수 있는 기회를 놓치게 된다.
그렇다면 어떻게 예외를 잘, 책임감 있게 다룰 수 있을까?
그러기 위해서는 먼저 예외에 대해서 이해 할 필요가 있다.
우리가 JAVA에서 throw를 통해 발생 시킬 수 있는 예외는 3가지가 있다.
이 3개의 예외에 대해서 간단하게 알아보도록 하자.
Error는 java.lang.Error 클래스의 서브 클래스들이다.
Error는 시스템에 뭔가 비정상적인 상황이 발생했을 경우 사용되며, 주로 자바 VM에서 발생 시키는 것이기에 이를 애플리케이션 단에서 잡으려고 하면 안된다.
OOM이나 ThreadDeath 같은 Error는 애플리케이션 단에서 catch 블록으로 잡아도 이렇다 할 대응 방법이 없기 때문이다.
이는 java.lang.Exception 클래스와 그 서브 클래스들이다.
Error와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외 상황이 발생했을 경우에 사용된다.
위 그림을 보면 알겠지만, Exception 클래스는 체크 예외
와 언체크 예외
로 구분된다.
체크 예외는 Exception 클래스의 서브 클래스이면서 RuntimeException 클래스를 상속하지 않은 것이고, 언체크 예외는 RuntimeException 클래스를 상속한 클래스를 칭한다.
일반적으로 예외라고 하면 Exception 클래스의 서브 클래스 중 RuntimeException 클래스를 상속하지 않은 것만을 말하는 체크 예외라고 생각해도 된다.
체크 예외가 발생 할 수 있는 메서드를 사용할 경우 반드시
해당 체크 예외를 처리하는 코드를 작성 해야 한다.
반드시 catch 문으로 잡든지, 아니면 throws를 통해 메서드 밖으로 던져야 한다.
그렇지 않으면 컴파일 에러가 난다.
RuntimeException 클래스를 상속한 언체크 예외들은 명시적인 예외처리를 강제하지 않는다.
런타임 예외
는 예상하지 못했던 예외 상황에서 발생하는 게 아니기에 굳이 catch 문이나 throws 사용을 강요 받지 않는다.
런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다.
이러한 런타임 예외로는 우리에게 익숙한 NPE나 IllegalArgumentException 등이 있다.
위에서 예외들에 대해서 알아보았다.
그렇다면 이제 예외들을 어떻게 처리 해야 옳은 방법인지에 대해서 알아야 할 차례이다.
예외들을 처리하는 방법에는 무엇이 있을까?
예외 복구
는 예외 상황을 파악하고 문제를 해결해서 프로그램을 정상 상태로 돌려놓는 것이다.
public static String getHeaderInf(filePath) {
// Header 파일을 가져오는 코드...
File headerFile = new File(filePath);
}
위 예시 코드에서 인자로 받은 filePath 경로의 파일을 가져오는 코드는 얼마든지 IOException
을 발생할 여지가 있는 코드이다.
이 때 Client에게 단순히
발생한 IOException 메세지를 그대로 던지는 것은 예외 복구라고 할 수 없다.
예외 복구라는 것은 비록 기능적으로는 사용자에게 예외 상황으로 비쳐도 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행 돼야 한다.
위의 말을 요약하면 아래와 같다.
즉 예외가 발생 했어도 프로그램이 미리 예상 해놓은 프로세스로 흘러가게 해야 한다.
public static String getHeaderInf(filePath) {
try {
// Header 파일을 가져오는 코드...
File headerFile = new File(filePath);
} catch (IOException e) {
// 새로운 Header 파일을 생성
}
}
위 코드를 보면 위 설명을 이해하는데 도움을 줄 것이다.
위 코드에서는 만약 파일이 없어도, 새로운 헤더 파일을 생성 해주거나 아예 헤더 파일 생성을 해준다.
여기서 중요한 것은 예외가 발생 하더라도 미리 예상 해놓은 프로세스의 흐름대로 프로그램이 흘러간다.
이 코드를 사용하는 Client 또한 해당 경로에 파일이 없었다는 것을 알지만 프로세스 상으로는 정상적으로 진행 된다.
이를 통해 알 수 있는 것은 예외 복구에서 말하는 것은 책임감
이다.
여기서의 책임감이란 본인 코드에서 발생한 예외를 묵인하거나 단순히 정보만을 전달하는게 아니라 문제를 직접 해결하거나 고객인 Client에게 다른 작업 흐름으로 향하게끔 자연스럽게 유도하는 것이다.
이러한 예외 복구는 예외처리 코드를 강제하는 체크 예외 중 복구 할 가능성이 있는 경우에 사용한다.
두번째 예외처리 방법은 제목에서 들어나듯이 예외처리를 자신이 담당 하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.
throws
문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch
문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 방식이다.
예외 처리를 회피 하기 위해서는 반드시 다른 오브젝트나 메서드가 예외를 대신 처리할 수 있도록 아래와 같이 던져주어야 한다.
public void add() throws SQLException {
// JDBC 로직...
}
public void add() throws SQLException {
try {
// JDBC 로직...
} catch(SQLException e) {
// 로그 출력
throw e;
}
}
JdbcContext나 JdbcTemplate가 사용하는 콜백 오브젝트
는 ResultSet이나 PreparedStatement를 이용해서 작업 중 발생한 SQLException을 자신이 처리하지 않고 자신을 호출한 템플릿
에게 던져버린다.
콜백 오브젝트가 이렇게 책임 해보일 수도 있게 던진 이유는 SQLException 예외를 처리하는 건
본인의 역할이 아니라
판단 하기 때문이다.
public void getHeaderInf(filePath) throws IOException {
// Header 파일을 가져오는 코드...
}
내가 이번 글에서 지속적으로 설명한 위 코드도 그럼 예외 처리에 적합할까?
사람마다 의견이 다르겠지만 나는 위의 예외 회피는 무책임 하다고 느껴진다.
getHeaderInf()
는 인자로 받은 특정 경로의 Header 파일을 가져오는 역할을 수행한다.
이 메서드를 사용하는 Client 입장에서는 해당 메서드가 파일을 가져오거나, 파일이 없더라도 무언가의 처리를 해내기를 바랄 것이다.
단순히 IOException 예외를 나에게 폭탄 넘기기 처럼 넘겨 내 코드를 더럽히지 않기를 바랄 것이다.
콜백 오브젝트와 템플릿처럼 긴밀하게 역할을 분담한 관계가 아니라면 위와 같이 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 책임 회피일 수 있다.
Spring의 3-layers 아키텍쳐로 생각을 해보자.
SQL 예외와 같은 DAO 예외를 Service나 Contoller 단까지 던지고, 던진다고 생각을 해보자.
Service 계층과 Controller 계층이 DAO 예외를 catch로 잡는다 한들 어떻게 처리할 수 있을까?
위의 예시들을 살펴보면서 중요한 것은 예외가 발생할 때 본인의 역할에 대해서 명확히 인지하는 것이다.
만약 내가 처리하는 게 맞으면
예외 복구
를 하는 것이고, 나의 책임이 아니다면예외 회피
를 하면 된다.
예외 회피는 긴밀한 관계
에 있는 다른 오브젝트에게 예외처리 책임을 분명하게 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 분명한 확신이 있어야 한다.
예외 전환은 예외를 메서드 밖으로 던지되, 발생한 예외를 그대로 넘기는게 아니라 적절한 예외로 전환
후 던진다.
예외 전환은 일반적으로 2가지 목적으로 사용된다.
첫번째 목적으로는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외 상황에 대한 적절한 의미를 부여해주지 못하는 경우이다.
public void addUser(User user) throws DuplicateUserIdException, SQLException {
try {
// JDBC 로직...
} catch(SQLException e) {
// ErrorCode가 MySQL의 "Duplicate Entry(1062)이면 예외 전환"
if (e.getErrorCode() == MysqlErrorNumbers.EP_DUP_ENTRY) {
throw DuplicateUserIdException();
} else {
throw e;
}
}
}
위의 addUser() 메서드는 User 객체를 받아서 이를 DB에 저장하는 역할을 가지고 있다.
이 때 DB이 이미 같은 ID 값을 가진 USER 데이터가 존재 한다면 SQLException 예외가 발생한다.
만약 이 경우에 해당 DAO 객체의 메서드가 SQLException 예외를 그대로 던지면, 이를 호출하여 사용하는 Service 계층은 무슨 이유로 예외가 발생한 것인지 명확히 파악할 수 없다.
그렇기에 DAO에서 SQLException 예외를 분석해 DuplicateUserIdException 예외와 같은 정확한 예외로 바꿔서 던져준 것이다.
이 때 전환하는 예외(ex: DuplicateUserIdException)에 원래 발생한 예외를 담아서 중첩 예외
로 만드는 것이 좋다.
catch(SQLException e) {
throw DuplicateUserIdException(e);
}
catch(SQLException e) {
throw DuplicateUserIdException().initCause(e);
}
두번째 전환 방법은 예외를 처리하기 쉽고 단순하게 만들도록 포장
하는 방법이다.
이 방법은 중첩 예외를 이용해서 새로운 예외를 만들고, 원인이 되는 예외를 내부에 담아서 던지는 방식은 같다.
하지만 의미를 명확하게 하려고 다른 예외로 전환하는게 아니라 예외 처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외
로 바꾸는 경우에 사용한다.
이 때 throw new
를 사용한다.
class ContentNullException extends RuntimeException {
public ContentNullException(String message, Throwable cause) {
super(message, cause);
}
}
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
return content.toString();
} catch (IOException e) {
throw new ContentNullException("파일을 읽는 도중 에러가 발생했습니다.", e);
}
ContentNullException은 RuntimeException 클래스를 상속한 런타임 예외이다.
이렇게 런타임 예외로 만들어서 전달하면 이를 사용하는 클라이언트에서 체크 예외와 같이 일일이 예외를 잡거나 다시 던질 수고를 겪을 필요가 없다.
이 때는 어차피 복구할만한 방법이 없을 때 사용한다.
어차피 복구가 불가능한 예외이거나 로직상 굳이 이 예외를 해결 할 필요가 없으면, 가능한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메서드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해야 한다.