본문 바로가기

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

4.1. 사라진 SQLException

4.1.1. 초난감 예외처리

public void deleteAll() throws SQLException {
    jdbcContext.executeSQL("delete from users");
}
public void deleteAll() {
    jdbcTemplate.update("delete from users");
}

>>> 우리가 직접 만든 JdbcContext의 executeSQL은 결국

 

 

public void executeSQL(String query) throws SQLException {
    workWithStatementStrategy(new StatementStrategy() {
        @Override
        public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
            PreparedStatement ps = c.prepareStatement(query);
            return ps;
        }
    });
}

>>> makePreparedStatement() 메서드를 호출하고,

 

 

PreparedStatement prepareStatement(String sql)
    throws SQLException;

>>> prepareStatement() 메서드에서 SQLException을 던진다.

 

 

이번에는 JdbcTemplate의 update() 메서드를 보겠다.

public int update(final String sql) throws DataAccessException {
    Assert.notNull(sql, "SQL must not be null");
    if (this.logger.isDebugEnabled()) {
        this.logger.debug("Executing SQL update [" + sql + "]");
    }

    class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
        UpdateStatementCallback() {
        }

        public Integer doInStatement(Statement stmt) throws SQLException {
            int rows = stmt.executeUpdate(sql);
            if (JdbcTemplate.access$1(JdbcTemplate.this).isDebugEnabled()) {
                JdbcTemplate.access$1(JdbcTemplate.this).debug("SQL update affected " + rows + " rows");
            }

            return rows;
        }

        public String getSql() {
            return sql;
        }
    }

    return (Integer)this.execute((StatementCallback)(new UpdateStatementCallback()));
}

>>> update()도 doInStatement() 메서드가 SQLException을 던진다.

 

 

그런데 다시 보면,

public void deleteAll() throws SQLException {
    jdbcContext.executeSQL("delete from users");
}
public void deleteAll() {
    jdbcTemplate.update("delete from users");
}

>>> JdbcTemplate을 호출하는 deleteAll() 쪽에서는

SQLException을 던지지 않는다.

 

왜일까?

 

 

예외 블랙홀

모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고

운영자 또는 개발자에게 분명하게 통보되어야 한다.

 

catch (SQLException e) { }

>>> 그런데 예외를 무작정 잡아 놓고 처리하지 않는다거나

 

catch (SQLException e) {
    e.printStackTrace();
}

>>> 콘솔에 예외 내용만 출력하는 것은 아무런 도움이 되지 않는다.

특히나 운영 서버에서는 콘솔을 모니터링하면서

예외 로그를 골라내기 쉽지 않다고 한다.

 

예외를 제대로 처리하지 못할 것 같으면

차라리 호출한 쪽에 throws SQLException하는 것이 낫다.

 

 

무의미하고 무책임한 throws

public void method1() throws Exception { }
public void method2() throws Exception { method1(); }
public void method3() throws Exception { method2(); }

>>> method1()에서 발생한 예외는

method2()를 거쳐 method3()으로 가는데,

Exception으로 예외를 전혀 명시하지 않은 채

무책임하게 예외를 넘겨주고만 있다.

 

 

 

4.1.2. 예외의 종류와 특징

자바에서 throw를 통해 발생시킬 수 있는 예외가

크게 세 가지 있다.

 

Error

java.lang.Error 클래스의 서브 클래스들이다.

애플리케이션이 아니라 시스템 레벨에서 발생하는 에러이기 때문에

catch하더라도 코드 레벨에서 대응할 수 있는 방법이 없다.

 

 

Exception과 체크 예외

java.lang.Exception과 그 서브 클래스들은 

애플리케이션 레벨에서 발생하는 예외다.

 

 

체크 예외

Exception 클래스의 서브 클래스이면서

RuntimeException을 상속하지 않은 클래스들.

 

즉 컴파일 이전에 예외가 예상되므로,

체크 예외를 명시한 메서드를 호출했을 때에는

예외를 catch하든지 자기 바깥으로 던져줘야 한다.

 

 

언체크 예외

Exception 클래스의 서브 클래스이면서

RuntimeException을 상속한 클래스들.

체크 예외와 다르게 예외 처리가 강제되지 않는다.

 

 

 

4.1.3. 예외처리 방법

예외 복구

예외 상황을 파악하고 문제를 해결하여 정상 상태로 돌려놓는 방식.

 

예를 들어 사용자가 찾으려고 하는 파일명에 해당하는 파일이 없을 때

비슷한 이름의 다른 파일로 커서를 옮겨주는 것을 예외 복구라고 할 수 있다.

 

반면에 검색에 실패했을 때 그냥 IOException을 보여주는 것은

문제 해결과 아무런 관련이 없으므로 예외 복구라고 할 수 없다.

 

체크 예외들은 보통 문제 해결이 가능하기 때문에

예외를 처리하도록 강제되는 경우가 많다.

 

 

예외처리 회피

예외 처리를 자신이 하지 않고 자신을 호출한 쪽으로 던져버리는 방식.

 

메서드 선언부에 throws를 넣어 예외 발생 시 자동으로 던져지도록 하거나,

catch로 잡아 두고 예외 로그를 남긴 후 다시 던질 수도 있다.

 

public void executeSQL(String query) throws SQLException {
    workWithStatementStrategy(new StatementStrategy() {
        @Override
        public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
            PreparedStatement ps = c.prepareStatement(query);
            return ps;
        }
    });
}

>>> 우리가 직접 작성한 템플릿 JdbcContext와 엮여 있는

콜백 executeSQL()도 SQLException을 던지고 있다.

 

어디로 던지는 것일까?

public void workWithStatementStrategy(StatementStrategy ss) throws SQLException {
    Connection connection = null;
    PreparedStatement ps = null;

    try {
        connection = dataSource.getConnection();
        ps = ss.makePreparedStatement(connection);
        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if (connection != null) {
            try {
                ps.close();
            } catch (SQLException e) { }
        }
        if (ps != null) {
            try {
                connection.close();
            } catch (SQLException e) { }
        }
    }
}

>>> 템플릿인 workWithStatementStrategy()에서

makePreparedStatement()를 호출하고 있으니

여기로 던져지는 것이고,

여기서 다시 예외를 던지고 있다.

 

이처럼 예외를 회피하는 이유는

예외가 발생하는 지점보다도 그 메서드를

호출하는 쪽에서 처리하는 것이 합리적이라고 판단되기 때문이다.

 

 

예외 전환

예외 회피와 유사하게 메서드 바깥으로 예외를 떠넘긴다는 것은 동일하나

대신에 자신이 받은 예외와 다른 예외를 적절히 선택하여 넘겨준다.

 

목적

1. API에서 발생시키는 예외 상황을 좀 더 특정하여 전달하기 위해 "전환"

public void add(User user) throws DuplicateKeyException, SQLException {
    try {
        jdbcContext.executeSQL_setString("insert into users(id, name, password) values(?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    } catch (SQLException e) {
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
            throw DuplicateKeyException();
        else
            throw e;
    }
}

>>> 기존 JdbcContext에서의 add()는 executeSQL_setString()을 하는 과정에서

이미 SQLException을 처리해야만 했다.

 

그런데 SQLException에는 쿼리 문법이 틀렸다든지, DB와 연결이 제대로 이뤄지지 않았다든지 하는

여러 예외들을 전부 포함하고 있었고,

우리가 그 중에서 이미 테이블에 동일한 ID가 존재할 경우

SQLException이 아닌 더 구체적인 예외 경우를 뽑아내고 싶을 때 예외 전환을 이용할 수 있다.

 

따라서 add()에서는 예외의 에러 코드를 구해서 테이블에 동일한 엔트리가 있다는

코드를 받았을 경우 SQLException에서 DuplicateKeyException으로 예외를 전환한다.

 

catch (Exception e) {
    if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
        throw DuplicateKeyException().initCause(e);
    else throw e;
}

>>> 또한 위처럼 큰 예외에서 작은 예외로 전환할 때에는

큰 예외 SQLException을 아예 버리는 것이 아니라

initCause()를 통해 중첩시켜주는 것이 필요하다.

 

 

2. 예외를 처리하기 쉽고 단순하게 만들기 위해 "포장" 

catch (Exception e) {
    if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
        throw DuplicateKeyException().initCause(e);
    else throw e;
}

>>> 기존 예외를 담은 새로운 예외로 전환한다는 점은 동일하다.

다만 정보를 잃지 않기 위해서라기보다는

체크 예외를 런타임 예외로 바꿔 예외 처리를 무시하기 위해서다.

 

주로 런타임 예외로 전환시키는 이유는 API가 던지는 예외는

애플리케이션 코드 상으로 처리하기가 어렵기 때문이다.

 

 

 

4.1.4. 예외처리 전략

런타임 예외의 보편화

API에서 시작되는 대응 불가능한 예외는

애플리케이션 레벨에서 런타임 예외로 미리 전환하여
쓸데없는 throws Exception의 다발을 차단하는 것이 좋다.

 

실제로 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는

API가 발생시키는 예외를 처음부터 런타임 예외로 선언하여

불필요한 예외 복구를 강제하지 않고 있다.

 

 

add() 메서드의 예외처리

public void add(User user) throws SQLException, DuplicateUserIdException {
    try {
        jdbcContext.executeSQL_setString("insert into users(id, name, password) values(?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    } catch (SQLException e) {
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
            throw new DuplicateUserIdException(e);
        else throw e;
    }
}

>>> 현재 모습을 보면 SQLException이 발생했을 때

DuplicateUserIdException, 즉 이미 동일 ID가 있을 경우의 예외와

SQLException, 나머지 예외들로 나뉜다.

 

그런데 SQLException에 포함될만한 예외적 상황들은

애플리케이션 레벨에서 처리가 불가능하기 때문에

런타임 예외로 포장시켜주는 것이 낫다.

 

public class DuplicateUserIdException extends RuntimeException {
    public DuplicateUserIdException(Throwable cause) {
        super(cause);
    }
}

>>> 우선 간단하게 DuplciateUserIdException을 정의했다.

 

public void add(User user) throws DuplicateUserIdException {
    try {
        jdbcContext.executeSQL_setString("insert into users(id, name, password) values(?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    } catch (SQLException e) {
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
            throw new DuplicateUserIdException(e);
        else throw new RuntimeException(e);
    }
}

>>> SQLException을 catch해서

DuplciateUserIdException인 것과 아닌 것으로 분류한다.

 

아닌 예외의 경우 처리가 불가능하다는 가정 하에

RuntimeException으로 포장시켜준다.

( 생성자에 원래 예외를 중첩시켜준다는 점을 빼먹지 말자 )

 

참고로, 메서드 선언부를 보면 SQLException이 사라졌다.

이제 런타임 예외인 RuntimeException으로 포장되기 때문이다.

똑같이 런타임 예외인 DuplicateUserIdException은

add() 메서드를 호출할 쪽에 이러한 예외가 있다는 것을 알리기 위해 throws에 포함시켰다.

 

 

애플리케이션 예외

방금 살펴본 add()의 SQLException은 어차피

애플리케이션 레벨에서 처리가 불가능하기 때문에

우리가 런타임 예외로 포장시켜버렸다.

 

( 이러한 낙관적인 접근 방식은

혹시나 복구가 가능한 예외가 있을 경우를 의식하는

체크 예외의 비관적인 접근 방식과 대비된다 )

 

그런데 문제는,

몇몇 런타임 예외는 우리가 컴파일 시점 이전에 처리를 해줘야한다는 점이다.

이러한 예외를 애플리케이션 예외라고 한다.

그러한 예외까지 무시하고 전부 런타임 예외로 돌려둘 수는 없다.

 

애플리케이션 예외 처리 방법

1. 비정상적 입력 값에 대해 반환 값을 통해 상태 정보를 전달

>>> 예를 들어 잔고를 초과하는 출금 요청이 있을 때 -1을 반환하는 식이다.

그러면 메서드를 호출한 쪽에서 다시 -1을 조건문으로 걸러내어 상태를 확인한다.

 

생각보다 로직은 단순하지만,,

그런데 만약 이처럼 한 겹이 아니라 여러 겹의 호출이 일어난다면

if의 끝없는 연속으로 한 눈에 흐름을 파악하기도 어렵고

코드도 더러워진다.

 

 

2. 예상되는 비정상적 상황을 걸러낼 수 있는 예외 생성

>>> 예를 들어 잔고를 초과하는 출금 요청이 있을 때 InsufficientBalanceException을 일으키는 식이다.

그러면 그 메서드 자체에서 catch 문에 처리 로직을 집어넣을 수도 있고,

밖으로 throw해서 처리하도록 요청할 수도 있다.

 

그리고 애플리케이션 예외는 시스템으로 넘어가기 이전에

처리해야 하기 때문에

런타임 예외가 아니라 체크 예외로 둬야 한다.

그래야 다른 사람이 해당 메서드를 호출할 때 잊지 않고 예외를 처리할 수 있다.

 

 

 

4.1.5. SQLException은 어떻게 됐나?

public void add(User user) {
    jdbcTemplate.update("insert into users(id, name, password) values (?,?,?)",
            user.getId(), user.getName(), user.getPassword());
}

"JdbcTemplate에는 왜 SQLException이 없을까?"

우리가 4.1장을 시작하며 가장 먼저 던진 의문이었다.

이제 예외처리 전략을 배웠으니 어느 정도 예상할 수 있을 것 같다.

 

SQLException은 애플리케이션 레벨에서 처리가 불가능하니

JdbcTemplate을 만들 때부터 예외를 포장해서 런타임 에러로 바꿔주었다.

 

public int update(String sql, Object... args) throws DataAccessException {
    return this.update(sql, this.newArgPreparedStatementSetter(args));
}

>>> 다만 우리가 처리할 수 있는 애플리케이션 예외

DataAccessException은 던져주고 있다.

 

메서드를 호출하는 UserDao에서 처리해달라는

정보가 메서드 선언부에 정의되어 있는 것이다.

'스프링 (인프런) > 토비의 스프링' 카테고리의 다른 글

5.1. 사용자 레벨 관리 기능 추가  (0) 2023.02.04
4.2. 예외 전환  (0) 2023.02.02
3.6. 스프링의 JdbcTemplate  (0) 2023.01.30
3.5. 템플릿과 콜백  (0) 2023.01.29
3.4. 컨텍스트와 DI  (0) 2023.01.28