초난감 예외처리
자바를 처음 배우면 예외처리를 다음과 같이 try-catch문을 써서 처리한다.
try {
...
} catch(SQLException e) {
}
예외가 발생하는 것을 catch블록을 써서 잡아내는 것은 좋은데 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 위험한 일이다. (리소스 소진, 예상치 못한 다른 문제를 야기한다.)
다음과 같은 코드들도 잘못 된 예외 처리 방식이다. 오류 메세지는 출력해주지만, 다른 로그나 메세지에 금방 묻혀버려 놓치기 쉽다.
catch(SQLException e) {
System.out.println(e);
}
catch(SQLException e) {
System.out.println();
}
모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자에게 통보되어야 한다.
무의미하고 무책임한 throws
public void method1() throws Exception {
method2();
}
public void method2() throws Exception {
method3();
}
메소드 선언에서 얻을 수 있는 의미 있는 정보는 없다. 따라서 실행 중에 어떤 예외적인 상황이 발생할 수 있는지 알 수 없다.
Error vs Exception
오류(Error)는 시스템에 비정상적인 상황이 생겼을 때 발생한다. 이는 시스템 레벨에서 발생하는 심각한 오류이기 때문에 애플리케이션에서 오류에 대한 처리를 신경 쓰지 않아도 된다.
예외(Exception)은 개발자가 구현한 로직에서 발생하기 때문에 발생할 상황을 미리 예측하여 처리할 수 있다. 따라서 애플리케이션 개발자는 예외를 구분하고 적절한 예외 처리를 하는 것이 중요하다.
예외는 Chekced Exception과 RuntimeException(Unchecked Exception)으로 나뉜다.
[Checked Exception]
- 반드시 예외 처리를 해야 한다.
- 컴파일 단계에서 확인된다.
- RuntimeException을 제외한 모든 예외가 여기에 해당된다. (IOException, SQLException)
[RuntimeException]
- 명시적인 처리를 강제하지 않는다.
- 실행단계에서 확인된다.
- NullPointerException, IllegalArgumentException 등이 있다.
예외처리 방법
예외 복구
첫 번째 예외처리 방법은 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예를 들어 사용자가 요청한 파일을 읽으려고 시도했는데 해당 파일이 없어서 IOException이 발생했다고 가정해보자. 이때는 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외상황을 해결할 수 있다. 이처럼 다른 작업 흐름으로 자연스럽게 유도하는 방법을 예외 복구라고 한다.
가끔 서버가 안되는 열악한 환경에 있는 시스템이라면 SQLException이 발생했을 때 재시도를 해볼 수 있다. 다음은 재시도를 통해 예외를 복구하는 코드이다.
int maxretry = MAX_RETRY;
while(maxretry-- > 0) {
try {
// 예외가 발생할 가능성이 있는 시도
return ;
} catch(Exception e) {
// 로그 출력
} finally {
//리소스 반납. 정리 작업
}
}
throw new RetryFailedException(); // 최대 재시도 횟수를 넘기면 직접 예외 발생
예외처리 회피
두 번째 방법은 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.
public void add() throws SQLException {
//JDBC API
}
public void add() throws SQLException {
try{
// JDBC API
}
catch(SQLException e) {
// 로그 출력
throw e;
}
}
JdbcContext나 JdbcTemplate이 사용하는 콜백 오브젝트는 예외 처리를 템플릿으로 던져버린다. 하지만 콜백과 템플릿처럼 긴밀하게 역할을 분담하고 있는 관계가 아니라면 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 책임회피일 수 있다.
예외 전환
예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던진다. 하지만 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다. 보통 예외 전환은 두 가지 목적으로 사용된다.
- 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다.
- 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어서 DB 에러가 발생하면 JDBC API는 SQLException을 발생시킨다. 이 경우 DAO 메소드를 이용해 사용자를 추가하려고 한 서비스 계층 등에서는 왜 SQLException이 발생했는지 알 수 없다. 이런 경우 SQLException을 해석해서 DuplicateUserIdException 같은 예외로 바꿔서 던져주는게 좋다.
public void add(User user) throws DuplicateUserIdException, SQLException {
try {
// JDBC를 이용해 user정보를 DB에 추가하는 코드 또는
// 그런 기능을 가진 다른 SQLException을 던지는 메소드를 호출하는 코드
} catch (SQLException e) {
// ErrorCode가 MySQL의 "Duplicate Entry(1062)"이면 예외 전환
if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
throw DuplicateUserIdException();
else
throw e;
}
}
보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋다. 중첩 예외는 생성자를 통해 넣어주면 된다.
catch(SQLException e) { ... throw DupulicateUserIdException(e); }
- 언체크 예외를 런타임 예외로 바꾸는 경우
- 중첩 예외를 이용해 새로운 예외를 만들고 원인이 되는 예외를 내부에 담아서 던지는 방식이지만 목적이 다르다. EJB 컴포넌트 코드에서 발생하는 대부분의 체크 예외는 비즈니스 로직으로 볼 때 의미 있는 예외이거나 복구 가능한 예외가 아니다. 이런 경우에는 런타임 예외인 EJBException으로 포장해서 던지는 편이 낫다. 대부분의 서버환경에서는 애플리케이션 코드에서 처리하지 않고 전달된 예외들을 일괄적으로 다룰 수 있는 기능을 제공한다. 어차피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던지고, 예외처리 서비스 등을 이용해 자세한 로그를 남겨서 관리자와 사용자에게 안내 메세지를 보여주는 것이 좋다.
예외처리 전략
add() 메소드의 예외처리
DuplicateUserIdException은 충분히 복구 가능한 예외이고 add() 메소드를 사용하는 쪽에서 잡아서 대응할 수 있다. 하지만 SQLException은 대부분 복구 불가능한 예외이므로 잡아봤자 처리할 것도 없고, 결국 throws를 타고 계속 앞으로 전달되다가 애플리케이션 밖으로 던져질 것이다. 이럴 때는 차라리 런타임 예외로 포장해 버려서 그 밖의 메소드들이 신경 쓰지 않게 해주는 편이 낫다.
- 먼저 DuplicateUserIdException 메소드를 클래스를 만들자.
public class DuplicateUserIdException extends RuntimeException {
public DuplicateUserIdException(Throwable cause) {
super(cause);
}
}
- add() 메소드에서 SQLException을 런타임 예외로 전환해서 던지도록 만든다.
public void add(User user) throws DuplicateUserIdException, SQLException {
try {
// JDBC를 이용해 user정보를 DB에 추가하는 코드 또는
// 그런 기능을 가진 다른 SQLException을 던지는 메소드를 호출하는 코드
} catch (SQLException e) {
if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
throw DuplicateUserIdException();
else
throw new RuntimeException(e); // 예외 포장
}
}
애플리케이션 예외
시스템 또는 외부의 예외상황이 원이이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외를 애플리케이션 예외라고 한다.
사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메소드가 있다고 생각해보자. 만약 요청한 출금 금액이 잔액보다 많다면 출금 작업을 중단시키고, 적절한 경고를 사용자에게 보내야 한다. 이런 기능을 담은 메소드를 설계하는 두 가지 방법이 있다.
-
- 정상적인 출금처리를 했을 겨우와 잔고 부족이 발생했을 겨우에 각각 다른 종류의 리턴 값을 돌려주는 방법.
- 리턴 값으로 결과를 확인하고 예외상황을 체크하면 일관된 예외상황에서의 결과 값이 달라질 수 있고, 조건문이 자주 등장하기 때문에 코드가 지저분해질 가능성이 다분하다.
- 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 띤 예외를 던지는 방법.
- 잔고 부족인 경우 InsufficientBalanceException 등을 던지는 방법으로 이때 사용하는 예외는 의도적으로 체크 예외로 만들어서, 잊지 않고 잔고 부족처럼 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는게 좋다.
- 정상적인 출금처리를 했을 겨우와 잔고 부족이 발생했을 겨우에 각각 다른 종류의 리턴 값을 돌려주는 방법.