스프링 DB-예외 처리

예외 처리

저번 포스팅에서 체크 예외와 언체크 예외의 장단점, 그리고 어떻게 단점을 해결하는지에 대해 배웠다.
이번 포스팅에서는 이전에 진행했던 예제의 문제점들을 저번 포스팅에서 배운 것과 여타 기술들을 활용해 해결해볼 것이다.
먼저 구현체에 의존하던 Service 코드를 인터페이스를 새로 만들어 인터페이스에 의존하도록 해보자.

인터페이스

이전에 배웠던 것과 같이 구현체에 의존하지 않고 인터페이스에 의존하게 한다면 스프링의 DI를 통해 Service 코드의 변경 없이 구현체를 갈아끼울 수 있게 된다. 구현한 인터페이스는 아래와 같다.

public interface MemberRepository {
    Member save(Member member);
    Member findById(String memberId);
    void update(String memberId, int money);
    void delete(String memberId);
}

기존 Repository에서 처리하던 메서드들을 추상 메서드화한 인터페이스이다. 특정 기술에 의존하지 않아 해당 인터페이스에 의존하는 Service 코드도 기술의 의존 관계 문제가 없도록 만들어준다.

하지만 지금 당장 이 인터페이스를 사용하기엔 큰 문제점이 있다. 또다시 체크 예외가 발목을 잡는데 구현체의 메서드에서 체크 예외를 사용한다면 인터페이스에서도 체크 예외를 던져주는 코드가 선언되어야 한다. 따라서 코드가 아래와 같이 되어야 한다.

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;
}

이렇게 되면 체크 예외에 의존하게 되므로 Service 코드의 순수성이 깨지게 된다. 이러한 문제를 해결하려면 저번 포스팅에서 배웠던 대로 체크 예외를 언체크 예외로 변환하여 던져주면 된다.
먼저 구분이 쉽도록 언체크 예외인 RuntimeException을 상속받은 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);
    }
}

여러 생성자들을 상속 받았을 뿐, 기존 RuntimeException과 거의 동일한 클래스이다. 이제 이 예외를 사용하여 기존 Repository 코드에서 체크 예외를 던지던 부분을 수정해보자.

    @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);
        }
    }

기존 SQLException을 던지던 부분을 try-catch 문으로 잡아 대신 MyDBException을 던지도록 수정한 코드이다. 이 메서드 이외에도 체크 예외를 던지는 부분을 모두 똑같은 방법으로 수정해주었다. 또한 예외를 변환할 때 기존 예외를 꼭 넣어주어야 하는 부분도 준수하였다.
이제 인터페이스는 더 이상 체크예외를 던질 필요가 없다. 인터페이스에 의존하는 Service 코드도 마찬가지다. Service 코드도 체크 예외 없이, 인터페이스 의존하도록 수정해보자.

@Slf4j
@Service
public class MemberServiceV4 {
    final MemberRepository memberRepository;
    //final PlatformTransactionManager transactionManager;


    public MemberServiceV4(MemberRepository memberRepository) {
        this.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 static void validation(Member toMember) {
        if(toMember.getMemberId().equals("ex")) {
            throw new IllegalArgumentException("이체중 예외 발생");
        }
    }
}

이제 구현체가 아닌 DI를 통해 구현체가 주입되는 인터페이스에 의존하는 코드가 되었다. 또한 체크 예외가 넘어오지 않기 때문에 예외에도 의존하지 않아 드디어 순수한 Service 코드가 완성되었다.

특정 예외 처리

체크 예외의 문제점을 해결하기 위해서는 언체크 예외로 변환해서 던져야 한다. 하지만 이렇게 언체크 예외로 모두 변환하여 던지면 어떤 특정한 해결하고 싶은 예외가 있어도 구분을 할 수가 없지 않을까?
DB 예외의 경우 DB에서 에러 코드를 같이 던져 이 문제를 해결한다.

에러코드

만약 ID가 같아 키 중복 오류가 나는 경우엔 Service 계층에서 해결하고 싶다면 이 오류 코드를 확인하고 별개의 예외를 던져줌으로써 해결할 수 있다.

일단 키 중복 예외에만 던져질 별개의 예외를 하나 만들자.

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);
    }
}

RuntimeException이 아닌 MyDBException을 상속받은 이유는 DB 관련 예외라는 계층을 만들 수 있기 때문이다. 후에 MyDBException의 자식인지 확인하여 DB 괸련 예외임을 쉽게 알아볼 수 있을 것이다.
이 예외는 키 중복 오류에만 던져질 것이고, MyDBException과 같이 기술에 종속적이지 않은 순수한 예외이다. 이제 이 예외를 활용하여 로직을 구현해보자.

    @RequiredArgsConstructor
    static class Repository {
        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 {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(con);
            }
        }
    }

    @Slf4j
    @RequiredArgsConstructor
    static class Service {
        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));
            }

        }
        private String generateNewId(String memberId) {
            return memberId+new Random().nextInt(10000);
        }
    }

static class로 테스트 코드 내부에 객체를 만들어 테스트해본 코드이다. Repository에서는 에러 코드를 확인하고 키 중복 오류가 맞다면 예외를 변환하여 던져주고, Service에서는 변환된 예외가 왔다면 잡아서 추가 로직을 실행한다.

스프링 예외 추상화

스프링에서는 앞서 발생한 여러 문제들을 해결하기 위해 데이터 접근과 관련된 예외들을 추상화하여 제공한다. 예외를 추상화함으로써 예외가 기술에 종속적이지 않게 되고, 언체크 예외이므로 코드를 번잡하게 하지도 않는다. 또한 예외 이름들이 직관적이므로 기존 SQLException과 같은 예외와 달리 원인을 알아보기도 쉽다.

스프링 예외

그림을 단순화하기 위해 일부 계층을 삭제한 스프링 데이터 접근 예외의 계층 구조이다. 최상위에 RuntimeException을 상속받았으므로 언체크 예외이다.
스프링 예외는 NonTransient 예외와 Transient 예외 두 가지로 구분된다. NonTransient 예외는 일시적이지 않은 오류라는 뜻이다. 따라서 다시 같은 SQL을 실행하면 다시 실패한다. 대표적으로 SQL 문법 오류, DB 제약조건 위반 등이 있다.
Transient 예외는 일시적인 오류를 뜻한다. 이런 예외의 경우 같은 SQL을 다시 실행하면 성공할 가능성이 있다. 대표적으로는 쿼리 타임아웃, 락과 관련된 오류들이 있다.

그런데 오류 코드를 보고 직접 오류 코드마다 스프링 예외로 변환해주는 것은 미친 짓이다. 따라서 스프링은 각각의 DB에서의 오류코드와 스프링 예외를 매칭한 맵을 따로 XML 파일로 저장해두고, 그를 이용한 예외 변환기를 제공한다.
스프링이 제공하는 예외 변환기는 다음과 같이 사용하면 된다.

SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);

translate() 메서드의 첫 파라미터는 자신이 진행했던 작업에 대한 간단한 설명이 들어가고, 두번째는 실행한 SQL문, 세번째는 발생한 SQLException이 들어간다. 파라미터를 입력해주면 스프링에서 위에서 설명한 XML문을 보고 매칭하여 예외를 변환해준다.

스프링 데이터 접근 예외를 사용하면 기술에 종속적이지 않고, 스프링에만 종속되는 예외를 간편하게 사용이 가능하다. 스프링에마저 종속적이고싶지 않다면 직접 예외를 정의하고 변환해도 되지만, 그냥 스프링을 쓰자.

JDBC 템플릿

이제 대부분의 문제를 해결했지만, JDBC를 사용할 때 커넥션 조회, pstmt 생성 및 sql 입력 등등의 과정이 계속해서 반복되는 것이 마음에 들지 않는다. 이러한 반복 구조는 템플릿 콜백 패턴을 사용하면 쉽게 처리할 수 있고, 스프링에서는 이를 위해 JDBCTemplate라는 템플릿을 제공한다.
템플릿 콜백 패턴은 후에 자세히 알아보고, 일단 스프링의 JDBCTemplate을 활용하여 지금까지의 코드를 간략하게 리팩토링 해보자.

@Slf4j
public class MemberRepositoryV5 implements MemberRepository{
    final JdbcTemplate jdbcTemplate;

    public MemberRepositoryV5(DataSource dataSource) {
        this.jdbcTemplate=new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql="insert into member(member_id, money) values (?,?)";
        jdbcTemplate.update(sql,member.getMemberId(),member.getMoney());
        return member;
    }

    @Override
    public void update(String memberId, int money) {
        String sql="update member set money=? where member_id=?";
        jdbcTemplate.update(sql,money,memberId);
    }

    @Override
    public void delete(String memberId) {
        String sql="delete from member where member_id=?";
        jdbcTemplate.update(sql,memberId);
    }

    @Override
    public Member findById(String memberId) {
        String sql="select * from member where member_id=?";
        return jdbcTemplate.queryForObject(sql,memberRowMapper(),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;
        };
    }
}

이제 커넥션을 동기화하고, pstmt를 생성하고 로직 진행 후 리소스를 닫는 모든 과정을 템플릿에서 알아서 진행해준다. Member 객체 생성을 위해 RowMapper 메서드 하나만 추가된 것 치고는 굉장한 가성비이다.
JDBC템플릿은 트랜잭션을 위한 커넥션 동기화는 물론, 예외가 발생하면 스프링 예외로 변환해주는 것까지 자동으로 진행된다.

정리

기존의 예제에서 체크 예외로 인해 발생했던 문제들을 언체크 예외로 변환하여 리팩토링 하였고, 특정 예외 해결을 위한 데이터 계층 예외 작성과 이러한 과정을 손쉽게 처리할 수 있는 스프링의 데이터 계층 예외에 대해 배웠다.
또한 JDBC의 반복되는 코드를 편리하고 간략하게 줄여주는 JDBC 템플릿에 대해서도 배울 수 있었다. 템플릿 콜백 패턴은 지금의 커리큘럼에서는 없는 내용인데 상당히 편하고 중요해보이니 따로 공부를 해야할 것 같다.
이제 스프링 DB강의 1편이 끝이 났는데, 지금까지 웹에 대해 아는것이 없어 공부에만 너무 매진한 것 같다. 체득하는 과정이 없었던게 마음에 걸린다.
DB2편은 잠시 미뤄두거나 병행하면서 지금까지 공부한 것을 복습하고, 토이 프로젝트를 간단하게 기획하고 만들면서 지금까지 배운 것을 체득하는 과정이 필요할 것 같다.