스프링은 아래와 같이 데이터 접근과 관련한 예외를 추상화해서 제공해주고 있습니다.
예외의 최상위 예외는 org.springframework.dao.DataAccessException 입니다. 런타임 예외를 상속 받았기 때문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외입니다.
DataAccessException 은 크게 NonTransient 예외와 Transient 예외로 구분할 수 있습니다.
- Transient : 일시적이라는 뜻으로 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있습니다.
- 예를 들어 쿼리 timeout, lock과 관련된 오류들입니다. 이런 오류들은 DB 상태가 좋아지거나 lock이 풀렸을 때 다시 시도하면 성공할 수 있습니다.
- NonTransient : 일시적이지 않다는 뜻으로 같은 SQL을 반복해서 실행해도 실패합니다.
- SQL 문법 오류나 데이터베이스 제약조건 위배 등이 있습니다.
이제 이 스프링 예외 변환기가 필요한 이유를 먼저 설명하겠습니다.
데이터베이스의 오류에 따라서 특정 예외는 따로 처리하여 복구하고 싶은 경우도 있을 것 입니다. 예를 들어 DB에 동일한 ID를 가진 데이터를 저장하고자 하면 SQLException이 발생하는데, 이때 해당 예외를 그냥 두는 것이 아니라 다른 ID로 바꾸어서 저장하도록 처리하고 싶을 수 있을 것입니다.
SQLException 에는 데이터베이스가 제공하는 errorCode 라는 것이 들어 있습니다. 예를 들어 H2 데이터베이스의 키 중복 오류 코드는 아래와 같습니다.
e.getErrorCode() == 23505
이 오류 코드를 이용하여 예외를 아래와 같이 처리할 수도 있을 것입니다.
1. MyDuplicateKeyException 예외 정의
package hello.jdbc.repository.ex;
public class MyDuplicateKeyException extends MyDbException {
public MyDuplicateKeyException() {
}
public MyDuplicateKeyException(String message) {
super(message);
}
public MyDuplicateKeyException(String message, Throwable cause) {
super(message, cause);
}
public MyDuplicateKeyException(Throwable cause) {
super(cause);
}
}
2. Service 및 Repository 클래스 정의
아래 코드의 Repository 클래스에서 rcode가 23505 이면 MyDuplicateKeyException을 던지도록 하였습니다.
그리고 Service 클래스에서는 MyDuplicateKeyException 예외가 발생하면 generateNewId(accountId) 를 통해 새로운 ID 생성을 시도 후 다시 저장합니다. (예외 복구)
package hello.jdbc;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.ex.MyDbException;
import hello.jdbc.repository.ex.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;
public class TestClass {
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
public Account save(Account account) {
String sql = "insert into member(member_id, money) values(?,?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = dataSource.getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, account.getAccountId());
pstmt.setInt(2, account.getMoney());
pstmt.executeUpdate();
return account;
} catch (SQLException e) {
//h2 db
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}
}
@Slf4j
@RequiredArgsConstructor
static class Service {
private final Repository repository;
public void create(String accountId) {
try {
repository.save(new Account(accountId, 0));
log.info("saveId={}", accountId);
} catch (MyDuplicateKeyException e) {
log.info("키 중복, 복구 시도");
String retryId = generateNewId(accountId);
log.info("retryId={}", retryId);
repository.save(new Account(retryId, 0));
} catch (MyDbException e) {
log.info("데이터 접근 계층 예외", e);
throw e;
}
}
private String generateNewId(String accountId) {
return accountId + new Random().nextInt(10000);
}
}
Repository repository;
Service service;
@BeforeEach
void init() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL,
USERNAME, PASSWORD);
repository = new Repository(dataSource);
service = new Service(repository);
}
@Test
void duplicateKeySave() {
service.create("myId");
service.create("myId");//같은 ID 저장 시도
}
}
하지만 여기서 문제가 발생합니다. SQL ErrorCode는 데이터베이스마다 다릅니다. 그렇기 때문에 데이터베이스가 변경될 때마다 ErrorCode도 모두 변경해야 합니다.
그리고 데이터베이스가 전달하는 오류는 키 중복 외에도 많은 오류가 있을 것입니다. 이런 코드들을 모두 알고 코드에 작성하는 것은 매우 번거로울 것입니다.
위와 같은 문제들을 해결하기 위해서 스프링의 예외 변환기를 사용합니다. 예외 변환기는 아래와 같이 사용합니다.
@Test
void exceptionTranslator() {
String sql = "select bad grammar";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
SQLErrorCodeSQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exceptionTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
- translate() 메서드의 첫 번째 파라미터는 읽을 수 있는 설명을, 두 번째는 실행한 SQL 문을, 마지막은 발생된 SQLException을 전달하면 됩니다.
- 눈에 보이는 반환 타입은 최상위 타입인 DataAccessException 이지만 실제로는 BadSqlGrammarException 예외가 반환되는 것을 확인할 수 있습니다. (BadSqlGrammarException은 DataAccessException의 자식 클래스)
이렇게 스프링 예외 추상화 (스프링 예외 변환기) 덕분에 특정 기술에 종속되지 않게 되었고 다른 기술로 변경되어도 예외로 인한 변경을 최소화할 수 있습니다.
끝으로 TestClass를 스프링 예외 변환기를 이용하여 수정하였습니다.
Repository 클래스 수정
Service 클래스 수정
init 부분 수정
전체코드
public class TestClass {
Repository repository;
Service service;
@BeforeEach
void init() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL,
USERNAME, PASSWORD);
repository = new Repository(dataSource, new SQLErrorCodeSQLExceptionTranslator(dataSource));
service = new Service(repository);
}
@Test
void duplicateKeySave() {
service.create("myId");
service.create("myId");//같은 ID 저장 시도
}
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
private final SQLErrorCodeSQLExceptionTranslator translator;
public Account save(Account account) {
String sql = "insert into member(member_id, money) values(?,?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = dataSource.getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, account.getAccountId());
pstmt.setInt(2, account.getMoney());
pstmt.executeUpdate();
return account;
} catch (SQLException e) {
//h2 db
/* if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);*/
throw translator.translate("insertTest", sql, e);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}
}
@Slf4j
@RequiredArgsConstructor
static class Service {
private final Repository repository;
public void create(String accountId) {
try {
repository.save(new Account(accountId, 0));
log.info("saveId={}", accountId);
} catch (DuplicateKeyException e) {
log.info("키 중복, 복구 시도");
String retryId = generateNewId(accountId);
log.info("retryId={}", retryId);
repository.save(new Account(retryId, 0));
} catch (DataAccessException e) {
log.info("데이터 접근 계층 예외", e);
throw e;
}
}
private String generateNewId(String memberId) {
return memberId + new Random().nextInt(10000);
}
}
}