framework/spring

6. 스프링과 문제 해결 - 예외 처리, 반복

wooweee 2023. 9. 23. 19:23
728x90

1. 체크 예외와 인터페이스

  • 서비스 계층이 예외에 대한 의존을 해결 하기 위해서 throws SQLException도 제거 필요
  • 서비스가 처리할 수 없는 repository에서 던지는 SQLException 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던진다.

 

 

  • 인터페이스를 도입하면 인터페이스에만 의존하면 된다.
  • 구현 기술을 변경하고 싶으면 DI를 사용해서 MemberService 코드의 변경 없이 구현 기술을 변경할 수 있다.

 

  • memberRepository interface
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이 체크 예외여서 인터페이스에도 해당 예외를 선언해야한다.
      자바의 인터페이스 구현의 조건 중 조상보다 예외가 많으면 안된다.
    • 그래서 미리 만들었다가는 SQLException에 의존하는 interface가 생성된다.

 

 

2. 런타임 예외 적용

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

 

  • repository4_1
package hello.jdbc.repository;

/**
 * 예외 누수 문제 해결
 * 체크 예외를 런타임 예외로 변경
 * throws SQLException 제거
 */
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository{

    // data source 생성
    // DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
    private final DataSource dataSource;

    // 생성자 생성
    public MemberRepositoryV4_1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 1. 등록
    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(? , ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection(); // 1. connection 연결

            pstmt = con.prepareStatement(sql); // 2. db랑 소통
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate(); // db에서 실행됨

            return member;

        } catch (SQLException e) {
            throw new MyDbException(e); // 기존 예외를 runtime 예외에 담는다.
        } finally {
            close(con, pstmt, null);
        }

    }

    // 2. 조회
    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

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

    // transaction 용 findById 제거

    // 3. 수정
    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            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) {
            throw new MyDbException(e);
        } finally {
            close(con, pstmt, null);
        }
    }

    // transaction 용 findById 제거

    // 4. 삭제
    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            int resultSize = pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);

        } catch (SQLException e) {
            throw new MyDbException(e); // 원인 꼭 넣어줘야함
        } finally {
            close(con, pstmt, null);
        }
    }

    // close() 예외 처리
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 util
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
//        JdbcUtils.closeConnection(con);
        // 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException{
//        Connection con = dataSource.getConnection();
        // 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }
}

 

  • 첫 MemberRepository 인터페이스 구현
  • SQLException 을 MyException 에 담아서 런타임 에러로 변경했다.
  • 예외 변환시, 꼭 기존 예외를 담아야한다. - 기존 예외를 무시하면 절대 안된다.
catch(SQLException e) {
    throw new MyDbException(e);
}

 

  • MemberServiceV4 
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;


@Slf4j
//@Transactional class와 method 둘다 붙여도 된다.
public class MemberServiceV4 {

    private final MemberRepository memberRepository; // 인터페이스에만 의존해서 DI를 지킨다.

    public MemberServiceV4(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional // spring AOP - proxy 사용
    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, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

  • 특징
    • 인터페이스 구현한 repository를 이용
    • service에 따라 붙던 throws SQLException을 제거

 

 

  • 남은 문제점
    • 리포지토리에서 넘어오는 특정한 예외의 경우 복구를 시도할 수 있다.
    • 현재 MyDbException이 받는 예외는 어떤것이 올줄 몰라서 특정 상황을 꼬집어서 처리하기가 힘들다.
    • 그래서 특정 조건일 때, 생성되는 runtimeException을 만들어서 반환하도록 한다.

 

3. 데이터 접근 예외 직접 만들기

  • 특정 예외는 복구하고 싶을 수 있다.
  • 회원 가입시 DB에 이미 동일 ID 존재시 새로운 아이디를 만드는 상황이 있다고 가정
  • 이럴 경우 pk가 중복되어서 db에서는 예외를 던지게 되고 이와 같은 특정 조건일 경우를 business logic에서 처리하는 logic을 수행 할 것이다.

 

 

  • 서비스 계층 작업
    • 예외 복구를 위해 key 중복 오류를 확인할 수 있어야 한다.
    • 바로 예외를 확인해서 복구하는 과정이다.
    • 오류 코드를 활용하기 위해 SQLException을 서비스 계층으로 던지게 되면, 서비스 계층이 SQLexception이라는 JDBC 기술에 의존하게 된다.
    • 그래서 repository에서 runtimeException을 만들어서 던져주는 방식을 택한다.

 

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

 

  • 기존에 사용했던 MyDbException을 상속받아서 의미있는 계층을 형성한다.
  • db 관련 예외라는 계층을 만들 수 있게 된다.
  • db 중복인 경우만 던지기 위해서 이름도 MyDuplicateKeyException이라고 지었다.

 

  • test code 핵심 로직 code block

 

@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) {
            // runtimeException이지만 잡을 필요가 있을 때 사용한다. 의존성이 없다. 하지만 db로 부터 온 error code를 받을 수 있는 형태가 된다.
            log.info("key 중복, 복구 시도");
            String retryId = generateNewId(memberId);
            log.info("retryId={}", retryId);
            repository.save(new Member(retryId, 0));
        }
    }
    private String generateNewId(String memberId) {
        return memberId + new Random().nextInt(1000);
    }
}

 

// repository
@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;

    public Member save(Member member) {
    ...

        } catch (SQLException e) {
            //h2 db
            // 핵심 코드, repository 내부에서 error message를 확인하고 해당 error인 조건에 맞는 runtimeException(내가 만든 것) 날림
            // 의존성은 없지만 error code를 활용할 수 있는 형태가 된다.
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        } finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(con);
        }
    }
}

 

 

  • 남은 문제
    • sqlErrorCode는 각각 db마다 다르다.
    • 결과적으로 db가 변경될 때마다 Repository의 errorCode도 변경해야한다.

 

4. 스프링 예외 추상화 이해

 

 

  • 특징
    1. RuntimeException
    2. db 접근에 관한 수십가지의 예외를 정리한 계층을 제공 - sql-error-codes.xml 에 정의
    3. 특정 기술에 종속적이지 않게 설계되었기 때문에 service 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
    4. 스프링 메뉴얼에 모든 예외가 정리되진 않았기 때문에 코드를 직접 열어서 확인해보는 것이 필요

4.1. NonTransient 예외와 Transient 예외

  • NonTransient
    • 일시적이지 않다는 뜻
    • 반복해서 실행시 실패
    • SQL 문법 오류, 데이터베이스 제약조건 위배
  • Transient
    • 일시적
    • 동일 SQL 다시 시도했을 때 성공 가능성이 높다.
    • 쿼리 타임아웃, 락과 관련된 오류가 대표적

 

5. 스프링이 제공하는 예외 변환기

  • 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공

 

  • test code
package hello.jdbc.exception.translator;

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); // h2 db code
            int errorCode = e.getErrorCode();
            log.info("errorCode={}", errorCode);
            // org.h2.jdbc.JdbcSQLSyntaxErrorException
            log.info("error", e);
        }
    }
    // spring이 제공해주는 예외 변환기
    @Test
    void exceptionTranslator(){
        String sql = "select bad grammar";
        try{
            Connection con = dataSource.getConnection();
            PreparedStatement stmt = con.prepareStatement(sql);
            stmt.executeQuery();
        } catch (SQLException e){
            // org.springframework.jdbc.support.sql-error-codes.xml -> 각 db마다 해당하는 error 코드가 있다.
            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // xml 파일을 통해서 적절한 error 코드를 찾는다.
            // org.springframework.jdbc.BadSqlGrammarException
            DataAccessException resultEx = exTranslator.translate("select", sql, e); // 해당 db의 예외 class 반환

            log.info("resultEx", resultEx);

            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

 

  • 핵심코드
 // org.springframework.jdbc.support.sql-error-codes.xml -> 각 db마다 해당하는 error 코드가 있다.
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // xml 파일을 통해서 적절한 error 코드를 찾는다.
// org.springframework.jdbc.BadSqlGrammarException
DataAccessException resultEx = exTranslator.translate("select", sql, e); // 해당 db의 예외 class 반환

 

  • new SQLErrorCodeSQLExceptionTranslator(dataSource)
    : dataSource를 생성자 초기화 값으로 넣어줘야 어떤 db인지 예측 가능해진다.

  • translate()
    • method의 첫번째 파라미터는 읽을 수 있는 설명, 두번째는 실행한 sql, 마지막은 발생된 SQLException을 전달
    • 적절한 스프링 데이터 접근계층의 예외로 변환해서 반환된다. = runtimeException

  • db별로 sqlErrorcode를 알 수 있는 이유 : sql-error-codes.xml 에 미리 다 정의 되어있기 때문에 가능

 

6. 스프링 예외 추상화 적용

 

6.1. MemberRepositoryV4_2

package hello.jdbc.repository;
/* * spring 제공 예외 변환기 - SQLExceptionTranslator
 * SQLExceptionTranslator             : interface
 * SQLErrorCodeSQLExceptionTranslator : 구현체 - 이거말고 다른 구현체도 존재. ex) status 관련한 구현체 등등
 */
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{

    // data source 생성
    // DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
    private final DataSource dataSource;
    private final SQLExceptionTranslator exTranslator; // 인터페이스로 생성자 주입 받을 code

    // 생성자 생성
    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // 구현부 생성자 주입
    }

    // 1. 등록
    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(? , ?)";

        Connection con = null;
        PreparedStatement pstmt = null; // sql params를 바인딩 받는 class

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection(); // 1. connection 연결

            pstmt = con.prepareStatement(sql); // 2. db랑 소통
            pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
            pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
            pstmt.executeUpdate(); // db에서 실행됨

            return member;

        } catch (SQLException e) {
            throw exTranslator.translate("save", sql, e); // 스프링 변환기로 변환된 db 예외 관련 RuntimeException
        } finally {
            close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
        }
    }

    // 2. 조회
    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

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

    // 3. 수정
    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            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) {
            throw exTranslator.translate("update", sql, e);
        } finally {
            close(con, pstmt, null);
        }
    }

    // 4. 삭제
    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            int resultSize = pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);

        } catch (SQLException e) {
            throw exTranslator.translate("delete", sql, e);
        } finally {
            close(con, pstmt, null);
        }
    }

    // close() 예외 처리
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 util
        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;
    }
}

 

  • 핵심 코드
catch (SQLException e) {
    throw exTranslator.translate("save", sql, e); // 해당 method()가 반환하는 게 예외이다.
}

 

6.2. MemberService

 

  • repository로 부터 오는 예외를 잡아서 복구해야 하는 경우, 예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 된다.

 

 

7.  반복 문제 해결 - jdbc template

  • service 계층의 Transaction 문제 해결 완료
  • 이제 Repository의 JDBC 반복문제 해결 필요

 

  • repository의 상당 부분이 반복되는데 반복을 효과적으로 처리하는 방법이 템플릿 콜백 패턴이다.
  • 스프링은 JDBC의 반복문제를 해결하기 위해 JDBCTemplate 제공
  • 중복이 제거된다는 것에 초점

 

7.1. MemberRepositoryV5

  • 결론 : connection, 여러 생성자 주입, statement, resultSet, spring 예외변환기, close() 과정이 내부적으로 수행됨.
package hello.jdbc.repository;
/**
 * JDBC tmeplate 사용
 */
@Slf4j
public class MemberRepositoryV5 implements MemberRepository{
    private final JdbcTemplate template; // connection, pstmt, close, 예외 변환까지 다 수행
    // 생성자 생성
    public MemberRepositoryV5(DataSource dataSource) { // dataSource는 기본 설정값만 넣으면 springboot가 자동으로 넣어줌
        template = new JdbcTemplate(dataSource);
    }
    // 1. 등록
    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(? , ?)";
        template.update(sql, member.getMemberId(), member.getMoney());
        return member;
    }
    /** connection, 여러 생성자 주입, statement, resultSet, spring 예외변환기, close() 과정이 내부적으로 수행됨.
     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); // 스프링 변환기로 변환된 db 예외 관련 RuntimeException
        } finally {
            close(con, pstmt, null);
        }
    }
    // close() 예외 처리
    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        DataSourceUtils.releaseConnection(con, dataSource);
    }
    // TxManager로부터 connection 받아오기 or 직접 connection 생성 획득하기
    private Connection getConnection() throws SQLException{
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }
    * */

    // 2. 조회
    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        // sql, memberId로 가지고 온 단일 결과를 memberRowMapper()를 이용해서 member 반환
        return template.queryForObject(sql, memberRowMapper(), memberId);
    }

    // 3. 수정
    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id = ?";
        template.update(sql, money, memberId); // 순서 중요
    }

    // 4. 삭제
    @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;
        });
    }
}