StatementPreparedStatement의 차이가 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()을 사용한다.

생각해 볼 문제

  1. PreparedStatement가 SQL Injection을 막는 정확한 메커니즘은 무엇인가? (이스케이프 vs 구문 분리)
  2. 연결 풀(HikariCP)이 직접 DriverManager보다 빠른 이유는? (TCP 연결 재사용)
  3. executeQuery() vs executeUpdate() vs execute()의 반환 타입 차이는?
  4. JDBC의 SAVEPOINT를 사용해 부분 롤백을 구현해 보자.
  5. try-with-resources에서 연결이 닫히기 전에 트랜잭션이 커밋되지 않으면 어떻게 되는가?

참고

'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