JdbcTemplate이 여전히 필요한 이유
JPA/Hibernate가 주류인 시대에도 JdbcTemplate은 여전히 강력한 도구다. 복잡한 네이티브 SQL, 대용량 배치 처리, 성능 크리티컬 쿼리에서는 ORM의 추상화가 오히려 방해된다. Spring Boot 3.2+에서 추가된 JdbcClient까지 포함해, JDBC 기반 데이터 접근의 심화 패턴을 정리한다.
JdbcTemplate 기본 CRUD
@Repository
@RequiredArgsConstructor
public class MemberJdbcRepository {
private final JdbcTemplate jdbcTemplate;
// 단건 조회: queryForObject
public Member findById(Long id) {
String sql = "SELECT id, name, email, age, status FROM member WHERE id = ?";
return jdbcTemplate.queryForObject(sql, memberRowMapper(), id);
}
// 목록 조회: query
public List<Member> findByStatus(String status) {
String sql = "SELECT id, name, email, age, status FROM member WHERE status = ?";
return jdbcTemplate.query(sql, memberRowMapper(), status);
}
// INSERT: update + KeyHolder
public Long save(Member member) {
String sql = "INSERT INTO member (name, email, age, status) VALUES (?, ?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
ps.setString(1, member.getName());
ps.setString(2, member.getEmail());
ps.setInt(3, member.getAge());
ps.setString(4, member.getStatus());
return ps;
}, keyHolder);
return keyHolder.getKey().longValue();
}
// UPDATE
public int update(Long id, String name, String email) {
String sql = "UPDATE member SET name = ?, email = ? WHERE id = ?";
return jdbcTemplate.update(sql, name, email, id);
}
// DELETE
public int delete(Long id) {
return jdbcTemplate.update("DELETE FROM member WHERE id = ?", id);
}
// RowMapper 추출
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> Member.builder()
.id(rs.getLong("id"))
.name(rs.getString("name"))
.email(rs.getString("email"))
.age(rs.getInt("age"))
.status(rs.getString("status"))
.build();
}
}
NamedParameterJdbcTemplate: 가독성 향상
위치 기반 ? 파라미터는 순서가 바뀌면 버그가 발생한다. NamedParameterJdbcTemplate은 이름 기반 파라미터로 이 문제를 해결한다.
@Repository
@RequiredArgsConstructor
public class OrderJdbcRepository {
private final NamedParameterJdbcTemplate namedTemplate;
// 이름 기반 파라미터: 순서 무관
public List<Order> search(OrderSearchCond cond) {
StringBuilder sql = new StringBuilder(
"SELECT o.id, o.member_id, o.amount, o.status, o.created_at " +
"FROM orders o WHERE 1=1"
);
MapSqlParameterSource params = new MapSqlParameterSource();
if (cond.getStatus() != null) {
sql.append(" AND o.status = :status");
params.addValue("status", cond.getStatus());
}
if (cond.getMinAmount() != null) {
sql.append(" AND o.amount >= :minAmount");
params.addValue("minAmount", cond.getMinAmount());
}
if (cond.getMemberId() != null) {
sql.append(" AND o.member_id = :memberId");
params.addValue("memberId", cond.getMemberId());
}
sql.append(" ORDER BY o.created_at DESC LIMIT :limit OFFSET :offset");
params.addValue("limit", cond.getSize());
params.addValue("offset", cond.getPage() * cond.getSize());
return namedTemplate.query(sql.toString(), params, orderRowMapper());
}
// IN 절: 자동 확장
public List<Order> findByIds(List<Long> ids) {
String sql = "SELECT * FROM orders WHERE id IN (:ids)";
MapSqlParameterSource params = new MapSqlParameterSource("ids", ids);
return namedTemplate.query(sql, params, orderRowMapper());
}
// BeanPropertySqlParameterSource: DTO 필드 자동 매핑
public int save(Order order) {
String sql = "INSERT INTO orders (member_id, amount, status) " +
"VALUES (:memberId, :amount, :status)";
return namedTemplate.update(sql, new BeanPropertySqlParameterSource(order));
}
}
IN (:ids)에 List를 넘기면 자동으로 IN (?, ?, ?)로 확장된다. JPA의 네이티브 쿼리에서는 지원하지 않는 편의 기능이다.
배치 처리: batchUpdate
// 대용량 INSERT 배치
public void bulkInsert(List<Member> members) {
String sql = "INSERT INTO member (name, email, age, status) VALUES (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Member m = members.get(i);
ps.setString(1, m.getName());
ps.setString(2, m.getEmail());
ps.setInt(3, m.getAge());
ps.setString(4, m.getStatus());
}
@Override
public int getBatchSize() {
return members.size();
}
});
}
// NamedParameter 배치
public void bulkInsertNamed(List<Member> members) {
String sql = "INSERT INTO member (name, email, age, status) " +
"VALUES (:name, :email, :age, :status)";
SqlParameterSource[] batchParams = members.stream()
.map(BeanPropertySqlParameterSource::new)
.toArray(SqlParameterSource[]::new);
namedTemplate.batchUpdate(sql, batchParams);
}
// 청크 단위 배치 (메모리 절약)
public void bulkInsertChunked(List<Member> members, int chunkSize) {
String sql = "INSERT INTO member (name, email, age, status) VALUES (?, ?, ?, ?)";
// 500개씩 나눠서 배치 실행
List<List<Member>> chunks = Lists.partition(members, chunkSize);
for (List<Member> chunk : chunks) {
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Member m = chunk.get(i);
ps.setString(1, m.getName());
ps.setString(2, m.getEmail());
ps.setInt(3, m.getAge());
ps.setString(4, m.getStatus());
}
@Override
public int getBatchSize() {
return chunk.size();
}
});
}
}
10만 건 INSERT 기준, 개별 update()는 약 45초, batchUpdate()는 약 2초로 20배 이상 빠르다. MySQL에서는 JDBC URL에 rewriteBatchedStatements=true를 추가하면 단일 INSERT 문으로 재작성되어 더 빨라진다.
JdbcClient: Spring Boot 3.2+ 최신 API
Spring 6.1에서 도입된 JdbcClient는 JdbcTemplate과 NamedParameterJdbcTemplate을 통합한 플루언트 API다.
@Repository
@RequiredArgsConstructor
public class ProductJdbcRepository {
private final JdbcClient jdbcClient;
// 단건 조회
public Optional<Product> findById(Long id) {
return jdbcClient.sql("SELECT * FROM product WHERE id = :id")
.param("id", id)
.query(Product.class) // 자동 매핑 (SimplePropertyRowMapper)
.optional();
}
// 목록 조회
public List<Product> findByCategory(String category) {
return jdbcClient.sql("""
SELECT id, name, price, category, stock
FROM product
WHERE category = :category
ORDER BY price DESC
""")
.param("category", category)
.query(Product.class)
.list();
}
// 단일 값 조회
public long countByStatus(String status) {
return jdbcClient.sql("SELECT COUNT(*) FROM product WHERE status = :status")
.param("status", status)
.query(Long.class)
.single();
}
// INSERT
public int save(Product product) {
return jdbcClient.sql("""
INSERT INTO product (name, price, category, stock)
VALUES (:name, :price, :category, :stock)
""")
.paramSource(product) // BeanPropertySqlParameterSource 자동
.update();
}
// 커스텀 RowMapper
public List<ProductSummary> findSummaries() {
return jdbcClient.sql("""
SELECT category, COUNT(*) as count, AVG(price) as avg_price
FROM product
GROUP BY category
""")
.query((rs, rowNum) -> new ProductSummary(
rs.getString("category"),
rs.getLong("count"),
rs.getBigDecimal("avg_price")
))
.list();
}
}
JdbcClient의 장점은 하나의 API로 위치/이름 파라미터, 단건/목록/Optional 반환을 모두 처리한다는 것이다. query(Product.class)는 컬럼명과 필드명을 자동 매핑(snake_case → camelCase 포함)한다.
ResultSetExtractor: 복잡한 결과 매핑
// 1:N 관계를 한 번의 쿼리로 매핑
public List<Member> findAllWithOrders() {
String sql = """
SELECT m.id as m_id, m.name, m.email,
o.id as o_id, o.amount, o.status as o_status
FROM member m
LEFT JOIN orders o ON m.id = o.member_id
ORDER BY m.id
""";
return jdbcTemplate.query(sql, (ResultSetExtractor<List<Member>>) rs -> {
Map<Long, Member> memberMap = new LinkedHashMap<>();
while (rs.next()) {
Long memberId = rs.getLong("m_id");
Member member = memberMap.computeIfAbsent(memberId, id ->
Member.builder()
.id(id)
.name(rs.getString("name"))
.email(rs.getString("email"))
.orders(new ArrayList<>())
.build()
);
Long orderId = rs.getLong("o_id");
if (!rs.wasNull()) {
member.getOrders().add(Order.builder()
.id(orderId)
.amount(rs.getBigDecimal("amount"))
.status(rs.getString("o_status"))
.build()
);
}
}
return new ArrayList<>(memberMap.values());
});
}
ResultSetExtractor는 RowMapper와 달리 ResultSet 전체를 직접 순회한다. 1:N JOIN 결과를 중복 없이 부모-자식 구조로 변환할 때 필수다. JPA의 N+1 문제 없이 한 번의 쿼리로 처리할 수 있다. N+1 관련 JPA 해결법은 Spring JPA N+1 해결 전략 글을 참고하자.
스트리밍 조회: 대용량 데이터
// 수백만 행 처리: 메모리에 전부 올리지 않고 스트리밍
public void exportMembers(OutputStream out) {
String sql = "SELECT id, name, email FROM member ORDER BY id";
jdbcTemplate.query(sql, (RowCallbackHandler) rs -> {
// 한 행씩 처리 → 메모리 사용량 일정
String line = String.format("%d,%s,%sn",
rs.getLong("id"),
rs.getString("name"),
rs.getString("email")
);
try {
out.write(line.getBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
// fetchSize 설정: MySQL은 Integer.MIN_VALUE로 스트리밍 모드 활성화
public void streamLargeData(Consumer<Member> consumer) {
jdbcTemplate.execute((ConnectionCallback<Void>) conn -> {
conn.setAutoCommit(false); // 필수
try (PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM member",
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(Integer.MIN_VALUE); // MySQL 스트리밍 모드
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
consumer.accept(Member.builder()
.id(rs.getLong("id"))
.name(rs.getString("name"))
.build()
);
}
}
}
return null;
});
}
SimpleJdbcInsert: 메타데이터 기반 INSERT
@Repository
public class EventJdbcRepository {
private final SimpleJdbcInsert simpleInsert;
public EventJdbcRepository(JdbcTemplate jdbcTemplate) {
this.simpleInsert = new SimpleJdbcInsert(jdbcTemplate)
.withTableName("event")
.usingGeneratedKeyColumns("id")
.usingColumns("name", "start_at", "end_at", "location");
}
// SQL 작성 불필요 — 테이블 메타데이터에서 자동 생성
public Long save(Event event) {
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("name", event.getName())
.addValue("start_at", event.getStartAt())
.addValue("end_at", event.getEndAt())
.addValue("location", event.getLocation());
return simpleInsert.executeAndReturnKey(params).longValue();
}
// 배치 INSERT
public int[] bulkSave(List<Event> events) {
SqlParameterSource[] batch = events.stream()
.map(BeanPropertySqlParameterSource::new)
.toArray(SqlParameterSource[]::new);
return simpleInsert.executeBatch(batch);
}
}
JPA + JdbcTemplate 공존 전략
// 실전에서는 JPA와 JdbcTemplate을 함께 사용
@Service
@RequiredArgsConstructor
public class ReportService {
private final MemberRepository memberRepository; // JPA
private final JdbcClient jdbcClient; // JDBC
// 단순 CRUD → JPA
public Member createMember(CreateMemberDto dto) {
return memberRepository.save(dto.toEntity());
}
// 복잡한 집계 쿼리 → JdbcClient
public List<MonthlyReport> getMonthlyReport(int year) {
return jdbcClient.sql("""
SELECT
DATE_FORMAT(o.created_at, '%Y-%m') as month,
COUNT(DISTINCT o.member_id) as unique_buyers,
COUNT(*) as order_count,
SUM(o.amount) as total_amount,
AVG(o.amount) as avg_amount
FROM orders o
WHERE YEAR(o.created_at) = :year
AND o.status = 'COMPLETED'
GROUP BY DATE_FORMAT(o.created_at, '%Y-%m')
ORDER BY month
""")
.param("year", year)
.query(MonthlyReport.class)
.list();
}
// 대용량 배치 → JdbcTemplate batchUpdate
@Transactional
public void deactivateInactiveMembers(LocalDate before) {
// JPA flush 후 JDBC 실행 (영속성 컨텍스트 동기화)
entityManager.flush();
entityManager.clear();
jdbcClient.sql("""
UPDATE member SET status = 'INACTIVE'
WHERE last_login_at < :before AND status = 'ACTIVE'
""")
.param("before", before)
.update();
}
}
핵심 규칙: 같은 트랜잭션에서 JPA와 JDBC를 섞을 때는 반드시 flush() → clear() 후 JDBC를 실행해야 한다. 그렇지 않으면 영속성 컨텍스트와 DB 상태가 불일치한다. 트랜잭션 전파에 대한 자세한 내용은 Spring Transaction 전파 심화 글을 참고하자.
마무리: 언제 JdbcTemplate을 쓸까
| 상황 | JPA | JdbcTemplate/JdbcClient |
|---|---|---|
| 단순 CRUD | ✅ 최적 | 과한 선택 |
| 복잡한 집계/통계 | 어려움 | ✅ 최적 |
| 대용량 배치 INSERT | 느림 | ✅ 최적 |
| 동적 쿼리 | Specification/QueryDSL | StringBuilder 직접 |
| DB 특화 기능 (Window 등) | 네이티브 쿼리 필요 | ✅ 자연스러움 |
JdbcTemplate은 “SQL을 직접 쓰되, 보일러플레이트는 제거한다”는 철학이다. Spring Boot 3.2+의 JdbcClient는 이를 모던한 플루언트 API로 한 단계 발전시켰다. JPA와 공존시키면서 각자의 강점을 활용하는 것이 실전 Spring 데이터 접근의 정석이다.