예외처리의 함정
예외처리는 코딩 초보 시절에는 왜 하는지도 모르고 그냥 넘어가기 쉽다. 그런데, 예외처리를 대충한다면, 막상 예외가 발생했을 때 디버그가 매우매우 어려워지는 상황이 발생할 수 있다.
가장 문제인 코드
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 |