4장에서는 JdbcTemplate을 대표로 하는 스프링의 데이터 액세스 기능에 담겨 있는 예외처리와 관련된 접근 방법에 대해 알아본다. 이를 통해 예외를 처리하는 베스트 프랙티스도 살펴본다.
1. 사라진 SQLException
3장에서 JdbcTemplate으로 바꾸고 나서 throws SQLException이 사라졌다.
먼저 개발자들의 코드에서 종종 발견되는 초난감 예외처리의 대표선수들을 살펴보자.
예외 블랙홀
try/catch 블록 중 catch로 예외를 잡고 아무것도 하지 않는 경우. 조치를 취할 방법이 없다면 굳이 예외를 잡지 말고 메소드에 throws SQLException을 선언해 자신을 호출한 코드에 예외처리 책임을 전가하자.
무의미하고 무책임한 throws
모든 메소드에 기계적으로 예외를 무조건 던져버리는 선언을 넣는 것
예외의 종류와 특징
자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.
1. Error : java.lang.Error 클래스의 서브클래스들. 시스템에 비정상적인 상황이 발생했을 경우에 사용된다. 주로 자바 VM에서 발생시켜 애플리케이션 코드에서 잡으려고 하면 안 된다. OutOfMemoryError, ThreadDeath 등
2. Exception과 체크 예외 : java.lang.Exception 클래스와 서브클래스들. 개발자가 만든 코드 작업 중 예외상황이 발생했을 경우에 사용된다.
- 체크 예외 : Exception 클래스의 서브클래스면서 RuntimeException 클래스를 상속하지 않은 것.
- 언체크 예외 : RuntimeException을 상속한 클래스
체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외 처리 코드를 작성해야 한다.
3. RuntimeException과 언체크/런타임 예외 : java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외 or 런타임 예외라고 한다. 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다. NullpointerException, IllegalArgumentException 등
이제 일반적인 예외 처리 방법을 살펴보자.
예외 복구
예외상황을 파악하고 문제를 해결해 정상 상태로 돌려놓는 것
예를 들어 사용자가 요청한 파일이 없어 IOException이 발생한 경우 다른 파일을 이용하도록 안내해서 예외상황을 해결할 수 있다. 예외로 인해 기본 작업 흐름이 불가능하다면 다른 작업 흐름으로 자연스럽게 유도해주는 것이다.
예외처리 회피
예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것
throws 문으로 선언해 예외가 발생하면 알아서 던져지게 하거나 catch문으로 예외를 잡은 후 로그를 남기고 다시 예외를 던지는 것이다.
public void add() throws SQLException {
try{
//JDBC API
} catch(SQLException e) {
//로그 출력
throw e;
}
}
JdbcContext나 JdbcTemplate이 사용하는 콜백 오브젝트는 SQLException을 자신이 처리하지 않고 템플릿으로 던져버린다. SQLException을 처리하는 일은 콜백의 역할이 아니라고 보기 때문이다.
Q. 만약 DAO가 SQLException을 생각 없이 던진다면?
A. 해당 예외를 서비스, 컨트롤러에서도 다시 던져 그냥 서버로 전달되고 말 것.
이처럼 무분별하게 예외를 회피하는 것은 무책임하므로 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있어야 한다.
예외 전환
발생한 예외를 적절한 예외로 전환해서 메소드 밖으로 던지는 것
예외 전환은 다음과 같은 두 가지 목적으로 사용된다.
1. 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못할 때, 의미를 분명하게 해주는 예외로 바꿔주기 위해서이다.
- ex) 새로운 사용자 등록 시 아이디 중복 → DAO가 SQLException을 밖으로 던지면 서비스 계층에서는 SQLException이 발생한 이유를 알기 힘들다.
예외 전환 기능을 가진 DAO 메소드
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 DuplicateUserIdException(e); //중첩 예외
//throw DuplicateUserIdException().initCause(e);
else
throw e; //그 외의 경우는 SQLException 그대로
}
}
2. 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하기 위해서이다.
- 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.
- ex) EJBException
예외 포장
try{
OrderHome orderHome = EJBHomeFactory.getInstance().getOrderHome();
Order order = orderName.findByPrimaryKey(Integer id);
} catch (NamingException ne){
throw new EJBException(ne);
} catch (SQLException se){
throw new EJBException(se);
} catch (RemoteException re){
throw new EJBException(re);
}
이제 예외의 종류와 처리 방법 등을 기준으로 예외처리 전략을 정리해보자.
add() 메소드의 예외처리
add() 메소드는 DuplicateUserIdException, SQLException 두 가지 체크 예외를 던진다. DuplicateUserIdException은 복구 가능한 예외이므로 add() 메소드를 사용하는 쪽에서 잡아 대응할 수 있다.
하지만 SQLException은 대부분 복구 불가능하므로 잡아봤자 처리할 수 있는 게 없기 때문에 그냥 런타임 예외로 포장해 던져서 그 밖의 메소드들이 신경쓰지 않게 해주는 편이 낫다.
DuplicateUserIdException도 의미 있는 예외이기 때문에 앞단의 오브젝트에서 다룰 수 있다. 따라서 굳이 체크 예외로 두지 말고 런타임 예외로 만들자. 대신 add() 메소드는 명시적으로 DuplicateUserIdException을 던진다고 선언해야 한다. 중첩 예외를 만들 수 있도록 생성자도 추가하자.
아이디 중복 시 사용하는 예외
public class DuplicateUserIdException extends RuntimeException {
public DuplicateIdException(Throwable cause) {
super(cause);
}
}
기존에는 SQLException을 직접 밖으로 던졌지만, 이제 런타임 예외로 전환해서 던지도록 만들자.
예외처리 전략을 사용한 add()
public void add(User user) throws DuplicateUserIdException {
try {
//JDBC를 이용해 user 정보를 DB에 추가하는 코드 또는
//그런 기능을 가진 다른 SQLException을 던지는 메소드를 호출하는 코드
} catch(SQLException e) {
if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
throw new DuplicateUserIdException(e); //예외 전환
else
throw new RuntimeException(e); //예외 포장. 런타임 예외로 전환
}
}
이제 SQLException 처리를 위해 불필요한 throws 선언을 하지 않으면서 DuplicateUserIdException을 사용할 수 있다.
애플리케이션 예외
복구할 수 있는 예외가 없어 시스템이 알아서 처리해 줄 것이라는 낙관적인 런타임 예외 중심 전략과 달리 애플리케이션 자체 로직에 의해 의도적으로 발생시키고 반드시 catch 해서 조치가 필요한 예외를 애플리케이션 예외라고 한다.
ex) 사용자가 요청한 금액을 계좌에서 출금하는 메소드 - 잔고 확인 후 부족하면 출금 중단 및 경고를 보내야 한다. 이를 설계하는 방법은 다음과 같은 두 가지이다.
- 정상 출금처리 한 경우와 잔고 부족이 발생한 경우 각각 다른 리턴 값을 돌려준다.
- 잔고 부족과 같은 예외 상황에서만 비즈니스적 의미의 예외(InsufficientBalanceException 등)를 던진다.
방법 1의 경우 이 메소드를 호출한 쪽은 반드시 번거롭게 리턴 값을 확인해야 하고 리턴값에 대한 혼란이 생길 수 있다.
반면 방법 2의 경우 예외가 발생할 코드를 try 안에 정리하고 예외상황에 대한 처리는 catch 안에 모아 가독성이 좋고 if문을 남발하지 않아도 된다. 이때 의도적으로 체크 예외를 사용한다.
애플리케이션 예외를 사용한 코드
try{
BigDecimal balance = account.withdraw(amount);
...
//정상적인 처리 결과를 출력하도록 진행
} catch(InsufficientBalanceException e) { //체크 예외
//InsufficientBalanceException에 담긴 인출 가능한 잔고금액 정보를 가져옴
BigDecimal availFunds = e.getAvailFunds();
...
//잔고 부족 안내 메시지 준비 및 출력
}
그래서 사라진 SQLException은 어떻게 됐나?
Q. SQLException은 과연 복구가 가능한 예외일까?
A. 99% 확률로 코드 레벨에서는 복구 불가능하다. → 예외처리 전략을 적용해야 한다.
필요없는 기계적 throws 선언을 방치하지 말고 언체크/런타임 예외로 전환해주자.
스프링의 JdbcTemplate은 이 예외처리 전략을 따른다. JdbcTemplate 템플릿과 콜백에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해 던져준다.
따라서 UserDao 메소드에서는 필요한 경우에만 DataAccessException을 잡아 처리하면 되고 그 외에는 무시해도 된다.
그래서 SQLException이 DAO 메소드에서 사라진 것이다.
2. 예외 전환
JDBC는 DB를 자유롭게 변경해서 사용할 수 있는 유연한 코드를 보장해주지 못한다. 현실적으로 DB를 자유롭게 바꿔 쓸 수 있는 DB 프로그램을 작성하는 데는 다음과 같은 두 가지 걸림돌이 있다.
1. 비표준 SQL
대부분의 DB는 비표준 문법과 기능을 제공한다. 이러한 비표준 SQL 문장은 결국 DAO에 들어가고 특정 DB에 종속적인 코드가 된다.
2. 호환성 없는 SQLException DB의 에러 정보
DB마다 에러의 종류와 원인은 제각각인데 JDBC는 다양한 예외를 그냥 SQLException 하나에 담아버린다. 그래서 SQLException은 예외가 발생했을 때의 DB 상태를 담은 SQL 상태정보를 제공하지만, 정확하지 않다. 호환성 없는 에러 코드와 부정확한 상태 코드는 DB에 독립적인 유연한 코드를 작성하기 힘들게 한다.
DB 종류가 바뀌어도 DAO를 수정하지 않으려면 이를 해결해야 한다. 먼저 2번에 대한 해결책을 알아보자.
일단 상태 코드는 신뢰할 수 없으므로 고려하지 않는다. DB 업체별로 만들어온 DB별 전용 에러 코드를 참고해서 발생한 예외의 원인이 무엇인지 해석하는 기능을 만들어 해결한다.
ex) 키 중복 오류 시 MySQL : 1062, Oracle : 1, DB2 : -802라는 에러코드를 받아 확인하면 SQLException을 DuplicateKeyException이라는 분명한 의미의 예외로 전환할 수 있다.
JdbcTemplate은 체크 예외인 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것이 아니라 DB 에러 코드를 DataAccessException 계층구조의 클래스 중 하나로 매핑해준다. 드라이버나 DB 메타정보를 참고해 DB 종류를 확인하고 DB별 매핑정보를 참고해 적절한 예외 클래스를 선택하기 때문에 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있다.
JdbcTemplate이 제공하는 예외 전환 기능을 이용하는 add() 메소드
public void add() throws DuplicateKeyException {
//JdbcTemplate을 이용해 User를 add 하는 코드
}
add() 메소드에는 예외 포장을 위한 코드가 따로 필요 없다. JdbcTemplate은 DB 종류와 상관없이 중복 키 에러는 DataAccessException의 서브클래스인 DuplicateKeyException으로 매핑해서 던져준다.
직접 정의한 예외를 발생시키고 싶을 때는 다음과 같이 하면 된다.
중복 키 예외의 전환
public void add() throws DuplicateUserIdException { //애플리케이션 레벨의 체크 예외
try {
//JdbcTemplate을 이용해 User를 add 하는 코드
} catch(DuplicateKeyException e) {
//로그를 남기는 등의 필요한 작업
throw new DuplicateUserIdException(e); //원인이 되는 예외 중첩
}
}
DataAccessException은 JDBC, JDO, JPA 등 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하게 해준다. 스프링이 왜 이렇게 기술에 독립적인 예외를 정의하고 사용하게 하는지 생각해보자.
DAO 인터페이스와 구현의 분리
Q. DAO를 굳이 따로 만들어서 사용하는 이유?
A1. 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리하기 위해.
A2. 분리된 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해.
DAO는 인터페이스를 사용해 구체적인 클래스 정보와 구현 방법을 외부로부터 감추고 DI를 통해 제공되도록 만들어야 한다.
메소드 선언에 나타나는 예외정보도 감추기 위해서는 UserDao를 다음과 같이 정의해야 한다.
기술에 독립적인 이상적인 DAO 인터페이스
public interface UserDao{
public void add(User user); //이렇게 선언하는 것이 가능할까?
...
}
하지만 DAO에서 사용하는 데이터 액세스 기술의 API가 예외를 던지기 때문에 위와 같은 메소드 선언은 사용할 수 없다.
인터페이스의 메소드 선언에는 없는 예외를 구현 클래스 메소드의 throws에 넣을 수 없다.
따라서 다음과 같이 선언돼야 한다.
public void add(User user) throws SQLException;
하지만 JDBC가 아닌 데이터 액세스 기술에서는 사용할 수 없다. 그럼 모든 예외를 다 받아주는 throws Exception으로 선언한다면?
다행히 JPA, Hibernate, JDO 등은 SQLException 같은 체크 예외 대신 런타임 예외를 사용하기 때문에 throws에 선언해주지 않아도 된다.
남은 건 JDBC API를 직접 사용하는 DAO인데, DAO 메소드 내에서 런타임 예외로 포장해 던져서 해결하자.
그럼 DAO 메소드는 처음 의도했던 대로 선언해도 된다.
public void add(User user);
이제 DAO에서 사용하는 기술에 독립적인 인터페이스 선언이 가능해졌다.
하지만
여전히 클라이언트 입장에서는 DAO의 사용 기술에 따라 예외 처리 방법이 달라져야 하고 결국 DAO에게 의존적이게 된다. 단지 인터페이스로 추상화하고, 일부 기술에서 발생하는 체크 예외를 런타임 예외로 전환하는 것만으론 불충분하다.
데이터 액세스 예외 추상화와 DataAccessException 계층구조
그래서 스프링은 다양한 데이터 액세스 기술 사용 시 발생하는 예외를 추상화해 DataAccessException 계층구조에 정리해놓았다. 이를 이용하면 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다.
결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수 있다.
이제 기술에 독립적인 UserDao를 만들어보자.
인터페이스 적용
UserDao를 인터페이스와 구현으로 분리하자. 기존 UserDao 클래스에서 클라이언트가 필요한 것만 추출하면 된다.
UserDao 인터페이스
public interface UserDao {
void add(User user);
User get(String id);
List<User> getAll();
void deleteAll();
int getCount();
}
UserDao의 setDataSource() 메소드는 구현 방법에 따라 변경될 수 있는 메소드고, 클라이언트가 알 필요도 없으니 인터페이스에 추가하지 않는다.
기존 UserDao 클래스는 다음과 같이 변경한다.
public class UserDaoJdbc implements UserDao {
userDao 빈 클래스 이름도 바꿔준다.
테스트 보완
테스트 코드에서 UserDao 인스턴스 변수 선언도 UserDaoJdbc로 변경해야 할까?
public class UserDaoTest {
@Autowired
private UserDao dao; //UserDaoJdbc로 변경해야 하나?
}
@Autowired는 스프링 컨텍스트 내에서 정의된 빈 중 인스턴스 변수에 주입 가능한 타입의 빈을 찾아준다. UserDao는 UserDaoJdbc가 구현한 인터페이스이므로 dao 변수에 UserDaoJdbc로 정의된 빈을 넣을 수 있다.
다음은 UserDao의 클라이언트와 구체적인 데이터 액세스 기술 사이에 DI가 적용된 모습이다.
'챕터정리방' 카테고리의 다른 글
[5장] 서비스 추상화 (0) | 2021.12.29 |
---|---|
[5장] 서비스 추상화 (0) | 2021.12.26 |
[3장] 템플릿 (0) | 2021.12.17 |
[2장] 테스트 (0) | 2021.12.11 |
[1장] 오브젝트와 의존관계 (0) | 2021.12.08 |
댓글