서비스 계층은 가급적 특정 구현 기술에 의존하지 않고 순수하게 유지하는 것이 좋다. 예를 들어서 서비스가 처리할 수 없는 SQlException
에 대한 의존을 제거하려면 어떻게 해야할까?
서비스가 처리할 수 없으므로 리포지토리가 던지는 체크예외인 SQLException
을 런타임예외로 전환해서 서비스 계층에 던지자. 이렇게 하면 서비스 계층이 예외를 무시할수 있기 때문에 특정 기술에 의존하는 부분을 제거할 수 있다.
런타임 예외 적용 - 인터페이스
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
MyDbException 런타임 예외
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
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
@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) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
@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);
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() {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
SQLException
이라는 체크예외를 MyDbException
이라는 런타임 예외로 변환해서 던지는 부분이다예외변환
catch (SQLException e) {
throw new MyDbException(e);
}
MemberService4
@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("이체중 예외 발생");
}
}
}
throws SQLException
부분이 제거된 것을 확인할 수 있다데이터베이스 오류에 따라서 특정예외는 복구하고 싶을 수도 있다. 예를 들어 새롭게 회원가입을 시도했는데 기존에 존재하던 ID라서 임의의 숫자를 붙여서 다시 저장될 수 있게 만드는 것이다.
데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다면 데이터베이스는 오류코드를 반환하고 이 오류 코드를 받은 JDBC 드라이버는 SQLException
을 던진다. 그리고 SQLException
에는 데이터베이스가 제공하는 errorCode
라는 것이 들어있다
SQLException
내부에 들어있는 errorCode
를 활용하면 데이터베이스에서 어떤 문제가 발생했는지 확인할 수 있 다
H2 예시
23505
: 키 중복 오류42000
: SQL 문법 오류서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인 할 수 있어야한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 이러한 과정이 예외를 확인해서 복구하는 과정으로 리포지토리는 SQLException
을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류코드를 확인해서 키 중복 오류인 경우 새로운 ID를 만들어서 다시 저장하면 된다. 하지만 SQLException
에 들어있는 오류코드를 활용하기 위해 SQLException
을 서비스 계층으로 던지면 서비스 계층이 JDBC 기술의 의존하게 되면서 서비스 계층의 순수성이 무너진다. 이 문제를 해결하려면 앞서 해던것처럼 예외를 변환해서 던지면 된다.
변환을 위한 예외 생성 - MyDuplicatekeyException
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);
}
}
MyDbException
을 상속받아서 의미있는 계층을 형성한다. 이렇게 하면 데이터베이스 관련 예외라는 계층을 만들 수 있다Test 예시
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);
}
}
스프링은 데이터 접근과 관련된 예외를 추상화해서 제공한다
@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);
//org.h2.jdbc.JdbcSQLSyntaxErrorException
log.info("error", e);
}
}
}
스프링이 제공하는 변환기
SQLExceptionTranslator exTranslator = new
SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
translate()
메서드의 첫번째 파라미터는 읽을 수 있는 설명이고 두번째는 실행한 sql, 마지막은 발생된 SQLException
을 전달하면된다. 이렇게 하면 스프링 데이터 접근 계층의 예외로 변환해서 반환해 준다 @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);
//org.springframework.jdbc.support.sql-error-codes.xml
SQLExceptionTranslator exTranslator = new
SQLErrorCodeSQLExceptionTranslator(dataSource);
//org.springframework.jdbc.BadSqlGrammarException
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
DataAccessException
이지만 실제로는 BasSqlGrammarException
이 반환됨
@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);
}
@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) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
.
.
.
}
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1