JDBC Statement와 PreparedStatement: SQL 실행 방식 선택
JDBC의 Statement와 PreparedStatement 처리 흐름, SQL 인젝션 위험, 반복 실행과 배치 처리 시 선택 기준을 정리한다.
2026-08-14 · 최초 발행 2025-08-10
SQL이 결과를 돌려주기까지
애플리케이션이 DBMS에 SQL을 전달하면 쿼리는 바로 실행되지 않는다. 구문을 해석하고, 테이블·컬럼·권한을 확인하고, 실행 계획을 세운 뒤 실행 가능한 형태로 변환하는 과정을 거친다.
처리 순서는 다음과 같다.
- 파싱은 SQL 구문을 분석해 구문 트리(Parse Tree)를 만든다.
- 정당성 검사에서는 테이블과 컬럼의 존재 여부, 권한을 확인한다.
- 최적화 단계에서 쿼리 실행 계획을 수립한다.
- 컴파일은 SQL을 실행 가능한 형태로 변환한다.
- 실행 단계에서 데이터베이스 작업이 수행된다.
- 결과는 클라이언트로 반환된다.
JDBC(Java Database Connectivity)를 포함한 데이터베이스 연결 인터페이스는 이 SQL을 실행할 때 Statement와 PreparedStatement라는 두 방식을 제공한다.
완성된 SQL을 보내는 Statement
Statement는 완성된 SQL 문자열을 만들어 직접 실행하는 기본 방식이다. 쿼리 내용이 실행 시점에 정해지는 경우에는 유연하지만, 매 실행마다 SQL 처리 단계를 거치며 사용자 입력을 문자열로 연결할 때 보안 문제가 생길 수 있다.
다음은 department_id가 100인 직원 정보를 조회하는 예제다.
Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement();
String sql = "SELECT * FROM employees WHERE department_id = 100";
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
System.out.println(rs.getString("employee_name"));
}
rs.close();
stmt.close();
conn.close();
Statement는 구현이 단순하고, 동적으로 완성한 쿼리를 실행할 수 있다. 반면 SQL 문자열을 매번 파싱·컴파일·최적화해야 하며, 입력값을 직접 결합하면 SQL 인젝션에 노출된다.
// 위험한 코드
String userInput = request.getParameter("userName");
String sql = "SELECT * FROM users WHERE username = '" + userInput + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
사용자가 admin' OR '1'='1를 입력하면 실제 SQL은 SELECT * FROM users WHERE username = 'admin' OR '1'='1'가 된다. 조건이 항상 참이 되어 모든 사용자 정보가 노출될 수 있다.
SQL 템플릿을 재사용하는 PreparedStatement
PreparedStatement는 파라미터화된 SQL을 준비하고, 이후 값만 바꿔 실행하는 방식이다. SQL 문을 한 번 파싱하고 컴파일해 재사용할 수 있으며, 파라미터 바인딩으로 사용자 입력을 다룬다.
동일한 SQL 템플릿에 서로 다른 department_id를 바인딩하는 코드는 다음과 같다.
Connection conn = DriverManager.getConnection(url, user, password);
String sql = "SELECT * FROM employees WHERE department_id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
// 첫 번째 실행
pstmt.setInt(1, 100);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
System.out.println(rs.getString("employee_name"));
}
rs.close();
// 두 번째 실행 (재사용)
pstmt.setInt(1, 200);
rs = pstmt.executeQuery();
while (rs.next()) {
System.out.println(rs.getString("employee_name"));
}
rs.close();
pstmt.close();
conn.close();
이 방식은 반복 SQL 처리 시간을 줄일 수 있고, SQL 인젝션 공격을 방지하며, 파라미터 바인딩 과정에서 데이터 타입을 검사한다. BLOB, CLOB 같은 대용량 데이터 처리에도 사용할 수 있다.
단발 실행과 반복 실행의 차이
단일 쿼리만 실행한다면 두 방식의 성능 차이는 크지 않을 수 있다. Statement는 별도의 준비 과정이 없으므로 초기 오버헤드가 적을 수 있다.
같은 쿼리를 다른 파라미터와 함께 여러 번 실행하면 PreparedStatement의 재사용성이 드러난다. Statement는 각 요청에서 파싱과 실행을 반복하지만, PreparedStatement는 SQL 파싱 및 컴파일을 1회 수행한 뒤 바인딩과 실행을 반복한다.
쿼리 성격에 따른 선택
한 번만 실행하는 동적 쿼리, CREATE·ALTER·DROP 같은 DDL, 파라미터가 없는 간단한 SQL, 테이블명이나 컬럼명이 동적으로 달라지는 경우에는 Statement를 사용할 수 있다.
// 동적 테이블 쿼리 예시
String tableName = "sales_" + year + "_" + month;
String sql = "SELECT sum(amount) FROM " + tableName;
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
반대로 동일한 SQL을 다른 파라미터로 반복 실행할 때, 사용자 입력이 포함된 쿼리, 대량 데이터 배치 처리, BLOB·CLOB 처리에는 PreparedStatement가 적합하다.
// 배치 처리 예시
String sql = "INSERT INTO products (name, price) VALUES (?, ?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
for (Product product : productList) {
pstmt.setString(1, product.getName());
pstmt.setDouble(2, product.getPrice());
pstmt.addBatch();
}
int[] results = pstmt.executeBatch();
대형 금융 시스템 사례에서는 뱅킹 시스템이 수천만 건의 거래를 처리하며 PreparedStatement를 사용해 CPU 사용률을 30% 감소시켰고, 증권 거래 플랫폼은 주문 처리 시스템에 적용해 초당 처리량을 2배 증가시켰다. 보험 청구 시스템에서는 PreparedStatement 활용으로 SQL 인젝션 공격 위험을 제거했다.
재사용과 배치 처리
PreparedStatement는 연결을 가져온 뒤 한 번 생성해 여러 파라미터에 재사용할 수 있다. 다음 코드는 여러 category_id 조회에 하나의 PreparedStatement를 사용한다.
// Connection 풀에서 연결 가져오기
Connection conn = dataSource.getConnection();
// PreparedStatement 생성 및 재사용
PreparedStatement pstmt = null;
try {
pstmt = conn.prepareStatement("SELECT * FROM products WHERE category_id = ?");
// 여러 카테고리 조회
for (int categoryId : categoryIds) {
pstmt.setInt(1, categoryId);
ResultSet rs = pstmt.executeQuery();
// 결과 처리
rs.close();
}
} finally {
if (pstmt != null) pstmt.close();
conn.close(); // 연결 반환
}
대량 INSERT에서는 자동 커밋을 비활성화하고, 1000개마다 배치를 실행한 뒤 남은 항목을 처리할 수 있다.
PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO orders (customer_id, product_id, quantity) VALUES (?, ?, ?)");
// 자동 커밋 비활성화
conn.setAutoCommit(false);
for (Order order : orderList) {
pstmt.setInt(1, order.getCustomerId());
pstmt.setInt(2, order.getProductId());
pstmt.setInt(3, order.getQuantity());
pstmt.addBatch();
// 1000개마다 배치 실행
if (++count % 1000 == 0) {
pstmt.executeBatch();
}
}
// 남은 배치 실행
pstmt.executeBatch();
conn.commit();
대부분의 애플리케이션에서는 PreparedStatement를 기본으로 두고, 완성된 동적 SQL이 꼭 필요한 경우에만 Statement를 선택한다. 이 구분은 SQL 처리 성능과 입력값 보안을 함께 다루는 기준이 된다.