체크예외, 언체크 예외에 대해 더 깊이 이해할 수 있는 시간이었다. DB별 예외처리를 위해 스프링에서 코드 매핑 테이블에 한땀 한땀 매핑했다는 것도 재밌었다.
💬
관련해 토프링 읽기모임에서 나왔던 얘기들을 노션 링크로 공유한다.
예외와 관련된 코드는 자주 엉망이 되거나 무성의하게 만들어지기 쉽다.
때론 잘못된 예외처리 코드 때문에 찾기 힘든 버그를 낳을 수도 있고, 생각지 않았던 예외상황이 발생했을 때 상상 이상으로 난처해질 수도 있다.
올바른 예외처리 방법을 알아보자!
SQLException
public void deleteAll() throws SQLException {
this.jdbcContext...
}
public void deleteAll() {
this.jdbcTemplate...
}
jdbcTemplate
을 사용할 시 throws SQLException
가 사라졌다. 어디로 갔을까???
SQLException
은 JDBC API
메소드들이 던져주는 것이므로 당연히 있어야 한다.JDBC API
를 쓰는 jdbcTemplate
가 SQLException
을 바깥으로 던지지 않는가?모든 예외는
- 적절하게 복구되든지
- 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.
💬 예외 블랙홀
try {
...
} catch (Exception e) {}
예외가 발생했음에도 아무것도 하지 않는 건 정말 위험하다!😡
💬 예외 출력만 하기
} catch (Exception e) {
System.out.println(e);
}
} catch (Exception e) {
e.printStackTrace();
}
단순히 출력하는 것은 예외를 처리한 것이 아니다!😡
💬 차라리 시스템을 종료해라
} catch (SQLException e) {
e.printStackTrace();
System.exit(1);
}
물론 실전에서는 위와 같이 만들면 안되겠지만, 예외를 잡아서 뭔가 조치를 취할 방법이 없다면 잡지 말라는 뜻이다.
💬 무의미, 무책임한 throws
public void method1() throws Exception {
method2();
...
}
public void method2() throws Exception {
method3();
...
}
public void method3() throws Exception ...
메소드 선언에 throws Exception
을 기계적으로 붙였다😨
catch
블록으로 예외를 잡아봐야 해결할 방법도 없고 JDK API나 라이브러리가 던지는 각종 이름도 긴 예외들을 처리하는 코드를 매번 throws
로 선언하기도 귀찮아진 개발자의 임시방편EJB
시절에 흔했던 코드예외 블랙홀보단 낫지만…
throws Exception
을 따라서 붙여야만 한다…!예외 처리에 대한 나쁜 습관은 어떤 경우에도 용납하지 않아야 한다.
💬 Error
java.lang.Error
클래스의 서브 클래스JVM
단에서 발생한다.💬 Exception
java.lang.Exception
클래스와 그 서브 클래스로 정의되는 예외들💬 checked Exception
Exception
클래스의 서브 클래스이면서, RuntimeException
클래스를 상속하지 않은 것들을 말한다.IDE
에서 예외처리를 강요한다.catch
로 처리하지 않거나,, throws
로 밖으로 예외를 던지지 않을 시, 컴파일 에러가 발생한다.💬 unchecked Exception
RuntimeException
을 상속한 클래스들을 말한다.IDE
에서 예외처리를 강제하지 않는다.NullPointerException
, IllegalArgumentException
등이 있다.JDK 초기 설계자들은 체크 예외를 발생 가능한 모든 예외에 적용하려고 노력했던 것 같으나, 현재 자바 API들은 가능한 체크 예외를 만들지 않고 있다.
💬 예외 복구
int maxRetry = MAX_RETRY;
while(maxRetry --> 0) {
try {
... // 예외가 발생할 수 있는 시도
return; // 작업 성공
}
catch(SomeException e) {
// 로그 출력, 정해진 시간만큼 대기
}
finally {
// 리소스 반납, 정리 작업
}
}
throw new RetryFailedException(); // 최대 재시도 횟수를 넘기면 직접 예외 발생
예외 상황을 파악하고, 문제를 해결해서 정상 상태로 돌려놓는 것을 말한다.
IOException
) 다른 파일을 이용해보라고 안내하면 된다.IOException
에러 메세지가 사용자에게 그냥 던져진다면 예외 복구라고 볼 수 없다.💬 예외처리 회피
public void add() throws SQLException {
try {
// JDBC API
}
catch(SQLException e) {
// 로그 출력
throw e;
}
}
예외 처리를 자신이 담당하지 않고, 자신을 호출한 쪽으로 예외를 던져버리는 방식.
DAO
가 SQLException
을 던지면, 이 예외는 처리할 곳이 없어서 서비스 레이어로 갔다가 컨트롤러로 가고 결국 그냥 서버로 갈 것이다.예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다.
💬 예외 전환
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 그대로
}
}
예외를 메소드 밖으로 던지지만, 예외 회피와 달리 발생한 예외를 적절한 예외로 전환해서 던진다는 차이가 있다!
SQLException
을 DuplicateUserIdException
💬 예외 전환 시 예외 전달 방식
중첩 예외(nested exception)
포장 예외(wrap exception)
가 있다.catch(SQLException e) {
...
throw DuplicateUserIdException(e);
}
중첩 예외는 getCause()
메소드를 이용하여 처음 발생한 예외가 무엇인지 확인할 수 있게 해준다.
try {
...
} catch (NamingException ne) {
throw new EJBException(ne);
} catch (SQLException se) {
throw new EJBException(se);
} catch (RemoteException re) {
throw new EJBException(re);
}
포장 예외는 체크 예외를 언체크예외로 바꾸는 경우에 사용한다.
💬 언제 체크드
로, 언제 언체크드
로 바꿀까?
어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.
💬 런타임 예외의 보편화
자바 환경이 서버 환경으로 넘어가면서, 체크 예외의 활용도와 가치는 떨어지고 있다.
체크 예외는 복구할 가능성이 조금이라도 있는, 말 그대로 예외적인 상황이기 때문에 자바는 이를 처리하는 catch 블록이나 throws 선언을 강제하고 있다.
서버 환경은 일반적인 애플리케이션 환경과 다르다.
어플리케이션 환경
서버 환경
💬 add() 메소드의 예외처리
public class DuplicateUserIdException extends RuntimeException{
public DuplicateUserIdException(Throwable cause) {
super(cause);
}
}
public void add() throws DuplicateUserIdException {
try {
//
}
catch (SQLException e) {
if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
throw new DuplicateUserIdException(e); // 예외 전환
else
throw new RuntimeException(e); // 예외 포장
}
}
DuplicatedUserIdException
도 굳이 체크 예외로 둬야 하는 것은 아니다.
DuplicatedUserIdException
처럼 의미 있는 예외는 add()
메소드를 바로 호출한 오브젝트 대신 더 앞단의 오브젝트에서 다룰 수도 있다.DuplicatedUserIdException
을 잡아서 처리할 수 있다면 굳이 체크 예외로 만들지 않고 런타임 예외로 만드는 게 낫다.add()
메소드가 DuplicatedUserIdException
을 던진다고 명시적으로 선언해야 한다. 런타임 예외도 throws
로 선언 가능하다!💬 런타임 예외를 일반화 시 단점
💬 어플리케이션 예외
런타임 예외 중심 전략은 굳이 이름을 붙이자면 낙관적 예외처리 기법이다.
반면 어플리케이션 예외도 있다.
catch
해서 무엇인가 조치를 취하도록 요구한다.try {
BigDecimal balance = account.withdraw(amount);
...
// 정상적인 처리 결과를 출력하도록 진행
}
catch(InsufficientBalanceException e) { // 체크 예외
// InsufficientBalanceException에 담긴 인출 가능한 잔고 금액 정보를 가져옴
BigDecimal availFunds = e.getAvailFunds();
...
// 잔고 부족 안내 메세지를 준비하고 이를 출력하도록 진행
}
SQLException
은 어떻게 됐나?💬 앞서 배운 것 정리
앞서 체크 예외
와 언체크 예외
를 배워보았다.
언체크 예외
를 던져버리는 편이 낫다는 것을 배웠다.런타임 예외의 보편화
와 함께 만일 비즈니스적으로 더 명확한 의미를 줄 수 있는 경우에는 의미를 분명하게 전달할 수 있는 예외를 만들고 중첩 예외
로 던져버리는 편이 낫다는 결론을 얻었다.체크 예외
로 만들면 나쁜 예외처리 습관을 가진 개발자에 의해 더 최악의 시나리오가 발생할 수도 있다.💬 SQLException
은 복구 불가능
💬 스프링 런타임 예외 보편화 전략
스프링 API 메소드에 정의되어 있는 대부분의 예외는 런타임 예외이다.
SQLException
이 사라진 이유는 스프링의 JdbcTemplate
은 런타임 예외의 보편화
전략을 따르고 있기 때문이다.
JdbcTemplate
템플릿과 콜백 안에서 발생하는 모든 SQLException
을 런타임 예외인 DataAccessException
으로 포장해서 던져준다.
JdbcTemplate
의 update()
, queryForInt()
, query()
메소드 선언을 잘 살펴보면 모두 throws DataAccessException
이라고 되어 있음을 발견할 수 있다.
public int update(final String sql) throws DataAccessException {
//...
}
throws
로 선언되어 있긴 하지만 DataAccessException
이 런타임 예외이므로 update()
를 사용하는 메소드에서 이를 잡거나 다시 던질 이유는 없다.
JDBC는 Connection, Statement, ResultSet 등의 표준 인터페이스로 기능을 제공한다. 따라서 개발자들은 DB 종류와 상관없이 일관된 방법으로 프로그래밍이 가능하다. 객체지향적 프로그래밍이 가능하다.
하지만, DB 종류에 따라 데이터 액세스 코드가 달라질 수 있다.
💬 비표준 SQL
DB마다 SQL 비표준 문법이 제공된다. 페이지네이션이나 쿼리 조건 관련 추가적인 문법이 있을 수 있다.
만약 작성된 비표준 SQL이 DAO 코드에 들어간다면, 해당 DAO는 특정 DB에 종속적인 코드가 된다!
이를 해결하기 위해서는
해당 방법은 7장에서 다뤄볼 예정이다.
💬 호환성 없는 SQLException의 DB 에러 정보
DB마다 에러의 종류, 원인이 제각각이다.
// MySQL에서 중복된 키를 가진 데이터를 입력하려고 시도했을 때
if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) { ...
SQLException.getErrorCode()
로 에러 코드를 가져왔을 때, DB 벤더마다 에러코드가 달라서 각각 처리해주어야 한다.getSQLState()
와 같은 메소드로 예외 상황에 대한 상태 정보를 가져올 수 있지만, 해당 값을 신뢰하기 힘들다. 어떤 DB는 표준 코드와 상관없는 엉뚱한 값이 들어있기도 하다.결과적으로 SQL 상태 코드를 믿고 결과를 파악하도록 코드를 작성하는 것은 위험하다. 결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진
SQLException
만으로 DB에 독립적인 유연한 코드를 작성하는 것은 불가능에 가깝다.
스프링은 DataAccessException
의 서브 클래스로 세분화된 예외 클래스들을 정의하고 있다.
BadSqlGrammerException
DataAcessResourceFailureException
DataIntegrityViolationException
DuplicatedKeyException
문제가 있다. DB마다 에러 코드가 다르다.
이를 해결하기 위해, DB별 에러 코드를 참고해 발생한 예외 원인을 해석해줄 해석기가 필요하다!
💬 스프링 코드 매핑 테이블
<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>
이를 통해, JdbcTemplate
은 DB 에러 코드를 적절한 DataAccessException
서브클래스로 매핑한다.
public void add(User user) throws DuplicateKeyException {
this.jdbcTemplate.update("insert into users(id, name, password) values (?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
);
}
public void add(User user) throws DuplicateUserIdException {
try {
this.jdbcTemplate.update("insert into users(id, name, password) values (?, ?, ?)"
, user.getId()
, user.getName()
, user.getPassword()
);
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
위와 같이 더 명확한 예외 클래스로 예외전환도 가능하다.
DataAcessException
은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다.
데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.
💬 DAO 인터페이스와 구현의 분리
DAO를 인터페이스로 분리하면, 내부의 데이터 액세스 기술에 의존하지 않게 된다. POJO를 주고받으며 데이터 액세스 기능을 사용하기만 하면 된다.
이때 우리는 DAO 안에서 throw하지 않는다. 만약 throw를 했을 시…
public interface UserDao {
public void add(User user) throws SQLException;
}
자바 DATA 접근 API가 바뀔 시 인터페이스도 바뀐다.
public interface UserDao {
public void add(User user) throws PersistentException; // JPA
public void add(User user) throws HibernateException; // Hibernate
public void add(User user) throws JdoException; // JDO
}
이를 통해 DAO 사용 기술에 독립적인 인터페이스 선언이 가능해졌다!
💬 데이터 엑세스 예외 추상화와 DataAcessException 계층구조
스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAcessException
계층구조 안에 정리해놓았다!
JdbcTemplate
과 같이 스프링의 데이터 액세스 지원 기술을 이용해 DAO
를 만들면, 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다.
결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException
예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수 있다.
💬 인터페이스 적용
public interface UserDao {
void add(User user);
User get(String id);
User getByName(String name);
List<User> getAll();
void deleteAll();
int getCount();
}
setDataSource()
메소드는 인터페이스에 추가하면 안된다.
UserDao
의 구현 방법에 따라 변경될 수 있는 메소드이다.UserDao
를 사용하는 클라이언트가 알고 있을 필요도 없다.이후 빈 클래스를 변경하면 된다!
<bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="username" value="postgres" />
<property name="password" value="iwaz123!@#" />
<property name="driverClass" value="org.postgresql.Driver" />
<property name="url" value="jdbc:postgresql://localhost/toby_spring" />
</bean>
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="userDao" class="toby_spring.user.dao.UserDaoJdbc">
<property name="jdbcTemplate" ref="jdbcTemplate" />
</bean>
테스트는 굳이 수정해줄 필요 없다. @Autowired 를 통해 자동으로 빈을 주입받는다.
💬 DataAccessException 활용 시 주의사항
이렇게 스프링을 활용하면
하지만 안타깝게도 DuplicateKeyException
은 JDBC
를 이용하는 경우에만 발생한다!
하이버네이트
나 JPA
를 사용했을 때도 동일한 예외가 발생할 것으로 기대하지만 실제로 다른 예외가 던져진다.ConstraintViolationException
을 발생시킨다.따라서, DataAccessException
을 잡아서 처리하는 코드를 만들려고 한다면 미리 학습 테스트를 만들어서 실제로 전환되는 예외의 종류를 확인해 둘 필요가 있다.
만약 DAO
에서 사용하는 기술의 종류와 상관없이 동일한 예외를 얻고 싶다면?
DuplicatedUserIdException
처럼 직접 예외를 정의해두고, 각 DAO
의 add()
메소드에서 좀 더 상세한 예외 전환을 해주면 된다.💬 Jdbc에서 SQLException 해석해보기
SQLErrorCodeSQLExceptionTranslator
를 통해
SQLException
를 DB, 데이터 접근 기술에 따라 해석할 수 있다!
@Test
@DisplayName("SQLException DB 에러코드 해석기로 DataAccessException 해석해보기")
public void sqlExceptionTranslate() {
try {
userDao.add(user1);
userDao.add(user1);
}catch(DataAccessException ex) {
SQLException sqlEx = (SQLException) ex.getRootCause();
SQLExceptionTranslator set =
new SQLErrorCodeSQLExceptionTranslator(this.dataSource);
DataAccessException translate = set.translate(null, null, sqlEx);
Assertions.assertEquals(DuplicateKeyException.class, translate.getClass());
}
}
Spring Data JPA
단, 이때
DataSource
를 인자로 주지 않을 시, 더욱 포괄적인 에러인DataIntegrityViolationException
가 결과로 나온다!
@Test
public void save() {
try {
Item item = new Item("A");
Item item2 = new Item("A");
itemRepository.save(item);
itemRepository.save(item2);
} catch(DataAccessException e) {
SQLException sqlException = (SQLException) e.getRootCause();
SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException translate = set.translate(null, null, sqlException);
assertThat(translate.getClass()).isEqualTo(DuplicateKeyException.class);
}
}
최근 레거시 형태의 JDBCTemplate
을 직접 리팩토링하는 미션을 진행중인데, 예외처리가 모두 SQLException
으로 처리되고, 일부 콜백함수가 SQLException
을 throws
하고 있었다.
책을 읽고 나니 DataAccessException
, 그중에서도 각 상세 예외상황에 맞는 서브클래스로 예외전환을 해야겠다고 생각했다.
평소에 체크예외를 직접 구현하거나 적용하지 않았는데, 특정 경우에는 체크 예외가 더 나을 수도 있을 것 같다. 어플리케이션 비즈니스 로직을 담기 위한 예외의 경우 그렇다. 그런데 정말 체크 예외를 직접 구현해서 쓰나?
new
가 붙는 RuntimeException()
과 아닌 경우 어떤 차이가 있는지 궁금하다.(예외전환의 중첩예외/포장예외 예시에서) → 중첩 클래스getSQLState()
를 신뢰하기 어려운지 궁금하다. → 여전히 해당 문제가 있다.Checked Exception
을 직접 구현하기도 하는지 궁금하다. → 쓴다! 관련해 여러 논의들이 있다.