개발/Spring DB

예외와 예외 처리

Debin 2022. 6. 17.
반응형
본 게시글은 인프런 김영한 선생님 강의 스프링 DB 1편을 완강하고 배운 것을 남기고자 적은 포스팅입니다.
강의 링크는 아래와 같습니다.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard

 

스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의

백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., - 강의

www.inflearn.com

우선 기초적인 예외에 대한 부분을 복습할 수 있었다.
필자는 자바 스터디를 하면서 예외에 대한 부분을 학습했는데, 이번에 복습을 할 수 있는 좋은 기회였다.
예외를 정리한 포스팅 링크는 다음과 같다. 

https://devdebin.tistory.com/184?category=1004578 

 

Java Exception

백기선님이 과거에 진행했던 Java 스터디 9주차 스터디 입니다. 자바에서 예외 처리 방법 (try, catch, throw, throws, finally) 예외처리란, 프로그램 실행 시 발생할 수 있는 예기치 못한 예외의 발생에 대

devdebin.tistory.com

그래서 예외 파트는 몇 가지 중요한 부분만 정리하고 예외 처리 파트에 관해 중심적으로 글을 쓸 예정이다.
예외 파트에 대한 필자가 중요하게 느끼는 부분은 아래와 같다.

 

  1. 예외는 체크 예외와 언체크 예외가 존재하는데, 체크 예외는 기술에 대한 의존성이 생기고 throws 코드를 너무 많이 작성해야 한다.
    레포지토리 예외 발생 -> 서비스 예외 발생 -> 컨트롤러 예외 발생 -> 예외 공통 처리. 불필요한 의존성이 너무 많이 생긴다.
    또한 체크 예외 SQLException 같은 경우는 애플리케이션 로직에서 해결이 불가능하다.
  2. 물론 던져야만 하는 예외를 컴파일 단계에서 잡아주므로 좋은 안전 장치이지만, 결론은 단점이 너무 많다.
  3. 따라서 언체크 예외를 자주 활용한다고 한다. 물론 문서화를 통해 언체크 예외를 놓치면 안된다.
  4. 결국 기술의 의존성이 안 생기면서, throws 코드도 적지 않는 언체크 예외를 자주, 많이 사용하자!
  5. 실제로 필자가 작은 스프링 부트 프로젝트나 실습을 할 때 언체크 예외를 사용해서,
    예외를 던지면 결국 ControllerAdvice가 예외를 처리해줬다.
  6. 체크 예외는 결제와 같은 정말 중요한 부분에서 사용하는게 좋다. 물론 런타임 예외를 사용할 수도 있다.
  7. 반드시 커스텀 예외로 전환할 때 기존의 발생한 예외를 넣어줘야 오류를 정확하게 확인할 수 있다.

그럼 이제 예외에 대한 기초적인 부분은 정리했으니, 예외 처리와, 스프링이 제공하는 예외 처리도 알아보겠다.

우리는 멤버를 저장하는 멤버 레포지토리를 구현하려고 한다.

JDBC 기술을 쓸 수도 있고, JPA 기술을 쓸 수도 있으니 인터페이스로 설계를 해 구현 기술을 쉽게 변경할 수 있게 만들 것이다.

아래는 특정 기술에 종속되지 않는 순수한 인터페이스다. 이 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만든다.

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

이제 우리는 체크 예외를 사용할 것이다.

그러면 체크 예외 코드를 적용해야하므로 인터페이스가 아래와 같이 변한다.

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

인터페이스 추상 메서드에 throws SQLException이 있으므로

인터페이스를 구현한 클래스의 오버라이딩된 메서드도 throws SQLException을 작성해야 한다.

결과적으로 구현 기술을 쉽게 변경하기 위해서 인터페이스를 도입하려고 해도

SQLException과 같은 특정 구현 기술에 종속적인 체크 예외를 사용하게 되면 인터페이스에도 해당 예외를 포함해야 한다.

이것은 결국 순수한 인터페이스가 아니다. JDBC에 종속적인 인터페이스다.

이러면 JDBC가 아닌 다른 기술로 변경할 경우 인터페이스를 수정해야 하는데, 그러면 수정해야할 코드가 정말 어마어마할 것이다.

 

언체크 예외, 즉 런타임 예외는 이런 부분에서 자유롭다. 아는 것처럼 런타임 예외는 throws 구문을 적을 필요가 없기 때문이다.

그러면 인터페이스가 특정 기술에 종속적이지 않아도 된다. 런타임 예외를 적용하면 순수한 인터페이스를 도입할 수 있다.

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

런타임 예외를 도입한 첫 번째 실습

  • 순수한 인터페이스를 상속한 구현체 레포지토리 클래스를 만들었다.
  • 먼저 RuntimeException을 상속한 커스텀한 예외를 만들었다.
  • 그리고 레포지토리 메소드의 각 catch문에서 런타임 예외를 상속한 커스텀 예외를 throw 한다.
    이러면 메소드 선언부에 throws를 적을 필요가 없다.
  • 즉 코드의 핵심은 SQLException이라는 체크 예외를 커스텀 예외로 변환해서 던지는 것이다.
  • 그럼 이제 서비스 클래스에서도 throws 구문을 삭제할 수 있다.
 catch (SQLException e) {
        throw new MyDbException(e); //커스텀한 예외. 반드시 기존 예외를 바탕으로 객체를 만들 것.
 }
 
 //이렇게 기존 예외를 무시하면 안된다.
 catch (SQLException e) {
        throw new MyDbException();
}

데이터 접근 예외 만들기

데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다.

우리는 PK 역할을 하는 ID가 데이터베이스에 저장되어 있으면, 새로 생성해서 저장할 것이다.

데이터베이스에 만약 ID가 이미 데이터베이스에 저장되어 있다면, 데이터베이스는 오류 코드를 반환하고,

이 오류 코드를 받은 JDBC 드라이버는 SQLException을 던진다.

그리고 SQLException은 데이터베이스가 제공하는 errorCode가 들어있다.

데이터베이스 오류 그림

SQLException 내부에 들어있는 errorCode를 활용해 데이터베이스에서 어떤 오류가 발생했는지 확인할 수 있다.

예를 들어 H2 데이터베이스에서 키 중복 오류 코드는 23505이고, MySQL은 1062다.

 

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다.

그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 이러한 과정이 바로 예외를 확인해서 복구하는 과정이다.

레포지토리는 SQLException 을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류 코드를 확인해서

키 중복 오류( 23505 )인 경우 새로운 ID를 만들어서 다시 저장하면 된다.

그런데 SQLException 에 들어있는 오류 코드를 활용하기 위해 SQLException 을 서비스 계층으로 던지게 되면, 서비스 계층이 SQLException 이라는 JDBC 기술에 의존하게 되면서, 지금까지 우리가 고민했던 서비스 계층의 순수성이 무너진다.

이 문제를 해결하려면 앞서 배운 것처럼 레포지토리에서 체크 예외를 변환해서 런타임 예외를 상속한 커스텀 예외로 던져야 한다.

첫 번째 실습

  • 이번에는 레포지토리 모든 메서드에서 catch문에서 체크 예외를 커스텀한 예외로 변환해 던져줬다.
  • 중요한 부분은 서비스가 예외를 복구할 수 있도록, 에러 코드를 확인하고 적절한 커스텀 예외를 던져 준 것이다.
  • 핵심 코드는 아래와 같다.
catch (SQLException e) {
      //h2 db
       if (e.getErrorCode() == 23505) {
          throw new MyDuplicateKeyException(e);
    }
      throw new MyDbException(e);
}

정리해보면 다음과 같다.

 

  • 에러 코드를 통해 데이터베이스에 오류를 확인할 수 있었다.
  • 예외 변환을 통해 체크 예외에 의존하지 않고 직접 만든 커스텀 예외를 던질 수 있었다.
  • 리포지토리가 예외를 변환한 덕분에 서비스 계층을 순수하게 유지할 수 있다.
  • 그러나 문제도 존재한다.
  • 데이터베이스마다 에러 코드가 다르므로 DB가 바꾸면 에러 코드와 관련된 조건문은 전부 수정해야 한다.
  • 데이터베이스가 전달하는 오류는 키 중복 뿐만 아니라 락이 걸린 경우, SQL 문법에 오류 있는 경우 등등 수십 수백가지 오류 코드가 있다.
  • 이 모든 상황에 맞는 예외를 지금처럼 다 만들어야 할까? 추가로 앞서 이야기한 것 처럼 데이터베이스마다 이 오류 코드는 모두 다르다.
  • 역시 이 문제를 스프링은 이미 해결했다. 킹 갓.

스프링 예외 추상화 이해

스프링 데이터 접근 예외 계층 (생략된 부분도 있다)

  • 스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다.
  • 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
    예를 들어서 JDBC 기술을 사용하든, JPA 기술을 사용하든 스프링이 제공하는 예외를 사용하면 된다.
  • JDBCJPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 스프링이 제공한다.
  • 예외의 최고 상위는 DataAccessException이다. 크게 2가지로 구분한다.
  • 먼저 Transient는 일시적이라는 뜻이다. Transient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공 가능성이 있다.
    예를 들어 쿼리 타임아웃, 락과 관련된 오류다.
  • NonTransient 는 일시적이지 않다는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다.
    SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.
  • 결국 이 방식을 택하면 스프링에 의존하지만 이는 어쩔 수 없는 합의점이라고 한다.

스프링 예외 변환기

개발자가 데이터베이스의 SQL ErrorCode를 확인하고 하나하나 스프링이 만들어준 예외를 변환하는 것은 사실 불가능하다.

따라서 스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다.

핵심 코드는 다음과 같다.

catch (SQLException e) {
    throw translator.translate("save", sql, e);
}

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

translate 메서드는 첫 번째는 읽을 수 있는 설명을 보내고, 두 번째는 실행한 sql, 마지막은 발생된 SQLException을 전달하면 된다.

이렇게 하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해준다.

 

참고로 스프링이 각각의 DB가 제공하는 SQL ErrorCode까지 고려해서 예외를 던질 수 있는 것은 sql-error-codes.xml 파일 덕분이라고 한다.

스프링 예외 변환기 사용 실습

스프링 예외 변환기 코드를 적용한 레포지토리 코드는 아래와 같다.

**
 *
 * SQLExceptionTranslator 추가
 */

@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{

    private final DataSource dataSource;
    private final SQLExceptionTranslator translator;

    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.translator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
    }

    @Override
    public Member save(Member member)  {
        //생략
    }

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


    @Override
    public void update(String memberId, int money) {
         //생략
    }


    @Override
    public void delete(String memberId) {
         //생략
    }

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

드디어 예외에 대한 부분을 깔끔하게 정리했다.
스프링이 예외를 추상화해준 덕분에, 서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않게 되었다.
따라서 서비스 계층은 특정 구현 기술이 변경되어도 그대로 유지할 수 있게 되었다
추가로 서비스 계층에서 예외를 잡아서 복구해야 하는 경우,
예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 된다.

마지막으로 레포지토리에서 반복적인 코드를 JdbcTemplate을 사용해 제거했다.

이번학기 동아리 스터리를 진행하면서 정말 많이 사용했는데, 이 부분을 학습하므로 인해 더 많은 부분을 깨닫게 되었다.

아래는 본인이 작성했던 동아리 스터디 JdbcTemplate 코드다.

@Repository
@RequiredArgsConstructor
public class PostDao {

    private final JdbcTemplate jdbcTemplate;

    public List<GetPostsRes> selectPosts(int userIdx) {
        String selectPostsQuery =
                "select p.id as post_id,  p.content, u.id as user_id, u.nickname, p.like, ph.photo_url, p.created_at,\n" +
                "if(commentCount is null, 0 , commentCount) as comment_count\n" +
                "from Post as p\n" +
                "join User as u on u.id = p.user_id\n" +
                "left join Photo as ph on ph.user_id = u.id and ph.type = 'PROFILE'\n" +
                "left join Follow as f on f.following_id = u.id and f.status = 'ACTIVE'\n" +
                "left join (select post_id, count(id) as commentCount from Comment as c \n" +
                "where c.status ='ACTIVE' group by post_id) c on c.post_id = p.id \n" +
                "where f.follower_id = ? and p.status ='ACTIVE'\n" +
                "group by p.id;";

        int selectPostsParam = userIdx;
        return this.jdbcTemplate.query(selectPostsQuery,
                (rs, rowNum) -> new GetPostsRes(
                        rs.getInt("post_id"),
                        rs.getInt("user_id"),
                        rs.getString("nickname"),
                        rs.getString("photo_url"),
                        rs.getString("content"),
                        rs.getInt("like"),
                        rs.getInt("comment_count"),
                        rs.getString("created_at"),
                        this.jdbcTemplate.query(
                                "select ph.id as photo_id, ph.photo_url, p.id as post_id from Photo as ph \n" +
                                "join Post as p on p.id = ph.post_id where ph.status = 'ACTIVE' and p.id = ?;",
                                (rs2,rowNum2)->new GetPostImgRes(
                                        rs2.getInt("photo_id"),
                                        rs2.getString("photo_url"))
                                ,rs.getInt("post_id")
                                )
                ),selectPostsParam);
    }

    public int insertPostImgs(int postIdx,int userIdx, String imgUrl) {
        String insertPostImgsQuery = "INSERT INTO Photo(post_id, photo_url, user_id, type) VALUES (?,?,?,?)";
        Object[] insertPostImgsParams = new Object[]{postIdx,imgUrl, userIdx, "POST"};
        this.jdbcTemplate.update(insertPostImgsQuery,insertPostImgsParams);

        String lastInsertIdxQuery = "select last_insert_id()"; //마지막에 인서트한 데이터 아이디를 가져온다.
        return this.jdbcTemplate.queryForObject(lastInsertIdxQuery,int.class);


    }

    public int insertPosts(int userIdx, String content) {
        String insertPostQuery = "INSERT INTO Post(user_id, content) VALUES (?,?)";
        Object [] insertPostParams = new Object[] {userIdx, content};
        this.jdbcTemplate.update(insertPostQuery,insertPostParams);

        String lastInsertIdxQuery = "select last_insert_id()"; //마지막에 인서트한 데이터 아이디를 가져온다.
        return this.jdbcTemplate.queryForObject(lastInsertIdxQuery,int.class);

    }

    public int updatePost(int postIdx, String content) {
        String  updatePostQuery = "UPDATE Post SET content = ? WHERE id = ?";
        Object[] updatePostParams = new Object[]{content, postIdx};

        return this.jdbcTemplate.update(updatePostQuery,updatePostParams);
    }

    public int checkPostExist(int postIdx){
        String checkPostIdxQuery = "select exists(select id from Post where id = ?)";
        int checkPostIdxParams = postIdx;
        return this.jdbcTemplate.queryForObject(checkPostIdxQuery,
                int.class,
                checkPostIdxParams);

    }

    public int deletePost(int postIdx) {
        String deletePostQuery = "DELETE FROM Post where id = ?";
        int deletePostParams = postIdx;
        return this.jdbcTemplate.update(deletePostQuery,deletePostParams);
    }
}

 

이제 레포지토리와 서비스에서 반복 코드를 많이 줄이고, 기술의 의존성도 현저히 낮췄다.

스프링 데이터베이스 접근 기술에 깊은 이해를 더한 것 같아서 뿌듯하다.

 

이상으로 포스팅을 마치겠습니다. 감사합니다.

반응형

댓글