概要

Statement に文字列連結で値を埋め込む書き方は、SQL インジェクションという深刻なセキュリティリスクを抱えています。PreparedStatement を使えば、SQL とパラメータが分離されるため、悪意ある入力がそのまま SQL として解釈される事態を防げます。セキュリティだけでなく、DB 側で SQL の実行計画をキャッシュできるため、同じ構造のクエリを繰り返す場合にパフォーマンス面でも有利です。この記事では、基本的なパラメータバインドの書き方、複数件を一括処理するバッチ INSERT、そして SQL インジェクションがなぜ危険なのかを具体例で示します。Statement からの移行ポイントも整理するため、既存コードの改善にも役立ちます。

使いどころ

ユーザーが入力した検索条件で従業員を絞り込む画面を SQL インジェクション対策付きで実装する

CSV ファイルから読み取った数百件のデータを executeBatch でまとめて DB に取り込む

ログイン認証でユーザー名・パスワードを WHERE 条件に使う際に安全なクエリを組み立てる

コード例

PreparedStatement でパラメータバインドとバッチ処理を実装する
import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class PreparedStatementSample {

    record User(int id, String username, String email, int age) {}

    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(
            "jdbc:h2:mem:preptest;DB_CLOSE_DELAY=-1", "sa", "");
    }

    public static void setup(Connection conn) throws SQLException {
        try (var stmt = conn.createStatement()) {
            stmt.execute("""
                CREATE TABLE IF NOT EXISTS users (
                    id       INT PRIMARY KEY,
                    username VARCHAR(50),
                    email    VARCHAR(100),
                    age      INT
                )
                """);
            stmt.execute("DELETE FROM users");
        }
    }

    public static int insertUser(Connection conn, User user) throws SQLException {
        var sql = """
            INSERT INTO users (id, username, email, age)
            VALUES (?, ?, ?, ?)
            """;
        try (var pstmt = conn.prepareStatement(sql)) {
            pstmt.setInt(1, user.id());
            pstmt.setString(2, user.username());
            pstmt.setString(3, user.email());
            pstmt.setInt(4, user.age());
            return pstmt.executeUpdate();
        }
    }

    public static List<User> findByAge(Connection conn, int minAge)
            throws SQLException {
        var results = new ArrayList<User>();
        var sql = "SELECT id, username, email, age FROM users"
            + " WHERE age >= ? ORDER BY age";
        try (var pstmt = conn.prepareStatement(sql)) {
            pstmt.setInt(1, minAge);
            try (var rs = pstmt.executeQuery()) {
                while (rs.next()) {
                    results.add(new User(
                        rs.getInt("id"), rs.getString("username"),
                        rs.getString("email"), rs.getInt("age")));
                }
            }
        }
        return results;
    }

    public static int[] batchInsert(Connection conn, List<User> users)
            throws SQLException {
        var sql = """
            INSERT INTO users (id, username, email, age)
            VALUES (?, ?, ?, ?)
            """;
        try (var pstmt = conn.prepareStatement(sql)) {
            for (var user : users) {
                pstmt.setInt(1, user.id());
                pstmt.setString(2, user.username());
                pstmt.setString(3, user.email());
                pstmt.setInt(4, user.age());
                pstmt.addBatch();
            }
            return pstmt.executeBatch();
        }
    }

    public static void main(String[] args) throws SQLException {
        try (var conn = getConnection()) {
            setup(conn);

            insertUser(conn, new User(1, "tanaka", "[email protected]", 28));
            insertUser(conn, new User(2, "suzuki", "[email protected]", 35));
            insertUser(conn, new User(3, "sato", "[email protected]", 22));

            System.out.println("=== age >= 25 ===");
            for (var user : findByAge(conn, 25)) {
                System.out.printf("id=%d username=%s age=%d%n",
                    user.id(), user.username(), user.age());
            }

            var batch = List.of(
                new User(10, "yamada", "[email protected]", 30),
                new User(11, "ito", "[email protected]", 27));
            var counts = batchInsert(conn, batch);
            System.out.println("バッチ件数: " + counts.length);
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

テキストブロック(""")で複数行の SQL を読みやすく書ける。record でバインド対象のデータを型安全に保持できる。

Java 17
// Java 17: テキストブロック + record で簡潔に
record User(int id, String username, String email, int age) {}
var sql = """
    INSERT INTO users (id, username, email, age)
    VALUES (?, ?, ?, ?)
    """;
try (var pstmt = conn.prepareStatement(sql)) {
    pstmt.setInt(1, user.id());
    pstmt.setString(2, user.username());
    pstmt.setString(3, user.email());
    pstmt.setInt(4, user.age());
    pstmt.executeUpdate();
}

Library Comparison

Pure JDBC PreparedStatementSQL を直接制御したい場合や、フレームワーク非依存の処理で使う。SQL インジェクション対策が標準で組み込まれる。コード量はやや多い。
Spring NamedParameterJdbcTemplate名前付きパラメータ(:name)で SQL の可読性を上げたい場合。? の番号管理が不要になるが、Spring 依存が前提。
jOOQSQL をタイプセーフに組み立て、コンパイル時にエラーを検出したい場合。型安全性は高いが、学習コストとライセンスの確認が必要。

注意点

プレースホルダ(?)のインデックスは 1 始まり。0 を指定すると SQLException になるため、setInt(0, ...) は典型的なバグ

IN 句(WHERE id IN (?, ?, ?))のプレースホルダ数は動的に生成する必要がある。件数に応じて ? を連結するヘルパーを用意するとよい

executeBatch の戻り値は各行の更新件数配列だが、ドライバによっては SUCCESS_NO_INFO(-2)を返す場合がある

PreparedStatement は再利用を前提に設計されている。ループ内で毎回 prepareStatement() を呼ぶのは非効率。ループの外で1回だけ prepare する

FAQ

Statement と PreparedStatement はどう使い分けますか?

ユーザー入力を含む SQL は必ず PreparedStatement を使います。DDL やリテラルのみの SQL は Statement でも構いません。

バッチ処理で途中の行がエラーになった場合はどうなりますか?

ドライバにより挙動が異なります。一般的には BatchUpdateException が発生し、成功した行の情報が配列で取れます。

PreparedStatement はスレッドセーフですか?

いいえ。Connection と同様にスレッド間で共有するものではありません。スレッドごとに取得してください。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます