DB 관련 예외 처리
관련 게시글
2023.02.22 - [스프링/DB] - JDBC, ConnectionPool, DataSource
2023.02.25 - [스프링/DB] - Transaction_1
2023.02.28 - [스프링/DB] - Transaction_2
체크 예외, 언체크 예외
예외에는 체크 예외와 언체크 예외가 존재한다.
어플리케이션을 개발할 때 체크 예외와 언체크 예외는 어떻게 사용하는 것이 좋을까
기본적으로는 언체크 예외를 사용하는 것이 좋다.
체크 예외는 비즈니스 로직 상 의도적으로 던져야하는 예외에만 사용하는 것이 좋다.
즉 해당 예외를 잡아서 반드시 처리해야 할 때만 체크 예외를 사용해야한다.
예시 코드들을 통해 왜 기본적으로 언체크 예외를 사용하는 것이 좋은지 알아보자.
체크 예외 활용 예시 코드
CheckedAppTest는 스프링 어플리케이션에서 체크 예외를 사용하는 테스트 코드이다.
Repository, NetworkClient의 call()은 각각 체크 예외를 발생시킨다.
Repository, NetworkClient는 해당 체크 예외들을 잡아서 처리할 수 없으므로 호출한 곳으로 던진다.
이 예외들은 체크 예외이므로 throws 키워드를 통해 밖으로 던진다는 것을 선언해야한다.
Service 내부에는 logic()이 구현되어있으며,
logic() 내부에서 Repository와 NetworkClient의 call()을 호출한다. call()에서 던져진 체크 예외가 logic()으로 올라온다.
logic() 또한 해당 예외들을 처리할 수 없으므로, throws 키워드를 통해 밖으로 던진다는 것을 선언해야한다.
Controller 내부에는 request()가 구현되어있으며,
request() 내부에서 Service의 logic()을 호출한다. logic()에서 던져진 체크 예외가 request()로 올라온다.
request() 또한 해당 예외들을 처리할 수 없으므로, throws 키워드를 통해 밖으로 던져야한다.
결국 logic()에서 던져진 체크 예외들이 checked()까지 올라온다.
실제 웹 어플리케이션이라면 서블릿의 오류페이지, 또는 스프링이 제공하는 ControllerAdvice에서 이런 예외를 공통으로 처리할 것이다.
해당 코드에서 체크 예외 사용의 문제점을 발견할 수 있다.
데이터베이스 예외 또는 네트워크 예외처럼 대부분의 예외는 복구 불가능한 경우가 많다.
특히 서비스와 컨트롤러는 예외를 해결할 수 없다.
따라서 이러한 예외들은 로그를 남기고 개발자가 빠르게 인식할 수 있는 것이 좋다.
서블릿 필터, 스프링 인터셉터, 스프링 ControllerAdvice 등을 사용하면 이런 부분을 공통으로 깔끔하게 처리할 수 있다.
데이터베이스 또는 네트워크에서 발생한 체크 예외가 서비스 계층과 컨트롤러 계층으로 올라왔다고 가정하자.
서비스와 컨트롤러에서는 처리할 수 없는 예외이기 때문에 밖으로 던져야한다.
이 때 체크 예외이기 때문에 throws 키워드를 통해 던진다는 것을 반드시 선언해줘야한다.
이것이 문제가 된다.
throws 키워드를 사용하는 순간 서비스, 컨트롤러는 처리할 수 없는 예외에 의존하게 되는 것이다.
현재는 JDBC 기술을 사용하기 때문에 SQLException을 의존하고 있지만, JPA 기술로 변경하면 SQLException이 아닌 JPAException을 의존하도록 고쳐야한다.
구현 기술(구현체)를 변경할 때 클라이언트의 코드를 고쳐야하는 것이다.
@Slf4j
public class CheckedAppTest {
@Test
void checked() {
Controller controller = new Controller();
/**
* repository, networkClient에서 발생한 체크 예외를 잡아서 처리하지 못한 결과,
* service -> Controller를 거쳐 이곳 까지 올라오게 된다.
*/
assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}
static class Controller {
Service service = new Service();
/**
* 컨트롤러 계층은 두 개의 예외를 잡아서 처리할 수 없다.
* 두 예외는 체크 예외이기 때문에 throws 키워드를 통해 던진다는 것을 선언해야한다.
* 결국 컨트롤러 계층은 두 체크 예외에 의존하게 된다..
*/
public void request() throws SQLException, ConnectException {
service.logic();
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
/**
* 서비스 계층은 두 개의 예외를 잡아서 처리할 수 없다.
* 두 예외는 체크 예외이기 때문에 throws 키워드를 통해 던진다는 것을 선언해야한다.
* 결국 서비스 계층은 두 체크 예외에 의존하게 된다..
*/
public void logic() throws ConnectException, SQLException {
networkClient.call();
repository.call();
}
}
static class NetworkClient {
public void call() throws ConnectException {
// 체크 예외
throw new ConnectException("Network 에러");
}
}
static class Repository {
public void call() throws SQLException {
// 체크 예외
throw new SQLException("DB 에러");
}
}
}
언체크 예외 활용 코드
언체크 예외를 사용하면 체크 예외의 문제점을 해결할 수 있다.
우선 코드를 먼저 살펴보자.
UncheckedAppTest에서 변경된 점은 Repository와 NetworkClient에서 체크 예외를 언체크 예외로 변경해서 던진다는 것이다.
Service, Controller에는 언체크 예외가 올라오기 때문에 throws 키워드를 사용할 필요가 없다.
서비스 계층과 컨트롤러 계층에서 처리할 수 없는 예외에 대해 불필요한 의존관계를 가질 필요가 없는 것이다.
@Slf4j
public class UncheckedAppTest {
@Test
void unchecked() {
Controller controller = new Controller();
/**
* repository, networkClient에서 발생한 언체크 예외를 잡아서 처리하지 못한 결과,
* service -> Controller를 거쳐 이곳 까지 올라오게 된다.
*/
assertThatThrownBy(() -> controller.request())
.isInstanceOf(RuntimeException.class);
}
static class Controller {
Service service = new Service();
/**
* repository, networkClient -> 서비스를 통해 올라온 것은 언체크 예외이다.
* 컨트롤러 계층 또한 더 이상 throws 선언을 통해 해당 예외를 던질 필요가 없다.
* 예외에 대한 의존을 벗어난 것이다.
*/
public void request() {
service.logic();
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
/**
* repository, networkClient에서 체크 예외를 언체크 예외로 변경해서 던진다.
* 서비스 계층에 올라온 것이 언체크 예외이므로, 서비스 계층은 throws 선언을 통해 던질 필요가 없다.
* 예외에 대한 의존을 벗어난 것이다.
*/
public void logic() {
networkClient.call();
repository.call();
}
}
static class NetworkClient {
public void call() {
try {
run();
} catch (ConnectException e) {
// 체크 예외 -> 언체크 예외
throw new RuntimeConnectException(e);
}
}
public void run() throws ConnectException {
throw new ConnectException();
}
}
static class Repository {
public void call() {
try {
run();
} catch (SQLException e) {
// 처크 예외 -> 언체크 예외
throw new RuntimeSQLException(e);
}
}
public void run() throws SQLException {
throw new SQLException("DB 에러");
}
}
// 언체크 예외
static class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(Throwable cause) {
super(cause);
}
}
// 언체크 예외
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
}
Transaction_2에서 작성했던 MemberRepositoryV3, MemberServiceV3_3은 체크 예외를 사용하고 있었다.
MemberRepositoryV4_1, MemberServiceV4는 언체크 예외를 사용하도록 변경한 리포지토리, 서비스 계층이다.
코드에 대한 설명은 주석을 참고하자.
/**
* JDBC - 트랜잭션매니저(트랜잭션 동기화 매니저)를 통한 트랜잭션
* 체크 예외 -> 언체크 예외
* MemberRepository 인터페이스 사용
*/
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {
// DataSource 사용
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
// 생성
/**
* 체크 예외 -> 언체크 예외
* 언체크 예외를 던지므로, throws 키워드를 사용할 필요가 없다.
*/
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
// DB 연결, 해당 커넥션 반환
con = getConnection();
// sql문 작성 완료(파라미터 대입)
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
// 쿼리 실행
// 생성, 수정, 삭제는 executeUpdate()
// resultSize는 영향 받은 row의 수를 반환한다.
int resultSize = pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// 체크 예외 -> 언체크 예외
throw new MyDbException(e);
} finally {
// 리소스 정리
close(con, pstmt, null);
}
}
// 조회
/**
* 체크 예외 -> 언체크 예외
* 언체크 예외를 던지므로, throws 키워드를 사용할 필요가 없다.
*/
public Member findById(String memberId) {
...
}
// 수정
/**
* 체크 예외 -> 언체크 예외
* 언체크 예외를 던지므로, throws 키워드를 사용할 필요가 없다.
*/
public void update(String memberId, int money) {
...
}
// 삭제
/**
* 체크 예외 -> 언체크 예외
* 언체크 예외를 던지므로, throws 키워드를 사용할 필요가 없다.
*/
public void delete(String memberId) {
...
}
private void close(Connection con, Statement stmt, ResultSet rs) {
...
}
// DataSource를 통해 DB 연결, 해당 커넥션 반환
private Connection getConnection() throws SQLException {
...
}
}
/**
* JDBC - 트랜잭션매니저를 통한 트랜잭션 + 트랜잭션 AOP(@Transactional)
* 체크 예외 -> 언체크 예외
* MemberRepository 인터페이스 사용
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
private final MemberRepository memberRepository;
/**
* repository에서 올라온 예외는 언체크 예외이다.
* 따라서 서비스 계층에서도 throws 키워드를 사용할 필요가 없다.
*/
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
/**
* repository에서 올라온 예외는 언체크 예외이다.
* 따라서 서비스 계층에서도 throws 키워드를 사용할 필요가 없다.
*/
private void bizLogic(String fromId, String toId, int money) {
...
}
private void validation(Member toMember) {
...
}
}
스프링 예외 추상화
데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다.
데이터베이스 오류의 종류는 어떻게 구분할 수 있을까?
데이터베이스에서 오류가 발생했을 때, 데이터베이스는 오류 코드를 반환한다.
그리고 해당 오류 코드를 받은 JDBC 드라이버는 SQLException을 던진다.
그리고 SQLException의 errorCode에는 해당 오류 코드가 들어있다.
이 errorCode를 통해 데이터베이스에서 어떤 오류가 발생했는지 확인할 수 있다.
참고로 같은 종류의 데이터베이스 오류더라도 데이터베이스 종류에 따라 다르다.
예를 들어 MySQL은 1062, H2 DB는 1062가 키 중복 오류 코드이다.
만약 키 중복 오류가 발생했을 때는 서비스 계층에서 예외 복구를 하고 싶다고 가정하자.
리포지토리는 서비스 계층에서 키 중복 오류 발생을 확인할 수 있게 데이터베이스 오류의 종류에 따라 다른 예외를 던져야한다.
그리고 서비스 계층은 키 중복 오류에 대한 예외를 잡아서 예외를 복구할 수 있을 것이다.
데이터베이스 오류 종류에 따라 다른 예외를 던지는 과정을 편하게 진행할 수 있도록 도와주는 것이 스프링 예외 추상화이다.
스프링 예외 추상화 이해
스프링은 데이터베이스, 즉 데이터 접근 계층과 관련된 수십 가지 예외를 추상화해서 제공한다.
해당 예외들은 추상화되었기 때문에 JDBC, JPA 등 특정 기술에 종속되지 않게 설계되어 있다.
JDBC, JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 스프링이 제공한다.
따라서 서비스 계층에서도 스프링이 제공하는 이 예외들을 사용하면 된다.
스프링이 제공하는 데이터 접근 예외의 최상위는 DataAccessException이다.
DataAccessException은 RuntimeException을 상속하기 때문에 스프링 데이터 접근 예외는 모두 언체크 예외이다.
스프링 데이터 접근 예외 변환기
스프링이 제공하는 데이터 접근 예외 변환기를 통해 데이터베이스 오류코드를 스프링 데이터 접근 예외로 변환할 수 있다.
아래의 테스트 코드를 통해 스프링 데이터 접근 예외 변환기를 어떻게 사용하는지 알아보자.
SQLErrorCodeSQLExceptionTranslator가 스프링 데이터 접근 예외 변환기이다.
SQLErrorCodeSQLExceptionTranslator는 어떻게 오류 코드를 적절한 스프링 데이터 접근 예외로 변환하는 것일까?
답은 org.springframework.jdbc.support.sql-error-codes.xml에 있다.
org.springframework.jdbc.support.sql-error-codes.xml에는 DB, errorCode에 따른 스프링 데이터 접근 예외가 매핑되어있다.
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Test
void exceptionTranslator() {
String sql = "select bad grammar";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
/**
* org.springframework.jdbc.support.sql-error-codes.xml -
*/
assertThat(e.getErrorCode()).isEqualTo(42122);
/**
* 스프링이 제공하는 예외 변환기
*/
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(
dataSource);
/**
* exTranslator.translate() - 적절한 스프링 데이터 접근 계층의 예외로 변환하여 반환
* 스프링 데이터 접근 계층의 예외는 특정한 구현 기술(JDBC, JPA)에 종속되지 않는다.
* DB마다 다른 errorCode를 일일히 확인할 필요도 없다.
* org.springframework.jdbc.support.sql-error-codes.xml에 DB, errorCode에 따른 스프링 데이터 접근 예외가 매핑되어 있다!
*/
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx).isInstanceOf(BadSqlGrammarException.class);
}
}
}
아래의 MemberRepositoryV4_2는 MemberRepositoryV4_1에 스프링 데이터 접근 예외 변환기를 적용한 리포지토리이다.
/**
* JDBC - 트랜잭션매니저(트랜잭션 동기화 매니저)를 통한 트랜잭션
* 체크 예외 -> 언체크 예외
* MemberRepository 인터페이스 사용
* SQLExceptionTranslator(스프링 데이터 접근 예외 변환기) 사용
*/
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {
// DataSource 사용
private final DataSource dataSource;
// 스프링 데이터 접근 예외 변환기
private final SQLErrorCodeSQLExceptionTranslator exTranslator;
public MemberRepositoryV4_2(DataSource dataSource) {
this.dataSource = dataSource;
this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
// 생성
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
// DB 연결, 해당 커넥션 반환
con = getConnection();
// sql문 작성 완료(파라미터 대입)
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
// 쿼리 실행
// 생성, 수정, 삭제는 executeUpdate()
// resultSize는 영향 받은 row의 수를 반환한다.
int resultSize = pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// 스프링 데이터 접근 예외 변환기 사용
throw exTranslator.translate("save", sql, e);
} finally {
// 리소스 정리
close(con, pstmt, null);
}
}
// 조회
public Member findById(String memberId) {
...
}
// 수정
public void update(String memberId, int money) {
...
}
// 삭제
public void delete(String memberId) {
...
}
private void close(Connection con, Statement stmt, ResultSet rs) {
...
}
// DataSource를 통해 DB 연결, 해당 커넥션 반환
private Connection getConnection() throws SQLException {
...
}
}