@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException{
bizLogic(fromId,toId,money);
}
기존 구현했던 Service 코드를 확인해보면, throws SQLException 으로 JDBC 기술에 의존하고 예외를 처리하지 못했던 문제가 있었다. 해당 문제를 SQLException 체크 예외를 런타임 예외로 변경해서 서비스 계층에 던지자. 이렇게 하면 서비스에서 해당 예외를 무시할수 있기 때문에 문제를 해결할수 있다.
먼저 MemberRepository 인터페이스를 구현해서 구현 기술을 변경할수 있게 해보자.
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);
}
}
MemberRepository 인터페이스
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
이후 MemberRepository에서 해당 인터페이스를 상속받은 후 Override해서 throws SQLException을 제거하고 catch 문에서 throw new MyDbException(e); 로 변경한다.
MemberService
@Slf4j
public class MemberServiceV4 {
private final MemberRepository memberRepository;
public MemberServiceV4(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public void accountTransfer(String fromId, String toId, int money){
bizLogic(fromId,toId,money);
}
저장소 객체를 인터페이스로 주입 받고SQLException 예외가 제거 되었다. 하지만 위와 같이 MyDbException은 모든 예외를 하나의 예외 타입으로 처리하기 때문에 다양한 예외 발생 원인을 구체적으로 알기 어렵다.
데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다. 예를 들어서 회원 가입시 DB에 이미 같은 ID가 있으면 ID 뒤에 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정해보자. ID를 hello 라고 가입 시도 했는데, 이미 같은 아이디가 있으면 hello12345와 같이 뒤에 임의의 숫자를 붙여서 가입하는 것이다.
만약 회원 가입을 할때 DB에 같은 존재하는 아이디를 통해 가입을 진행하면 오류 코드를 반환하고 JDBC 드라이버는 SQLException을 던진다. 그안에 ErrorCode가 들어있다.
해당 키 오류를 Repository에서 런타임 예외로 변환해서 던지면 된다.
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을 상속받아 DB관련 예외라는 의미있는 계층을 형성한다.
테스트를 작성해보자.
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;
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("retry={}",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);
}
}
// Repository 핵심 코드
catch (SQLException e){
//h2 db
if(e.getErrorCode()==23505){
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
}
만약 같은 멤버 Id가 들어온다면 DB에서 PK 오류로 반환된 에러코드를 Repository에서 식별하고 앞서 만든 런타임 예외 MyDuplicateKeyException로 잡게된다. 그러면 Service에서 catch로 잡게되고 새로운 Id를 생성하여 DB에 다시 저장하게 된다.
결론
남은 문제
스프링 데이터 접근 예외 계층
앞서 발생한 문제에 대해 스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.
각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다.
예외의 최고 상위org.springframework.dao.DataAccessException이고 모든 예외는 런타임 예외이다.
DataAccessException은 크게 NonTransient예외와 Transient예외 이다.
Transient 오류: 동일한 SQL을 다시 시도했을때 성공할 가능성이 있다.(쿼리 타임아웃,락)
NonTransient 오류: 같은 SQL을 반복하더라도 항상 실패한다.(SQL 문법 오류,데이터베이스 제약조건 위반)
스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다.
@Test
void exceptionTranslator(){
String sql="select bad grammer";
try {
Connection con=dataSource.getConnection();
PreparedStatement pstmt=con.prepareStatement(sql);
pstmt.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);
}
}
스프링의 SQLErrorCodeSQLExceptionTranslator는 데이터베이스에서 발생하는 SQLException을 스프링의 추상화된 예외(DuplicateKeyException, BadSqlGrammarException 등)로 변환하여 서비스 계층이 특정 데이터베이스 기술에 종속되지 않도록 설계되었다. 이를 통해 DB마다 에러 코드를 알지 못하더라도 스프링 예외 변환기를 통해 서비스 계층에서 구체적인 예외에 대해 로직을 작성할수 있고, 데이터베이스 변경 시 최소한의 수정으로 일관된 방식으로 예외를 처리할 수 있다. 변환기는 SQL 상태 코드나 에러 코드를 기반으로 동작한다. (예: 23505 → DuplicateKeyException)
예외 변환기 적용
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator;
try {
//생략
}catch (SQLException e){
throw exTranslator.translate("save",sql,e);
}
이렇게 하면 문법 오류가 나면 BadSqlGrammarException,중복 키 오류(PK)는 DuplicateKeyException로 자동으로 변환하여 서비스 계층으로 넘기게 된다.
지금까지 서비스 계층의 순수함을 유지하기 위해 런타임 예외 변환 과정을 거쳤고, 덕분에 서비스 계층의 순수함을 유지하게 되었다. 이번에는 리포지토리에서 JDBC를 사용하기 때문에 발생하는 반복 문제를 해결해보자.
MemberRepositoryV5
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
this.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;
}
public Member findById(String memberId) {
String sql = "select * from member where member_id= ?";
//한건 조회 == ForObject
//두번째 파라미터는 쿼리 결과 객체를 어떻게 만들것인지
return template.queryForObject(sql, memberRowMapper(), memberId);
}
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
template.update(sql,money,memberId);
}
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;
};
}
}
해당 코드는 기존 Connection,PreparedStatement,런타임 예외 변환을 자동으로 해준다. update()는 update,insert,delete의 경우 사용되고 객체를 반환하는 경우 따로 Mapper() 메서드를 생성하여 Customazing 한다. DB 연결과 트랜잭션을 위한 커넥션 동기화,Close 전부 JDBCtemplate가 자동으로 해준다.