Java JDBC로 데이터베이스 연결과 SQL 실행 다루기

Java JDBC의 드라이버, Connection, Statement, ResultSet과 트랜잭션·커넥션 풀 활용 방식을 정리합니다.

2026-08-14 · 최초 발행 2026-01-12

Java 코드와 데이터베이스 사이의 표준 경계

JDBC(Java Database Connectivity)는 Java 프로그램이 관계형 데이터베이스에 접근할 때 사용하는 표준 API다. 애플리케이션은 벤더별 구현체인 JDBC Driver를 통해 데이터베이스와 통신하며, SQL 실행과 결과 처리는 공통 인터페이스로 다룬다. 데이터베이스를 바꿀 때 드라이버 교체를 중심으로 대응할 수 있다는 점도 이 구조의 핵심이다.

JDBC는 java.sql 패키지로 제공된다. 개발자가 사용하는 JDBC API, 드라이버 개발자를 위한 JDBC Driver API, 데이터베이스별 JDBC Driver, 그리고 드라이버를 관리하는 JDBC Driver Manager가 함께 구성된다.

이 표준화가 필요했던 배경에는 Oracle, MySQL, PostgreSQL, MS SQL Server 등 각 데이터베이스의 독자적 API가 있다. 이런 차이는 애플리케이션 종속성과 이식성 문제로 이어질 수 있다. JDBC는 ODBC(Open Database Connectivity)의 개념을 차용하면서 Java의 플랫폼 독립성을 반영했다.

JDBC가 다루는 범위는 단일 API를 통한 다양한 데이터베이스 접근, 드라이버 교체에 따른 애플리케이션 코드 변경 최소화, ANSI SQL-92 지원, 확장 SQL 기능의 선택적 지원이다. DDL, DML, DCL 실행과 저장 프로시저 호출도 포함하며, 배치 처리·트랜잭션 관리·Connection Pooling으로 데이터 접근의 효율을 높일 수 있다.

Java애플리케이션JDBC APIJDBCDriver ManagerJDBC DriverOracleMySQLPostgreSQLMS SQL Server

API와 드라이버가 나누는 역할

JDBC API의 중심은 java.sql이고, javax.sql은 DataSource와 Connection Pooling 같은 확장 기능을 제공한다. 개발 코드에서는 DriverManager, Connection, Statement, ResultSet 외에도 DatabaseMetaData와 ResultSetMetaData를 사용한다. SQL 오류는 SQLException, 경고는 SQLWarning, 배치 오류는 BatchUpdateException으로 다룬다.

DriverManager는 드라이버 등록과 관리, 연결 요청 라우팅, URL 패턴에 맞는 드라이버 선택을 맡는다. 드라이버는 Class.forName() 또는 자동 로딩으로 등록할 수 있으며, 애플리케이션이 DriverManager.getConnection()을 호출하면 URL 패턴에 맞는 드라이버가 Connection을 반환한다.

드라이버는 구현 방식에 따라 나뉜다.

  • Type 1인 JDBC-ODBC Bridge Driver는 JDBC 호출을 ODBC 호출로 변환한다. ODBC 드라이버가 필요하고 플랫폼 종속적이며 성능이 낮아 레거시 환경에서만 거의 사용되지 않는다.
  • Type 2인 Native-API Driver는 Java와 네이티브 라이브러리를 함께 사용한다. 데이터베이스 클라이언트 라이브러리가 필요하고 플랫폼 종속적이지만 성능이 좋다. Oracle OCI Driver가 예다.
  • Type 3인 Network Protocol Driver는 미들웨어 서버를 경유하는 순수 Java 드라이버다. 플랫폼 독립적이지만 미들웨어가 필요하며 IDS Server가 예다.
  • Type 4인 Thin Driver는 데이터베이스 프로토콜을 직접 구현한 순수 Java 드라이버다. 클라이언트 라이브러리가 필요 없고 플랫폼 독립적이어서 가장 많이 사용된다. MySQL Connector/J와 PostgreSQL JDBC Driver가 여기에 속한다.

연결에서 결과 집합까지

DriverManager는 연결을 만들고 드라이버를 등록하거나 해제하는 진입점이다.

// 연결 생성
Connection getConnection(String url)
Connection getConnection(String url, String user, String password)
Connection getConnection(String url, Properties info)

// 드라이버 등록
void registerDriver(Driver driver)
void deregisterDriver(Driver driver)

연결 URL은 데이터베이스별 형식을 따른다.

  • Oracle: jdbc:oracle:thin:@localhost:1521:orcl
  • MySQL: jdbc:mysql://localhost:3306/dbname
  • PostgreSQL: jdbc:postgresql://localhost:5432/dbname
  • MS SQL Server: jdbc:sqlserver://localhost:1433;databaseName=dbname

Connection은 데이터베이스 연결을 관리하면서 Statement 생성, 트랜잭션 제어, 메타데이터 조회를 담당한다.

// Statement 생성
Statement createStatement()
PreparedStatement prepareStatement(String sql)
CallableStatement prepareCall(String sql)

// 트랜잭션 제어
void setAutoCommit(boolean autoCommit)
void commit()
void rollback()

// 연결 종료
void close()

Statement 계열은 SQL 실행 목적에 따라 일반 Statement, 사전 컴파일된 SQL과 파라미터 바인딩을 지원하는 PreparedStatement, 저장 프로시저를 호출하는 CallableStatement로 구분된다.

// 쿼리 실행
ResultSet executeQuery(String sql)       // SELECT
int executeUpdate(String sql)            // INSERT, UPDATE, DELETE
boolean execute(String sql)              // 모든 SQL

// 배치 처리
void addBatch(String sql)
int[] executeBatch()

ResultSet은 쿼리 결과를 커서 기반으로 읽고, 행을 순회하며 컬럼 값을 꺼내는 인터페이스다.

// 커서 이동
boolean next()
boolean previous()
boolean first()
boolean last()

// 데이터 조회
String getString(int columnIndex)
int getInt(String columnName)
Date getDate(int columnIndex)
Object getObject(int columnIndex)

// 결과 셋 정보
int getRow()
ResultSetMetaData getMetaData()

커서 타입은 전진만 가능한 TYPE_FORWARD_ONLY, 변경을 감지하지 않는 양방향 스크롤 TYPE_SCROLL_INSENSITIVE, 변경을 감지하는 양방향 스크롤 TYPE_SCROLL_SENSITIVE가 있다. 동시성 모드는 읽기 전용 CONCUR_READ_ONLY와 업데이트 가능한 CONCUR_UPDATABLE로 나뉜다.

드라이버 로딩부터 자원 해제까지

JDBC 3.0 이하에서는 드라이버 클래스를 명시적으로 로딩한다.

// 드라이버 클래스 로딩
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("oracle.jdbc.driver.OracleDriver");

JDBC 4.0 이상에서는 드라이버 JAR 파일의 META-INF/services/java.sql.Driver 파일을 통해 드라이버가 DriverManager에 자동 등록되므로 Class.forName()이 필요 없다.

연결은 URL, 사용자, 비밀번호를 전달해 생성할 수 있다.

String url = "jdbc:mysql://localhost:3306/testdb";
String user = "root";
String password = "password";

Connection conn = DriverManager.getConnection(url, user, password);

Properties 객체로 연결 정보를 전달하는 방식도 있다.

Properties props = new Properties();
props.setProperty("user", "root");
props.setProperty("password", "password");
props.setProperty("useSSL", "false");

Connection conn = DriverManager.getConnection(url, props);

SELECT는 executeQuery()로 실행하고 ResultSet을 순회한다.

String sql = "SELECT id, name, email FROM users WHERE age > 20";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);

while (rs.next()) {
    int id = rs.getInt("id");
    String name = rs.getString("name");
    String email = rs.getString("email");
    System.out.println(id + ", " + name + ", " + email);
}

INSERT, UPDATE, DELETE에는 executeUpdate()를 사용한다.

String sql = "INSERT INTO users (name, email, age) VALUES ('John', 'john@example.com', 30)";
Statement stmt = conn.createStatement();
int rowsAffected = stmt.executeUpdate(sql);
System.out.println(rowsAffected + " rows inserted");

연결, Statement, ResultSet은 사용 후 해제해야 한다. try-with-resources는 블록을 벗어날 때 close()를 자동 호출한다.

try (Connection conn = DriverManager.getConnection(url, user, password);
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {

    while (rs.next()) {
        // 결과 처리
    }
} catch (SQLException e) {
    e.printStackTrace();
}
// 자동으로 close() 호출

명시적으로 해제해야 하는 구조라면 finally에서 ResultSet, Statement, Connection을 닫는다.

try {
    // 연결 및 쿼리 실행
} catch (SQLException e) {
    e.printStackTrace();
} finally {
    if (rs != null) rs.close();
    if (stmt != null) stmt.close();
    if (conn != null) conn.close();
}

SQL 실행 방식을 고를 때

PreparedStatement는 사전 컴파일된 SQL에 파라미터를 바인딩하는 방식이다. SQL Injection 방어와 타입 안전성, 코드 가독성에 도움이 되며 반복 실행 시 성능 향상을 기대할 수 있다.

String sql = "SELECT * FROM users WHERE name = ? AND age > ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, "John");
pstmt.setInt(2, 20);

ResultSet rs = pstmt.executeQuery();

CallableStatement는 저장 프로시저와 함수를 호출하고 IN, OUT, INOUT 파라미터를 지원한다.

// 저장 프로시저: {CALL get_user_count(?, ?)}
CallableStatement cstmt = conn.prepareCall("{CALL get_user_count(?, ?)}");
cstmt.setInt(1, 20);                    // IN 파라미터
cstmt.registerOutParameter(2, Types.INTEGER);  // OUT 파라미터

cstmt.execute();
int count = cstmt.getInt(2);
System.out.println("User count: " + count);

여러 SQL을 하나의 작업 단위로 다룰 때는 자동 커밋을 끄고, 성공하면 commit, 오류가 나면 rollback을 호출한다.

try {
    conn.setAutoCommit(false);  // 자동 커밋 비활성화

    // 여러 SQL 실행
    stmt.executeUpdate("INSERT INTO accounts (id, balance) VALUES (1, 1000)");
    stmt.executeUpdate("UPDATE accounts SET balance = balance - 100 WHERE id = 1");

    conn.commit();  // 커밋
} catch (SQLException e) {
    conn.rollback();  // 롤백
    e.printStackTrace();
} finally {
    conn.setAutoCommit(true);  // 자동 커밋 복원
}

격리 수준은 Connection에서 설정한다.

// 트랜잭션 격리 수준 설정
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);

배치 처리는 여러 SQL을 한 번에 실행해 네트워크 왕복을 줄이고 대량 데이터를 처리하는 데 사용한다.

Statement stmt = conn.createStatement();

stmt.addBatch("INSERT INTO users VALUES (1, 'John', 'john@example.com')");
stmt.addBatch("INSERT INTO users VALUES (2, 'Jane', 'jane@example.com')");
stmt.addBatch("INSERT INTO users VALUES (3, 'Bob', 'bob@example.com')");

int[] results = stmt.executeBatch();
System.out.println("Batch executed: " + results.length + " statements");

PreparedStatement에도 배치를 적용할 수 있다.

String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
PreparedStatement pstmt = conn.prepareStatement(sql);

for (User user : users) {
    pstmt.setString(1, user.getName());
    pstmt.setString(2, user.getEmail());
    pstmt.addBatch();
}

int[] results = pstmt.executeBatch();

Connection Pool과 DataSource

데이터베이스 연결을 새로 만드는 과정에는 네트워크 설정, 인증, 초기화 비용이 든다. 연결을 매번 생성하고 해제하면 성능이 저하될 수 있고, 동시 연결 수 제한도 고려해야 한다. Connection Pool은 미리 생성한 연결을 풀에 보관하고 재사용해 이런 오버헤드를 줄이며 동시 접속을 제어한다.

주요 구현체로는 HikariCP, Apache DBCP(Database Connection Pool), C3P0, Tomcat JDBC Pool이 있다. HikariCP는 현재 가장 빠른 Connection Pool이며 Spring Boot 기본 Connection Pool로 사용된다. Apache DBCP는 Apache Commons 프로젝트의 널리 쓰이는 구현체다. C3P0은 오래된 Connection Pool로 강력한 기능과 복잡한 설정을 갖고, Tomcat JDBC Pool은 Tomcat에 내장된 DBCP 개선 버전이다.

javax.sql.DataSource는 Connection 생성 팩토리 역할을 하는 인터페이스다. Connection Pool과 통합할 수 있고 JNDI 등록도 가능하다. HikariCP를 사용하는 예시는 다음과 같다.

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/testdb");
config.setUsername("root");
config.setPassword("password");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(30000);

HikariDataSource dataSource = new HikariDataSource(config);

// Connection 얻기
Connection conn = dataSource.getConnection();
// 사용 후 close() → 풀로 반환
conn.close();

Spring Boot에서는 다음과 같이 설정할 수 있다.

spring.datasource.url=jdbc:mysql://localhost:3306/testdb
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2

JDBC를 직접 다루는 범위

JDBC는 데이터베이스 독립성, Java 표준 API 기반의 일관된 개발 방식, DDL·DML·DCL 실행과 저장 프로시저 호출·트랜잭션 제어·배치 처리의 유연성을 제공한다. Connection Pooling, PreparedStatement 재사용, 배치 처리, 효율적인 데이터 전송도 성능 측면의 장점이다.

반면 저수준 API이므로 반복 코드가 많고 예외 처리와 자원 관리가 번거롭다. SQL 문자열 하드코딩, 타입 안전성 부족, 컴파일 타임 오류 검출의 한계, 객체-관계 임피던스 미스매치도 함께 고려해야 한다. 객체와 테이블의 매핑 및 관계 매핑을 수동으로 처리해야 하므로 반복적인 매핑 코드와 유지보수 부담이 생길 수 있다.

이런 한계를 추상화하는 기술로 JPA/Hibernate, MyBatis, Spring JDBC Template, jOOQ가 있다. JPA/Hibernate는 ORM 표준이고, MyBatis는 SQL 매핑 프레임워크이며, Spring JDBC Template은 JDBC 추상화, jOOQ는 타입 안전 SQL 빌더를 제공한다.

JDBCJava데이터베이스SQL커넥션 풀