관리 메뉴

나구리의 개발공부기록

스프링과 문제 해결 - 예외처리 및 반복, 체크 예외와 인터페이스, 런타임 예외 적용, 데이터 접근 예외 직접 만들기, 스프링 예외 추상화 이해, JDBC 반복 문제 해결 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 1편 - 데이터 접근 핵심 원리

스프링과 문제 해결 - 예외처리 및 반복, 체크 예외와 인터페이스, 런타임 예외 적용, 데이터 접근 예외 직접 만들기, 스프링 예외 추상화 이해, JDBC 반복 문제 해결

소소한나구리 2024. 9. 16. 18:12

  출처 : 인프런 - 스프링 DB 1편 데이터 접근 핵심 원리 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

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


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

  • 서비스 계층은 가급적 특정 구현 기술에 의존하지 않고 순수하게 유지해야 하는데, 이렇게 하려면 예외에 대한 의존도 함께 해결해야함
  • 서비스가 처리할 수 없는 SQLException에 대한 의존을 제거하기 위해서 체크 예외인 SQLException 체크예외를 런타임 예외로 전환해서 서비스 게층에 던지면 서비스 계층이 해당 예외를 무시할 수 있기 때문에 특정 구현 기술에 의존하는 부분을 제거하고 서비스 게층을 순수하게 유지할 수 있음

1) 인터페이스 도입

  • 리포지토리에 인터페이스를 도입하여 MemberService가 MemberRepository라는 인터페이스에만 의존하도록 변경
  • 이러면 구현 기술을 변경하고 싶을 때 DI를 사용해서 MemberService 코드의 변경 없이 구현 기술을 변경할 수 있음

 

(1) MemberRepository 인터페이스

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

2) 체크 예외와 인터페이스

  • 기존에도 인터페이스를 사용했으면 될 것 같은데 만들지 않고 직접 구현했던 이유도 SQLException이 체크 예외였기 때문
  • 체크 예외를 사용하려면 인터페이스에도 해당 체크 예외가 선언 되어 있어야 함

(1) 체크 예외 코드에 인터페이스 도입시 문제점 - 구현 클래스

  • 인터페이스의 구현체가 체크 예외를 던지려면 인터페이스 메서드에 먼저 체크 예외를 던지는 부분이 선언되어 있어야 구현 클래스의 메서드도 체크 예외를 던질 수 있음
  • MemberRepositoryV3의 메서드에 throws SQLException을 하려면 MemberRepositoryEx 인터페이스의 메서드에도 throws SQLException이 필요하다는 뜻
  • 구현 클래스에서 선언할 수 있는 예외는 부모 타입에서 던진 예외와 같거나 하위 타입 이여야 하므로 인터페이스에서 throws Exception을 선언하면 구현 클래스 메서드에 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;
}

// MemberRepositoryEx를 구현한 MemberRepositoryV3 클래스
@Slf4j
public class MemberRepositoryV3 implements MemberRepositoryEx {
    
    // ...

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";
    
    // ... 이하 코드들
}

 

(2) 특정 기술에 종속되는 인터페이스

  • 구현 기술을 쉽게 변경하기 위해서 인터페이스를 도입하더라도 SQLException과 괕은 특정 구현 기술에 종속적인 체크 예외를 사용하게 되면 인터페이스에도 해당 예외를 포함해야 하는데, 이것은 구현하고자 했던 순수한 인터페이스가 아니라 JDBC 기술에 종속적인 인터페이스가 되어버림
  • 인터페이스를 만드는 목적은 구현체를 쉽게 변경하기 위함인데 이미 인터페이스가 특정 구현 기술에 종속되어 버렸으므로 향후 JDBC가 아닌 다른 기술로 변경한다면 인터페이스 자체를 변경해야함

(3) 런타임 예외와 인터페이스

  • 런타임 예외는 이런부분에서 자유로워 인터페이스에 런타임 예외를 따로 선언하지 않아도 되므로 인터페이스가 특정 기술에 종속적일 필요가 없음

2. 런타임 예외 적용

  • 작성한 MemberRepository 인터페이스를 적용해보기

1) 적용 해보기

(1) MyDbException

  • RuntimeException을 상속받았기 때문에 MyDbException은 런타임 예외가 됨
  • 여러 생성자를 생성할 수 있지만 여기서는 기본생성자, 메세지를 담을 수 있는 생성자, 메세지 및 원인을 담을 수 있는 생성자, 원인을 담을 수 있는 생성자 이렇게 4개를 선언
// RuntimeException을 상속받은 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);
    }
}

 

(2) MemberRepositoryV4_1

  • MemberRespository 인터페이스를 구현
  • 해당 코드의 핵심은 SQLException이라는 체크 예외를 MyDbException 이라는 런타임 예외로 변환해서 던지는 부분
package hello.jdbc.repository;

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

    // ... 코드 동일
    
    @Override
    public Member save(Member member) {
    
    // ... 코드 동일
    
        } catch (SQLException e) {
            throw new MyDbException(e);
            
    // ... 코드 동일

    @Override
    public Member findById(String memberId) {
    
    // ... 코드 동일
    
        } catch (SQLException e) {
            throw new MyDbException(e);
            
    // ... 코드 동일

    @Override
    public void update(String memberId, int money) {

    // ... 코드 동일
    
        } catch (SQLException e) {
            throw new MyDbException(e);
            
    // ... 코드 동일

    @Override
    public void delete(String memberId) {

    // ... 코드 동일
    
        } catch (SQLException e) {
            throw new MyDbException(e);

    // ... 코드 동일
   
}

 

예외 변환

  • 예외 변환 코드를 보면 throw new MyDbException(e); 처럼 기존 예외를 생성자를 통해서 포함하고 있는데, 원인이 되는 예외를 내부에 포함할 수 있도록 꼭 이렇게 작성해 줘야만 예외를 출력했을 때 원인이 되는 기존 예외도 함께 확인할 수 있음
  • MyDbException이 내부에 SQLException을 포함하고 있다고 이해하면 됨
  • throw new MyDbException(); 처럼 기존 예외를 무시하고 작성하면 절대 안됨
  • 생성자에 기존예외를 포함하지 않고 새로운 예외만 던지게 되면 MyDbException은 내부에 원인이되는 예외를 포함하지 않으므로 예외를 스택 트레이스를 통해 출력하면 기존에 원인이 되는 부분을 확인할 수 없음
  • 만약 SQLException에서 문법 오류가 발생했다면 그 부분을 확인할 방법이 없게 됨
  • 기존예외를 포함하지 않으면 장애가 발생하고 로그에서 진짜 원인이 남지 않는 심각한 문제가 발생할 수 있으므로 예외를 변환할 때는 기존 예외를 꼭 포함해야 함 -> 중요해서 계속 강조 중

(3) MemberServiceV4

  • MemberRespository 인터페이스에 의존하도록 코드를 변경
  • 각 메서드들에 throws SQLException 부분이 제거되어 드디어 순수 자바 코드들만 남게 됨
package hello.jdbc.service;

/**
 * 예외 누수 문제 해결
 * MemberRepository 인터페이스 의존 -> SQLException 제거
 */
@Slf4j
public class MemberServiceV4 {

    // 인터페이스에 의존
    private final MemberRepository memberRepository;

    public MemberServiceV4(MemberRepository memberRepository) {
    // ... 코드 동일
    
    @Transactional  // 트랜잭션 적용
    public void accountTransfer(String fromId, String toId, int money) {
    // ... 코드 동일

    private void bizLogic(String fromId, String toId, int money) {
    // ... 코드 동일
}

 

(4) MemberServiceV4Test

  • 의존관계를 MemberRespository 인터페이스와 MemberServiceV4로 변경하고 Bean의 리포지토리 구현체를 MemberRepositoryV4_1로 변경하고 테스트 메서드들에 throws SQLException 제거됨
  • 작성 후 테스트를 진행해보면 정상적으로 통과 됨
package hello.jdbc.service;

/**
 * 예외 누수 문제 해결
 * MemberRepository 인터페이스 의존 -> SQLException 제거
 */
@Slf4j
@SpringBootTest  // 스프링 컨테이너를 생성 -> 빈을 등록해서 테스트
class MemberServiceV4Test {
    // ... 코드 동일
    
    // MemberRepository 인터페이스와 MemberServiceV4 의존관계 등록
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private MemberServiceV4 memberService;

    // 빈으로 등록
    @TestConfiguration
    static class TestConfig {

    // ... 코드 동일

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

        @Bean
        MemberServiceV4 memberService() {
            return new MemberServiceV4(memberRepository());
        }
    }
 
    // ... 코드 동일

    @Test
    @DisplayName("정상 이체")   // 테스트 이름
    void accountTransfer() {
    // ... 코드 동일

    }

    @Test
    @DisplayName("이체중 예외 발생")   // 테스트 이름
    void accountTransferEx() {
    // ... 코드 동일

}

2) 정리

  • 체크 예외를 런타임 예외로 변환하면서 인터페이스와 서비스 계층의 순수성을 유지할 수 있게 되었으며 향후 JDBC에서 다른 구현 기술로 변경하더라도 서비스 계층의 코드를 변경하지 않고 유지할 수 있음

남은 문제

  • 리포지토리에서 넘어오는 특정한 예외의 경우 복구를 시도할 수 있는데 지금 방식은 항성 MyDbException 이라는 예외만 넘어오기 때문에 예외를 구분할 수 없는 단점이 있음

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

1) 특정 예외 코드를 복구하는 예시

  • 회원가입시 DB에 이미 같은 ID가 있으면 ID 뒤에 임의의 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정 -> 특정 예외를 복구 하는 예시
  • 데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다, 데이터 베이스는 오류코드를 반환하고 이 오류코드를 받은 JDBC 드라이버는 SQLException을 던지고 SQLExeption에는 데이터베이스가 제공하는 errorCode라는 것이 들어있음

(1) 데이터베이스의 키 중복 오류 코드

e.getErrorCode() == 23505
  • SQLException 내부에 들어있는 errorCode를 활용하면 데이터베이스에서 어떤 문제가 발생했는지 확인할 수 있는데, H2 데이터 베이스의 경우 23505는 키 중복 오류, 42000은 SQL 문법 오류 등으로 에러 코드들이 정의 되어 있음
  • 같은 오류여도 각각의 데이터베이스마다 정의된 오류 코드가 다르기 때문에 오류코드를 사용할 때는 각 데이터베이스의 메뉴얼을 확인해야 함
  • 키 중복의 오류코드의 경우 H2 데이터베이스는 23505, MySQL은 1062임
  • H2 데이터베이스 오류코드 참고 : https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html

2) 적용

  • 서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 새로운 ID를 만들어서 다시 저장을 시도할 수 있음
  • 이러한 과정이 예외를 확인해서 복구하는 과정임
  • 리포지토리는 SQLException을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류 코드를 확인해서 키 중복 오류(23505)인 경우 새로운 ID를 만들어서 저장하면 되는데, 서비스 계층이 SQLException이라는 JDBC 기술에 의존하게 되면서 또 서비스 계층의 순수성이 무너지게 됨
  • 이런 문제를 해결하기 위해 리포지토리에서 예외를 변환해서 던지면 해결됨

(1) MyDuplicateKeyException

  • 기존에 사용했던 MyDbException을 상속받아서 DB에서 발생한 오류임을 알 수 있도록 카테고리화 할 수 있음(데이터 베이스 예외라는 계층을 만든 것)
  • 이름을 MyDuplicateKeyException이라는 이름을 지었으므로 이 예외는 데이터 중복의 경우에만 던져져야 함
  • 이 예외는 직접 만든 것이기 때문에 JDBC나 JPA같은 특정 기술에 종속적이지 않으므로 이 예외를 사용하더라도 서비스 계층의 순수성을 유지할 수 있으며 DB를 다른 기술로 바꿔도 이 예외는 유지할 수 있음
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);
    }
}

 

(2) ExTranslatorV1Test

  • 테스트 코드를 작성 후 실행한 뒤 로그를 확인해보면 중복 id로 저장했을 때 로그가 출력 되면서 retryId에 새로운 id가 출력됨
  • DB에서 확인해보면 retryId가 반영된 것을 확인할 수 있음

리포지토리 코드의 중요한 부분

  • e.getErrorCode() == 23505: 오류 코드가 키 중복 오류(23505)인 경우 MyDuplicatekeyException을 새로 만들어서 서비스 계층에 던짐
  • 나머지 예외는 기존에 만들었던 MyDbException을 던짐

서비스 코드의 중요한 부분

  • 저장을 시도할 때 리포지토리에서 MyDuplicateKeyException 예외가 올라오면 해당 예외를 잡고 generateNewId() 메서드를 호출하여 새로운 id를 생성 하여 다시 저장을 시도 -> 예외를 복구하는 부분
  • 만약 복구할 수 없는 예외(MyDbException)이면 로그를 남기고 다시 예외를 던짐
    • 지금 테스트 코드에서는 이부분에서 로그를 남기고 예외를 던졌지만 굳이 실무에서는 여기서 굳이 이렇게 잡을 필요가 없음
    • 어차피 복구할 수 없는 예외는 예외를 공통으로 처리하는 부분까지 전달이 되고 해당 예외를 공통으로 처리하는 곳에서 예외 로그를 남기는 것이 좋기 때문
    • 여기서는 다양하게 예외를 잡아서 처리할 수 있다는 예시를 보여주기위해 코드를 작성한 것
package hello.jdbc.exception.translator;

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

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


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

    @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 일때
                if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }
                throw new MyDbException(e);
            } finally {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(con);
            }
        }
    }
}

3) 정리

(1) 직접 복구

  • SQL ErrorCode로 데이터 베이스에 어떤 오류가 있는지 확인이 가능함
  • 예외 변환을 통해 SQLException을 특정 기술에 의존하지 않는 직접 만든 예외인 MyDuplicateKeyException으로 변환한 덕분에 서비스 계층이 특정 기술에 의존하지 않고 순수성을 유지한채 문제를 복구 할 수 있음

(2) 남은 문제

  • 그러나 SQL ErrorCode는 각각의 데이터베이스 마다 다르기 때문에 결과적으로 DB가 바뀔때마다 ErrorCode도 모두 변경해야 함
  • 키 중복 문제뿐 아니라, 락이 걸린경우, SQL문법 오류 등등 수십 수백가지의 오류 코드가 존재하며 DB마다 또 오류코드가 다름
  • 이 모든 상황에 맞는 예외를 지금처럼 다 만들기에는 매우 어려운 일인데, 이부분을 스프링이 해결해줌

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

1) 스프링 데이터 접근 예외 계층

스프링 데이터 접근 예외 계층 일부 - 단순화 하기위해 일부 생략

  • 스프링은 데이터 접근 계층에 대한 수십가지 예외를 정리해서 일관된 예외 계층을 제공함
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있으므로 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 어떤 DB기술을 사용하더라도 순수성을 유지할 수 있음
  • JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공해주는 예외로 변환해 주는 역할도 스프링이 제공함
  • 위 이미지는 스프링 데이터 접근 예외 계층 중 일부만 표현 한 것
  • 예외의 최고 상위는 org.springframework.dao.DataAccessException 이며 런타임 예외를 상속 받았기 때문에 스프링이 제공하는 데이터 접근 계층 예외는 모두 런타임 예외임
  • DataAccessException은 크게 NonTransient예외와 Transient예외가 있음
    • Transient: 일시적 이라는 뜻 -> 해당 예외 및 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있음
    • 쿼리 타임아웃, 락과 관련된 오류들 등은 데이터베이스 상태가 좋아지거나 락이 풀리게 된 후 다시 시도하면 성공 결과를 얻을 수 있음
    • NonTransient: 일시적이지 않다는 뜻이며 같은 SQL을 그대로 반복하면 무조건 실패함
    • SQL 문법 오류, 데이터베이스 제약조건 위배 등

** 참고

  • 스프링 메뉴얼에 모든 예외가 정리되어 있지는 않기 때문에 IDE에서 코드를 직접 검색해서 확인해 보는 것이 필요함

2)  SpringExceptionTranslatorTest - 스프링이 제공하는 예외 변환기 적용 테스트

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

(1) sqlExceptionErrorCode() 메서드

  • SQL ErrorCode를 직접 확인하는 방법
  • 이렇게 데이터베이스마다 다른 오류코드를 확인하고 스프링의 예외 체계에 맞추어 예외를 직접 변환하는 것은 현실성이 없음

(2) exceptionTranslator() 메서드

  • new SQLErrorCodeSQLExceptionTranslator() -> 스프링이 제공하는 SQL 예외 변환기 생성
  • translate() 메서드의 첫번째 파라미터는 읽을 수 있는 설명, 두번째는 실행한 sql, 마지막은 발생된 SQLException을 전달하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해줌
  • 예제에서는 SQL 문법이 잘못되었으므로 BadSqlGrammarException을 반환하는 것을 확인할 수 있음
  • 반환된 resultEx의 타입은 최상위 타입인 DataAccessException 이지만 실제로는 BadSqlGrammerException 예외가 반환됨
package hello.jdbc.exception.translator;

@Slf4j
public class SpringExceptionTranslatorTest {

    DataSource dataSource;

    @BeforeEach
    void init() {
        dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }

    @Test
    void sqlExceptionErrorCode() {
        String sql = "select bad grammar";  // 문법이 잘못된 SQL 작성

        try {
            Connection con = dataSource.getConnection();
            PreparedStatement stmt = con.prepareStatement(sql);
            stmt.executeUpdate();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);
            int errorCode = e.getErrorCode();
            log.info("errorCode: {}", errorCode);
            log.info("error", e);
        }
    }

    @Test
    void exceptionTranslator() {

        String sql = "select bad grammar";

        try {
            Connection con = dataSource.getConnection();
            PreparedStatement stmt = con.prepareStatement(sql);
            stmt.executeUpdate();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);
            SQLErrorCodeSQLExceptionTranslator exTranslator =
                                    new SQLErrorCodeSQLExceptionTranslator();

            DataAccessException resultEx = exTranslator.translate("select", sql, e);
            log.info("resultEx", resultEx);
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

 

(3) sql-error-codes.xml

  • 각각의 DB마다 다른 SQL ErrorCode를 스프링이 고려해서 변환할 수 있이유는 sql-error-codes.xml이라는 파일에 모두 정의되어있기 때문임
  • 스프링 SQL 예외 변환기가 SQL ErrorCode를 해당 파일에 대입해서 어떤 스프링 데이터 접근 예외로 전환해야할지를 찾아서 반환함
  • H2, MySQL,Maria DB, 오라클, MS-SQL, PostgreSQL 등 우리가 사용하는 대부분 관계형 데이터베이스를 지원하고 있음
<beans xmlns="http://www.springframework.org/schema/beans"
	// ...
	<bean id="DB2" name="Db2" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductName">
			<value>DB2*</value>
		</property>
	// ...
	</bean>

	<bean id="Derby" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductName">
			<value>Apache Derby</value>
		</property>
	// ...

	</bean>

	<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="badSqlGrammarCodes">
			<value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
		</property>
        // ...
	</bean>

	<bean id="HDB" name="Hana" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductNames">
			<list>
				<value>SAP HANA</value>
				<value>SAP DB</value>
			</list>
		</property>
	// ...
	</bean>

	<bean id="HSQL" name="Hsql" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductName">
			<value>HSQL Database Engine</value>
		</property>
	// ...
	</bean>

	<bean id="Informix" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductName">
			<value>Informix Dynamic Server</value>
		</property>
	// ...
	</bean>

	<bean id="MS-SQL" name="SqlServer" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductName">
			<value>Microsoft SQL Server</value>
		</property>
		<property name="badSqlGrammarCodes">
			<value>156,170,207,208,209</value>
		</property>
		<property name="permissionDeniedCodes">
			<value>229</value>
		</property>
	// ...
	</bean>

	<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductNames">
			<list>
				<value>MySQL</value>
				<value>MariaDB</value>
			</list>
		</property>
	// ...
	</bean>

	<bean id="Oracle" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="badSqlGrammarCodes">
			<value>900,903,904,917,936,942,17006,6550</value>
		</property>
	// ...
	</bean>

	<bean id="PostgreSQL" name="Postgres" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="useSqlStateForTranslation">
			<value>true</value>
		</property>
	// ...
	</bean>

	<bean id="Sybase" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductNames">
			<list>
				<value>Sybase SQL Server</value>
				<value>Adaptive Server Enterprise</value>
				<value>ASE</value>  <!-- name as returned by jTDS driver -->
				<value>SQL Server</value>
				<value>sql server</value>  <!-- name as returned by jTDS driver -->
			</list>
	// ...
	</bean>

</beans>

3) 정리

  • 스프링은 데이터 접근 계층에 대한 일관된 예외 추상화를 제공함
  • 스프링이 제공하는 예외 변환기를 통해서 SQLException의 ErrorCode에 맞는 적절한 스프링 데이터 접근 예외로 변환해줌
  • 만약 서비스, 컨트롤러 계층에서 예외 처리가 필요하면 특정 기술에 종속적인 SQLException 같은 예외를 직접 사용하는 것이 아니라 스프링이 제공하는 데이터 접근 예외를 사용하면 특정 DB 기술에 종속되지 않으므로 JDBC에서 JPA 같은 기술로 변경되어도 예외로 인한 변경을 최소화 할 수 있음
  • 물론 스프링이 제공하는 예외를 사용하기 때문에 스프링에 대한 기술 종속성은 발생하지만 스프링에 대한 기술 종속성까지 완전 제거하려면 예외를 모두 직접 정의하고 예외 변환도 직접해야하는데, 실용적인 방법은 아니라고 판단되며, 현재는 스프링이 아닌 다른 기술이 예외 추상화를 이정도까지 지원해주는지에 대한 의문도 존재함

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

1) MemberRepositoryV4_2

  • SQLExceptionTranslator 인터페이스 선언 후 생성자로 구현체를 구현
  • 여러 구현체가 있지만 더 들어가면 복잡하므로 지금은 에러코드를 기반으로 처리하는 SQLErrorCodeSQLExceptionTranslator로 구현
  • 구현 시 db정보등이 필요하므로 dataSource를 받아야 함
  • 각 메서드의 catch 부분에 throw exTranslator.translate("save", sql, e); 처럼 예외변환기를 적용
package hello.jdbc.repository;

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

    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) {
    // ... 코드 동일
        } catch (SQLException e) {
       
            // 스프링이 제공하는 예외 변환기 적용
            // 반환하는게 예외이기 때문에 바로 throw로 던질수 있음
            throw exTranslator.translate("save", sql, e);
            
    // ... 코드 동일

    @Override
    public Member findById(String memberId) {
    // ... 코드 동일

        } catch (SQLException e) {
            throw exTranslator.translate("findById", sql, e);
    // ... 코드 동일
    
    @Override
    public void update(String memberId, int money) {
    // ... 코드 동일
    
        } catch (SQLException e) {
            throw exTranslator.translate("update", sql, e);
        } finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
    // ... 코드 동일

    @Override
    public void delete(String memberId) {
    // ... 코드 동일
        } catch (SQLException e) {
            throw exTranslator.translate("delete", sql, e);
    // ... 코드 동일

}

2) MemberServiceV4Test  - @Bean 수정

  • 기존 작성한 MemberServiceV4Test에서 @Bean의 구현체만 MemberRepositoryV4_2(dataSource)로 변경 -> 이것이 인터페이스의 장점
  • 리포지토리의 빈을 변경하고, MemberRepositoryV4_2의 sql코드에서 의도적으로 sql구문을 오류 낸뒤 테스트를 진행 후 로그를 확인해보면 스프링이 제공하는 구문 오류인 Bad SQL grammar로 변경되어 오류 트레이스가 출력된 것을 확인할 수 있음
// 리포지토리를 예외 변환기를 적용한 MemberRepositoryV4_2로 변경
@Bean
MemberRepository memberRepository() {
//  return new MemberRepositoryV4_1(dataSource);
    return new MemberRepositoryV4_2(dataSource);
}

SQL SyntaxErrorException -> bad SQL grammar 예외로 변경된 것을 확인할 수 있는 로그

3) 정리

  • 스프링이 예외를 추상화해준 덕분에 서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않게 깔끔하게 정리 되었음
  • DI를 제대로 활용하여 서비스 계층은 특정 구현 기술이 변경되어도 그대로 유지할 수 있게 되었음(스프링에는 종속적임)
  • 추가로 서비스 계층에서 예외를 잡아서 복구해야 하는 경우 예외가 스프링이 제공하는 데이터 접근 예외로 변경 되어서 서비스 계층으로 넘어오기 때문에 필요한 경우 예외를 catch로 잡아서 복구하면 됨

6. JDBC 반복 문제 해결 - JdbcTemplate

  • 지금까지 서비스 계층의 순수함을 유지하기위해 수많은 노력을 한 덕분에 서비스 계층의 순수함을 유지하게 되었음
  • 이제는 리포지토리에서 JDBC를 사용하기 때문에 발생하는 반복문제를 해결

1) JDBC 반복 문제

(1) 반복되는 부분들

  • 커넥션 조회, 커넥션 동기화
  • PreparedStatement 생성 및 파라미터 바인딩
  • 쿼리 실행
  • 결과 바인딩
  • 예외 발생시 스프링 예외 변환기 실행
  • 리소스 종료

(2) 템플릿 콜백 패턴

  • 리포지토리의 각각의 메서드를 보면 상당히 많은 부분이 반복되는데, 이런 반복을 효과적으로 처리하는 방법이 바로 템플릿 콜백 패턴임 -> 스프링 고급편에서 자세히 다룸
  • 스프링 JDBC의 반복 문제를 해결하기 위해 JdbcTemplate 이라는 템플릿을 제공함
  • JdbcTemplate에 대한 자세한 사용법은 스프링 DB2편에서 설명할 예정, 여기서는 전체 구조와 이 기능으로 반복 코드를 제거할 수 있다는 것 정도로만 이해하면 됨

2) MemberRepositoryV5 - JdbcTemplate 적용

  • JdbcTemplate를 적용하면서 반복되었던 엄청나게 긴 코드들이 대부분 해결되었음
  • 그 뿐만 아니라 지금까지 학습했던 트랜잭션을 위한 커넥션 동기화는 물론, 예외 발생시 스프링 예외 변환기도 자동으로 실행해줌
  • 기본적인 틀은 전부 제공되고 sql, 결과를 매핑하는 부분만 바꿔서 입력해주면 됨
  • 리포지토리 코드를 작성 후 테스트는 MemberServiceV4Test에서 @Bean만 return new MemberRepositoryV5(dataSource);로만 변경해서 등록 후 테스트를 해보면 코드들이 정상적으로 동작하는 것을 확인할 수 있음
package hello.jdbc.repository;

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

    // JdbcTemplate 생성
    private final JdbcTemplate template;
    public MemberRepositoryV5(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";
        // 템플릿 적용으로 커넥션생성, 실행, 예외 변환, 리소스 종료.. 등등을 전부다 해줌
        // template.update 도 executeUpdate 와 마찬가지로 실행개수를 int 타입으로 반환함
        template.update(sql, member.getMemberId(), member.getMoney());
        return member;
    }

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        // 한건 조회하는 것은 template.queryForObject
        // 매핑정보가 필요해서 memberRowMapper() 메서드를 만들어서 해당 정보를 파라미터 정보로 입력
        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;
        };
    }

    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id=?";
        template.update(sql, money, memberId);
    }

    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id=?";
        template.update(sql, memberId);
    }

}

 

3) 총정리

(1) 서비스 계층의 순수성 

  • 트랜잭션 추상화 + 트랜잭션 AOP 덕분에 서비스 계층의 순수성을 최대한 유지하면서 서비스 계층에서 트랜잭션을 사용할 수 있음
  • 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서도 예외도 사용할 수 있음
  • 서비스 계층이 리포지토리 인터페이스에 의존한 덕분에 향후 리포지토리가 다른 구현 기술로 변경되어도 서비스 계층을 순수하게 유지할 수 있음

(2) JdbcTemplate 반복 코드 제거

  • 리포지토리에서 JDBC를 사용하는 반복 코드가 JdbcTemplate를 사용함으로써 대부분 제거됨