[스프링 DB 1편] - 스프링과 문제 해결

Chooooo·2023년 1월 19일
0

스프링 DB 1편

목록 보기
10/11
post-thumbnail

이 글은 강의 : 김영한님의 - "[스프링 DB 1편 - 데이터 접근 핵심 원리]"을 듣고 정리한 내용입니다. 😁😁


체크 예외와 인터페이스

서비스 계층은 가급적 특정 구현 기술에 의존하지 않고, 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야한다.

private void bizLogic(String fromId, String toId, int money) throws SQLException {
	Member fromMember = memberRepository.findById(fromId);
    Member toMember = memberRepository.findById(toId);
}

예를 들어 위의 코드를 볼 수 있다. 위는 MemberServiceV3_3 코드인데, 메서드에 throws SQLException이 있는 것을 확인할 수 있다. @Transcational을 이용해 트랜잭션 추상화로 DB 기술 의존성을 해결한 것으로 이해를 했으나, 사실은 SQLException이 남아있기 때문에 아직까지 DB 기술에서 자유롭지는 않다.

앞서 배웠던 예외들을 살펴보면 이 SQLException은 Service 계층에서 처리할 수 없는 문제다. 즉, 서비스 계층이 신경쓰지 않아도 되는 문제다. 따라서 Repository 계층에서 SQLException을 런타임 예외로 바꿔서 던져주면 깔끔하게 DB 기술 의존성 문제가 해결된다.

인터페이스 도입

먼저 MemberRepository 인터페이스도 도입해서 구현 기술을 쉽게 변경할 수 있게 해보자.

DI를 잘 처리해주기 위해 Service 계층이 MemberRepository 인터페이스에 의존하고, 각 DB 기술은 인터페이스의 구현체를 구현하는 것으로 접근을 하려고 한다. 그런데 여기서 의문이 발생한다. 왜 아직까지 인터페이스를 만들고 있지 않았던 걸까?이유는 SQLException의 종속성 문제를 해결하지 못하고 있었기 때문이다.

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

SQLException이 해결되지 않는다면 인터페이스의 메서드에도 "throws SQLException"이 명시되어야 한다. 만약 인터페이스에서 이렇게 명시되어 있지 않다면 실제 구현체에서 'throws SQLException'이 들어간 메서드는 오버라이드 된 것이 아니라, 별개의 메서드가 만들어진다. 따라서 반드시 인터페이스에 'throws SQLException'을 해줘야한다. 그런데 이렇게 인터페이스 자체가 특정 DB 기술에 종속적인 인터페이스가 된다.

특정 기술에 종속적인 인터페이스

DB 구현 기술을 쉽게 변경하기 위해 인터페이스를 도입했다. 그렇지만 SQLException 같은 특정 구현 기술에 종속적인 체크 예외를 사용하게 되면, 인터페이스의 메서드에도 해당 예외가 포함되어야 한다. 즉, 기술 종속적인 인터페이스가 된다. 이 인터페이스는 향후 JDBC → JPA로 기술이 변경되면, 인터페이스 + 모든 구현체의 코드 수정이 필요해진다.

💨 런타임 예외와 인터페이스

SQLException은 체크 예외였기 때문에 각 메서드에 해당 예외 처리에 대한 부분이 명시되어야했다. 이 SQLException을 런타임 예외로 한번 감싸줄 경우, 인터페이스는 예외에서 자유로워지게 된다. 왜냐하면 런타임 예외는 처리 방법을 명시하지 않아도, 알아서 상위 객체로 던져주기 때문이다. 런타임 예외의 이런 성질을 사용하게 되면 DB 기술에 자유로운 인터페이스를 사용할 수 있게 된다.

🎃 런타임 예외는 이런 부분에서 자유롭다. → 인터페이스에 런타임 예외를 따로 선언하지 않아도 된다. 따라서 인터페이스가 특정 기술에 종속적이지 않게 된다.

코드 개선

🎈 MemberRepository 인터페이스를 만들어, 자유롭게 DI를 할 수 있도록 개선한다.
🎈 MemberRepository에서 발생하는 예외는 MyDbException(런타임 예외)로 감싸서 던져준다.

MemberRepository

public interface MemberRepository {

    Member save(Member member);
    Member findById(String memberId);
    void update(String memberId, int money);
    void delete(String memberId);

}
  • MemberRepository 인터페이스를 작성한다.
  • throws SQLException이 없는 것을 확인.

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);
    }
}
  • MyDbException을 만들어준다.
  • 런타임 예외로 만들어준다.

MemberRepositoryV4_1

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

    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {

        Connection conn = getConnection();
        pstmt = conn.prepareStatement(sql);
        pstmt.setString(1, memberId);

        rs = pstmt.executeQuery();
        log.info("Connection = {}, class = {}", conn,conn.getClass());

        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("error", e);
        // ***** 중요 포인트 *****
        throw new MyDbException(e);
    }finally {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(pstmt);
    }
}

🎈 MemberRepository 인터페이스를 구현하도록 한다.

  • 각 메서드를 @Override 처리한다.

🎈 SQLException을 잡아서 MyDbException으로 바꿔서 던져준다. (중요 포인트 !)

  • MyDbException은 런타임 예외이기 때문에 실제 메서드에 어떠한 Exception도 표현하지 않아도 된다.

  • MyDbException 생성 시, 반드시 기존 예외를 감싸서 던진다. 이렇게 해야 StackTrace에 정상적으로 로그가 출력된다.

  • 기존 예외를 무시하고 작성하면 절대 안된다 !

catch (SQLException e) {
	throw new MyDbException();
}

위 코드를 보면 new MyDbException()으로 해당 예외만 생성하고 기존에 있는 SQLException은 포합하지 않고 무시한다. → 따라서 MyDbException은 내부에 원인이 되는 다른 예외를 포함하지 않는다.
이렇게 원인이 되는 예외를 내부에 포함하지 않으면, 예외를 스택 트레이스를 통해 출력했을 때 기존에 원인이 되는 부분을 확인할 수 없다.

  • 만약 SQLException에서 문법 오류 발생했다면, 그 부분을 확인할 방법이 없게 된다...

MemberServiceV4

public class MemberServiceV4 {

    private final MemberRepository memberRepository;

    ...

    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);
        validate(toMember);
        memberRepository.update(toId, toMember.getMoney() + money);
    }
}

🎈 MemberServiceV4는 MemberRepository 인터페이스에 의존하도록 한다 !!
🎈 MemberRepository에서 런타임 예외를 던지기 때문에 기존 버전의 메서드에 존재하던 throws SQLException이 사라졌다.

  • Repository → Service로 Exception 누수가 해결 !

MemberServiceV4Test

@TestConfiguration
@RequiredArgsConstructor
static class TestConfig {

    private final DataSource dataSource;

    @Bean
    MemberRepository memberRepository() {
        return new MemberRepositoryV4_1(dataSource);
    }

    @Bean
    MemberServiceV4 memberServiceV4() {
        return new MemberServiceV4(memberRepository());
    }

}
  • MemberServiceV4는 다음과 같이 등록해주는 스프링 빈만 변경해준다.
  • H2 데이터베이스를 다시 실행하고 해당 테스트를 실행해보면 테스트가 모두 정상 동작하는 것을 확인할 수 있다 !

정리

🎃 체크 예외(SQLException)을 런타임 예외(MyDbException)으로 변경하면서, Repository 계층의 예외가 서비스 계층으로 누수되지 않도록 했다. 덕분에 Service 계층은 순수한 자바 코드로 작성할 수 있게 되었다.

🎃 Service 계층은 DB 기술에서 독립적으로 되었다. 따라서 향후 DB 기술에 따라 다른 Repository 구현체가 들어와도 Service 계층의 코드 변형은 없다. (인터페이스에 의존하기 때문 !)

남은 문제

Repository에서 넘어오는 특정한 예외들 중 일부는 Service 계층에서 복구를 시도해볼 수 있다. 예를 들어 Key값이 중복되어 발생하는 유니크 제약 조건 예외는 Service 계층에서 복구를 시도해볼 수 있다. 그런데 지금 방식은 MyDbException 예외만 넘어온다. 그렇기 때문에 예외를 구분할 수 없다는 단점이 있다.

  • 만약 특정 상황에서 발생하는 예외는 잡아서 복구하고 싶을 때, 이럴 때는 어떻게 처리할 수 있을까?

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

지금까지 위의 과정을 진행하면서 체크 예외를 런타임 예외로 감싸는 방법으로 서비스 계층으로 DB 예외가 누수되는 것을 방지했다. 그렇지만 DB 계층에서 발생할 수 있는 특정 예외들 중 일부는 서비스 계층에서 복구 시도를 해볼 수 있다.

예를 들어 회원 가입 시, DB에 이미 같은 ID가 있어 유니크 제약 조건에 걸려서 예외가 발생한다고 가정해보자. 이 때, 서비스 계층으로 예외가 올라오면 서비스 계층은 이 ID 뒤에 숫자를 붙여 새로운 ID를 만들어 다시 한번 저장을 시도해볼 수 있을 것이다. 즉, Repository 계층에서 발생할 수 있는 예외를 서비스 계층에서 복구를 해주는 것이다.

데이터 접근 예외 → Error 코드로 구분

데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다면, 데이터베이스는 오류 코드를 반환하고, 이 오류 코드를 받은 JDBC 드라이버는 SQLException 을 던진다. 그리고 SQLException 에는 데이터베이스가 제공하는 errorCode 라는 것이 들어있다.

  • 데이터베이스 오류 코드

JDBC를 예로 들어보자. JDBC는 DB에 접근했을 때 SQLException 예외를 발생시킨다. 이 때, SQLException에는 DB에서 어떤 문제 때문에 Exception이 발생했는지를 알려주는 Error 코드가 들어있다. 개발자는 이 Error 코드를 확인해서, 대처하기 원하는 예외에만 대응하는 Exception 클래스를 만들 수 있다.

  1. DB가 오류코드를 반환한다.
  2. JDBC 드라이버는 오류 코드를 바탕으로 SQLException을 만들어서 던진다.
  3. Repository는 SQLException의 ErrorCode를 확인해서 원하는 문제일 경우 런타임 예외로 감싸서 던져준다.

위와 같은 형식으로 예외 코드를 확인하고 이를 바탕으로 예외를 던져줄 수 있다. 이 때, Repository 계층에서 예외를 감싸서 던져주는 이유는 서비스 계층을 DB 접근 기술 종속으로부터 보호하기 위한 것이다. 체크 예외인 SQLException 전체를 서비스 계층으로 던지면, 서비스 계층은 다시 한번 DB 기술에 종속적으로 변하게 된다.

🎈 데이터베이스마다 에러 코드 다름 !

  • H2 데이터베이스의 중복 키는 23505, MySQL DB의 중복 키는 1062

한 가지 고려해야 할 부분은 같은 이유로 예외가 발생하더라도 각 DB마다 이를 알려주는 에러 코드는 다르다는 것이다. 위에서 볼 수 있듯이, 동일한 키 중복 오류가 발생해도 H2 DB와 MySQL DB가 보여주는 에러 코드는 다르다. 따라서, 어떤 DB를 쓰느냐에 따라서 예외 처리를 다르게 가져가야 한다.

지금까지 스프링의 추상화 패턴을 보면 알겠지만, DB마다 다른 예외 코드를 던져주고 이 예외를 감싸야한다는 점 때문에 스프링은 이 예외 코드들의 추상화를 나중에 처리해줄 것이다.

해당 과정 연습

  • 중복키 처리를 위한 런타임 예외를 하나 만든다.
  • 일부러 DB에서 중복키 에러가 발생하도록 한다.
  • 중복키 에러가 발생하면 그 예외를 런타임 예외로 전환해서 서비스 계층으로 던진다.
  • 서비스 계층은 중복키 런타임 예외를 잡아서 복구 시도를 해준다.

중복키 런타임 예외 생성

// 기존 MyDbException을 상속
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);
    }

    public MyDuplicateKeyException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
  • 중복키 런타임 예외 역시 계층적 구조. DBException 상속. 즉 런타임 예외

🎈 중복키 런타임 예외를 만든다.
🎈 기존에 만들었던 MyDbException(런타임 예외)를 상속받아 예외를 만든다.

  • 이렇게 예외를 만들면 RuntimeDBException이라는 의미있는 계층 구조가 생기기 때문!
  • 이렇게 만든 예외 계층 구조는 JDBC/JPA같은 DB 접근 기술에서 자유로워진다. → 따라서 서비스 계층에서도 자유롭게 사용할 수 있게 된다.

Repository 계층

@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) {
            
            // H2 DB Case // H2 DB의 중복키 에러 코드를 처리.
            
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        }finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(con);
        }
    }

🎃 H2 DB는 중복키 예외가 발생할 경우, '23505'라는 예외 코드를 보내준다.
R
🎃 Repository는 H2 DB가 주는 중복키 예외 에러 코드를 확인하면, MyDuplicatedException이라는 사용자 설정 런타임 예외로 감싸서 던져준다.

🎃 그렇지 않을 경우, MyDbException이라는 런타임 예외로 감싸서 던져준다. 이를 통해 DB 예외가 서비스 계층까지 누수되는 의존성 문제를 해결한다.

Service 계층

@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 계층은 두 가지 예외에 대한 catch를 처리한다.

  • MyDuplicatedKeyException이 발생하면, 이 예외를 잡아서 새로운 키를 만들어 Member를 다시 저장 시도한다.
  • MyDbException이 발생하면, 이 예외를 잡아서 단순히 Log만 출력해준다.

🎈 주의깊게 볼 장면은 두 가지

  • 사실 MyDbException은 Catch로 잡아주지 않아도 된다!!(catch MyDbException e) 잡지 않으면 자동으로 윗쪽 계층으로 던져진다. 여기서는 로그를 찍기 위해서 만들었다. → 이 계층에서 복구하지 않을 예외는 굳이 잡지 않아도 된다. 예외를 공통으로 처리해주는 부분까지 던지고, 그 부분에서 로그 처리를 해주는 것이 좋다.

  • catch는 여러 개를 사용해서 각각 다른 예외를 잡아서 처리할 수 있음을 보여준다.

테스트

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

🎈 동일한 키로 중복 저장을 한다.
1. DB에서 중복키 Exception이 발생한다.
2. Repository 계층에서 중복키 Exception을 catch해서 MyDuplicatedKeyException으로 바꿔서 서비스 계층으로 던진다.
3. Service 계층은 MyDuplicatedKeyException을 잡아서, 다른 Key값으로 Member를 다시 저장해준다. (서비스 계층에서 특정 예외 처리)

중복 키 에러를 잡아서 복구할 수 있다 !!

정리

  1. DB는 예외가 발생하면 예외 코드를 던진다. 그리고 JDBC Driver는 이 예외 코드를 받아서 SQL Exception에 포함시켜 던져준다.

  2. 개발자는 SQLException에 포함된 예외 코드를 확인해서 원하는 예외인 경우 런타임 예외로 감싸서 던져주면서, 서비스 계층이 DB 접근 기술에 의존하는 문제를 해결하고 더 나아가 원하는 예외만 잡아서 서비스 계층에서 복구를 시도할 수 있도록 해준다.

  3. 여러 Catch 문을 동시에 사용해서 다양한 예외에 대해 각각 다르게 처리해줄 수 있다.

  4. 사용자 예외를 계속 만들어야 한다면, 계층 구조로 형성하는 것을 시도해본다. MyDbException을 만들고, 그 아래에 MyDuplicatedKeyException을 만들어 하나의 의미를 가지는 계층 구조를 형성해본다.

  5. 현재 계층에서 처리할 수 없는 예외는 로그를 남기지 않고, 예외를 공통으로 처리하는 부분으로 던지는 것이 좋다.

그래도 아직까지도 한 가지 문제점이 남는다. 바로 DB마다 같은 에러를 가리켜도 서로 다른 에러 코드를 반환해준다는 점! → 이런 에러 코드들은 DB마다 매우 많은데, 개발자가 모든 DB의 모든 에러 코드에 대해 대응할 수 없다.

🎈 다행히도 스프링은 이런 예외 코드를 추상화해서 예외를 만들어주는 기능을 제공한다.

profile
back-end, 지속 성장 가능한 개발자를 향하여

0개의 댓글