SQLExceptionDataAccessException으로 바뀌는 순간 — 데이터 접근 추상화

try {
    jdbcTemplate.queryForObject("SELECT name FROM users WHERE id = ?", String.class, id);
} catch (SQLException e) {
    // 이 catch 블록은 실행되지 않는다
}

JdbcTemplateSQLException을 던지지 않는다. Spring은 모든 데이터 접근 예외를 unchecked DataAccessException 계층으로 변환한다 — 벤더별 에러 코드(MySQL 1062, PostgreSQL 23505, Oracle 1)를 통일된 타입(DuplicateKeyException 등)으로 매핑한다.

이 글은 JdbcTemplate, 예외 변환(exception translation), Spring Data의 공통 추상화를 풀어간다. (Spring Framework Reference - Data Access)

JDBC의 문제 — Spring이 해결한 것

순수 JDBC의 문제점:

  • checked exception: SQLException을 try-catch하거나 throws로 전파해야 함
  • 벤더별 에러 코드: MySQL, PostgreSQL, Oracle의 중복 키 에러 코드가 모두 다름
  • 보일러플레이트: Connection → PreparedStatement → ResultSet → close → close → close

Spring의 해결:

  • unchecked exception: DataAccessException(RuntimeException 하위)으로 변환
  • 통일된 예외 계층: 벤더 무관하게 DuplicateKeyException 하나로 처리
  • JdbcTemplate: 반복 코드를 프레임워크가 처리

JdbcTemplate — JDBC의 보일러플레이트 제거

// Spring 7 / Java 25 — JdbcTemplate
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbc;

    public UserRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    // 조회 — RowMapper로 ResultSet → 객체
    public Optional<User> findById(Long id) {
        try {
            User user = jdbc.queryForObject(
                "SELECT id, name, email FROM users WHERE id = ?",
                (rs, rowNum) -> new User(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getString("email")
                ),
                id
            );
            return Optional.ofNullable(user);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    // 목록 조회
    public List<User> findAll() {
        return jdbc.query(
            "SELECT id, name, email FROM users",
            (rs, rowNum) -> new User(
                rs.getLong("id"),
                rs.getString("name"),
                rs.getString("email")
            )
        );
    }

    // 삽입
    public void save(User user) {
        jdbc.update(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            user.name(), user.email()
        );
    }

    // 업데이트
    public int updateEmail(Long id, String email) {
        return jdbc.update(
            "UPDATE users SET email = ? WHERE id = ?",
            email, id
        );
    }
}

JdbcTemplate이 Connection, PreparedStatement, ResultSet의 생성/해제를 모두 처리한다. 개발자는 SQL과 매핑 로직만 작성한다. SQLException은 발생하지 않는다 — Spring이 DataAccessException으로 변환한다.

SQLException이 어떻게 DataAccessException으로 바뀌는가 — 내부 메커니즘: JdbcTemplate이 SQL을 실행하고 SQLException이 발생하면 → SQLErrorCodeSQLExceptionTranslator가 이를 가로챈다 → 데이터베이스의 SQLExceptionTranslator가 JDBC 드라이버에서 제공하는 에러 코드(MySQL: 1062, PostgreSQL: SQLSTATE 23505)를 읽는다 → 이 코드를 Spring의 통일된 예외 타입(DuplicateKeyException)으로 매핑한다. 개발자는 "MySQL인지 PostgreSQL인지" 모른 채 DuplicateKeyException 하나로 처리할 수 있다.

비유: 각 나라 공항 세관이 "마약 소지"를 각자 다른 법률 조항으로 기소한다 — 미국은 "21 USC 841", 한국은 "마약류관리법 제60조". Spring은 이것을 "국제 표준 마약 소지죄"(DuplicateKeyException) 하나로 번역한다. 개발자는 각국 법률을 외울 필요 없이, 표준 용어 하나로 대응할 수 있다.

JdbcTemplate이 처리하는 것

항목 순수 JDBC JdbcTemplate
Connection 관리 수동(open/close) 자동
Statement 관리 수동 자동
ResultSet 관리 수동 자동
예외 변환 SQLException(checked) DataAccessException(unchecked)
트랜잭션 통합 수동 @Transactional과 자동 통합

예외 변환(Exception Translation)

flowchart LR
    subgraph db["데이터베이스별 에러"]
        M["MySQL: error 1062"] 
        P["PostgreSQL: SQLSTATE 23505"]
        O["Oracle: ORA-00001"]
    end
    M --> S["SQLException"]
    P --> S
    O --> S
    S --> T["Spring 예외 변환기<br/>(DataAccessException)"]
    T --> D["DuplicateKeyException<br/>(통일된 타입)"]
// Spring 7 / Java 25 — 통일된 예외 처리
import org.springframework.dao.DuplicateKeyException;

@Repository
public class UserRepository {
    public void createUser(User user) {
        try {
            jdbc.update("INSERT INTO users ...", ...);
        } catch (DuplicateKeyException e) {
            // MySQL, PostgreSQL, Oracle 모두 이 타입으로 잡힘
            throw new BusinessException("이미 존재하는 사용자", e);
        }
        // SQLException catch 불필요!
    }
}

DataAccessException 계층

DataAccessException (RuntimeException)
├── DuplicateKeyException          // 중복 키
├── DataIntegrityViolationException // 데이터 무결성 위반
├── EmptyResultDataAccessException  // 결과 없음
├── IncorrectResultSizeDataAccessException // 결과 수 불일치
└── ...

Spring은 SQLExceptionTranslator가 벤더별 에러 코드와 SQLState를 DataAccessException 하위 타입으로 매핑한다. 애플리케이션 코드는 벤더를 알 필요 없이 DuplicateKeyException 하나로 처리한다. (Spring Framework Reference - DAO Exception Handling)

Spring Data 공통 추상화 — Repository 인터페이스

Spring Data(JPA, MongoDB, Redis 등)는 공통된 Repository 인터페이스 계층을 제공한다:

// Spring 7 / Java 25 — Spring Data 공통 인터페이스 (개념)
public interface Repository<T, ID> { }   // 마커 인터페이스

public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);
    Optional<T> findById(ID id);
    Iterable<T> findAll();
    long count();
    void deleteById(ID id);
}

// Spring Data JPA로 구현체 자동 생성
public interface UserRepository extends CrudRepository<User, Long> {
    // 구현체 작성 불필요 — Spring Data JPA가 런타임에 생성
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String name);
}

Spring Data JPA, Spring Data MongoDB 등이 각각 CrudRepository를 확장하여 구현체를 자동 생성한다. findByEmail 같은 메서드 이름만 정의하면 쿼리가 자동 생성된다 (Spring Boot 장 05 - Spring Data JPA 상세).

JdbcTemplate vs Spring Data JPA — 언제 무엇을?

항목 JdbcTemplate Spring Data JPA
SQL 직접 작성 자동 생성 + JPQL
매핑 RowMapper 수동 엔티티 자동 매핑
학습 곡선 낮음 중간 ~ 높음
성능 제어 높음 (SQL 최적화) 보통 (N+1 주의)
적합 복잡한 SQL, 최적화 필요 CRUD 중심, 도메인 모델링

간단한 CRUD + 복잡한 조인 쿼리가 섞인 프로젝트에서는 두 가지를 혼용한다 — 기본 CRUD는 Spring Data JPA, 복잡한 분석 쿼리는 JdbcTemplate으로 작성한다.

실습 — JdbcTemplate 패턴

// Spring 7 / Java 25 — JdbcPatternDemo.java
import java.util.*;

public class JdbcPatternDemo {
    record User(Long id, String name, String email) {}

    public static void main(String[] args) {
        // DB 없이 패턴 시뮬레이션 — RowMapper 역할
        List<Map<String, Object>> fakeResults = List.of(
            Map.of("id", 1, "name", "Alice", "email", "alice@test.com"),
            Map.of("id", 2, "name", "Bob", "email", "bob@test.com")
        );

        // RowMapper 시뮬레이션: Map → User
        List<User> users = fakeResults.stream()
            .map(row -> new User(
                ((Number) row.get("id")).longValue(),
                (String) row.get("name"),
                (String) row.get("email")
            ))
            .toList();

        users.forEach(u -> System.out.println(u.id() + ": " + u.name() + " <" + u.email() + ">"));
    }
}
java JdbcPatternDemo.java
1: Alice <alice@test.com>
2: Bob <bob@test.com>

확인할 것: RowMapper가 데이터베이스 행(row)을 객체로 변환한다. JdbcTemplate이 Connection/Statement/ResultSet 관리를 처리하고, 개발자는 매핑 로직만 작성한다.

NamedParameterJdbcTemplate — 이름 기반 매개변수

JdbcTemplate? 자리표시자를 쓰지만, 매개변수 순서가 헷갈린다. NamedParameterJdbcTemplate은 이름 기반 매개변수를 제공한다:

// Spring 7 / Java 25 — NamedParameterJdbcTemplate
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;

@Repository
public class UserRepository {
    private final NamedParameterJdbcTemplate npjdbc;

    public UserRepository(NamedParameterJdbcTemplate npjdbc) { this.npjdbc = npjdbc; }

    public List<User> findByCityAndAge(String city, int minAge) {
        var params = new MapSqlParameterSource()
            .addValue("city", city)
            .addValue("minAge", minAge);

        return npjdbc.query(
            "SELECT * FROM users WHERE city = :city AND age >= :minAge",
            params,
            (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))
        );
    }
}

:city, :minAge로 이름을 붙이면 순서에 신경 쓸 필요 없다. 복잡한 쿼리에서 가독성이 크게 향상된다.

SimpleJdbcInsert — INSERT 간소화 + 자동 키 생성

// Spring 7 / Java 25 — SimpleJdbcInsert로 INSERT + KeyHolder
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

@Repository
public class UserRepository {
    private final SimpleJdbcInsert insert;

    public UserRepository(DataSource dataSource) {
        this.insert = new SimpleJdbcInsert(dataSource)
            .withTableName("users")
            .usingGeneratedKeyColumns("id");   // 자동 생성 키
    }

    public long save(User user) {
        Map<String, Object> params = Map.of(
            "name", user.name(),
            "email", user.email()
        );
        Number key = insert.executeAndReturnKey(params);
        return key.longValue();   // 생성된 PK 반환
    }
}

배치(Batch) 처리

// Spring 7 / Java 25 — 배치 INSERT
public void batchInsert(List<User> users) {
    npjdbc.batchUpdate(
        "INSERT INTO users (name, email) VALUES (:name, :email)",
        users.stream().map(u -> {
            var params = new MapSqlParameterSource()
                .addValue("name", u.name())
                .addValue("email", u.email());
            return params;
        }).toArray(MapSqlParameterSource[]::new)
    );
    // 수천 건을 한 번에 INSERT — 개별 INSERT보다 수십 배 빠름
}

요약 — 이 글의 결론

  • JdbcTemplate이 JDBC 보일러플레이트를 제거한다. Connection, Statement, ResultSet 관리를 프레임워크가 처리한다. 개발자는 SQL과 RowMapper만 작성.
  • Spring이 예외를 변환한다. 벤더별 SQLException을 통일된 DataAccessException(unchecked) 계층으로 변환. DuplicateKeyException 하나로 모든 DB의 중복 키 에러를 처리.
  • DataAccessException은 unchecked다. try-catch를 강제하지 않아 코드가 간결해진다. 필요한 곳에서만 catch.
  • Spring Data는 공통 Repository 추상화를 제공한다. Spring Data JPA, MongoDB 등이 같은 인터페이스 계층 위에 구축된다.
  • JdbcTemplate과 Spring Data JPA는 혼용할 수 있다. 복잡한 SQL은 JdbcTemplate, CRUD 중심은 Spring Data JPA.

생각해 볼 문제

  1. SQLExceptionTranslator가 벤더별 에러 코드를 어떻게 매핑하는가? SQLErrorCodeSQLExceptionTranslator의 동작 원리는?
  2. JdbcTemplate에서 PreparedStatement 재사용(prepared statement pooling)은 어떻게 이루어지는가?
  3. DataAccessException의 하위 타입 중 TransientDataAccessExceptionNonTransientDataAccessException의 차이는?
  4. Spring Data의 @Repository 어노테이션이 예외 변환에 어떤 역할을 하는가?
  5. Spring 7에서 JPA 3.2(Hibernate 7)로 올라가면서 변경된 점은?

참고

'Develop Artifacts > Spring' 카테고리의 다른 글

Spring - 09. transaction  (0) 2026.07.12
Spring - 08. validation exception  (0) 2026.07.12
Spring - 07. spring mvc  (0) 2026.07.12
Spring - 06. aop  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12