본문 바로가기
BackEnd/Spring

[SpringDB1] 스프링과 문제 해결 - 예외 처리, 반복

by 개발 Blog 2026. 7. 30.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

앞선 포스트에서 자바 예외의 기본을 짚으면서, 체크 예외를 런타임 예외로 바꿔야 서비스 계층을 순수하게 지킬 수 있다는 것까지 정리했다. 이번 글에서는 그 원리를 실제 코드에 적용한다. 리포지토리가 던지는 SQLException 을 런타임 예외로 전환하고, 스프링이 제공하는 예외 추상화로 기술 종속을 끊은 뒤, 마지막으로 JdbcTemplate 으로 리포지토리에 남아있던 JDBC 반복까지 걷어낸다. 트랜잭션 편에서 미뤄둔 예외 누수와 JDBC 반복 문제를 여기서 마무리하는 셈이다. 결론부터 말하면, 예외를 전환할 때는 반드시 기존 예외를 포함하고, 서비스에서는 SQLException 대신 스프링이 제공하는 데이터 접근 예외를 쓰는 것이 이번 글의 핵심이다.

체크 예외와 인터페이스

서비스 계층을 순수하게 지키려면, 데이터 접근 계층을 인터페이스로 두고 구현 기술을 자유롭게 바꿀 수 있어야 한다. JDBC 구현을 JPA 구현으로 교체해도 서비스 코드는 그대로 두는 그림이다. 그런데 지금까지 이런 인터페이스를 만들지 못한 이유가 있었다. SQLException 이 체크 예외이기 때문이다.

 

체크 예외를 던지는 구현체를 인터페이스로 묶으려면, 인터페이스 메서드에도 throws 를 선언해야 한다.

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

구현 클래스가 체크 예외를 던지려면, 인터페이스에 먼저 그 예외가 선언돼 있어야 한다. 구현 메서드는 부모인 인터페이스가 던지기로 한 예외와 같거나 그 하위 예외만 던질 수 있기 때문이다. 그래서 throws SQLException 이 인터페이스까지 올라온다.

 

문제는 이렇게 되면 인터페이스가 더 이상 순수하지 않다는 점이다. SQLException 은 JDBC 기술에 종속적인 예외인데, 그것이 인터페이스에 박히면 인터페이스 자체가 JDBC에 묶인다. 구현체를 자유롭게 바꾸려고 인터페이스를 뒀는데, 정작 인터페이스가 특정 기술에 오염되는 것이다. 나중에 다른 기술로 바꾸면 인터페이스부터 다시 손봐야 한다.

 

반면 런타임 예외는 이런 제약에서 자유롭다. 인터페이스에 따로 선언하지 않아도 되니, 인터페이스를 특정 기술과 무관한 순수한 상태로 둘 수 있다. 인터페이스를 순수하게 만들 길은 열렸지만, 그러려면 리포지토리 구현체가 SQLException 을 밖으로 흘리지 않고 안에서 처리해야 한다. 그 방법이 다음 이야기다.

런타임 예외 적용

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

그리고 리포지토리에서 SQLException 을 잡아 이 런타임 예외로 바꿔 던진다.

} catch (SQLException e) {
    throw new MyDbException(e);   // 기존 예외(e)를 포함해 전환
} finally {
    close(con, pstmt, null);
}

리포지토리가 예외를 런타임으로 바꿔주면, 서비스는 throws SQLException 을 더 이상 달고 다니지 않아도 된다. 순수한 MemberRepository 인터페이스에만 의존하는 서비스가 완성된다.

@Transactional
public void accountTransfer(String fromId, String toId, int money) {
    bizLogic(fromId, toId, money);   // throws SQLException 이 사라진 순수 서비스
}

여기서 놓치면 안 되는 규칙이 하나 있다. 예외를 전환할 때는 반드시 기존 예외를 포함해야 한다.

// O 원인 예외 포함 - 스택 트레이스에 Caused by: java.sql.SQLException 까지 남는다
throw new MyDbException(e);

// X 원인 예외 누락 - 진짜 원인이 사라진다
throw new MyDbException();

원인이 되는 예외를 생성자에 넘기지 않으면, 스택 트레이스에서 SQLException 이 통째로 사라진다. DB에서 문법 오류가 났는지 제약 조건을 위반했는지, 실제로 무슨 일이 있었는지 확인할 방법이 없어진다. 장애가 났는데 로그에 진짜 원인이 남지 않는 치명적인 상황이라, 예외를 전환할 때는 반드시 원인을 함께 넘겨야 한다.

 

이제 서비스는 순수해졌다. 그런데 리포지토리에서 넘어오는 예외가 전부 MyDbException 하나로 뭉뚱그려지는 점이 걸린다. 어떤 것은 복구할 수 있는 예외일 텐데, 지금 구조로는 구분할 방법이 없다.

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

데이터베이스 오류 중에는 복구를 시도할 만한 것도 있다. 예를 들어 회원 가입 때 ID가 겹치면, 뒤에 임의의 숫자를 붙여 새로운 ID로 다시 저장할 수 있다. 이렇게 하려면 지금 발생한 오류가 키 중복이라는 것을 알아야 한다.

 

그 정보는 SQLException 안에 들어있다. 데이터베이스는 오류가 나면 오류 코드를 반환하고, 그 코드가 SQLException 의 errorCode 에 담긴다.

e.getErrorCode() == 23505   // H2에서 키 중복 (42000은 SQL 문법 오류)

문제는 이 errorCode 를 확인하려고 SQLException 을 서비스까지 던지면, 애써 끊어낸 JDBC 의존이 되살아난다는 점이다. 그래서 리포지토리에서 예외를 구분해 던진다. MyDbException 을 상속한 MyDuplicateKeyException 을 만들어, 키 중복일 때만 이 예외를 쓴다. 직접 만든 예외라 JDBC나 JPA 같은 특정 기술에 묶이지 않는다.

public class MyDuplicateKeyException extends MyDbException {
    public MyDuplicateKeyException() {}
    public MyDuplicateKeyException(Throwable cause) { super(cause); }
}

리포지토리는 errorCode 를 보고 키 중복이면 MyDuplicateKeyException 을, 그 외에는 MyDbException 을 던진다.

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

서비스는 복구할 수 있는 예외만 잡아 새로운 ID로 다시 저장하고, 복구할 수 없는 예외는 다시 밖으로 던진다.

try {
    repository.save(new Member(memberId, 0));
} catch (MyDuplicateKeyException e) {          // 복구 가능
    String retryId = generateNewId(memberId);
    repository.save(new Member(retryId, 0));
} catch (MyDbException e) {                     // 복구 불가
    throw e;
}

덕분에 서비스는 특정 기술에 의존하지 않으면서도 필요한 예외를 잡아 복구할 수 있다. 여기까지 오면 원하는 그림은 나온다. 그런데 현실적으로 걸리는 게 있다. 오류 코드는 데이터베이스마다 다르다. 키 중복만 해도 H2는 23505, MySQL은 1062다. 게다가 오류 종류는 키 중복 말고도 락, 문법 오류 등 수백 가지다. 이 모든 상황에 맞는 예외를 지금처럼 일일이 만들 수는 없다.

스프링 예외 추상화 이해

스프링은 바로 이 지점을 해결한다. 데이터 접근에서 발생하는 예외를 특정 기술과 무관한 일관된 계층으로 추상화해서 제공한다.

RuntimeException
 └ DataAccessException
    ├ NonTransientDataAccessException   ← 재시도해도 실패
    │   ├ BadSqlGrammarException            (SQL 문법 오류)
    │   └ DataIntegrityViolationException
    │       └ DuplicateKeyException         (키 중복)
    └ TransientDataAccessException      ← 재시도하면 성공 가능
        ├ QueryTimeoutException            (타임아웃)
        ├ OptimisticLockingFailureException
        └ PessimisticLockingFailureException

최상위인 DataAccessException 은 런타임 예외다. 그래서 스프링이 제공하는 데이터 접근 예외는 모두 런타임 예외이고, 서비스는 이 예외를 throws 로 달고 다닐 필요가 없다. JDBC를 쓰든 JPA를 쓰든 서비스에서는 이 예외를 그대로 사용하면 된다.

 

계층은 크게 둘로 나뉜다. Transient 는 일시적이라는 뜻으로, 같은 SQL을 다시 실행하면 성공할 수도 있는 경우다. 쿼리 타임아웃이나 락 관련 오류가 여기 속한다. NonTransient 는 반대로 다시 시도해도 똑같이 실패하는 경우로, SQL 문법 오류나 제약 조건 위반이 해당한다.

 

그렇다면 데이터베이스마다 다른 오류 코드는 어떻게 감당할까. 스프링은 오류 코드와 예외를 매핑해둔 sql-error-codes.xml 을 들고 있다.

<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
    <property name="badSqlGrammarCodes">
        <value>42000,42001, ... ,42122,42132</value>
    </property>
    <property name="duplicateKeyCodes">
        <value>23001,23505</value>
    </property>
</bean>

그리고 이 파일을 참고해서 SQLException 을 알맞은 스프링 예외로 바꿔주는 변환기를 제공한다. SQLErrorCodeSQLExceptionTranslator 가 그것이다.

SQLExceptionTranslator exTranslator =
        new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);

translate() 에 설명, 실행한 SQL, 발생한 SQLException 을 넘기면 알맞은 예외를 돌려준다. 문법이 틀린 SQL이었다면 BadSqlGrammarException 이 반환된다. 반환 타입은 최상위인 DataAccessException 이지만, 실제로는 그 하위의 구체적인 예외가 돌아온다. 이 파일에는 우리가 흔히 쓰는 관계형 데이터베이스가 대부분 정의돼 있어서, 데이터베이스가 달라져도 같은 방식으로 동작한다.

스프링 예외 추상화 적용

이제 직접 만든 예외 변환 대신 스프링이 제공하는 변환기를 쓰도록 리포지토리를 바꾼다.

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

@Override
public Member save(Member member) {
    String sql = "insert into member(member_id, money) values(?, ?)";
    try {
        // ...
    } catch (SQLException e) {
        throw exTranslator.translate("save", sql, e);   // 스프링 예외로 변환
    }
}

앞에서 인터페이스를 미리 만들어 두었으니, 등록하는 구현 빈만 바꿔 끼우면 적용이 끝난다. 이제 서비스와 컨트롤러는 SQLException 도, 직접 만든 예외도 아닌 스프링 표준 예외를 사용하므로, 데이터 접근 기술이 바뀌어도 흔들리지 않는다. 물론 스프링이 제공하는 예외를 쓰는 만큼 스프링에 대한 의존은 생긴다. 이것까지 없애려면 예외를 전부 직접 정의해야 하는데 현실적인 방법은 아니다. 스프링에 대한 의존 정도는 실용적인 선택으로 받아들이는 편이 낫다.

 

예외 문제는 이렇게 깔끔하게 정리됐다. 그런데 리포지토리 코드를 다시 들여다보면, 커넥션을 열고 쿼리를 실행하고 리소스를 닫는 비슷한 코드가 메서드마다 반복되고 있다. 이 반복이 마지막 숙제다.

JDBC 반복 문제 해결 - JdbcTemplate

리포지토리의 각 메서드를 보면 커넥션 조회와 동기화, PreparedStatement 생성과 파라미터 바인딩, 쿼리 실행, 결과 매핑, 예외 변환, 리소스 종료가 매번 똑같이 반복된다. 실제로 달라지는 부분은 SQL과 파라미터, 그리고 결과를 객체로 만드는 부분뿐이다. 이런 반복 역시 템플릿 콜백 패턴으로 해결할 수 있는데, 스프링은 이를 위해 JdbcTemplate 을 제공한다.

public class MemberRepositoryV5 implements MemberRepository {
    private final JdbcTemplate template;

    public MemberRepositoryV5(DataSource dataSource) {
        template = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";
        template.update(sql, member.getMemberId(), member.getMoney());
        return member;
    }

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        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;
        };
    }
}

커넥션을 열고 닫는 코드도, try·catch·finally 도 사라졌다. JdbcTemplate 이 반복되는 부분을 대신 처리하고, 개발자는 SQL과 결과 매핑만 넘긴다. 그뿐 아니라 앞에서 다룬 트랜잭션 커넥션 동기화와 스프링 예외 변환까지 안에서 알아서 해준다. 여기서도 등록하는 빈만 바꾸면 적용이 끝난다. 이런 구조를 템플릿 콜백 패턴이라고 하는데, 지금은 스프링이 반복을 걷어내 준다는 정도로만 이해해도 충분하다.

정리

이번 글에서는 예외를 런타임 예외로 전환해 서비스의 순수성을 지키고, 스프링 예외 추상화로 기술 종속을 끊은 뒤, JdbcTemplate 으로 JDBC 반복까지 제거했다. 앞선 트랜잭션 추상화와 이번 예외 추상화, 그리고 리포지토리 인터페이스가 맞물리면서, 서비스 계층은 데이터 접근 기술이 바뀌어도 흔들리지 않는 순수한 상태가 됐다. 처음에 말한 두 가지, 예외를 전환할 때 원인을 포함하는 것과 SQLException 대신 스프링 예외를 쓰는 것이 그 핵심이었다.

 

이것으로 스프링 DB 1편에서 데이터 접근의 핵심 원리를 마무리한다. 다음 포스트에서는 이 원리 위에 실제 데이터 접근 기술을 얹는 내용으로 이어간다.