서론
서비스 계층은 가급적 특정 구현 기술에 의존하지 않도록 순수하게 유지해야 합니다.
따라서, 이전 글에 남아있는 SQLException의 의존성을 스프링이 제공하는 예외 추상화를 통해 해결하도록 하겠습니다.
MemberService
package hello.jdbc.service;
...
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if(toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- JDBC의 예외인 SQLException이 서비스 계층에도 남아 있다.
- 특정 구현체인 MemberRepositoryV3을 의존한다.
인터페이스 도입
SQLException을 해결하기 전에, MemberRepository 인터페이스를 도입해봅시다.
인터페이스를 도입함으로써, JDBC나 JPA의 구현 기술로 자유롭게 코드의 변경없이 교체할 수 있습니다.
MemberRepository 인터페이스
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
❔처음부터 인터페이스를 도입하지 않은 이유는?
처음부터 인터페이스를 도입하지 않은 이유는 체크 예외인 SQLException이 서비스 계층에 남아 있기 때문입니다.
체크 예외를 사용하기 위해선, 인터페이스에도 해당 예외가 선언이 되어야 합니다.
package hello.jdbc.repository;
...
public interface MemberRepositoryEx {
Member save(Member member) throws SQLException;
Member findById(String memberId) throws SQLException;
void update(String memberId, int money) throws SQLException;
void delete(String memberId) throws SQLException;
}
특정 구현 기술에 종속된 인터페이스
구현 기술을 쉽게 변경하기 위해 도입한 것이 인터페이스인데, 특정 구현 기술인 JDBC가 발생시키는 SQLException으로 인해 다른 기술로 변경하려면 인터페이스 또한 수정해야 하는 불상사가 발생합니다.
따라서, SQLException 해결과 MemberRepository 인터페이스 도입을 함께 작성하게 되었습니다.
참고 런타임 예외는 인터페이스에 따로 선언하지 않아도 됩니다. 따라서 인터페이스가 특정 기술에 종속되지 않습니다.
런타임 예외 적용
MyDbException 런타임 예외 생성
- RuntimeException을 상속받았으므로, MyDbException은 런타임(언체크) 예외입니다.
package hello.jdbc.repository.ex;
public class MyDbException extends RuntimeException {
public MyDbException() {
}
public MyDbException(String message) {
super(message);
}
public MyDbException(String message, Throwable cause) {
super(message, cause);
}
public MyDbException(Throwable cause) {
super(cause);
}
}
MemberRepositoryV4 - 구현체
- MemberRepository 인터페이스를 구현합니다.
- 핵심은 SQLException 체크 예외를 MyDbException 런타임 에외로 전환하여 던집니다.
package hello.jdbc.repository;
...
/**
* MemberRepository 인터페이스 사용
* 예외 누수 문제 해결 : 체크 예외를 런타임 에외로 변경
* → throws SQLException 제거
* → JDBC의 의존성 제거
* → OCP 원칙 지킴
*/
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, rs);
}
}
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
💡주의 예외 전환 시 기존 예외 포함
위에 작성한 코드와 같이 기존에 발생한 SQLException을 MyDbException 생성자에 전달하여 포함시켜야 합니다.catch (SQLException e) { throw new MyDbException(e); }
포함시키지 않으면 로그에 기존의 원인이 되는 예외를 출력하지 못하게 됩니다. 따라서, 반드시 MyDbException(e)와 같이 포함하여 기존 예외와 전환한 예외 둘 모두 스택 트레이스를 통해 확인할 수 있도록 작성해야 합니다.
MemberService
- MemberRepository 인터페이스를 의존합니다.
- MemberRepository에서 발생한 JDBC 의존 예외인 SQLException을 언체크 예외인 MyDbException으로 전환하여 던짐으로써, 서비스 계층의 SQLException이 제거되었습니다.
package hello.jdbc.service;
...
/**
* 예외 누수 문제 해결 (SQL Exception 제거)
* MemberRepository 인터페이스 의존
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if(toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
추가) 특정 예외 복구
현재 MemberRepository에선 MyDbException이라는 예외만 넘어옵니다.
만약, 특정 상황에서 넘어오는 예외는 서비스 계층에서 넘기는 게 아니라, 해결하고 싶다면 어떻게 해야 할까요?
특정 예외 상황에서만 발생하는 예외는 MyDbException이 아닌 다른 예외를 생성하여 넘기도록 하면 됩니다.
그리고 해당 예외가 서비스로 넘어오면 잡아서 해결하도록 작성해봅시다.
데이터 접근 예외 직접 만들기
만약, 회원가입 시 DB에 이미 같은 ID가 있다면, 입력한 ID 뒤에 임의의 랜덤한 숫자를 붙여 중복되지 않는 새 ID를 만들어 다시 회원가입을 하도록 수정해 봅시다.
예를 들어, hello라는 ID로 회원가입을 시도했는데, 이미 같은 ID가 DB에 존재한다면 hello12345와 같이 새 ID를 만들어 회원가입을 하도록 예외를 처리해봅시다.
데이터베이스 중복 ID 오류 발생 로직
우선, 데이터베이스에 중복된 ID 삽입을 시도하면 어떻게 동작하는지 알아봅시다.
- 데이터베이스에 이미 중복된 ID의 삽입을 시도하면, 데이터베이스는 오류 코드를 반환합니다.
- 이 오류 코드를 받은 JDBC 드라이버는 SQLException을 던집니다.
- 이 SQLException에는 데이터베이스가 제공하는 errorCode가 들어있습니다.
H2 데이터베이스 키 중복 오류 코드
e.getErrorCode() == 23505
해당 오류 코드로 인해 데이터베이스에서 어떤 문제로 인해 오류를 발생시켰는지 구분할 수 있습니다.
- 23505 : 키 중복 오류 (H2)
- 42000 : SQL 문법 오류 (H2)
참고 오류 코드는 데이터베이스마다 다릅니다.
예를 들어, 키 중복 발생 시, H2는 23505 코드를 사용하고, MySQL은 1062 코드를 사용합니다.
따라서, 오류 코드를 사용할 때는 데이터베이스 메뉴얼을 확인해야 합니다.
H2 데이터베이스 오류 코드 참고
https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html
MyDuplicateKeyException
오류 코드를 활용하여, 키 중복 오류가 발생하면 MyDuplicateKeyException을 던지도록 작성해봅시다.
- 기존에 생성했던 MyDbException을 상속받습니다.
- 이렇게 함으로써, 데이터베이스 관련 예외라는 것을 알 수 있습니다.
- 해당 예외는 직접 만든 것이기 때문에, JDBC나 JPA와 같은 특정 기술에 종속적이지 않습니다. 따라서 이 예외를 던지면 서비스 계층의 순수성을 유지할 수 있습니다.
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);
}
}
ExTranslatorTest - 실행
package hello.jdbc.exception.translator;
...
public class ExTranslatorV1Test {
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 저장 시도
}
@Slf4j
@RequiredArgsConstructor
static class Service {
private final Repository repository;
public void create(String memberId) {
try {
repository.save(new Member(memberId, 0));
log.info("saveId={}", memberId);
} catch (MyDuplicateKeyException e) {
log.info("키 중복, 복구 시도");
String retryId = generateNewId(memberId);
log.info("retryId={}", retryId);
repository.save(new Member(retryId, 0));
} catch (MyDbException e) {
log.info("데이터 접근 계층 예외", e);
throw e;
}
}
private String generateNewId(String memberId) {
return memberId + new Random().nextInt(10000);
}
}
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
public Member save(Member member) {
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, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
//h2 db
if(e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
closeStatement(pstmt);
closeConnection(con);
}
}
}
}
실행 로그
Service - saveId=myId
Service - 키 중복, 복구 시도
Service - retryId=myId9203
같은 ID 저장을 시도하자, Service에서 예외를 잡아 복구했습니다.
따라서, Service를 호출한 duplicateSave()에는 예외가 전달되지 않습니다.
참고 복구할 수 없는 예외는 로그를 남기지 않아도 된다.
참고로 이 경우에는 log를 남기지 않아도 됩니다. 복구할 수 없는 예외는 공통으로 처리하는 부분까지 전달이 되기 때문에, 공통으로 처리하는 곳에서 예외 로그를 남기는 것이 좋습니다.try { repository.save(new Member(memberId, 0)); log.info("saveId={}", memberId); } catch (MyDuplicateKeyException e) { log.info("키 중복, 복구 시도"); String retryId = generateNewId(memberId); log.info("retryId={}", retryId); repository.save(new Member(retryId, 0)); } catch (MyDbException e) { log.info("데이터 접근 계층 예외", e); throw e; }
여기서는 다양하게 예외를 처리할 수 있는 점을 보여주기 위해 남겨두었습니다.
정리
- ErrorCode로 데이터베이스에 어떤 오류가 발생했는지 확인할 수 있습니다.
- 예외 전환을 통해 SQLException을 특정 기술에 의존하지 않도록 직접 만든 예외인 MyDuplicateKeyExcpeption으로 전환했습니다.
- Repository 계층에서 에외를 전환해준 덕분에, 서비스 계층은 특정 기술에 의존하지 않는 것을 유지하면서, MyDuplicateKeyException을 복구하여 해결할 수 있습니다.
남아있는 문제
- 하지만, ErrorCode는 데이터베이스마다 다릅니다.
- 또한, 데이터베이스가 전달하는 오류는 키 중복 뿐만 아니라, 락인 걸린 경우, SQL 문법이 발생한 경우 등 수백가지 오류 코드가 있습니다.
이 모든 상황에 맞는 예외를 지금처럼 다 만들어야 할까요?
이 문제를 해결하기 위해 스프링은 데이터베이스 접근과 관련된 예외 추상화를 제공합니다.
스프링의 예외 추상화
스프링 데이터 접근 예외 계층
- 스프링은 데이터 접근 계층과 관련된 다양한 예외를 정리하여 계층으로 제공합니다.
- 각각의 예외는 특정 기술에 종속적이지 않습니다. 따라서, 서비스 계층도 스프링이 제공하는 예외를 사용할 수 있습니다. (JDBC를 사용하든, JPA를 사용하든 스프링이 제공하는 예외를 사용하면 됩니다.)
- JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환시킵니다.
- 가장 상위 계층인 DataAccessException은 RuntimeExcpetion을 상속받았기 때문에, 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임예외입니다.
- DataAccessException은 NonTransient, Transient 2가지로 구분합니다.
- Transient는 일시적이란 뜻입니다. 즉, 동일한 SQL을 다시 시도했을 때, 성공할 가능성이 있습니다.
- 예) 쿼리 타임아웃, 락과 관련된 오류들
- 이런 오류들은 데이터베이스의 상태가 좋아지거나, 락이 풀렸을 때 다시 시도할 경우 성공할 수도 있습니다.
- NonTransient는 일시적이지 않다는 뜻입니다. 즉, 같은 SQL을 반복해서 시도해도 실패합니다.
- 예) SQL 문법 오류, 데이터베이스 제약 조건 위배
SpringExceptionTranslatorTest - 기존
- ErrorCode를 직접 확인하는 코드입니다.
package hello.jdbc.exception.translator;
...
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Test
void sqlExceptionErrorCode() {
String sql = "select bad grammer";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
Assertions.assertThat(e.getErrorCode()).isEqualTo(42122);
int errorCode = e.getErrorCode();
log.info("errorCode={}", errorCode);
log.info("error", e); //org.h2.jdbc.JdbcSQLSyntaxErrorException
}
}
}
SpringExceptionTranslatorTest - Exeption Translator 적용
- 스프링 예외 변환기를 적용해 봅시다.
package hello.jdbc.exception.translator;
...
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Test
void exceptionTranslator() {
String sql = "select bad grammer";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
assertThat(e.getErrorCode()).isEqualTo(42122);
//org.springframework.jdbc.support.sql-error-codes.xml
//예외 변환기 생성
SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
//org.springframework.jdbc.BadSqlGrammerException
//변환기를 적용하여 반환된 스프링이 제공하는 예외 객체
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
//sql에 묹법 오류가 있을 경우, BadSqlGrammerException 객체 반환
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
}
SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
- translate()의 첫 번째 파라미터는 설명입니다. 두 번째는 실행한 sql, 마지막은 발생된 SQLException을 전달하면 됩니다.
- 스프링은 데이터 접근 계층의 예외로 변환하여 반환해줍니다.
- 현재 예제에선 SQL 문법이 잘못되었으므로, BadSqlGrammerException이 반환되었습니다.
- BadSqlGrammerException은 최상위 타입인 DataAccessException의 자식 타입입니다.
❔각각의 DB마다 ErrorCode는 다른데, 스프링이 각각의 DB가 제공하는 ErrorCode까지 고려하여 예외를 변환할 수 있는 이유는?
스프링은 다음 파일을 사용하여 구분합니다.
sql-error-codes.xml
<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
</property>
<property name="duplicateKeyCodes">
<value>23001,23505</value>
</property>
</bean>
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>1054,1064,1146</value>
</property>
<property name="duplicateKeyCodes">
<value>1062</value>
</property>
</bean>
- 스프링 SQL 예외 변환기는 ErrorCode를 이 파일에 대입하여 어떤 스프링 데이터 접근 예외로 전환해야 할지 찾아냅니다.
- 예를 들어, H2 데이터베이스에서 42000이 발생하면, badSqlGrammerCodes임을 확인하고 BadSqlGrammerException을 반환합니다.
- 해당 파일에 10개 이상의 우리가 사용하는 대부분의 관계형 데이터베이스 ErrorCode가 포함되어 있습니다.
실습
MemberRepositoryV4_2
- 예외 발생 시, 다음과 같이 예외 변환기를 사용하도록 변경되었습니다.
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
}
package hello.jdbc.repository;
...
/**
* SQLExceptionTranslator 추가
*/
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {
private final DataSource dataSource;
private final SQLExceptionTranslator 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 {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
throw exTranslator.translate("findById", sql, e);
} finally {
close(con, pstmt, rs);
}
}
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
throw exTranslator.translate("update", sql, e);
} finally {
close(con, pstmt, null);
}
}
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
throw exTranslator.translate("delete", sql, e);
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
정리
- SQLException이 깔끔하게 정리되었습니다.
- 스프링이 제공한 예외 추상화 덕분에, 서비스 계층은 특정 Repository의 구현 기술과 예외에 더이상 종속적이지 않게 되어, 다시 DI를 제대로 활용할 수 있게 되었습니다.
- 추가로, 서비스 계층에서 예외를 잡아 복구하고 싶을 경우, 스프링이 제공하는 데이터 접근 예외로 변경되어 서비스 계층으로 넘어오기 때문에 필요한 경우 해당 예외를 잡아 복구하면 됩니다.
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
'CS > 데이터베이스' 카테고리의 다른 글
[JDBC] JdbcTemplate 간단히 알아보기 (0) | 2023.10.17 |
---|---|
[DB] 트랜잭션 매니저, 트랜잭션 템플릿, @Transactional (0) | 2023.10.08 |
[DB] 트랜잭션 이해 (0) | 2023.10.08 |
[DB] 커넥션풀과 데이터소스 이해 (0) | 2023.10.07 |
[DB] JDBC 이해 (0) | 2023.10.07 |