Statement와 PreparedStatement의 차이가 SQL Injection을 막는 이유 — JDBC 기초
// Java 25 — 위험: SQL Injection 취약
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT * FROM users WHERE name = '" + userInput + "'"
);
// userInput = "'; DROP TABLE users; --" → 테이블 삭제!
이 코드를 작성한 개발자는 악의가 없었다. 그냥 문자열을 연결했을 뿐이다. 하지만 공격자가 '; DROP TABLE users; --를 입력하면 SQL 문이 두 개로 나뉘어 실행된다. PreparedStatement를 쓰면 이 공격이 원천 차단된다 — 매개변수가 값으로 취급되어 SQL 구조에 영향을 주지 않기 때문이다. (JDBC API)
이 글은 JDBC의 핵심 — Connection, PreparedStatement, 트랜잭션, 그리고 안전한 데이터베이스 접근 패턴을 풀어간다.
JDBC 아키텍처 — 드라이버와 연결
flowchart LR
APP["Java 애플리케이션"] -->|JDBC API| DRV["JDBC 드라이버<br/>(PostgreSQL, MySQL 등)"]
DRV -->|네이티브 프로토콜| DB[(데이터베이스)]
JDBC는 Java에서 데이터베이스에 접근하는 표준 API다. 각 DB 벤더가 JDBC 드라이버를 제공하며, 애플리케이션은 벤더에 무관하게 같은 API를 사용한다.
연결 (Connection)
// Java 25 — JDBC 연결 (H2 인메모리 DB 예시)
import java.sql.*;
// 방식 1: DriverManager (전통적)
String url = "jdbc:h2:mem:testdb";
Connection conn = DriverManager.getConnection(url, "sa", "");
// 방식 2: DataSource (권장 — 연결 풀 지원)
// HikariCP, Tomcat JDBC Pool 등 연결 풀 라이브러리 사용
// DataSource ds = new HikariDataSource(hikariConfig);
// Connection conn = ds.getConnection();
프로덕션에서는
DriverManager를 직접 쓰지 않는다 — 매 연결마다 새 TCP 연결을 생성하므로 비효율적. 연결 풀(connection pool)인 HikariCP, Tomcat JDBC Pool 등을 쓴다. Spring Boot는 HikariCP를 기본 연결 풀로 사용한다.
Statement vs PreparedStatement — SQL Injection 방어
// Java 25 — PreparedStatement로 안전한 쿼리
String sql = "SELECT id, name, email FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, userInput); // 매개변수 바인딩
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong("id") + ": " + rs.getString("name"));
}
}
}
? 자리표시자(placeholder)에 setString(), setLong() 등으로 값을 바인딩한다. 드라이버가 값을 이스케이프 처리하여 SQL 구조에 영향을 주지 않는다.
| 항목 | Statement | PreparedStatement |
|---|---|---|
| SQL Injection | 취약 | 방어 |
| 성능 (반복 실행) | 매번 컴파일 | 실행 계획 재사용 (서버 측 prepared statement) |
| 매개변수 바인딩 | 없음 (문자열 연결) | ? + setXxx() |
| 사용 시기 | DDL(테이블 생성 등) | 모든 DML(SELECT/INSERT/UPDATE/DELETE) |
규칙: 사용자 입력이 포함된 모든 쿼리는 PreparedStatement를 사용한다. 예외 없음. Statement는 고정된 DDL(
CREATE TABLE)에만 쓴다.
INSERT / UPDATE / DELETE
// Java 25 — INSERT with PreparedStatement
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)")) {
ps.setString(1, "Alice");
ps.setString(2, "alice@example.com");
ps.setInt(3, 30);
int affected = ps.executeUpdate(); // 영향받은 행 수 반환
System.out.println(affected + "행 삽입");
}
// Java 25 — UPDATE
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE users SET age = ? WHERE name = ?")) {
ps.setInt(1, 31);
ps.setString(2, "Alice");
ps.executeUpdate();
}
Batch 실행 — 여러 행을 한 번에
// Java 25 — Batch INSERT
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name) VALUES (?)")) {
for (String name : List.of("Alice", "Bob", "Charlie")) {
ps.setString(1, name);
ps.addBatch(); // 배치에 추가
}
int[] results = ps.executeBatch(); // 한 번에 실행
}
트랜잭션 — ACID 보장
JDBC의 기본 자동 커밋(autocommit)은 각 SQL이 즉시 커밋된다. 여러 SQL을 원자적으로 처리하려면 수동 트랜잭션을 사용한다:
// Java 25 — 수동 트랜잭션
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 자동 커밋 비활성화
// 여러 SQL 실행
try (PreparedStatement ps1 = conn.prepareStatement(
"UPDATE accounts SET balance = balance - ? WHERE id = ?")) {
ps1.setLong(1, 1000);
ps1.setLong(2, fromAccount);
ps1.executeUpdate();
}
try (PreparedStatement ps2 = conn.prepareStatement(
"UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
ps2.setLong(1, 1000);
ps2.setLong(2, toAccount);
ps2.executeUpdate();
}
conn.commit(); // 전체 성공 시 커밋
} catch (SQLException e) {
conn.rollback(); // 하나라도 실패 시 전체 롤백
throw e;
} finally {
conn.setAutoCommit(true); // 연결 반납 전 복구 (풀링 환경)
conn.close();
}
Spring의
@Transactional이 이 과정을 자동화한다 —conn.setAutoCommit(false), 커밋/롤백, 예외 변환을 프록시로 처리한다. JDBC를 직접 쓸 때는 이 패턴을 수동으로 구현해야 한다.
격리 수준 (Isolation Level)
// Java 25
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 옵션: NONE, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
| 수준 | 더티 읽기 | 반복 불가 읽기 | 팬텀 읽기 | 성능 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 허용 | 허용 | 허용 | 최고 |
| READ_COMMITTED | 차단 | 허용 | 허용 | 높음 |
| REPEATABLE_READ | 차단 | 차단 | 허용 | 중간 |
| SERIALIZABLE | 차단 | 차단 | 차단 | 낮음 |
ResultSet — 결과 순회
// Java 25
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM users")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { // 다음 행으로 이동
long id = rs.getLong("id");
String name = rs.getString("name");
Integer age = rs.getObject("age", Integer.class); // null-safe
}
}
}
rs.getLong("id")는 데이터베이스 값이NULL이면0L(long 기본값)을 반환한다 —NullPointerException이 아니다. NULL 여부를 확인하려면rs.wasNull()을 호출하거나rs.getObject("id", Long.class)를 사용한다.
실습 — JDBC 기본 패턴
// Java 25 — JdbcDemo.java (H2 인메모리 DB 필요)
import java.sql.*;
public class JdbcDemo {
public static void main(String[] args) throws SQLException {
String url = "jdbc:h2:mem:testdb";
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
// 테이블 생성
try (Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200),
age INT
)
""");
}
// 데이터 삽입 (PreparedStatement)
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)")) {
ps.setString(1, "Alice"); ps.setString(2, "alice@example.com"); ps.setInt(3, 30);
ps.executeUpdate();
ps.setString(1, "Bob"); ps.setString(2, "bob@example.com"); ps.setInt(3, 25);
ps.executeUpdate();
}
// 조회
try (PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE age >= ? ORDER BY name")) {
ps.setInt(1, 25);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.printf("id=%d, name=%s, email=%s, age=%d%n",
rs.getLong("id"), rs.getString("name"),
rs.getString("email"), rs.getInt("age"));
}
}
}
}
}
}
# H2 드라이버 필요 (의존성 추가 또는 classpath에 JAR)
java -cp h2.jar JdbcDemo.java
id=1, name=Alice, email=alice@example.com, age=30
id=2, name=Bob, email=bob@example.com, age=25
확인할 것: PreparedStatement로 매개변수를 바인딩하여 안전하게 쿼리를 실행한다. 모든 Connection, PreparedStatement, ResultSet을 try-with-resources로 자원 해제한다.
연결 풀(Connection Pool) — 왜 필요한가
데이터베이스 연결 생성 비용은 매우 높다 — TCP 핸드셰이크, 인증, 세션 설정에 수십 ~ 수백 ms가 소요된다. 매 요청마다 새 연결을 만들면 성능이 급격히 저하된다.
연결 풀은 미리 N개의 연결을 생성해 두고, 요청 시 재사용한다:
- 연결 생성 비용: 최초 1회만
- 연결 반납:
close()호출 시 실제로 닫지 않고 풀에 반환 - Spring Boot 기본: HikariCP (maximumPoolSize 기본값 10)
// Java 25 — HikariCP 예시 (외부 라이브러리)
// HikariConfig config = new HikariConfig();
// config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
// config.setUsername("user");
// config.setPassword("password");
// config.setMaximumPoolSize(20);
//
// HikariDataSource ds = new HikariDataSource(config);
//
// try (Connection conn = ds.getConnection()) {
// // 연결 사용 (풀에서 대여)
// } // close() → 실제로는 풀에 반환
maximumPoolSize는 데이터베이스와 애플리케이션의 하드웨어에 따라 튜닝해야 한다. 너무 크면 DB 커넥션 한계 초과, 너무 작으면 요청 대기. 일반적으로 CPU 코어 수 × 2 + 디스크 수를 시작점으로 사용한다 (HikariCP 권장).
JDBC 4.0+ — Class.forName() 불필요
Java 6 이전에는 명시적으로 드라이버를 로드해야 했다:
// Java 5 — 레거시
Class.forName("org.postgresql.Driver"); // 드라이버 로드 필수
Connection conn = DriverManager.getConnection(url);
Java 6+(JDBC 4.0)부터는 ServiceLoader 메커니즘으로 자동 등록된다:
// Java 25 — 드라이버 로드 불필요
Connection conn = DriverManager.getConnection(url);
// DriverManager가 classpath의 JAR에서 META-INF/services/java.sql.Driver를 자동 검색
드라이버 JAR의
META-INF/services/java.sql.Driver파일에 드라이버 클래스명이 등록되어 있으면,DriverManager가 시작 시 자동으로 로드한다.Class.forName()은 레거시 — 새 코드에서는 필요 없다.
요약 — 이 글의 결론
- 항상
PreparedStatement를 사용한다.Statement+ 문자열 연결은 SQL Injection 위험이 있다.Statement는 DDL에만 쓴다. try-with-resources로 Connection/Statement/ResultSet을 닫는다. 닫지 않으면 연결 누수가 발생하고, 연결 풀이 고갈된다.- 트랜잭션은
setAutoCommit(false)로 수동 제어한다. 성공 시commit(), 실패 시rollback(). Spring의@Transactional이 이를 자동화한다. - 프로덕션은 연결 풀(HikariCP)을 쓴다.
DriverManager.getConnection()은 매번 새 TCP 연결을 생성한다. rs.getLong()이 NULL일 때 기본값(0)을 반환한다. NULL 처리가 필요하면getObject()또는wasNull()을 사용한다.
생각해 볼 문제
PreparedStatement가 SQL Injection을 막는 정확한 메커니즘은 무엇인가? (이스케이프 vs 구문 분리)- 연결 풀(HikariCP)이 직접
DriverManager보다 빠른 이유는? (TCP 연결 재사용) executeQuery()vsexecuteUpdate()vsexecute()의 반환 타입 차이는?- JDBC의
SAVEPOINT를 사용해 부분 롤백을 구현해 보자. try-with-resources에서 연결이 닫히기 전에 트랜잭션이 커밋되지 않으면 어떻게 되는가?
참고
- JDBC API (Java 25) - 접근 2026-07-10
- Connection API - 접근 2026-07-10
- PreparedStatement API - 접근 2026-07-10
- HikariCP - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 23. modules (0) | 2026.07.12 |
|---|---|
| Java - 22. networking (0) | 2026.07.12 |
| Java - 20. virtual threads (0) | 2026.07.12 |
| Java - 19. concurrent (0) | 2026.07.12 |
| Java - 18. thread sync (0) | 2026.07.12 |
