Spring

토비의 Spring_Chapter04_예외

강용민 2022. 10. 27. 01:10

JdbcTemplate을 대표로 하는 스프링의 데이터 액세스 기능에 담겨 있는 예외처리와 관련된 접근 방법에 대해 알아본다. 이를 통해 예외를 처리하는 베스트 프랙틱스도 살펴본다.

 

4.1 사라진 SQLException

3장에서 JdbcTempltae을 적용하면서 SQLException이 사라졌다.

 

UserDao.java 일부

//jdbcTemplate 적용 전
public void deleteAll() throws SQLException { 
    this.jdbcContext.executeSql("delete from users"); 
} 

//jdbcTemplate 적용 후
public void deleteAll() { 
    this.idbcTemplate.update("delete from users");
}

그렇다면 이 SQLExcption은 왜 사라졌을까?

 

초난감 예외처리

예외 블랙홀

JDBC 코드에서 초보들이 흔히 할 수 있는 실수다. 

//예제 1
try{
    ...
}catch(SQLException e){
}

//예제 2
}catch(SQLException e){
    System.out.println(e);
}

//예제 3
}catch(SQLException e){
    e.printStackTrace();
}

try/catch문을 사용한건 좋으나 catch블럭에서 아무 처리도 안하고 넘어가는건 결국 발생한 예외로 인해 심각한 오류로 이어진다.

 예외 를 처리할 때 반드시 지켜야 할 핵심 원칙은 한 가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분하게 통보돼야 한다.

 

무의미하고 무책임한 throws

public void method1() throws Exception{
   method2();
}
public void method2() throws Exception{
   method3();
}
public void method3() throws Exception{
   //
}

발생가능한 특정 예외를 선언하는 것이 아니라 기계적으로 Exception을 던지는 코드이다. 또한 이런 메소드를 사용하는 메소드에서도 역시 Exception을 따라서 붙이는 수밖에 없다. 결과적으로 적절한 처리를 통해 복구될 수 있는 예외상황도 제대로 다룰 수 있는 기회를 박탈당한다.

 

예외의 종류와 특징

자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

  • Error
    • java.lang.Error 클래스의 서브클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황(OutOfMemoryError나 ThreadDeath)이 발생했을 경우에 사용된다. 따라서 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.
  • Exception과 체크 예외
    • java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.
    • Exception 클래스는 다시 체크 예외와 언체크 예외로 구분된다. 전자는 Exception 클래스의 서브클래스이면서 RuntimeException 클ㄹ래스를 상속하지 않은 것들이고, 후자는 RuntimeException을 상속한 클래스들을 말한다.
      일반적으로 에외라 하면 체크 에외이며 체크 예외는 예외처리 코드가 필수이다. 안 그러면 컴파일 에러가 발생한다.(IOException, SQLException 등)
  • RuntimeException과 언체크 / 런타임 예외
    • java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라불린다.에러와 마찬가지로 catch문으로 잡거나 throws로 선언하지 않아도 된다.
    • 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다. NullPointerExceptioin이나 IllegaArgumentException 등 피할 수 있지만 개발자가 부주의해서 발생하는 예외이다. 이는 조건을 다시 체크하면 된다.

 

예외처리방법

예외 복구

예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예를들어 네트워크가 불안해 원격 DB 서버에 접속하다 SQLException이 발생하는 경우에 재시도를 해볼 수 있다. 다시 접속하는 것만으로 예외상황으로부터 복구를 시도할 수 있는것이다.

예외처리 코드를 강제하는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. 다음 코드가 예이다.

int maxRetry=MAX_RETRY;
 
while(maxRetry-- > 0) {
   try{
       //예외 처리할 코드
   }catch(SomeException e){
       //로그 출력. 정해진 시간만큼 대기
   }finally {
       //리소스 반납 및 정리작업
   }
}
 
throw new RetryFailedException(); //재시도 횟수 초과시 직접 예외 발생

 

예외처리 회피

 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는(rethrow) 것이다.

 JdbcContext나 JdbcTemplate이 사용하는 골백 오브젝트는 작업하다 발생하는 SQLException을 템플릿으로 던져 버린다. SQLEXception을 처리하는 일은 콜백 오브젝트의 역할이 아니라고 보기 때문이다.

 하지만 예외를 회피하는 것을 예외를 복구하는 것처럼 의도가 분명해야한다. 콜백/템플릿처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있어야한다.

 

예외전환

 예외 회피와 비슷ㅅ하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.

 예외 전환은 보통 두 가지 목적으로 사용된다.

첫번째로는 예외상황에 대한 적절한 의미 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서이다. 예를 들어 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어 SQLException이 났을 경우 DuplicateUserIdException 같은 예외로 바꿔서 던져주는 게 좋다.

public void add(User user) throws DuplicateUserIdException, SQLException {
    try{
        //JDBC관련 코드
        //SQLException을 던지는 메소드 호출 코드
    }catch(SQLException e){
        if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) throw DuplicateUserIdException();
    	else throw e;
    }
}

보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외(nested exception)로 만드는 것이 좋다. 중첩 예외는 getCause() 메소드나 initCause() 메소드를 이용해 처음 발생한 예외가 무엇인지 확인하는것이 좋다.

 두번째 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다.중첩 예외를 이용해 새로운 예외를 만들고 원이 되는 예외를 내부에 담아서 던지는 방식은 같다. 하지만 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아닌 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

try{
    OrderHome orderHome = EJBHomeFactory.getInstance().getOrderHome();
    Order order = orderHome.findByPrimaryKey(Integer id);
}catch(NamingException ne){
    throw new EJBException(ne);
}catch(SQLException se){
    throw new EJBException(se);
}catch(RemoteException re){
    throw new EJBException(re);
}

EJBException은 RuntimeException을 상속한 클래스로 Naming,SQL,Remote 예외처리를 언체크 예외가 되게 만들었다. 어짜피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던져버리고, 관리자에게 로그와 메일등으로 통보하는 것이 바람직하다.

 

예외처리 전략

지금까지 살펴본 예외의 종류와 처리 방법 등을 기준으로 일관된 예외처리 전략을 정리해보자.

 

런타임 예외의 보편화

 독립형 애플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션의 작업이 중단됙 종료되지 않게 해주고 상황을 복구해야 했다.

 하지만 JEE는 수많은 사용자가 동시에 요청을 보내기에 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단시키면 그만이다. 독립형 애플리케이션과 달리 서버의 특정 계층에서 에외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외 상황을 복구할 수 있는 방법이 없다. 그래서 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는게 낫다.

 

add() 메소드의 예외처리

DuplciatedUserIdException처럼 의미 있는 예외는 add() 메소드를 바로 호출한 오브젝트 대신 더 앞단의 오브젝트에서 다룰 수도 있다. 어디에서든 DuplicatedUserIdException을 잡아서 처리할 수 있다면 굳이 체크 예외로 만들지 않고 런타임 예외로 만드는 게 낫다. 대신 add() 메소드는 명시적으로 DuplicatedUserIdException을 던진다고 선언해야 한다.

 

DuplicateUserIdException.java

package springbook.exception;

public class DuplicateUserIdException extends RuntimeException{
	public DuplicateUserIdException(Throwable cause) {
		super(cause);
	}
}

이제 add()메소드를 수정하자. SQLException 을 메소드 밖으로 던지게 했는데, 이제는 런타임 예외로 전환해서 던지도록 만든다. 반면에 DulicatedUserIdException을 메소드의 throws 선언에 포함시키다.

 

UserDao.java 일부

public void add(final User user) throws DuplicateUserIdException{
    try {
        this.jdbcTemplate.update("insert into users(id,name, password) values(?,?,?)",user.getId(),user.getName(),user.getPassword());
    }catch(SQLException e) {
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) throw new DuplicateUserIdException(e);
        else throw new RuntimeException(e);
    }
}

런타임 예외 중심 전략은 복구할 수 있는 예외는 없다고 가정하며 예외가 생겨도 어차피 런타임 예외로 만들어 시스템 레벨에서 알아서 처리해주거나 꼭 필요한 경우는 런타임 예외라도 잡아서 복구 및 대응해줄 수 있으니 문제 없다는 태도의 전략이다.

 

애플리케이션 예외

시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외를 애플리케이션 예외라 한다. 흔한 예로 은행계좌 출금 메소드가 있다. 출금 요청 시에 잔고 확인 후 허용 범위를 넘으면 작업을 중단시키고 사용자에게 경고를 보내야 한다. 이런 경우 처리 방법은 두 가지가 있다.

  1. try/catch를 사용하는 것이 아니라 분기문(if)을 사용하여 다른 리턴 값을 돌려주는 것이다. 정상 출금이면 요청금액을 리턴하고 잔고 부족의 경우 0이나 -1을 리턴하도록 한다. 이런 경우 문제는 예외상황에 따라 리턴 값을 명확하게 코드화하지 않으면 혼란이 온다는 것이다. 잔고 부족의 경우 0,-1이 아니라 -99를 돌려주는 개발자도 있기 때문에 일관성 문제가 생길 수 있다. 또한 if 블록 범벅으로 가독성이 떨어진다.
  2. 두 번째 방법은 잔고 부족이 뜨는 경우만 의미 있는 예외를 던지는 것이다. 이 때 예외는 의도적으로 체크 예외로 만들어야 개발자가 잊지 않고 잔고 부족처럼 자주 발생하는 예외상황에 대한 로직을 구현하도록 강제할 수 있다.

두 번째 예시인 애플리케이션 예외를 코드 에시로 만들면 다음과 같다.

try{
    BigDecimal balance = account.withdraw(amount);
    //정상적인 처리 결과를 출력하도록 진행
}catch(InsufficientBalanceException e){    //체크 예외
    //인출 가능한 잔고금액 정보를 위의 예외 오브젝트에서 가져옴
    BigDecimal availFunds = e.getAvailFunds();   
    //잔고 부족 안내 메세지를 준비하고 이를 출력하도록 진행
}

 

SQLException은 어떻게 됐나?

 JdbcTemplate에 throws SQLException은 사라졌다. SQLException의 대부분은 복구가 불가능한 예외이다. 더군다나 DAO 밖에서 SQLException을 다룰 수 있는 가능성은 거의 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없느 ㄴ기계적인 throws 선언이 등장하도록 방치하지 말고 가능한한 빨리 언체크/런타임 예외로 전환해줘야 한다.

 스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JDbcTemplate 템플리과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DAtaAccessException으로 포장해서 던져준다.런타임 예외는 강제가 아니기 떄문에 UserDao의 DAO메소드들에서 SQLExcpetion이 사라진 것이다.