반응형

1. 개요

 일반적으로 비지니스 로직 개발만큼 예외처리에 투자하지 않는다. 무성의한 예외처리는 어플리케이션의 많은 버그를 낳을 수 있다. 올바른 예외처리 방법을 알아보자.

 


2. 초난감 예외처리

 개발자들의 코드에서 종종 발견되는 초난감한 예외처리 케이스들이다.

 

2.1. 예외 블랙홀

 예외 상황이 발생해도 이를 무시하고 블랙홀처럼 먹어버리는 케이스이다. 이 경우 아래 코드와 같이 IOException이 발생해도 try/catch 블록을 빠져나가 다음 로직을 계속 수행하게 된다. 이는 비지니스 로직이 스무스하게 흘러갔다는 착각을 가져올 수 있고, 버그를 야기할 수 있다.

try{
	...
}catch(IOException e){
	// 예외 꺼억
}

 

 콘솔에 에러 메시지만 출력시키는 것도 예외 블랙홀이다. 출력시켜도 다음 로직을 수행하는건 마찬가지니까.

try{
	...
}catch(IOException e){
    System.out.println(e);
    e.printStackTrace();
}

 

2.2. 무책임 throws

 catch 블록으로 라이브러리가 던지는 예외들을 매번 throws로 선언하기 귀찮아 상위 클래스인 Exception 클래스를 throws하는 케이스이다. 예외는 이 메서드를 호출한 메서드로 던져지겠지만, 그 메서드에서도 throws하는 것을 반복할 가능성이 높다. 이러한 케이스는 발생할 예외에 대한 정보를 얻을 수 없다. throws Exception만 보고는 파일을 찾지 못한건지, DB 예외가 난건지, IO 예외가 난건지 알 수 없기 때문이다.

//AS-IS
public void test() throws IOException, FileNotFoundException {
        
        ... //CheckedException을 던지는 라이브러리 코드 사용 중
        
 }
 
 //TO-BE → 예외 처리따위 세탁기에 돌려버리자!
 public void test() throws Exception {
        
        ... //CheckedException을 던지는 라이브러리 코드 사용 중
        
 }

 


3. 예외의 종류 및 특징

 예외를 어떻게 다뤄야할지를 알아보기 전, 예외의 종류와 특징을 알아보자. 자바에서 발생시킬 수 있는 예외는 크게 세 가지가 있다. Error, Checked Exception, UnCheckedException.

 

3.1. Error

 Error는 java.lang.Error 클래스의 서브 클래스들이다. Error는 OutOfMemory나 ThreadDeath와 같이 시스템에 비정상적인 상황 발생 시 사용된다.  자바 VM에서 발생시키는 것이므로 어플리케이션 코드에서 잡을 수 없다.

 

3.2. Checked Exception (체크 예외)

 Exception은 Error와 달리 어플리케이션 코드 작업 중 예외상황이 발생했을 경우 사용된다. 이 Exception은 Checked Exception과 Unchecked Exception으로 구분된다. Checked Exception은 RuntimeException을 상속받지 않은 클래스, UnCheckedException은 RuntimeException을 상속한 클래스이다.

Exception의 구조

 

 

 Checked Exception를 발생시키는 메서드를 사용할 경우 반드시 예외 처리 코드를 함께 작성해야 한다. 그렇지 않으면 컴파일 에러가 발생한다. 예를들어 예외를 처리하지 않고 getInputStream() 메서드 사용 시 IOException 예외가 처리되지 않았다는 컴파일 에러가 발생한다. 해당 메서드는 IOException 예외를 던지고 있고, IOException은 Exception을 상속받는다. 즉, IOException 는 Checked Exception 이기 때문에 예외 처리를 하지 않아 컴파일 에러가 발생하는 것이다.

getInputStream() 메서드에서 예외 발생
getInputStream 메서드
Exception 상속

 

3.3. UncheckedException

 RuntimeException 클래스를 상속한 예외들은 예외처리를 강제하지 않기때문에 언체크 예외라고 불린다. 대표적인 예로는 NullPointException이 있다.

 

3.4. 필자가 생각하는 둘의 구분점 

 필자가 생각하는 Checked Exception와 UncheckedException의 구분점은 대비책을 마련할 수 있냐 없냐로 생각한다.

 예외 체크가 된다는 건 예외 발생이 예상 가능하단 뜻이고, 예상 가능하다는 건 대비책을 미리 마련해놓을 수 있다. 만약 어떤 문제가 발생했을 때 대비책을 마련할 가능성이 있다면, Checked Exception을 적용할 수 있다는 것이다.  CheckedException이 트랜잭션 롤백을 하지 않는 이유도 예외 발생 시 적절한 대비책을 마련하여 복구할 가능성이 있기 때문이다.

 반대로 UncheckedException은 대비책을 마련할 필요가 없거나 마련할 수 없다. 대표적인 예로 허용되지 않는 값을 사용해서 메서드를 호출할 때 발생하는 IllegalArugmentException이나, 할당하지 않는 레퍼런스 변수를 사용할 때 발생하는 NullPointException가 있는데 이는 모두 개발자의 실수와 부주의로 발생한다. 이는 어디에서든 일어날 수 있다. 만약 이러한 예외에 대해서도 대비해야 한다면, 모든 코드에 try, catch나 thrwos와 같은 예외처리 방법을 사용해야 할것이다. 이러한 예외들은 그 범위가 매우 방대하므로 대비책을 마련할 필요성이 없다.

 

필자의 팁
만약 Checked Exception과 Unchcked Exception이 잘 구분되지 않는다면, 전자는 컴파일 단계에서 체크(check)하는 Exception이므로 Checked Exception, 후자는 컴파일 단계에서 체크하지 않고(Unchecked) 런타임(Runtime)시 발생할 수 있으므로 UncheckedException이라고 이해해 보는건 어떨까요?

 


4. 예외처리 방법

 이제 예외 처리 방법에 대해 알아보자. 방법으로는 예외 복구, 예외처리 회피, 예외 전환이 있다.

 

4.1. 예외 복구

 말 그대로 예외를 복구하는 방법이다. 여기서의 복구란 '예외 상황을 파악하고, 문제를 해결해서 정상 상태로 돌려놓는 것'이다. 정상 상태로 가는 다른 작업 흐름으로 유도하는 것도 예외 복구로 해석한다.

 예를들어 사용자가 요청한 파일이 없을 경우 IOException이 발생할 것이다. 이 때 사용자에게 문제 상황에 대한 에러 메시지를 뿌려주고, 다른 파일을 이용하도로 안내하는 것도 예외 복구이다.

 

 네트워크가 불안정한 A 시스템은 원격지에 위치한 DB 서버 접속 시 실패하여 SQLException이 종종 발생한다. 이 경우 일정 시간 대기했다가 재 접속을 시도하는 방법을 통해 예외 복구를 시도할 수 있다.

	int maxretry = 5;

        while(maxretry -- > 0){
            try{
                //예외 발생 가능성이 있는 코드
            }catch(Exception e){
                //로그 출력 및 정해진 시간만큼 대기
            }finally {
                //리소스 반납 및 정리작업
            }
        }

        throw new InternalException("에러가 발생하였습니다.");

 

 예외 처리를 강제하는 Checked Exception들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. API를 사용하는 개발자로 하여금 예외가 발생할 수 있음을 인식하도록 도와주고, 적절한 복구를 시도해보도록 요구하는 것이다.

 

4.2. 예외처리 회피

 예외 처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 방법이다. 메서드 시그니처에 throws 문으로 선언하거나, catch 문으로 예외를 잡은 후 로그따위를 남기고 예외를 던지는 것이다.

    public static void exceptionEvasion() throws SQLException{

        try{
            /// JDBC API
            throw new SQLException();
        }catch(SQLException e){
            throw e;
        }
    }

 

 JdbcTemplate에서 사용하는 콜백 오브젝트는 ResultSet이나 Statement 등을 이용해서 작업하다 발생하는 SQLException을 자신이 처리하지 않고 외부로 던진다. 그래서 콜백 오브젝트의 메서드는 모두 throws SQLException이 붙어있다. 예외를 처리하는 일은 템플릿의 역할이라고 보기 때문이다.

 하지만 콜백과 템플릿처럼 긴밀한 관계가 아니라면 자신의 코드에서 발생하는 예외를 그저 던지는 건 무책임한 책임회피일 수 있다. 때문에 긴밀한 관계가 아니라면 자신을 사용하는 쪽에서 예외를 처리하는게 좋다.

JdbcTemplate.update 메서드

 

4.3. 예외 전환

 예외를 다른 예외로 전환하는 방법이다. 전환을 사용하는 두가지 상황이 있다.

 

 첫째, 발생된 예외가 그 예외 상황에 대해 적절한 의미를 부여하지 못할 때이다. 예를들어 회원 가입 시 아이디가 중복될 경우 DB 에러가 발생하면 SQLException이 발생한다. 이때 아래와 같이 예외를 외부로 던진다면, 이를 호출한 메서드에서는 SQLException 발생원인을 알기 어렵다. 

// 회원가입 메서드를 호출하는 메서드
public static void requestJoin(User user){
    try{
    	join(user);
    }catch(SQLException e){
    	// 예외 처리를 해야하는데, SQLException이 왜 발생한거지? 내부 로직을 확인해봐야겠다..
    }
}

// 회원가입 메서드
public static void join(User user) throws SQLException{

    // 1. 회원가입 로직 ...
    // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생
    // 3. 메서드 시그니처의 throws SQLException 을 통해 예외 throw
}

  requestJoin 메서드를 보는 개발자 입장에서는 join 메서드에서 SQLException이 발생한다는 건 알 수 있으나 ID가 중복되어 발생했다는 건 바로 알수 없다. SQLException만으로는 ID가 중복으로 인한 예외의 의미를 부여하지 못하기 때문이다. 이때 예외 전환을 사용한다면 다음과 같이 변경할 수 있다.

 

// 회원가입 메서드를 호출하는 메서드
public static void requestJoin(User user){
    try{
    	join(user);
    }catch(DuplicateUserIdException e){ // ID 중복이 발생할 수 있구나!
    	// 예외 처리
    }
}

// 회원가입 메서드
public static void join(User user) throws DuplicateUserIdException{

    try{
        // 1. 회원가입 로직 ...
        // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생
    }catch(SQLException e){
        throw new DuplicateUserIdException(e);  // 3. DuplicateUserIdException 예외 throw
    }
        
}

 이때는 DuplicationUserIdException 예외 자체가 ID 중복의 의미를 담고 있기 때문에 개발자도 예외 원인을 한눈에 파악할 수 있다.

 

 둘째, checked Exception을 UncheckedException으로 바꿀때(혹은 포장할 때)이다.

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은 런타임 예외(Unchekced Exception) 이고 나머지 예외는 Checked Exception이다. 런타임 예외의 큰 특징 중 하나는 시스템 오류로 판단하고 트랜잭션을 자동으로 롤백해주는 것이다. 이 코드는 단순히 런타임 예외로 포장만 했지만, 트랜잭션 롤백 처리도 하게 되었다. 또한, 위 메서드를 호출하는 다른 메서드에서는 EJBExceptoin에 대한 예외를 처리할 필요가 없으니 메서드의 시그니처나 로직이 변경될 필요가 없다. 변경에 닫혀있으니 OCP를 잘 따른다고 할 수 있다.


5. 예외 처리 전략

 이러한 예외 처리 방법을 통해 어떤 형식의 예외 처리 전략을 가져가야 할지 알아보자.

 

5.1. 런타임 예외를 보편적으로 사용하자

 체크 예외가 발생할 경우 사실상 예외를 복구하는 것보다 해당 요청의 작업을 취소하고 개발자에게 통보하는 편이 낫다. 대게 체크 예외는 복구가 불가능한 상황이 많기 때문이다. 실제로 자바의 환경이 서버로 이동하면서 체크 예외의 활용도가 떨어지고 있으며 프레임워크에서도 API가 발생시키는 예외를 체크 예외 대신 런타임 예외로 정의하는 것이 일반화 되고 있다. OCP나 트랜잭션 등 앞서 알아본 런타임 예외의 이점을 생각해봤을 때 예외는 런타임 예외로 정의하는 것이 보편적이다.

 

5.2. 체크 예외는 런타임 예외로 전환하자.

 체크 예외가 발생할 경우 런타임으로의 예외로 전환/포장하는 방법을 적절히 사용하자. 아래와 같이 SQLExcetion이라는 체크 예외 발생할 경우 예외 클래스의 에러 코드를 통해 ID 중복으로 발생한 예외인지를 체크할 수 있다. 이렇게 체크한 후 DuplicationUserIdException() 따위의 런타임 예외로 전환/포장하는 전략을 사용하자. 이렇게 되면 의미있는 클래스를 통해 예외 처리가 가능하며, 외부에서는 이 예외에 대한 처리를 신경쓰지 않아도 된다.

public static void join(User user) throws DuplicateUserIdException{

        try{
            // 1. 회원가입 로직 ...
            // 2. ID, PW 정보 DB INSERT 시 ID 중복(무결정 제약조건 위배)되어 SQLException 발생

        }catch(SQLException e){
            if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY){
                throw new DuplicateUserIdException(e);  // 예외 포장/전환
            }else{
            	throw new RuntimeException(e); // 예외 포장
            }
        }
    }

6. 애플리케이션 예외

 애플리케이션 자체의 로직에 의해 의도적으로 발생시키는 예외를 말한다. 예를들어 사용자가 요청한 금액을 출금하는 메서드는 요청한 금액보다 잔고가 많을 경우에만 출금이 가능하다. 반대 상황일 때에는 애플리케이션 로직에서 의도적으로 예외를 발생시킨 후 사용자에게 잔고가 부족하다는 등의 메시지를 전달해야 한다. 아래 코드가 그 예이다.

try{
	BigDecimal balance = account.withdraw(amount);
	// 정상적인 처리 결과를 출력하도록 진행
}catch (InsufficientBalanceException e){ // Checked Exception
	// InsufficientBalanceException에 잔고금액 정보를 가져옴
	BigDecimal availFunds = e.getAvailFunds();
            
	// 잔고부족 안내 메시지를 준비하고 이를 출력하도록 진행
}

 InsufficientBalanceException은 Checked Exception으로 생성하였는데, 위와 같이 예외를 복구하는 로직을 강제하여 추가하기 위함이다.

 


7. JdbcTemplate의 SQLException

 예제 코드인 UserDao를 보면 스프링에서 제공하는 JdbcTemplate를 도입을 하면서 SQLException에 대한 예외 처리(예외 회피) 코드인 throws SQLException 구문이 모두 사라진 것을 알 수 있었다. SQLException에 대한 예외처리를 하지 않아도 컴파일 에러가 발생하지 않는 상황이다. SQLException은 ChekcedException이니 예외 처리가 강제되어야 하는데 말이다.

 호출하는 메서드를 확인해보니 콜백 오브젝트에서도 throws SQLException을 통해 예외를 던지고 있다. 이상하다고 생각했지만, 앞서 배운 내용을 적용하니 이유를 알 수 있었다.

JdbcTemplate.update에서 SQLException을 throws 하고있음
deleteAll 메서드에서 SQLException에 대한 예외처리를 하지 않음.

 

 

 * SQLException은 복구가 가능한가? 

 먼저 SQLException은 예외 복구가 가능할까? SQL 문법이 틀리거나, 제약조건을 위배하거나, DB 커넥션이 끊기는 등 다양한 이유로 발생하는 대부분의 예외들은 예외 복구가 불가능하다. 그렇기에 현업에서도 이러한 예외 처리는 에러 로그를 남기고 예외에 대한 메시지를 사용자에게 알려주는 방식으로 처리되었다. 그래서인지 이 메서드들은 콜백 메서드에선 SQLException을 던지고 있지만, 예외 전환/포장 전략을 사용하여 DataAccessException이라는 런타임 예외로 변환하여 던지고 있다.

 

 아래 update 메서드에서 콜백 객체 생성 후 execute() 메서드를 호출하는데 update() 메서드의 throws를 보면 DataAccessException을 던지고 있다.

JdbcTemplate.update에서 SQLException을 throws 하고있음

 

 호출되는 execute 메서드에 SQLException 예외를 처리하는 부분이 있는데, getExceptionTranslator().translate() 메서드를 호출한 결과를 throw 하는 것을 볼 수 있다.

JdbcTemplate.execute 메서드

 

 그리고 이 메서드가 SQLException을 DataAccessException으로 전환해주고 있었다. 즉 Checked Exception을 RuntimeException으로 전환하는 예외 전환을 사용하고 있고, RuntimeException은 예외처리 강제성이 없으므로 예외처리를 하지 않았던 것이다.

 이 외에 다른 메서드들이나, 스프링에서 제공하는 API 메서드들도 대부분 런타임 예외를 사용하고 있다.

SQLExceptionTranslator.translate 메서드 시그니처

Translate the given SQLException into a generic DataAccessException.
= 주어진 SQLException을 일반 DataAccessException으로 변환합니다

 

 정리하면, 콜백 오브젝트 메서드에서 발생한 Checked Exception을 Runtime Exception으로 예외 전환/포장하는 전략을 사용하여 외부로 던지고 있기 때문에 SQLException에 대한 예외처리가 강제되지 않고, 메서드 사용 시 예외처리를 신경쓸 필요도 없게 되었다.

 

DataAccessException
DataAccessExceptoin 는 런타임 예외중 하나로, 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화한 것들을 계층구조 형태로 모아놓은 클래스이다.
 이 클래스는 단순 SQLException을 전환하는 용도로만 사용되는 건 아니다. JDBC 뿐 아니라 JPA, 하이버네이트와 같은 ORM에서 발생하는 예외들도 이 클래스의 계층구조에 관리되어 있다. 예를들어 JDBC, JPA, 하이버네이트에 상관없이 데이터 액세스 기술을 부정확하게 사용했을 때는 InvalidDataAcessResourceUsageException 예외가 던져진다. 이는 각각 또 다른 예외로 세분화된다.

 


8. 기술에 독립적인 UserDao 만들기

 

8.1. 인터페이스 적용

 JDBC나 JPA와 같은 DB 데이터 처리 기술에 독립적인 UserDao를 만들기 위해 작성했던 UserDao 클래스를 인터페이스와 구현클래스로 분리해보자. 인터페이스는 접두사에 I를 붙여 만들고, 기존 UserDao 이름을 UserDaoJdbc로 변경하자. 나중에 JPA로 구현한다면 UserDaoJpa라고 이름을 붙일 수 있다. 인터페이스 분리를 통해 변경되고 생성된 코드들은 다음과 같다.

 

8.1.1. IUserDao.java

public interface IUserDao {

    public void add(User user);
    public void deleteAll();
    public User get(String id);
    public int getCount();
    public List<User> getAll();
}

 

8.1.2. UserDaoJdbc.java

public class UserDaoJdbc implements IUserDao{

    private JdbcTemplate jdbcTemplate;

    private RowMapper<User> userMapper = new RowMapper<User>() {

        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            User user = new User();
            user.setId(rs.getString("id"));
            user.setName(rs.getString("name"));
            user.setPassword(rs.getString("password"));
            return user;
        }
    };

    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
    public void add(final User user)  {
        jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
                user.getId(), user.getName(),user.getPassword());
    }

    public void deleteAll(){
        jdbcTemplate.update("delete from users");
    }


    public User get(String id){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                userMapper);
    }

    public int getCount(){
        return jdbcTemplate.queryForInt("select count(*) from users");
    }

    public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                userMapper);
    }
}

 

8.1.3 applicationContext.xml

...

    <bean id = "userDaoJdbc" class = "org.example.user.dao.UserDaoJdbc">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>

 

8.1.4 UserDaoJdbcTest.java

class UserDaoJdbcTest {

    private IUserDao userDaoJdbc; //인터페이스 형으로 변경
    private User user1;
    private User user2;
    private User user3;

    @BeforeEach
    void setUp(){
        ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
        userDaoJdbc = applicationContext.getBean("userDaoJdbc", UserDaoJdbc.class); // userDaoJdbc 빈 조회
        user1 = new User("test1","1234","테스터1");
        user2 = new User("test2","12345","테스터2");
        user3 = new User("test3","123456","테스터3");
    }
    
    ...

 

 이로써 UserDaoJdbc는 전략패턴을 사용하여 JDBC 기술에 독립된 클래스로 관리되었다.

UserDao 인터페이스 및 구현체 분리

 

8.2. 테스트

 코드가 수정되었으니 테스트 코드를 실행시켜 단위 테스트를 진행해보자. 예제에서 ID 중복에 대한 테스트 코드를 추가하길래 JUnit5에 맞게 메서드를 만들어 주었다.

    @Test
    public void duplicateId(){
        userDaoJdbc.deleteAll();
        userDaoJdbc.add(user1);
        assertThatThrownBy(()-> userDaoJdbc.add(user1)).isInstanceOf(DataAccessException.class);
    }

 


9. 회고

 Checked Exception과 UncheckedException을 사용하는 이유, OCP 관점에서 본 둘의 차이, 특징에 의거하여 체크 예외가 트랜잭션을 롤백하지 않는 이유, 예외처리 방법과 전략 등 예외에 대한 다양한 내용들을 알아보았다. 이 과정들을 스프링에서 제공하는 JdbcTemplate에 적용하니 이해가 훨씬 잘되었다. 출판한지 오랜된 책이지만 요즘 사람들의 입에 자주 언급될만큼의 가치들이 이런곳에서 나오는 것 같다. 

 예외 처리는 가벼히 여겨서는 안될 어플리케이션의 중요 요소 중 하나라고 생각한다. 이러한 코드들을 구현할 때 이번에 배운 내용들이 내가 작성하는 코드의 자신있는 근거가 될 수 있을것이라 확신한다.

반응형

+ Recent posts