예외 전환의 목적
- 굳이 필요하지 않은 예외 처리를 줄여줌
- 로우레벨의 예외를 좀 더 의미있는 예외로 바꿔줌
JdbcTemplate의 DataAccessException
- SQLException을 런타임 예외로 포장
- 상세한 예외정보를 의미있고 일관성 있는 예외로 전환해서 추상화
4.2.1 JDBC의 한계
JDBC
- 자바 표준 JDK에서 가장 많이 사용되는 기능 중 하나
- DB 접근 방법을 추상화된 API 형태로 정의
- 각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공
- DB에 상관없이 일관된 방법으로 개발 가능
JDBC API로 개발할 경우
- DB 프로그램 개발 방법을 학습하는 부담을 줄여줌
- 그러나, DB를 자유롭게 변경해서 사용할 수 있는 유연한 코드를 보장해주지는 않음
비표준 SQL
SQL
- 어느 정도 표준화된 언어 / 표준 규약 존재
- 그러나 대부분의 DB는 비표준 문법과 기능을 제공
- 비표준 SQL이 DAO에 들어가면 해당 DAO에 DB에 종속적인 코드가 됨
해결 방법
- 표준 SQL만 사용
- 현실성이 없음
- DB별로 별도의 DAO를 생성
- SQL을 외부로 독립
호환성 없는 SQLException의 DB 에러정보
SQLException
- 예외의 원인이 매우 다양함
- 문제 : DB마다 에러의 종류와 원인이 제각각
- SQLException의 getErrorCode()를 봐야하는데 DB 에러코드가 DB별로 모두 다름
- 현재 사용하는 DB의 상태를 보려면 getSqlState()를 사용
- getSqlState() : SOPEN SQL 스펙에 정의된 SQL 상태코드를 따름
- 그런데 JDBC 드라이버에서 SQLException에 담을 상태코드를 정확확게 만들어주지 않음
4.2.2 DB 에러코드 매핑을 통한 전환
DB에 독립적이기 위해서는 비표준 SQL과 에러코드 및 상태정보 문제를 해결해야함
- 비표준 SQL : 추후 논의
- SQLException에 담긴 SQL 상태는 신뢰할 수 없으므로 고려하지 않음
- DB에서 직접 제공해주는 DB 에러 코드는 어느 정도 일관성을 유지함
해결 방법 : DB별 에러코드를 참고해서 발생하는 예외의 원인이 무엇인지 해석해주는 기능
- 키 값 중복의 경우
- MySql : 1062
- Oracle : 1
- DB2 : -830
- 이러한 에러 코드 값을 확인할 수 있으면 SQLException 대신 DuplicateKeyException으로 전환 가능
- DB 종류에 관계없이 동일한 상황에서 일관된 예외를 받을 수 있다면 효과적 대응 가능
스프링의 대처 방법
- DataAccessException이라는 SQLException을 대체하는 런타임 예외를 정의
- DataAccessException의 서브클래스인 세분화 된 예외 클래스를 정의
- BadSqlGrammarException : SQL 문법 오류
- DataAccessResourceFailureException : DB 커넥션을 가져오지 못함
- DataIntegrityViolationException : 제약조건 위반
- DuplicateKeyException : 중복 키
DAO 메소드나 JdbcTemplate에서 DB 종류별로 에러코드를 매핑하는 것은 부담이 너무 큼
- 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑정보 테이블을 만들어두고 이를 이용
<bean id="Oracle" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>900, 903, 904, 917, 936, 942, 17006</value>
</property>
<property name="duplicateKeyCodes">
<value>1</value>
</property>
<!-- ... -->
</bean>
JdbcTemplate은 SQLException을 단순히 포장하는 것이 아니라 DataAccessException 계층구조의 클래스 중 하나와 매핑해준다.
add() 메소드의 수정
- JdbcTemplate을 이용하도록 수정
public void add() throws DuplicateKeyException{
// 예외 처리가 불필요함
// User를 DB에 add
}
JdbcTemplate을 이용하면 JDBC에서 발생하는 DB 관련 예외는 거의 신경쓰지 않아도 된다.
직접 정의한 예외를 발생시키고 싶은 경우
- DuplicateKeyException을 DuplicateUserIdException으로 전환하는 코드를 넣으면 된다.
public void add() throws DuplicateUserIdErrorException{
try {
// user를 DB에 등록
} catch (DuplicateKeyException e) {
throw new DuplicateUserIdException(e);
}
}
JDK 1.6 이상의 JDBC 4.0부터는 SQLException 좀 더 세분화해서 정의
- SQLSyntaxErrorException이나 SQLIntegrityConstraintViolationException 등으로 세분화
- 그러나 여전히 SQLException의 서브클래스이므로 체크 예외임
- 예외를 세분화하는 기준이 SQL 상태정보를 이용함
아직은 DataAccessException을 사용하는 것이 이상적
- 시간이 많이 지나서 JDK 6.0 이상을 사용하며, JDBC 4.0의 스펙을 충실히 따라 정확한 상태정보를 가지고 일관성있는 예외를 만들어주는 JDBC 드라이버가 충분히 보급된다면 모르겠다.
- 시간은 충분히 많이 지났고, JDK 11이 출시된 현재는 어떠한가?
4.2.3 DAO 인터페이스와 DataAccessException 계층구조
DataAccessException
- JDBC 외에도 자바 데이터 액세스 기술에서 발생하는 예외에도 적용
- JDO, JPA, IBatis 등
- 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록
DAO 인터페이스와 구현의 분리
DAO를 굳이 분리해서 사용하는 이유
- 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위함
- 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해
DAO의 사용 기술과 구현 코드는 전략 패턴과 DI를 사용하는 클라이언트에게 감출 수 있지만, 메소드 선언에 나타나는 예외정보가 문제될 수 있다.
이상적인 DAO 인터페이스
public interface UserDao {
public void add(User user);
// ...
}
예외 정보를 포함하여 선언하는 add()
public interface UserDao {
public void add(User user) throws SQLException;
// ...
}
JDBC가 아닌 데이터 액세스 기술(JPA, iBatis)로 전환하면 사용할 수 없다.
- 인터페이스로 메소드의 구현은 추상화했지만 구현 기술마다 던지는 예외가 다르므로 기술에 종속된다.
- 가장 단순한 해결방법 : throws Exception (WTF)
다행히 JDBC 이후의 JDO, Hibernate, JPA 등은 체크 예외 대신 런타임 예외를 사용하므로 throws 선언을 하지 않아도 된다.
- 즉, 인터페이스에서 예외 정보를 포함하지 않아도 된다.
- JDBC API를 직접 사용하는 DAO에서만 SQLException을 런타임 예외로 포장해주면 된다.
대부분의 데이터 액세스 예외는 애플리케이션에서 복구 불가능하지만, 모두 다 그런 것은 아니다.
- 중복 키 에러 등
- 시스템 레벨에서 예외를 의미있게 분류해야 하는 경우도 있다.
- 그러나 같은 상황에서도 기술에 따라 다른 예외가 던져진다.
- 결국, DAO를 사용하는 클라이언트 입장에서는 DAO의 사용기술에 따라 예외 처리 방법이 달라져야 한다.
데이터 액세스 예외 추상화와 DataAccessException 계층 구조
스프링의 대처방법
- 다양한 데이터 액세스 기술을 사용할 떄 발생하는 예외들을 추상화하여 DataAccessException 계층구조 내에 정리
DataAccessException
- JdbcTemplate의 SQLException의 에러 코드를 DB 별로 매핑하여 의미 있는 서브클래스로 전환해서 던져 줌
- JDBC 뿐만 아니라 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화
- InvalidDataAccessResourceUsageException : 데이터 액세스 기술을 부정확하게 사용한 경우
- JDBC의 BadSqlGrammarException
- Hibernate의 HibernateQueryException
- InvalidDataAccessResourceUsageException는 프로그램을 잘못 작성해서 발생하는 오류이며, 기술의 종류에 상관없이 같은 타입의 예외를 던져주므로 시스템 레벨의 예외처리 작업을 통해 개발자에게 빠르게 통보 가능
- ObjectOptimisticLockingFailureException : 낙관적 락킹(optimistic locking) 발생
스프링의 데이터 액세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 이상적인 DAO를 만들 수 있다.
4.2.4 기술에 독립적인 UserDao 만들기
인터페이스 적용
UserDao 클래스를 인터페이스와 구현으로 분리
- UserDao(Interface) + UserDaoJdbc(Class)
public interface UserDao {
void add(User user);
User get(String id);
List<User> getAll();
void deleteAll();
int getCount();
}
setDataSource()는 추출하면 안 됨
- UserDao 구현 방법에 따라 변경될 수 있는 메소드
- UserDao를 사용하는 클라이언트가 알 필요 없음
UserDaoJdbc 클래스에 implements 선언
public class UserDaoJdbc implements UserDao {
// ...
}
빈 설정 변경
- 빈의 이름은 클래스 이름이 아니라 구현 인터페이스의 이름을 따르는 경우가 일반적 (구현 클래스를 바꿔도 혼란이 생기지 않도록 하기 위함)
<bean id="userDao" class="com.david.tobysspring.user.dao.UserDaoJdbc">
<property name="dataSource" ref="dataSource" />
</bean>
테스트 보완
테스트 코드의 인스턴스 변수는 변경해야 할까?
- 굳이 하지 않아도 된다.
- Autowired는 스프링 컨텍스트 내에서 정의된 빈 중에서 인스턴스 변수에 주입 가능한 타입의 빈을 찾아준다.
변경할 수도 있다.
- 테스트의 관심이 Jdbc 기술 구현에 있는 경우
테스트의 관심이 구현 기술에 관계없이 DAO 기능 동작이라면 인터페이스를 받는 것이 낫다.
- 구현 기술이 바뀌더라도 테스트가 여전히 유효하기 때문
setUp()에서 setDataSource를 부르는 부분은 캐스팅을 해주어야 한다.
((UserDaoJdbc) dao).setDataSource(dataSource);
중복된 키를 가진 정보를 등록하였을 때 어떤 예외가 발생하는지를 확인하는 테스트 추가
- 스프링의 데이터 액세스 예외를 다루는 기능을 알아보기 위한 일종의 학습 테스트
- 어떤 예외가 나올 지 확인하기 위해서는 테스트가 실패하도록 하여야 한다.
- 같은 키 값을 넣을 경우, 예외가 발생할 것으로 예측되므로 expected를 사용하지 않고 테스트를 만든다.
@Test
public void duplicateKey() {
dao.deleteAll();
dao.add(user1);
dao.add(user1);
}
테스트를 실행하면 다음과 같은 메세지가 출력된다.
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO users(id, name, password) VALUES (?, ?, ?)]; ORA-00001: unique constraint (SPRINGBOOK_TEST.SYS_C007021) violated
; nested exception is java.sql.SQLIntegrityConstraintViolationException: ORA-00001: unique constraint (SPRINGBOOK_TEST.SYS_C007021) violated
...
DuplicateKeyException이 발생하였음을 확인할 수 있다.
- DuplicateKeyException은 DataAccessException의 서브클래스로 DataIntegrityViolationException의 한 종류이다.
expected 항목을 DuplicateKeyException으로 바꾸고 실행하면 성공한다.
- 좀 더 정확한 예외 발생을 확인하는 테스트가 됐다.
DataAccessException 활용 시 주의사항
스프링을 활용할 경우 DB 종류나 데이터 액세스 기술에 상관없이 키 값이 중복되는 상황에서 동일한 예외가 발생하리라 기대 가능
- 다만, DuplicateKeyException은 JDBC를 사용하는 경우에만 발생한다.
- Hibernate나 JPA를 이용할 경우 다른 예외가 던져짐
- DataIntegrityViolationException를 사용할 수는 있지만 이 경우 DuplicateKeyException보다는 이용가치가 떨어진다.
- 따라서 사용에 주의를 기울여야한다.
DAO에서 기술의 종류와 관계없이 동일한 예외를 얻고 싶다면 DuplicateUserIdException처럼 직접 예외를 정의해두고, 각 DAO의 add() 메소드에서 좀 더 상세한 예외 전환을 해 줄 필요가 있다.
SQLException을 직접 해석해 DataAccessException으로 전환하는 테스트
- SQLException을 DataAccessException으로 전환하는 다양한 방법 제공
- DB 에러 코드를 이용하는 것이 가장 보편적이고 효과적
- SQLExceptionTranslator 인터페이스의 SQLErrorCodeSQLExceptionTranslator 사용
- 현재 사용하는 DB의 종류를 파악하기 위해 DataSource가 필요함
UserDaoTest
public class UserDaoTest {
@Autowired
private UserDao dao;
@Autowired
private DataSource dataSource;
// ...
}
DuplicateSqlException으로 전환하는 테스트
@Test
public void sqlExceptionTranslate() {
dao.deleteAll();
try {
dao.add(user1);
dao.add(user1);
} catch (DuplicateKeyException ex) {
SQLException sqlEx = (SQLException)ex.getRootCause();
SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);
assertThat(set.translate(null, null, sqlEx), isA(DuplicateException.class));
}
}
원본 코드가 작동하지 않아 다음과 같이 변경
@Test(expected=DuplicateKeyException.class)
public void sqlExceptionTranslate() {
dao.deleteAll();
try {
dao.add(user1);
dao.add(user1);
} catch (DuplicateKeyException ex) {
SQLException sqlEx = (SQLException)ex.getRootCause();
SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);
throw set.translate(null, null, sqlEx);
}
}
테스트 프로세스
- JdbcTemplate을 이용하는 UserDao를 이용해 강제로 DuplicateKeyException을 발생시킴
- DuplicateKeyException : 중첩된 예외로 내부에 SQLException을 가지고 있음
- getRootCause() 메소드를 이용해 꺼내옴
- 주입받은 DataSource를 이용하여 SQLErrorCodeSQLExceptionTranslator의 오브젝트를 생성
- SQLException을 파라미터로 넣어서 translate() 메소드를 호출하면 SQLException을 DataAccessException 타입의 예외로 변환해 줌
JDBC 외의 기술을 사용할 때나 JDBC를 사용하고 있지만 JdbcTemplate과 같은 자동 예외 전환 기능을 사용할 수 없을 때 유용