GitHub

https://github.com/Choidongjun0830

Spring

[Spring DB 1편 듣고 복습, 토이 프로젝트 수정] 6. 스프링과 문제 해결 - 예외 처리, 반복

gogi masidda 2024. 3. 9. 16:58

체크 예외와 인터페이스

서비스 계층은 가급적 다른 구현 기술에 의존하지 않고 순수한 비즈니스 코드만 존재하는 것이 좋으므로, 예외에 대한 의존도 해결해야 한다. SQLException 같은 체크 예외를 런타임 예외로 전환해서 서비스 계층으로 던지면, 서비스 계층이 이 예외를 무시할 수 있어서 특정 구현 기술에 의존하는 부분을 제거하고 서비스 계층을 순수하게 유지할 수 있다.

 

MemberRepository라는 인터페이스를 도입해서 구현 기술을 쉽게 변경할 수 있게 해야한다.

MemberRepository 인터페이스를 도입하여 JdbcMemberRepository나 JpaMemberRepository라는 구현 클래스를 만들어서 사용하고, MemberService에서는 MemberRepository 인터페이스에만 의존하면 된다. 그러면 MemberService 코드의 변경 없이 구현 기술을 변경할 수 있다.

 

public interface MemberRepository {
    Member save(Member member);
    Member findById(Long id);
    List<Member> findAllMember();
    String findByLoginId(String loginId);
    Member findByNickname(String nickname);
    void updatePoint(String nickname, int point);
    void delete(Long id);
    void deleteByNickname(String nickname);
}

그래서 이렇게 MemberRepository 인터페이스를 만들었다. 하지만, SQLException이 체크 예외이기 때문에 인터페이스에도 해당 체크 예외가 선언되어 있어야 한다. 하지만 이렇게 되면 순수한 인터페이스를 만들 수 없다. 인터페이스를 만드는 목적은 구현체를 쉽게 변경하기 위함인데 인터페이스가 이미 특정한 구현 기술에 오염되어 버려서 나중에 구현 기술을 바꾼다면, 인터페이스 자체를 변경해야 한다. 그래서 런타임 예외를 사용해야 이런 문제에서 자유롭다.


런타임 예외 적용

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

런타임 예외를 상속받은 MyDBException을 만든다.

/**
 * 예외 누수 문제 해결
 * 체크 예외를 런타임 예외로 변경
 * MemberRepository 인터페이스 사용
 * throws SQLException 제거
 */
@Slf4j
@Repository
public class MemberRepositoryWithDBV4_1 implements MemberRepository{

    private final DataSource dataSource;
    private static long sequence = 0L;

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

    public Member save(Member member){
        String sql = "insert into member(id, register_date, name, loginid, password, nickname, point) values (?, ?, ?, ?, ?, ?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setLong(1, ++sequence);
            pstmt.setDate(2, Date.valueOf(LocalDate.now()));
            pstmt.setString(3, member.getName());
            pstmt.setString(4, member.getLoginId());
            pstmt.setString(5, member.getPassword());
            pstmt.setString(6, member.getNickname());
            pstmt.setInt(7, member.getPoint());

            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            throw new MyDBException(e);
        } finally {
            close(con, pstmt, null);
        }
    }

이렇게 SQLException을 잡아서 런타임예외인 MyDBException으로 전환하여 던지면 된다.

 

/**
 * 예외 누수 문제 해결
 * SQLException 제거
 * 
 * MemberRepository 인터페이스 의존
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
    private final MemberRepository memberRepository;

    //커뮤니티 포인트 전송
    @Transactional
    public void pointTransfer(String fromNickname, String toNickname, int point) {
        bizLogic(fromNickname,toNickname, point);
    }

    private void bizLogic(String fromNickname, String toNickname, int point) {
        Member fromMember = memberRepository.findByNickname(fromNickname);
        Member toMember = memberRepository.findByNickname(toNickname);
        memberRepository.updatePoint(fromNickname, fromMember.getPoint() - point);
        validation(toMember);
        memberRepository.updatePoint(toNickname, toMember.getPoint() + point);
    }

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

MemberService도 MemberRepository 인터페이스를 의존함으로써 throws SQLException을 없앨 수 있다. 이렇게 순수한 비즈니스 코드만 있는 MemberService를 만들 수 있다.


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

경우에 따라서 복구하고 싶은 예외가 있을 수 있는데, 그것은 errorCode로 찾을 수 있다.

만약 SQLException이 발생하면, 그 안에 들어 있는 errorCode로 데이터베이스에 어떤 문제가 발생했는지 알 수 있다. 그런데 이 코드는 데이터베이스 기술마다 달라서 사용하려는 데이터베이스 메뉴얼을 살펴보아야 한다.

 

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 그런데 SQLException을 던지면 순수한 서비스 계층이 무너지게 된다. 이 문제를 해결하려면 앞에서 한 것 처럼 예외를 변환해서 던지면 된다.

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

이렇게 예외를 만들었을 때,

...
catch (SQLException e) {
     //h2 db
     if (e.getErrorCode() == 23505) {
     throw new MyDuplicateKeyException(e);
     }
 	throw new MyDbException(e);
...

이 코드를 통해 23505 에러 코드일 때 키 중복을 알 수 있고, 그것을 MyDuplicateKeyException으로 변환해서 던질 수 있다.

 

이렇게 SQL ErrorCode로 데이터베이스에 어떤 오류가 있는지 확인할 수 있다. 그리고 SQLException을 사용하지 않고 MyDuplicateKeyException으로 변환하여 서비스 계층으로 던짐으로써 서비스 계층의 순수성을 유지할 수 있다.


스프링 예외 추상화

스프링은 앞에서 말한 문제를 해결 하기 위해 데이터 접근과 관련된 예외를 추상화해서 제공한다. 그리고 각각의 예외들은 런타임 예외이면서 특정 기술에 종속적이지 않게 설계되어 있다. 그래서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다. 

예외의 최고 상위는 org.springframework.dao.DataAccessException이고, 런타임 예외를 상속받는다. 그리고 이 예외는 크게 두가지로 NonTransient, Transient로 구분된다.

  • Transient: 일시적이다. 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.
    • 데이터베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수 있다.
  • NonTransient: 일시적이지 않다. 같은 SQL을 그래도 반복해서 실행하면 실패한다.
    • SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.

토이 프로젝트에 적용

/**
 * SQLExceptionTranslator 추가
 */
@Slf4j
@Repository
public class MemberRepositoryWithDBV4_2 implements MemberRepository{

    private final DataSource dataSource;
    private final SQLExceptionTranslator exTranslator;
    private static long sequence = 0L;

    public MemberRepositoryWithDBV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
    }

    public Member save(Member member){
        String sql = "insert into member(id, register_date, name, loginid, password, nickname, point) values (?, ?, ?, ?, ?, ?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setLong(1, ++sequence);
            pstmt.setDate(2, Date.valueOf(LocalDate.now()));
            pstmt.setString(3, member.getName());
            pstmt.setString(4, member.getLoginId());
            pstmt.setString(5, member.getPassword());
            pstmt.setString(6, member.getNickname());
            pstmt.setInt(7, member.getPoint());

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

save 이외에 다른 메서드들도 SQLErrorCodeSQLExceptionTranslator를 적용했다.

이렇게 스프링의 예외 추상화를 이용하여 순수한 서비스 계층을 유지할 수 있다.


JDBC 반복 문제 해결 - JdbcTemplate

커넥션 조회, 커넥션 동기화, PreparedStatement 생성과 파라미터 바인딩, 쿼리 실행, 결과 바인딩 등이 각 메서드마다 계속 반복되었다. 이런 반복을 효과적으로 처리하는 방법은 템플릿 콜백 패턴이다.

스프링은 JDBC의 반복 문제를 해결하기 위해 JdbcTemplate를 제공한다. 

 

토이프로젝트에 적용

/**
 * JdbcTemplate 사용
 */
@Slf4j
public class MemberRepositoryWithDBV5 implements MemberRepository{

    private final JdbcTemplate template;

    private static long sequence = 0L;

    public MemberRepositoryWithDBV5(DataSource dataSource) {
        template = new JdbcTemplate(dataSource);
    }
    @Override
    public Member save(Member member){
        String sql = "insert into member(id, register_date, name, loginid, password, nickname, point) values (?, ?, ?, ?, ?, ?, ?)";
        template.update(sql, ++sequence, Date.valueOf(LocalDate.now()), member.getName(), member.getLoginId(), member.getPassword(), member.getNickname(), member.getPoint());
        return member;
    }
    @Override
    public Member findById(Long id) {
        String sql = "select * from member where id = ?";
        return template.queryForObject(sql, memberRowMapper(), id);
    }
    @Override
    public List<Member> findAllMember() {
        String sql = "select * from member";
        return template.queryForObject(sql, memberListRowMapper());
    }
    @Override
    public String findByLoginId(String loginId) {
        String sql = "select name from member where loginId = ?";
        return template.queryForObject(sql, String.class, loginId);
    }
    @Override
    public Member findByNickname(String nickname) {
        String sql = "select * from member where nickname = ?";
        return template.queryForObject(sql, memberRowMapper(), nickname);
    }
    @Override
    public void updatePoint(String nickname, int point)  {
        String sql = "update member set point = ? where nickname = ?";
        template.update(sql, point, nickname);
    }
    @Override
    public void delete(Long id)  {
        String sql = "delete from member where id = ?";
        template.update(sql, id);
    }

    public void deleteByNickname(String nickname) {
        String sql = "delete from member where nickname = ?";
        template.update(sql,nickname);
    }

    private RowMapper<Member> memberRowMapper(){
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            member.setRegisterDate(rs.getDate("register_Date").toLocalDate());
            member.setLoginId(rs.getString("loginId"));
            member.setPassword(rs.getString("password"));
            member.setPoint(rs.getInt("point"));
            member.setNickname(rs.getString("nickname"));
            return member;
        };
    }

    private RowMapper<List> memberListRowMapper() {
        return (rs, rowNum) -> {
            List<Member> allMember = new ArrayList<>();
            while(rs.next()) {
                Member member = new Member();
                member.setId(rs.getLong("id"));
                member.setName(rs.getString("name"));
                member.setRegisterDate(rs.getDate("register_Date").toLocalDate());
                member.setLoginId(rs.getString("loginId"));
                member.setPassword(rs.getString("password"));
                allMember.add(member);
            }
            return allMember;
        };
    }
}

 

JdbcTemplate로 이전에 있었던 대부분의 반복을 해결할 수 있다. 게다가 트랜잭션을 위한 커넥션 동기화와 스프링 예외 변환기도 자동으로 실행해준다!

template.queryForObject로 select문의 파라미터를 바인딩할 수 있고, 실행 가능하다. 그리고 template.update()로 update와 delete문의 파라미터를 바인딩할 수 있고, 실행 가능하다.

728x90