본문 바로가기

스프링 (인프런)/토비의 스프링

4.2. 예외 전환

4.2.1. JDBC의 한계

JDBC는 자바를 이용해 DB에 접근하는 방법을

추상화된 API 형태로 정의해놓고,

각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다.

DB와 독립적으로 DB 접근 코드를 작성할 수 있게 도와주는 API다.

 

그러나 JDBC라고 아예 DB에 상관없이

일관적인 코드를 보장해주지는 못한다.

 

 

비표준 SQL

JDBC를 통해 입력하는 쿼리 문법이 표준인 것도 있지만,

특정 DB에 종속적인 비표준 문법도 있다.

해당 DB의 특별한 기능을 활용하기 위해 이러한 비표준 문법이 필요할 때가 있다.

 

보통은 DAO 내부 메서드에 미리 쿼리를 준비시켜두기 때문에

DAO에 비표준 문법 쿼리가 있었다면 다른 DB로 전환할 때

쿼리도 무조건 손을 봐줘야 한다.

 

따라서 표준 쿼리만 사용해서 DB에 독립적으로 만들든가

DB별로 DAO를 전부 작성해야 한다.

 

 

호환성 없는 SQLException의 DB 에러 정보

DB마다 고유한 비표준 쿼리 문법을 가지는 것처럼

DB마다 고유한 에러와 예외도 가진다.

 

그래서 JDBC는 각 에러를 깔끔하게 골라낼 수 없기 때문에

SQLException 하나로 퉁쳐버렸다.

 

catch (SQLException e) {
    if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
        throw new DuplicateUserIdException(e);
    else throw new RuntimeException(e);
}

>>> getErrorCode()를 통해 에러 코드를 받을 수는 있다.

문제는 DB 마다 에러 코드를 다르게 정의하기 때문에

위처럼 DB와 대응되는 클래스(MysqlErrorNumbers)를 가지고

에러 코드를 맞춰줘야 한다는 점이다.

 

만약 DB가 오라클로 변경된다면 ER_DUP_ENTRY라는,

동일한 엔트리가 있다는 에러 코드를 DB에서 다른 숫자로 넘겨줄 것이므로

저 if문은 무용지물이 된다.

 

 

 

4.2.2. DB 에러 코드 매핑을 통한 전환

SQLException에 담기는 DB 에러 정보가 DB마다 다르므로

가장 현실적인 해결책은 해당 DB의 에러 코드들을 직접 확인하는 것이다.

 

스프링에서는 SQLException에 포함되는 예외 상황들이 분류된

런타임 예외 클래스들이 정의되어 있다.

게다가 예외 클래스와 맞춰볼 수 있는 DB별 에러 코드를

에러 코드 매핑정보 테이블을 제공한다.

 

그리고 JdbcTemplate은 이러한 매핑 정보 테이블을 참고하여

update()와 같은 메서드에서 던지는 런타임 예외와

DB의 에러 코드를 연결시켜준다.

 

public int update(String sql, Object... args) throws DataAccessException {
    return this.update(sql, this.newArgPreparedStatementSetter(args));
}
public void add(User user) {
    jdbcTemplate.update("insert into users(id, name, password) values (?,?,?)",
            user.getId(), user.getName(), user.getPassword());
}

 

>>> add()에서 update()를 호출하고,

update()에서는 DataAccessException을 호출한다.

 

그리고 add()를 통해 테이블에 엔트리를 추가할 때 이미 동일한 ID의 엔트리가 있다면

DataAccessException의 서브 클래스인 DuplicateKeyException이 자동으로 던저진다.

그래서 만약 add()를 부른 쪽에서 이 예외를 처리하도록 넘겨주고 싶으면

throsw DuplicateKeyException을 메서드 선언부에 첨부해주면 된다.

 

public void add(User user) throws DuplicateUserIdException {
    try {
        jdbcTemplate.update("insert into users(id, name, password) values (?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    } catch (DuplicateKeyException exception) {
        throw new DuplicateUserIdException(exception);
    }
}

>>> 그런데 만약 DuplicateUserIdException과 같이 자신이 정의해둔 예외를 던지고 싶다면

위처럼 DuplicateKeyException을 받아서 전환하도록 코드를 작성하면 된다.

 

 

 

4.2.3. DAO 인터페이스와 DataAccessException 계층구조

4.2.2장에서 DataAccessException을 JDBC의 SQLException을

포장하기 위한 용도로만 소개했다.

하지만 DataAccessException은 이뿐만 아니라

MyBatis, JPA와 같은 데이터 액세스를 위한 표준 기술들에

독립적으로 예외를 발생시키는 데에도 활용된다.

 

 

DAO 인터페이스와 구현의 분리

우리가 처음에 UserDao를 만들 때 가장 먼저 분리한 것 중 하나는

데이터 액세스 로직과 데이터를 조작하는 로직이었다.

DAO를 사용하는 입장에서는 데이터 액세스 방법이 중요하지 않기 때문에

낮은 결합도를 위해 DataSource나 Connection에 관한 부분을 컨텍스트에 밀어넣었다.

 

public void add(User user) throws DuplicateUserIdException {
    try {
        jdbcTemplate.update("insert into users(id, name, password) values (?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    } catch (DuplicateKeyException exception) {
        throw new DuplicateUserIdException(exception);
    }
}

>>> 그런데 결국 가장 최종 버전인 UserDao에서도

JdbcTemplate이라는 JDBC의 클래스를 사용하고 있으니,

과연 UserDao가 데이터 액세스 로직에 완전히 독립적이라고 하기 어렵다.

 

따라서 정말로 UserDao를 데이터 액세스 로직에 독립적으로 만들기 위해서는

UserDao를 애초에 인터페이스로 정의하는 것이 옳다.

 

그런데 문제는 예외 선언에서 드러난다.

데이터 액세스 로직마다 동일한 기능에 대해 기대되는 예외가 다르기 때문이다.

 

public interface UserDao_Interface {
    public void add(User user);
}

>>> add()라는 메서드를 인터페이스에서 선언할 때 예외를 빼뒀다.

그러면 UserDao_JDBC에서 구현할 때에 갑자기 throws SQLException을 추가하는 것이 불가능해진다.

하물며 JDBC가 아니라 하이버네이트나 JPA에도 각각의 예외가 따로 존재한다.

 

만약 UserDao_JDBC에서 add()를 구현할 때

SQLException을 catch해서 런타임 예외로 전환해준다면 위 코드를 사용할 수는 있다.

( JDBC 이후의 데이터 액세스 기술들은 다행히도 런타임 예외를 사용한다 )

 

그러나 이와 같은 방법으로는 애플리케이션 예외를 무시하는 셈이 되는 데다가

시스템 레벨로는 예외에 대한 아무런 정보를 넘겨주지 못한다.

따라서 인터페이스로 구현한다는 선택지는 제외해야 한다...

 

 

데이터 액세스 예외 추상화와 DataAccessException 계층구조

다행히 스프링에서는 다양한 데이터 액세스 기술들의 공통적인 예외들뿐만 아니라

특정 기술들에서만 나타나는 예외까지 추상화해서

DataAccessException 계층구조에 정리해두었다.

( 여기서 계층구조라는 것은 DataAccessException을 슈퍼 클래스로 시작해서

서브 클래스로 뻗어나가면서 각 서브 클래스에서 해당 예외를 커버하고 있는 형태 )

 

예를 들어 데이터 액세스 기술을 부정확하게 사용할 경우

InvalidDataAccessResourceUsageException이 발생한다.

이 예외가 JDBC의 BadSqlGrammarException이나

하이버네이트의 HibernateQueryException 등에게

상속되도록 함으로써 여러 데이터 액세스 기술들을 공통적으로 포함할 수 있는 것이다.

 

혹은 자신이 사용하고 있는 데이터 액세스 기술에 정의되어 있지 않은

기능에 따른 예외를 직접 정의한 후 관련된 예외 수퍼 클래스를 상속하여 붙일 수도 있다.

 

또한 계층구조 내에 템플릿 메서드, DAO 메서드에서 직접 활용할 수 있는 예외도 정의되어 있다.

public User get(String id) {
    return jdbcTemplate.queryForObject("select * from users where id = ?",
            new Object[]{id},
            this.userRowMapper);
}

>>> 예를 들어 get()에서 호출하는 queryForObject()는

한 개의 오브젝트만 반환하는 쿼리에 사용되도록 정의되어 있다.

 

그런데 만약 두 개 이상 row가 쿼리 결과로 반환된다면

메서드 입장에서는 분명한 예외 상황이지만

JDBC에서는 예외를 발생시키지 못한다.

 

public class EmptyResultDataAccessException extends IncorrectResultSizeDataAccessException {
    public EmptyResultDataAccessException(int expectedSize) {
        super(expectedSize, 0);
    }

    public EmptyResultDataAccessException(String msg, int expectedSize) {
        super(msg, expectedSize, 0);
    }
}

>>> 이 때 DataAccessException의 계층 구조에 IncorrectResultSizeDataAccessException

미리 정의되어 있는 덕분에 queryForObject()에서는 이 예외를 상속하고 있는

EmptyResultDataAccessException을 일으킨다.

 

 

 

4.2.4. 기술에 독립적인 UserDao 만들기

인터페이스 적용

UserDao를 예시로 DAO 인터페이스를 제작하기로 한다.

public interface UserDao_Interface {
    void add(User user);
    void deleteAll();
    Integer getCount();
    User get(String id);
    List<User> getAll();
}

>>> 기존에 UserDao에서 만들었던 메서드를 그대로 썼다.

다만, setDataSource()는 제외되어 있다.

DataSource는 데이터 액세스 기술과 관련된 것이기 때문이다.

 

 

public class UserDao_JDBC implements UserDao_Interface

>>> 인터페이스를 구현하도록 선언하되,

구현 내용은 바뀔 것이 없다.

 

 

테스트 보완

public class StrategyTest {
    @Autowired
    private ApplicationContext context;

    @Autowired
    private UserDao_Interface userDao;

    private User user1;
    private User user2;
    private User user3;

>>> UserDao와 관련한 테스트를 진행할 때에는

UserDao_JDBC 등의 데이터 액세스 로직과 독립적일 수 있도록

변수 자체를 인터페이스로 선언하는 것이 좋다.

 

@Test ( expected = DataAccessException.class )
public void duplicateKey() {
    userDao.deleteAll();

    try {
        userDao.add(user1);
        userDao.add(user1);
    } catch (DataAccessException exception) {
        exception.printStackTrace();
        throw exception;
    }
}

>>> DataAccessException을 예상시켜두고 테스트를 돌려보면 성공이다.

 

 

>>> 테스트 중 printStackTrace()의 내용인데,

우리가 예상한 것은 DataAccessException이었지만

정확한 예외 클래스는 DuplicateKeyException이었다.

 

 

DataAccessException 활용 시 주의사항

언급했듯 스프링에서는 각 데이터 액세스 기술의 예외들을 최대한 포함하기 위해

추상화된 예외들을 DataAccessException의 계층 구조에 정리했다고 했다.

 

그리고 그 중 JDBC를 활용해서 ID가 중첩될 경우 DuplicateKeyException을 던진다고 했다.

그러면 하이버네이트나 JPA에서도 동일한 상황에서 DuplicateKeyException을 던질까?

그렇지 않다...

 

DataAccessException에 포함된다고 해서 동일한 상황에 동일한 예외를 가진다는 것이 아니다.

같은 계층 구조 안에서 나뉘게 되는 것이다.

실제로 하이버네이트든 JPA든 JDBC든 DataIntegrityViolationException을 상속하며

ID가 겹칠 경우의 예외들이 들어 있다.

 

그래서 DB 종류와 관계 없이 하나의 예외로 몰아주려면

두 가지 방법이 있다.

 

1. 수퍼 클래스인 DataIntegrityViolationException으로 catch 범위를 넓힌다.

>>> 확실하게 DB 독립적이기는 하나

예외를 통해 ID가 동일한 엔트리가 중첩됐다는 정보를 확실하게 전달하기가 어렵다.

 

2. DuplicateUserIdException과 같이 커스텀 예외를 제작한다.

>>> 각 데이터 액세스 기술의 DAO마다 DuplicateUserIdException을 던지도록

예외를 전환시켜줘야 한다.