모든 예외는 적절하게 복구되거나 작업을 중단시키고 개발자에게 통보되어야 한다.
자바에서 프로그램 상에 Throw 할 수 있는 예외는 총 세가지 종류가 있다.
예외의 종류
- java.lang.Error의 서브 클래스
OutOfMemoryError나 ThreadDeath와 같이 자바 VM에서 발생시키는 에러를 말한다.
- Exception과 Checked Exception
개발자의 애플리케이션 코드에서 발생한 에러이다.
Checked Exception이란 Exception의 서브 클래스이면서 RuntimeException을 상속하지 않은 것을 의미한다. 체크 예외가 발생하는 코드를 작성할 때는 예외를 처리하는 코드를 반드시 함께 작성한다.
예를 들어, 존재하지 않는 파일의 이름을 입력한 FileNotFoundException이나, ClassNotFoundException등이 있다.
- RuntimeException과 Unchecked Exception
런타임 예외는 대부분 catch하거나 throws로 명시하지 않아도 상관없다. 프로그램에 오류가 있을 때 발생하는 예외이며, NullPointerException이나 IllegalArgumentException 등의 프로그램이 실행 중에 발생할 수 있는 에러이다.
예외 처리 방법
- 예외 복구
에러가 난 부분을 재시도하거나 개발자에게 수정을 요구하여 정상적으로 복구하는 시도를 하는 방법이다.
주로 체크 예외들은 예외 처리 코드를 강제하기 때문에, 적절한 처리를 코드 상에서 적용한다.
- 예외처리 회피
메소드에서 발생한 오류를 자신을 호출한 쪽으로 그대로 던져서 회피하는 방법이다.
public void add() throws SQLException {
// JDBC API ....
}
그러나 단순히 예외를 던지기만 하는게 아니라, 예외를 복구하는 것처럼 그 의도가 분명해야 한다.
자신이 처리하는 것보다 호출하는 쪽에서 처리하는 것이 더 효과적이라는 근거가 있어야 한다.
- 예외 전환
예외처리 회피와 비슷하나 발생한 예외를 그대로 넘기는 것이 아니라, 적절한 예외로 전환해서 던지는 방법이다.
발생한 예외가 구체적이지 않은 경우 의미가 분명한 예외로 전환하는 것이 첫번째 목적이며, 두 번째는 처리하고자 하는 예외를 쉽고 단순하게 포장하기 위함이다. 즉 예외처리를 강제하는 체크 예외를 강제로 언체크 예외 (런타임 예외)로 만들기 위함이다.
public void add() throws SQLException {
try {
// 유저 정보를 DB에 저장하는 코드
} catch (SQLException e) {
if(e.getErrorCode() == MySQLErrorCode.ER_DUP_ENTRY) {
throw DuplicateException();
} else {
throw e;
}
}
}
첫 번째 목적을 달성하기 위한 코드이다. 단순히 SQLException이 아니라, 에러 코드를 통해 어떤 이유로 예외가 발생되었는 지 DuplicationException()과 같은 커스텀 예외 클래스로 바꾸어서 던져줄 수 있다. 처음 발생한 예외를 함께 중첩 예외로서 전달하기 위해 아래와 같이 사용할 수 있다.
if(e.getErrorCode() == MySQLErrorCode.ER_DUP_ENTRY) {
throw DuplicateException(e);
throw DuplicationException().initCause(e);
}
두 번째에서 언급한 체크 예외를 런타임 예외로 바꾼다는것은, 체크 예외가 발생할 수 있는 코드가 비즈니스 로직으로서 의미가 없는 부분일 때, 런타임 예외로 전환하여 트랜잭션을 자동으로 롤백해주기 위함이다. 이를 통해 다시 예외를 잡거나 던질 필요가 없다.
예외 처리 전략
- 런타임 예외 클래스 생성하기
public void add() throws SQLException {
try {
// 유저 정보를 DB에 저장하는 코드
} catch (SQLException e) {
if(e.getErrorCode() == MySQLErrorCode.ER_DUP_ENTRY) {
throw DuplicateException();
} else {
throw e;
}
}
}
위에서 작성한 코드를 다시 보자. SQLException은 복구 불가능한 예외이므로 catch하는 것이 큰 의미가 없다. 따라서 체크 예외를 굳이 만들지 않고 런타임 예외로 만들 수 있다. 대신 명확한 의미를 함께 전달하기 위해, 아래와 같이 런타임 예외 클래스를 생성하여 사용한다.
public class DuplicationUserIdException extends RuntimeException {
public DuplicationUserIdException(Throwable cause) {
super(cause);
}
}
public void add() throws SQLException {
try {
} catch (SQLException e) {
if(e.getErrorCode() == MySQLErrorCode.ER_DUP_ENTRY) {
throw new DuplicationUserIdException(e); // 예외 전환
} else {
throw new RuntimeException(e); // 예외 포함
}
}
}
이렇게 코드를 변경한 효과는 아래와 같다.
- 불필요한 throws가 필요 없다.
- 아이디 중복 상황을 처리하기 위해 필요에 따라 DuplicationUserIdException을 사용할 수 있다.
- 명확한 의미를 코드 상에서 전달할 수 있다.
- 애플리케이션 예외 처리하기
이렇게 런타임 예외 처리 중심의 전략은 낙관적인 예외처리 기법이라고 할 수 있다.
반면에 애플리케이션 자체 로직에 의해 의도적으로 발생시키고 반드시 잡아서 조치를 취해야하는 예외가 있다.
애플리케이션 예외 처리 전략에는 아래와 같은 선택지가 있다.
- 상황에 맞는 리턴 값을 코드화하여 관리하기
정상적인 경우에는 0, 예외 상황에는 1, 2 등과 같이 정책으로 규정된 코드를 반환하여 예외를 처리하는 전략이다. 분기문으로 애플리케이션 코드가 어지러워지는 문제가 생길 수 있다.
- 오류 사항에 따른 비즈니스적인 의미를 띄는 예외를 던지기
잔고 부족인 경우 InsufficientBalanceException 등의 예외를 던져서, catch 블록에서 이를 잡아 처리하는 것이다.
public void withdraw() {
try {
BigDecimal balance = account.withdraw(amount);
} catch(InsufficientBalanceException e) {
BigDecimal availFunds = e.getAvailFunds();
// 인출 가능한 잔고 출력
}
}
JdbcTemplate과 DAO
SQLException은 DB 단에서 SQL문을 처리할 때 발생하는 예외이다. 이 예외는 범위가 아주 넓고 원인이 다양해서 쉽게 그 원인을 파악할 수 없다. 이를 해결하기 위한 방법으로 JdbcTemplate을 이용할 수 있는데, DB 관련 예외가 SQLException이라는 체크 예외에서 DataAccessException이라는 언체크 예외로 포장하면서 그 서브 클래스의 DuplicateKeyException, DateIntegerityViolationCodes 등과 같이 명확한 예외로 매핑되어 관리하기 훨씬 쉬워진다.
public void add() throws DuplicateKeyException {
// JdbcTemplate를 이용해 User를 add하는 코드
}
따라서 DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이, 일관된 예외가 발생하도록 한다. 이렇게 독립적인 예외를 만들어 관리하는 이유는 사용자가 DAO를 따로 만들어 사용하는 이유와 같다.
- 데이터 액세스 로직을 담은 코드를 별도로 분리하여 관리
- 전략 패턴을 적용해 구현 방법을 얼마든지 변경할 수 있도록 구현
예를 들어, 데이터 액세스를 위한 API마다 오류가 발생하였을 때 던지는 예외가 모두 다르다.
public void add(User user) throws SQLException; // JDBC
public void add(User user) throws PersistentException; // JPA
public void add(User user) throws HibernateException; // Hibernate
public void add(User user) throws JdoException; // JDO
throws Exception을 사용해 광범위한 예외 클래스를 선언하는 것은 무책임하다.
다행히도 JDBC를 제외한 다른 API들은 런타임 예외로 구성되어있기 때문에, JDBC에서 SQLException을 런타임 예외로 전환하는 코드를 넣는다면 throws ~ 구문을 빼고 선언을 작성할 수 있다.
그러나 문제는, 데이터 액세스 예외를 모두 런타임 예외로 처리하기에는 비즈니스 로직에서 의미있게 처리할 필요도 있다. 이런 경우에는 결국 위와 같이 서로 다른 예외가 던져지기 때문에, 기술에 의존한 코드를 작성할 수 밖에 없다.
그래서 스프링에서는 DataAccessException이라는 계층 구조를 정립하여 기술 종류에 상관없이 공통적으로 나타나는 예외를 포함하여 대부분의 예외를 분류하였다.
낙관적인 락킹이 발생한 경우 사용자에게 안내 메시지를 출력하고 재시도할 수 있도록 처리해야한다. API마다 서로 다른 낙관적인 락킹 관련 오류를 내기 때문에, 예외 전환 방법을 적용하여 ObjectOptimisticLockingFailureException으로 통일하게 되는 것이다.
UserDao에 적용하기
JdbcTemplate을 사용해 사용자의 정보를 데이터베이스에 입출력하는 클래스인 UserDaoJdbc 구현 클래스를 생성해보겠다.
public interface UserDao {
void add(User user);
User get(String id);
List<User> getAll();
void deleteAll();
int getCount();
}
public class UserDaoJdbc implements UserDao {
}
public class UserDaoTest {
@Autowired
private UserDao dao;
@Test(expected=DataAccessException.class)
public void duplicateKey() {
dao.deleteAll();
dao.add(user1);
dao.add(user1);
}
}
테스트시에는 애플리케이션이 어떤 API를 사용하는 지 큰 관심이 없다. 따라서 인터페이스를 통해 그대로 빈을 주입하고 테스트 코드를 작성하면 되는 것이다. 그리고 아래 테스트코드에서 스프링이 데이터 액세스 예외를 다루고 처리할 수 있도록 기본 키 중복 예외를 발생시킨다.
정리하자면, 의미없는 throws Exception을 추가하는 대신 런타임 예외를 상속하는 의미있는 예외 클래스를 생성하여 던지거나 애플리케이션 로직 상 예외를 처리하기 위해서는 체크 예외로 구현한다.
추가로, SQLException의 경우 복구 불가능하므로 런타임 예외로 포장하며, 스프링 프레임워크를 사용할 시 DataAccessException을 통해 추상화된 런타임 예외 계층을 활용한다.
'DEV book > 토비의 스프링 3.1' 카테고리의 다른 글
[토비의 스프링 3.1] 6장 - AOP (1) 직접 구현하는 고립된 단위 테스트 (0) | 2024.01.20 |
---|---|
[토비의 스프링 3.1] 5장 서비스 추상화 - UserService, TransactionService 추상화 (0) | 2024.01.18 |
[토비의 스프링 3.1] 3장 템플릿 (3) - 템플릿과 콜백 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (2) - JdbcContext를 UserDao에서 사용하는 두 가지 방법 (0) | 2023.09.24 |
[토비의 스프링 3.1] 3장 템플릿 (1) - 디자인 패턴과 DI를 이용한 DAO 최적화 (0) | 2023.09.23 |