체크 예외와 인터페이스
서비스 계층에서 처리할 수 없는 예외의 의존을 제거해서 서비스 계층을 순수하게 유지할 수 있다.
먼저, 인터페이스를 도입해서 구현체를 쉽게 변경할 수 있도록 만들어보자.
인터페이스 도입 그림
- 이렇게 인터페이스를 도입하면 서비스는 인터페이스에만 의존하면 된다. 이 때 체크 예외를 사용하는 경우 인터페이스에서도 예외처리를 해결해야한다.
체크 예외 코드에 인터페이스 도입 시 문제점
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;
}
- 다음과 같이 구현체에서 체크 예외를 던지면 인터페이스에도 예외를 던지는 구현을 해야한다. 이것은 순수한 인터페이스 형태가 아니므로 잘못된 형태이다.
런타입 예외 적용
런타임 예외는 체크 예외와 달리 예외를 별도로 선언하지 않아도 되기 때문에 특정 기술에 종속적일 필요가 없다.
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);
}
- 순수한 인터페이스로 만들기 위해 예외를 던지는 부분을 제거하자.
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_1
@Override
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) {
log.error("db error", e); // 기존 e 예외를 받드시 받아주자.
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
- 기존의
catch
에서 새로 생성한 런타임 예외를 던지도록하고 메서드 레벨에서 예외를 던지는 부분을 제거하자. 참고로 클래스 레벨에서MemberRepository
를 상속 받아야 한다.
MemberServiceV4
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
@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
이라는 예외만 넘어오기 때문에 예외를 구분할 수 없다. 특정 상황에서 예외를 잡아서 복구하고 싶은 경우에 처리하는 방법에 대해 알아보자.
데이터 접근 예외 직접 만들기
예를 들어서 회원 가입 시 ID가 존재한다면 임의의 숫자를 덧붙여서 가입한다고 생각해보자. 서비스 계층에서 예외 복구를 처리하기 위해선 오류코드를 알고 있어야한다.이 때 리포지토리에서 예외를 변환해서 (체크 -> 런타임) 예외를 던져야한다.
- 디비는 ID가 중복된 것을 확인하고 오류 코드를 반환한다. 참고로 H2 데이터베이스에선 키 중복 오류 (23505), SQL 문법 오류(42000) 을 반환한다.
MyDuplicatekeyException
package hello.jdbc.repository.ex;
public class MyDuplicateKeyException extends MyDbException{
public MyDuplicateKeyException() {
super();
}
public MyDuplicateKeyException(String message) {
super(message);
}
public MyDuplicateKeyException(String message, Throwable cause) {
super(message, cause);
}
public MyDuplicateKeyException(Throwable cause) {
super(cause);
}
}
- 기존에 사용했던
MyDbException
을 상속 받아서 의미있는 계층을 형성한다. 이렇게하면 데이터베이스 관련 예외라는 계층을 만들 수 있다. - 이 예외는 데이터 중복의 경우만 던지도록 설정하자.
ExTranslatorV1Test
package hello.jdbc.exception.translator;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import hello.jdbc.repository.ex.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.springframework.jdbc.support.JdbcUtils.closeConnection;
import static org.springframework.jdbc.support.JdbcUtils.closeStatement;
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");
}
@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) {
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
closeStatement(pstmt);
closeConnection(con);
}
}
}
}
- 중복되는 경우
MyDuplicateKeyException
예외로 넘기고, 생성이 가능한 경우MyDbException
을 넘겨서 원하는 내용을 해결할 수 있다. - 이때 SQL ErrorCode는 개발자가 직접 알아야 처리가 가능하다. 이 에러 코드는 데이터베이스마다 다르다. 스프링은 이 문제를 해결하기 위해 관련된 예외를 추상화해서 제공한다.
스프링 예외 추상화 이해
스프링 데이터 접근 예외 계층
- 스프링은 데이터 접근 계층에 대한 수 많은 예외를 정리해서 일관된 예외 계층을 제공한다. 이 예외를 사용하면 JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 제공한다.
- 예외의 최고 상위는
DataAccessException
이다. 이 예외는NonTransient
예외와Transient
예외 2가지로 나뉜다.Transient
: 일시적이라는 뜻으로 해당 하위 예외는 SQL을 다시 시도했을 때 성공할 가능성이 있다. (락, 타임아웃 등)NonTransient
: 일시적이지 않다는 뜻으로 반복해도 실패하는 경우를 의미한다. (문법 오류, DB 제약조건 위배 등)
스프링이 제공하는 예외 변환기
스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다. 코드를 통해 스프링이 제공하는 예외 변환기를 알아보자.
SpringExceptionTranslatorTest - 변환기 사용 X
package hello.jdbc.exception.translator;
import hello.jdbc.connection.ConnectionConst;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Test
void sqlExceptionErrorCode() {
String sql = "select bad grammar";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
assertThat(e.getErrorCode()).isEqualTo(42122);
int errorCode = e.getErrorCode();
log.info("errorCode={}", errorCode);
log.info("error", e);
}
}
}
- SQL ErrorCode를 직접 확인하는 방법이다. DB 마다 오류 코드가 다르기 때문에 사용하기 불편하다.
SpringExceptionTranslatorTest - 변환기 사용 O
@Test
void exceptionTranslator() {
String sql = "select bad grammar";
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
assertThat(e.getErrorCode()).isEqualTo(42122);
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
스프링이 제공하는 SQL 예외 변환기
SQLExceptionTranslator exTranslator = new
SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
- translate() 메서드의 첫 번째 파라미터는 읽을 수 있는 설명이고, 두 번째는 실행한 sql, 마지막은 발생된 예외를 전달하면 된다. 이렇게 하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해준다.
- 예제에서는 SQL 문법이 잘못 되었으므로
BadSqlGrammarException
을 반환하는 것을 확인할 수 있다. (참고로 최상위 타입인DataAccessException
상속)
스프링이 제공하는 SQL 예외 변환기의 동작 원리 (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 예외 변환기는 데이터베이스별 오류 코드를 XML에서 보관하고 있다.
스프링은 데이터 접근 계층에 대한 일관된 예외 추상화를 제공한다. 구현 기술이 변경되어도 스프링 데이터 접근 예외로 변환해준다.
스프링 예외 추상화 적용
스프링이 제공하는 데이터 접근 예외 추상화와 SQL 예외 변환기를 적용해보자.
MemberRepositoryV4_2
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.jdbc.support.SQLExceptionTranslator;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
@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);
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeResultSet(rs);
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
@Override
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) {
log.error("db error", e);
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
@Override
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) {
log.error("db error", e);
throw exTranslator.translate("findById", sql, e);
} finally {
close(con, pstmt, rs);
}
}
@Override
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);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw exTranslator.translate("update", sql, e);
} finally {
close(con, pstmt, null);
}
}
@Override
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) {
log.error("db error", e);
throw exTranslator.translate("delete", sql, e);
} finally {
close(con, pstmt, null);
}
}
}
- 코드는 기존 코드가 대부분이며,
SQLExceptionTranslator
를 주입받아서catch
하는 부분에서exTranslator.translate
를 이용해서 예외 변환기를 사용하여 던지면 된다.
드디어 예외에 대한 부분을 깔끔하게 정리했다. 서비스 계층이 순수하게 구현되었기 때문에 DI를 제대로 활용할 수 있게 되었다. 추가로 서비스 계층에서 예외를 잡아서 해결해야하는 경우 예외 변환기를 사용해서 예외를 잡아서 복구하면 된다.
JDBC 반복 문제 해결 - JdbcTemplate
지금까지 서비스 계층의 순수함을 유지하기 위해 수 많은 노력을 했고, 덕분에 서비스 계층의 순수함을 유지하게 되었다. 이번에는 리포지토리에서 JDBC를 사용하기 때문에 발생하는 반복 문제를 JdbcTemplate
을 사용해서 해결해보자.
이런 반복을 효과적으로 처리하는 방법을 템플릿 콜백 패턴이라고 부른다.
JDBC 반복 문제
- 커넥션 조회, 커넥션 동기화
PreparedStatement
생성 및 파라미터 바인딩- 쿼리 실행, 결과 바인딩, 예외 발생 시 예외 변환기 실행
- 리소스 종료
MemberRepositoryV5
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import javax.sql.DataSource;
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
template = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?,?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
return template.queryForObject(sql, memberRowMapper(), memberId);
}
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
template.update(sql, money, memberId);
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
template.update(sql, memberId);
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
};
}
}
JdbcTemplate
의 사용으로 중복되는 커넥션 조회 및 동기화 등의 여러 복잡한 코드를 해결해준다.
MemberServiceV4Test - 수정
@Bean
MemberRepository memberRepository() {
return new MemberRepositoryV5(dataSource);
}
- 인터페이스 사용으로 등록한 빈만
JdbcTemplate
을 사용한 구현체로 변경한다.
정리
- 서비스 계층의 순수성
- 트랜잭션 추상화(DataSource)와 AOP 덕분에 서비스 계층의 순수성을 최대한 유지하면서 서비스 계층에서 트랜잭션을 사용할 수 있다.
- 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에, 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서 예외도 사용할 수 있다.
- 서비스 계층이 리포지토리 인터페이스에 의존한 덕분에 향후 리포지토리가 다른 구현 기술로 변경되어도 서비스 계층을 순수하게 유지할 수 있다.
- JDBC에서 사용되는 반복적인 코드를 템플릿 콜백 패턴인
JdbcTemplate
으로 대부분 제거할 수 있다.
본 포스팅은 인프런 - 김영한님의 '스프링 DB 1편 - 데이터 접근 핵심 원리'를 정리한 내용입니다.