자바 백엔드 입문 42편 — JdbcTemplate으로 SQL 다루기

2026-05-16자바 백엔드 입문

자바 백엔드 입문 42편. 20줄 JDBC 보일러플레이트를 1줄로 줄이는 JdbcTemplate. queryForObject·query·update와 RowMapper 패턴을 짤막한 SQL 작성 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 42편 — JdbcTemplate으로 SQL 다루기

이 글은 자바 백엔드 입문 시리즈 59편 중 42편이에요. 41편에서 JDBC 직접 쓰면 20줄 보일러플레이트가 박힌다고 했죠. 이번 42편의 주인공 JdbcTemplate 이 그 20줄을 1줄로 줄여줘요. Spring의 가장 오래된 데이터 접근 추상화이고, JPA 가기 전 한 단계 거쳐야 할 도구예요.

JdbcTemplate이 헷갈리는 이유

처음 JdbcTemplate 코드를 보면 — queryForObject·query·update·RowMapper 같은 메서드가 한꺼번에 등장해요. 또 "PreparedStatement는 어디 갔지?" 가 안 잡혀요.

이 글에서는 JdbcTemplate을 "짧은 SQL 작성 도구" 비유로 풀어요. JDBC 직접 = "매번 종이·연필·지우개·자 다 준비하고 SQL 한 줄 쓰기", JdbcTemplate = "SQL 한 줄만 적으면 나머지 다 알아서". 끝까지 따라오시면 5개 핵심 메서드 + RowMapper 패턴이 한 그림에 들어와요.

Before — JDBC 직접 (20줄)

비교를 위해 41편 의 예제를 다시 봐요.

public Order findById(Long id) {
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = dataSource.getConnection();
        ps = conn.prepareStatement("SELECT id, amount FROM orders WHERE id = ?");
        ps.setLong(1, id);
        rs = ps.executeQuery();
        if (rs.next()) {
            return new Order(rs.getLong("id"), rs.getInt("amount"));
        }
        return null;
    } catch (SQLException e) {
        throw new RuntimeException(e);
    } finally {
        if (rs != null) try { rs.close(); } catch (Exception ignore) {}
        if (ps != null) try { ps.close(); } catch (Exception ignore) {}
        if (conn != null) try { conn.close(); } catch (Exception ignore) {}
    }
}

20줄. 핵심은 SELECT ... WHERE id = ? 한 줄인데 나머지 19줄이 잡음.

After — JdbcTemplate (3줄)

같은 일을 JdbcTemplate으로 짜면.

public Order findById(Long id) {
    return jdbcTemplate.queryForObject(
            "SELECT id, amount FROM orders WHERE id = ?",
            (rs, rowNum) -> new Order(rs.getLong("id"), rs.getInt("amount")),
            id
    );
}

3줄로 끝. 커넥션 열기·예외 처리·자원 정리 — JdbcTemplate이 다 처리해줘요. 우리가 짜는 건 "SQL + 결과를 객체로 변환하는 람다 + 파라미터" 셋뿐.

JdbcTemplate 핵심 메서드 5종

자주 만나는 메서드 다섯 개.

메서드 용도 반환
queryForObject(sql, mapper, params) 단일 결과 조회 객체 한 개 (없으면 예외)
query(sql, mapper, params) 여러 결과 조회 List<T>
update(sql, params) INSERT·UPDATE·DELETE 변경된 행 수
queryForList(sql) Map 리스트로 조회 List<Map<String,Object>>
batchUpdate(sql, paramsList) 대량 INSERT·UPDATE 행 수 배열

5개로 90% 시나리오 처리.

조회 — query·queryForObject

// 단일 객체
Order order = jdbc.queryForObject(
        "SELECT * FROM orders WHERE id = ?",
        orderRowMapper,
        orderId);

// 여러 객체
List<Order> orders = jdbc.query(
        "SELECT * FROM orders WHERE status = ?",
        orderRowMapper,
        "ACTIVE");

// 단일 값 (count·sum 등)
int count = jdbc.queryForObject(
        "SELECT COUNT(*) FROM orders WHERE status = ?",
        Integer.class,
        "ACTIVE");

변경 — update

// INSERT
int rows = jdbc.update(
        "INSERT INTO orders (product_id, amount) VALUES (?, ?)",
        productId, amount);

// UPDATE
jdbc.update(
        "UPDATE orders SET status = ? WHERE id = ?",
        "COMPLETED", orderId);

// DELETE
jdbc.update("DELETE FROM orders WHERE id = ?", orderId);

update() 반환값은 "몇 행이 영향받았는가". INSERT는 보통 1, UPDATE·DELETE는 조건에 매칭된 행 수.

RowMapper — ResultSet을 자바 객체로

query()·queryForObject() 의 두 번째 인자가 RowMapper. "DB 한 행을 자바 객체 하나로 어떻게 변환하나" 를 정의.

// 1. 람다 표현 (간단)
RowMapper<Order> mapper = (rs, rowNum) ->
        new Order(rs.getLong("id"), rs.getInt("amount"));

// 2. 별도 클래스 (재사용)
public class OrderRowMapper implements RowMapper<Order> {
    @Override
    public Order mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Order(
                rs.getLong("id"),
                rs.getInt("amount"),
                rs.getString("status"),
                rs.getTimestamp("created_at").toLocalDateTime()
        );
    }
}

같은 RowMapper를 여러 메서드에서 재사용하려면 별도 클래스가 깔끔. 한 번만 쓰면 람다가 짧음.

BeanPropertyRowMapper — 자동 매핑

매번 RowMapper 짜기 귀찮을 때 BeanPropertyRowMapper 가 답. "컬럼 이름과 객체 필드 이름이 같으면 자동 매핑".

public class Order {
    private Long id;
    private int amount;
    private String status;
    private LocalDateTime createdAt;
    // getter/setter
}

// 자동 매핑
List<Order> orders = jdbc.query(
        "SELECT id, amount, status, created_at FROM orders",
        new BeanPropertyRowMapper<>(Order.class));

created_at (snake_case) → createdAt (camelCase) 자동 변환도 지원. 컬럼명 컨벤션만 맞추면 RowMapper 한 줄로 끝.

여기서 시험 함정 자주 나와요. "BeanPropertyRowMapper가 setter를 호출한다" 는 X. 사실 — public setter가 있어야 동작. 기본 생성자도 필요. Lombok @Setter + @NoArgsConstructor 박아두기.

NamedParameterJdbcTemplate — ? 대신 이름

물음표 ? 가 여러 개 있으면 순서가 헷갈리잖아요. NamedParameterJdbcTemplate 이 이름 기반 파라미터를 지원.

// JdbcTemplate — 순서 매개변수
jdbc.update(
        "UPDATE orders SET amount = ?, status = ? WHERE id = ?",
        15000, "COMPLETED", 123L);

// NamedParameterJdbcTemplate — 이름 매개변수
Map<String, Object> params = new HashMap<>();
params.put("amount", 15000);
params.put("status", "COMPLETED");
params.put("id", 123L);

namedJdbc.update(
        "UPDATE orders SET amount = :amount, status = :status WHERE id = :id",
        params);

매개변수가 5개 이상이면 NamedParameterJdbcTemplate이 훨씬 깔끔. Spring Boot가 자동으로 두 Bean 다 등록해줘요.

Bean 주입 패턴

JdbcTemplate을 서비스에 주입하는 표준 패턴.

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final JdbcTemplate jdbcTemplate;          // 자동 주입
    private final NamedParameterJdbcTemplate namedJdbcTemplate;   // 자동 주입

    public Order findById(Long id) {
        return jdbcTemplate.queryForObject(
                "SELECT * FROM orders WHERE id = ?",
                new BeanPropertyRowMapper<>(Order.class),
                id);
    }

    public List<Order> findByStatus(String status) {
        return namedJdbcTemplate.query(
                "SELECT * FROM orders WHERE status = :status",
                Map.of("status", status),
                new BeanPropertyRowMapper<>(Order.class));
    }
}

18편@Repository + 생성자 주입 패턴. Spring Boot가 JdbcTemplate Bean을 자동 등록하니까 그저 주입만 받으면 끝.

예외 변환 — Spring의 표준 DataAccessException

JDBC 표준 예외 SQLException체크드 예외라 매번 try-catch 박아야 해요. Spring이 이걸 자동으로 런타임 예외 DataAccessException 로 변환해줘요.

// JdbcTemplate은 SQLException을 자동으로 DataAccessException으로 변환
try {
    jdbc.queryForObject(...);
} catch (EmptyResultDataAccessException e) {
    // 결과 없음 (queryForObject가 0건일 때)
} catch (DuplicateKeyException e) {
    // 유니크 키 중복
} catch (DataAccessException e) {
    // 그 외 DB 오류
}

DataAccessException 은 RuntimeException 상속이라 try-catch 강제 X. 그리고 DB별 예외(PostgreSQL·MySQL·Oracle)가 다 통일된 Spring 예외로 변환 — DB 바꿔도 catch 코드 그대로.

SimpleJdbcInsert — INSERT 한 줄 더 짧게

INSERT 자주 쓰면 SimpleJdbcInsert 가 한 단계 더 추상화.

public class OrderRepository {

    private final SimpleJdbcInsert insert;

    public OrderRepository(DataSource dataSource) {
        this.insert = new SimpleJdbcInsert(dataSource)
                .withTableName("orders")
                .usingGeneratedKeyColumns("id");
    }

    public Long save(Order order) {
        Map<String, Object> params = Map.of(
                "amount", order.getAmount(),
                "status", order.getStatus());
        Number key = insert.executeAndReturnKey(params);
        return key.longValue();
    }
}

SQL 안 짜고 — 테이블 이름 + 컬럼 Map만 주면 INSERT 처리 + auto-generated PK 반환. 단순 INSERT에 자주 활용.

💡 JdbcTemplate vs JPA

JdbcTemplate = "SQL을 내가 직접 짠다". 세밀한 제어, 복잡한 쿼리·튜닝에 유리. JPA(29~32편) = "SQL을 거의 안 짜고 객체 그래프로 다룬다". 빠른 개발, 단순 CRUD에 유리. 한국 회사 백엔드는 보통 JPA 기본 + 복잡한 쿼리에만 JdbcTemplate 조합.

한국 회사 표준 — Repository 패턴

JdbcTemplate으로 짠 회사 시스템 표준 골격.

// 1. 도메인 객체
public class Order {
    private Long id;
    private Long productId;
    private int amount;
    private String status;
    private LocalDateTime createdAt;
    // getter/setter
}

// 2. Repository 인터페이스 (옵션)
public interface OrderRepository {
    Optional<Order> findById(Long id);
    List<Order> findByStatus(String status);
    Long save(Order order);
    int updateStatus(Long id, String status);
}

// 3. JdbcTemplate 구현체
@Repository
@RequiredArgsConstructor
public class JdbcOrderRepository implements OrderRepository {

    private final JdbcTemplate jdbcTemplate;
    private static final RowMapper<Order> MAPPER = new BeanPropertyRowMapper<>(Order.class);

    @Override
    public Optional<Order> findById(Long id) {
        try {
            return Optional.ofNullable(jdbcTemplate.queryForObject(
                    "SELECT * FROM orders WHERE id = ?", MAPPER, id));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    @Override
    public List<Order> findByStatus(String status) {
        return jdbcTemplate.query(
                "SELECT * FROM orders WHERE status = ?", MAPPER, status);
    }
    // ... 나머지 구현
}

인터페이스로 추상화하면 — 나중에 JPA·MyBatis 같은 다른 구현으로 교체 쉬워요. 옵션이지만 회사 시스템 표준 패턴.

한 줄 정리 — JdbcTemplate = JDBC 보일러플레이트 20줄을 1~3줄로 압축. queryForObject·query·update 세 메서드 + RowMapper(또는 BeanPropertyRowMapper) 패턴. NamedParameterJdbcTemplate은 매개변수 이름 기반.

시험 직전 한 번 더 — JdbcTemplate 입문자가 매번 헷갈리는 것

  • JdbcTemplate = JDBC 보일러플레이트 자동 처리 (Spring 가장 오래된 데이터 접근)
  • 커넥션·예외·자원 정리 = Spring이 자동
  • 핵심 메서드 = queryForObject·query·update·queryForList·batchUpdate
  • queryForObject = 단일 결과 (없으면 EmptyResultDataAccessException)
  • query = List<T> 반환
  • update = INSERT·UPDATE·DELETE, 반환은 영향받은 행 수
  • RowMapper = ResultSet → 자바 객체 변환 람다
  • 별도 클래스 RowMapper도 가능 — 재사용 시 유리
  • BeanPropertyRowMapper = 컬럼명·필드명 자동 매핑
  • snake_case → camelCase 자동 변환
  • BeanPropertyRowMapper 동작 = setter + 기본 생성자 필요
  • NamedParameterJdbcTemplate = :name 이름 매개변수
  • 매개변수 5개+ 면 NamedParameter 사용 표준
  • Spring Boot = 두 Template Bean 자동 등록
  • SQLException 자동 → DataAccessException (RuntimeException)
  • DB별 예외도 통일된 Spring 예외 변환 (DuplicateKeyException 등)
  • SimpleJdbcInsert = INSERT 한 단계 더 추상화. auto-generated PK 자동 반환
  • @Repository + 생성자 주입 표준
  • 인터페이스 분리 = JPA·MyBatis 교체 쉬움
  • JdbcTemplate vs JPA = SQL 직접 vs 객체 그래프
  • 회사 백엔드 = JPA 기본 + 복잡한 쿼리에 JdbcTemplate 조합

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!