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

고동현·2024년 6월 11일
0

DB

목록 보기
5/13

이전 글에서 보면, Service가 Repository에 종속적인것을 볼 수 있다.
Service에서 Repository repository = new Repository()로 직접 생성해서 사용중이다.

DI를 사용해볼것인데,

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

이런식으로 인터페이스를 만들고,

@Slf4j
public class MemberRepositoryV3 implements MemberRepository{
    private final DataSource dataSource;

    public MemberRepositoryV3(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";//sql쿼리문
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = getConnection(); // 커넥션맺고
            pstmt = con.prepareStatement(sql); //PreparedStatement로 sql날림
            pstmt.setString(1, member.getMemberId()); //?부분 바인딩
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate(); //실행
            return member;
            ...

이런식으로 하면 되지 않나? 싶을 수 있다.
그러나, 이게 안되서 우리가 이전글에서 DI를 사용하지 못했던것이다.

왜냐하면 Repository의 save메서드에서 SQLException인 체크예외를 던지면, 해당 인터페이스에서도 throw SQLException을 선언해줘야한다.

Member save(Member member) throw SQLEXception;

그런데, 이러면 또 인터페이스가 해당 SQLException인 JDBC기술에 의존적이게 된다.

체크 예외를 사용해버리면 인터페이스가 특정 기술에 종속적이게 오염되버린다.
허나, 런타임 예외는 이런 부분에서 자유롭다. 인터페이스에 따로 런타임 예외를 선언하지 않아도 된다.

런타임 예외 적용

MemberRepository 인터페이스

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

    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";//sql쿼리문
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = getConnection(); // 커넥션맺고
            pstmt = con.prepareStatement(sql); //PreparedStatement로 sql날림
            pstmt.setString(1, member.getMemberId()); //?부분 바인딩
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate(); //실행
            return member;
        } catch (SQLException e){
            throw new MyDbException(e);
        }finally {
            //항상 finally에 connection종료
            close(con,pstmt,null);
        }
    }

    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        Connection con = 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);
        }finally {
            close(con,pstmt,rs);
        }
        return null;
    }

    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 new RuntimeException(e);
        }finally {
            close(con,pstmt,null);
        }
    }


    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){
            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() throws SQLException {
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get Connection = {}, class = {}",con,con.getClass());
        return con;
    }
}

다른부분은 이전 로직과 동일하다, 다만

해당 인터페이스의 구현체이므로 각 메서드마다 CheckedException을 던지지 않는다. 즉 throw SQLException을 없앤다.

그리고 SQLException대신에 RuntimeException인 MyDbException으로 변경한다.

중요한점은 반드시 기존오류인 SQLException을 가져와서 넣어줘야 기존의 오류를 확인 할 수 있다.

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);
    }
    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("이체중 예외 발생");
        }
    }
}

여기서 중요한 부분은 더이상 각 메서드에 throw SQlException을 명시하지 않아도된다. 즉 서비스로직에서 더이상 특정 기술에 의존하지 않는 순수한 java코드로 작성된다.

Test


@Slf4j
@SpringBootTest
class MemberServiceV4Test {
    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    MemberServiceV4 memberService;
    @AfterEach
    void after() {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }
    @TestConfiguration
    static class TestConfig {
        private final DataSource dataSource;
        public TestConfig(DataSource dataSource) {
            this.dataSource = dataSource;
        }
        @Bean
        MemberRepository memberRepository() {
            return new MemberRepositoryV4_1(dataSource); //단순 예외 변환
        }
        @Bean
        MemberServiceV4 memberServiceV4() {
            return new MemberServiceV4(memberRepository());
        }
    }
    @Test
    void AopCheck() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberRepository class={}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);
        //when
        memberService.accountTransfer(memberA.getMemberId(),
                memberB.getMemberId(), 2000);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }
    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        //when
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(),
                        2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        //memberA의 돈이 롤백 되어야함
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

Test code 로직도 이전과 동일하다. 다만 이제는 MeberRepository가 인터페이스를 사용하므로, Config부분에서 MemberRepository를 생성자 주입할때 구현체(MemberRepositoryV4_1)를 생성자의 파라미터로 넣어주는 부분을 확인 하면 된다.

정리하자면 Repository에서 발생하는 체크 예외를 런타임 예외로 변환하면서, 인터페이스와 서비스 계층의 순수성을 유지 할수 있다.

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

대부분 예외는 처리하지 못함 -> but, 특정예외는 복구해서 처리하고 싶음

예를들어, 동일한 pk ID가 들어왔으면 랜덤으로 숫자를 붙여서 다시 ID를 만들어주는 경우,

동일한 PK인 경우 SQLException code:23505를 던진다. 그때 우리는 체크 예외가 아니라 언체크 예외 MyDuplicateKeyException으로 변환하여서 던질것이다.

MyDuplicateKeyException

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

MyDuplicateKeyException은 이전에 사용한 MyDbException을 상속받았다.
MyDbException은 언체크 예외이다.

public class MyDbException extends RuntimeException{
		...
}

Test코드

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

Repository에서 DataSource를 주입받고 save메서드를 만들었다.

중요한점은 SQLException이 발생할 수 있는데 e.getErrorCode로 코드를 가져와서

23505, 즉 동일한 PK인 ID가 중복된 오류면 체크 예외인 SQLException을
MyDuplicateKeyException으로 변화하여 던졌다.

나머지 오류 또한 언체크예외로 바꾸기위해서 MyDbException으로 변환해서 던졌다.

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

Service에서
MyDuplicateKeyException이 날라오는경우 -> getnerateNewId메서드를 활용해 새롭게 save메서드 호출
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);
    }

BeforeEach를 통해서 조립

    @Test
    void duplicateKeySave(){
        service.create("myId");
        service.create("myId");
    }

동일한 Pk myId로 저장

스프링 예외 추상화

이전까지 했던것중에 문제가 있다.
SQL ErrorCode는 각 DB마다 다르다. H2를 사용하다가 MySQL로 바꾸면 키 중복 오류코드가 바뀐다.
그러면 또 Repository의 코드를 수정해야한다.

이를 해결하기위해서 스프링이 데이터 접근과 관련된 예외를 추상화해서 제공한다.

  • 각각의 예외들은 특정 기술에 종속적이지 않게 설계, JDBC사용하던 JPA를 사용하던 스프링이 제공하는 예외를 사용하면 된다.
  • 최상위는 DataAccessException, RuntimeException을 상속받았으므로 스프링이 제공하는 데이터 접근 계층의 모든 예외는 RuntimeException이다.
  • Transient: 일시적 -> 다시 동일한 SQL 실행시 성공할 수도 있음
  • NonTransient: 반복해도 실패

TestCode

public class SpringExceptionTranslatorTest {

    DataSource dataSource;
    @BeforeEach
    void init(){
        dataSource = new DriverManagerDataSource(URL,USERNAME,PASSWORD);
    }
    @Test
    void exceptionTranslator(){
        String sql = "select bad grammer";

        try{
            Connection con = dataSource.getConnection();
            PreparedStatement pstmt = con.prepareStatement(sql);
            pstmt.executeQuery();
        }catch (SQLException e){
            SQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exceptionTranslator.translate("select",sql,e);
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

catch부분의 SQLException을 SQLExceptionTranslator의 translate메서드를 사용해 변환한다.
첫번째 파라미터에는 오류에대한 설명, 두번째 파라미터에는 sql, 세번째는 마지막에 발생된 error를 넣으면 된다.

눈에보이는 반환은 최상위 타입인 DataAccessException이지만, 실제로는 구현된 BadSqlGrammarException이 반환된다.

그런데, H2에서의 SqlError코드와 Mysql의 SqlError코드가 서로 다른데 어떻게 스프링은 각 DB마다 에러코드를 보고 해당 오류에 맞는 에러를 반환해줄까?

sql-error-codes.xml


특별히 다른게 없고 그냥 각 DB에 해당하는 에러코드를 파일에 노가다로 넣은것이다.
그래서 H2를 사용할때 SQLException이 터지면 42000가 발생하고, 그다음에 Spring에서는 H2의 오류코드를 뒤져서 BadSqlGrammerException을 반환한다.
Mysql을 사용할때 SQLException이 터지면 1054라는 오류코드가 발생하고, 그다음 스프링이 Mysql 오류코드를 뒤져서 해당 오류코드에 맞는 badSqlGrammarException을 반환해준다.

스프링 예외 추상화 적용

리포지토리에서 try catch부분에 SQLException(체크예외) -> 내가 만든 MyDBException(언체크예외) 로 바꿔서 던졌다.

이제는 내가 만든 예외가 아닌, 스프링이 제공하는 Translator를 사용하겠다.

@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {
    private final DataSource dataSource;
    private final SQLExceptionTranslator exceptionTranslator;
    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
    }

생성자 주입을 사용 -> SQLExceptionTranslator는 인터페이스 이므로, 해당 구현체를 SQLErrorCodeSQLExceptionTranslator로 설정하고, dataSource를 넘겨준다.

 public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";//sql쿼리문
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = getConnection(); // 커넥션맺고
            pstmt = con.prepareStatement(sql); //PreparedStatement로 sql날림
            pstmt.setString(1, member.getMemberId()); //?부분 바인딩
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate(); //실행
            return member;
        } catch (SQLException e){
            throw exceptionTranslator.translate("save",sql,e);
        }finally {
            //항상 finally에 connection종료
            close(con,pstmt,null);
        }
    }

로직은 변경된것이 없고, 결국 throw부분에 exceptionTranslator의 translate메서드를 호출하여서 오류를 변환하여 던진다.

JDBCTemplate

근데 지금 리포지토리의 메서드들을 보면

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) {
            throw exceptionTranslator.translate("update",sql,e);
        }finally {
            close(con,pstmt,null);
        }
    }

위의 save메서드와 같이 하나같이 커넥션 맺고 try catch하고 finally에 close하고 이런게 반복되어있다.

이 패턴을 해결하기 위해 jdbcTemplate을 사용한다.

@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
    private final JdbcTemplate template;
    public MemberRepositoryV5(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }

jdbc템플릿에 dataSource넘겨서 주입


    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";//sql쿼리문
       template.update(sql,member.getMemberId(),member.getMoney());
       return member;
    }

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

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

quryForObject메서드를 정확히 이해할필요는 없다.
다만 현재 템플릿의 update메서드를 통해서 try catch finally없어졌는것을 볼 수 있다.

정리

결국 이전강의에서 학습했던 트랜잭션을 위한 동기화(예전에는 con.getConnection()을 통해 동기화 수행) + 스프링 예외 변환기도 Jdbc템플릿을 사용하면 해결이된다. 허무하다고 할수도 있지만, 해당 내용을 원초적인 부분부터 차근차근 업데이트 해가면서 공부하면 원리를 이해 할 수 있다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글