Spring JdbcTemplate JdbcClient 심화

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에서 도입된 JdbcClientJdbcTemplateNamedParameterJdbcTemplate을 통합한 플루언트 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());
    });
}

ResultSetExtractorRowMapper와 달리 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 데이터 접근의 정석이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux