예외처리의 함정
예외처리는 코딩 초보 시절에는 왜 하는지도 모르고 그냥 넘어가기 쉽다. 그런데, 예외처리를 대충한다면, 막상 예외가 발생했을 때 디버그가 매우매우 어려워지는 상황이 발생할 수 있다.
가장 문제인 코드
try {
...
} catch(Exception e) {
// no code
}
catch
블록에 아무것도 적지 않는 개발자가 많다.- 예외 발생 시 무엇이 문제인지도 모른채 코드는 정상적으로 실행되지 않는 상태가 될 수 있다.
- 소위 예외 블랙홀로 불리며 모든 예외를 잡아먹는다.
덜 문제인 코드
try {
...
} catch(Exception e) {
e.printStackTrace();
}
- 예외가 무엇인지 적어도 프린트라도 하는 코드이다.
올바른 예외처리 방법은?
크게 복구하는 방법과 단순히 알리기만 하는 방법으로 나누어진다.
- 복구하는 방법
- 예외 상황을 복구한다.
- ex) 몇 초, 몇 분뒤 다시 시도한다.
- 알리기만 하는 방법
- 운영자 혹은 개발자에게 어떤 에러가 발생했다고 명확히 알린다.
- 메일, 문자, 슬랙 알림 등 다양한 방법이 있다.
- 운영자 혹은 개발자에게 어떤 에러가 발생했다고 명확히 알린다.
생각해봐야 할 코드
public void method1() throws Exception {
method2();
...
}
public void method2() throws Exception {
method3();
...
}
public void method3() throws Exception ...
- 아무 생각없이 메서드 시그니쳐에
Exception
만 붙인다. - 예외 블랙홀보단 낫지만 이 메서드를 호출하는 상위 스택이 전부 더러워진다.
- 하위 메서드에서
Exception
을 던지면 하위 메서드를 호출하는 상위 메서드도 강제로Exception
을 던져주어야 한다.
- 하위 메서드에서
체크 예외와 언체크 예외
예외는 크게 체크 예외와 언체크 예외로 나눌 수 있다.
체크 예외의 특징
try ... catch
문 작성을 강요한다.RuntimeException
을 상속하지 않는다.
언체크 예외의 특징
try ... catch
문 작성을 강요하지 않는다.RuntimeException
을 상속한다.
안정성을 위해 예외 처리를 강제했더니 예외 블랙홀 같은 코드가 나오는 부작용이 생겨버렸다. 요즘 나오는 라이브러리는 오히려 언체크 예외가 더 많이 쓰인다고 한다.
예외처리 방법 정리
예외 복구, 예외처리 회피, 예외 전환이 있다.
예외 복구
- 어떻게든 정상 상태로 돌려놓는 것이다.
- ex) 몇 초 뒤 다시 시도 혹은 다른 방법으로 시도하라고 안내한다.
int maxRetry = MAX_RETRY;
while(maxRetry --> 0) {
try {
... // 예외가 발생할 수 있는 시도
return; // 작업 성공
}
catch(SomeException e) {
// 로그 출력, 정해진 시간만큼 대기
}
finally {
// 리소스 반납, 정리 작업
}
}
throw new RetryFailedException(); // 최대 재시도 횟수를 넘기면 직접 예외 발생
예외처리 회피
- 메서드 시그니쳐 뒤에 붙는
throws Exception
을 통해 예외처리를 이 메서드를 호출한 곳으로 넘기는 것이다. - 이 메서드를 호출한 곳에서 예외를 처리하는 것이 더 낫다는 분명한 근거가 있을 때 사용해야 한다.
빈
try ... catch
코드를 작성하는 것과는 다름에 유의하자.
예외 전환
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 DuplicateUserException();
else
throw e; // 그 외의 경우는 SQLException 그대로
}
}
- 첫번째 방법으로 범용적인 예외를 더 구체적인 예외로 전환하여 예외를 던지는 방법이다.
try {
...
} catch (NamingException ne) {
throw new EJBException(ne);
} catch (SQLException se) {
throw new EJBException(se);
} catch (RemoteException re) {
throw new EJBException(re);
}
- 두번째 방법으로 예외를 처리하기 쉽게 단순히 만드는 포장 방법이 있다.
EJBException
은RuntimeException
을 상속한 클래스로 예외처리를 좀 더 쉽게 할 수 있도록 포장하는 방식을 사용한 예이다.RuntimeException
을 상속하면 언체크 예외가 되는 것을 기억해두자.
예외처리 전략
런타임 예외의 보편화 (예외 전환)
- 어차피 복구 못할 예외라면, 그냥 런타임 예외(언체크 예외)로 변경하여 불필요한
throws
를 작성하지 않아도 되도록 변경하자.
애플리케이션 예외로 처리하기
- 복구 가능한 예외 혹은 꼭 한번 생각해봐야 할 예외를 체크드 예외로 처리하여 무엇인가 조치를 취하게 하자
어떤 전략이 좋은가?
- 결국 상황마다 다르지만, 체크 예외의 경우 예외 블랙홀을 만드는 경우도 있고 생각처럼 개발자들은 귀찮은 예외처리를 꼼꼼하게 하지 않는다. 의미없는
catch
와throws exception
만 코드에 추가되어 불편함만 가중시키고 있다. JdbcTemplate
과 스프링 프레임워크의 대부분의 구현은 런타임 예외의 보편화 전략을 따르고 있다.- 그러나 반드시 예외를 처리하는 것을 고려해야 하는 중요한 비즈니스의 경우에는 애플리케이션 예외 방식을 사용하자.
스프링의 DataAccessException
을 통한 예외 전환
SQLException
은 보통 작성한 SQL 에 대한 예외이므로 재시도 한다고 해서 복구되기는 어려운 예외이면서도 체크 예외이다.JdbcTemplate
은DataAccessException
을 통해SQLException
을 런타임 예외로 전환해 불필요한catch/throws
를 줄여준다.
JDBC 예외처리의 어려움
- 대표적으로 DB를 제공하는 벤더문제가 있다. 벤더는
MySQL
,Oracle
,MSSQL
등 다양하다. 다들 비슷하지만, 각각의 SQL 문법(비표준 SQL)이 있다. - 이와 같이 모든 벤더는 비슷한 에러에도 다른 예외를 제공한다.
DataAccessException
의 해결책
<bean id="Oracle" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>900,903,904,917,936,942,17006,6550</value>
</property>
<property name="invalidResultSetAccessCodes">
<value>17003</value>
</property>
<property name="duplicateKeyCodes">
<value>1</value>
</property>
<property name="dataIntegrityViolationCodes">
<value>1400,1722,2291,2292</value>
</property>
<property name="dataAccessResourceFailureCodes">
<value>17002,17447</value>
</property>
<property name="cannotAcquireLockCodes">
<value>54,30006</value>
</property>
<property name="cannotSerializeTransactionCodes">
<value>8177</value>
</property>
<property name="deadlockLoserCodes">
<value>60</value>
</property>
</bean>
DataAccessException
는 벤더별 에러코드를 파악하여, 통합 예외를 제공한다.
통합 예외를 제공하는 이유
- 위에서 말했듯 런타임 예외로 감싸
catch/throws
를 줄여준다. - 높은 추상수준의 코드를 이용하여 DB 벤더가 바뀌어도 자바 코드 자체는 바꾸지 않아도 되도록 만들어준다.
- 기술에 의존적이지 않은 예외처리를 한다는 특징 때문에 가능한 것이다.
예제 코드
@Test
@DisplayName("존재하지 않는 회원을 조회할 때")
public void getUserFailure() {
// 스프링이 제공하는 EmptyResultDataAccessException 예외가 나타나게 만들자.
assertThrows(EmptyResultDataAccessException.class, () -> {
userDao.get("not_existing_user_id");
});
}
@Test
@DisplayName("QueryForObject를 이용해 2개 이상의 Row 결과가 나왔을 때")
public void getUserFailure2() {
userDao.add(new User("user1", "김똘일", "1234"));
userDao.add(new User("user2", "김똘일", "1234"));
assertThrows(IncorrectResultSizeDataAccessException.class, () -> {
userDao.getByName("김똘일");
});
}
userDao
는 스프링JdbcTemplate
을 이용하여 DB 에 접근하는 오브젝트이다.JdbcTemplate
은 스프링이 제공하는 통합 예외를 던진다.
EmptyResultDataAccessException
은 DB 벤더가 아닌 스프링에서 제공하는 예외이다.- 사용 기술 혹은 DB에 의존하지 않는 독립적인 추상수준 높은 코드를 만들 수 있다.
사용자 정의 예외로 예외 더욱 구체화하기
public class DuplicateUserIdException extends RuntimeException{
public DuplicateUserIdException(Throwable cause) {
super(cause);
}
}
public void add(User user) throws DuplicateUserIdException {
try {
this.jdbcTemplate.update("insert into users(id, name, password, level, login_count, recommend_count) values (?, ?, ?, ?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
, user.getLevel().intValue()
, user.getLoginCount()
, user.getRecommendCount()
);
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
- 예외를 한번 더 감싸서 더 구체적으로 만들 수 있다.
DataAccessException
의 하위 예외인DuplicateKeyException
을 상속받아DuplicateUserIdException
클래스를 구성했다.- 예외의 의미도 훨씬 명확해졌으며, 런타임 예외라서 체크를 강요하지도 않는다.
개발에 적용해볼 점
- 수동적으로
SpringFramework
에 있는 예외 구조만 사용하지 말고, 직접 예외를 랩핑하여 더욱 명시적이며 용도에 맞는 예외를 만들어보도록 하자.
정리
- 예외를 잡아서 아무런 조치도 취하지 않거나 throws를 남발하는 것은 위험하다.
- 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해야 한다.
- 좀 더 의미 있는 예외로 변경하거나, 불필요한 catch/throws를 피하기 위해 런타임 예외로 포장하는 두가지 방법의 예외 전환이 있다.
- 복구할 수 없는 예외는 가능한 한 빨리 런타임 예외로 전환하는 것이 바람직하다.
- 애플리케이션의 로직을 담기 위한 예외는 체크 예외로 만든다.
- JDBC의 SQLException은 대부분 복구할 수 없는 예외이므로 런타임 예외로 포장해야 한다.
- SQLException의 에러 코드는 DB에 종속되기 때문에 DB에 독립적인 예외로 전환될 필요가 있다.
- 스프링은 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다.
- DAO를 데이터 액세스 기술에서 독립시키려면 인터페이스 도입과 런타임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다.
'프레임워크 > 토비의 스프링' 카테고리의 다른 글
토비의 스프링 5장 요약 정리 - 서비스 추상화 (0) | 2022.09.06 |
---|---|
토비의 스프링 3장 요약 정리 - 템플릿 (0) | 2022.06.20 |
토비의 스프링 2장 요약 정리 - 테스트 (2) | 2021.12.26 |
토비의 스프링 1장 요약 정리 - 오브젝트와 의존관계 (3) | 2021.12.26 |
토비의 스프링 0장 정리 (0) | 2021.12.14 |