JDBC와 커넥션 풀 — 자바가 데이터베이스에 접근하는 원리
이 글은 자바로 DB를 처음 붙여보는 주니어/학생을 위한 글입니다. JDBC 기본 개념은 몰라도 되고, 자바 문법만 알고 있으면 따라올 수 있습니다.
JPA에서 em.persist(user)를 호출하면 Hibernate가 INSERT SQL을 생성하고, 결국 JDBC의 PreparedStatement로 실행합니다. MyBatis도 마찬가지입니다. 어떤 ORM이나 SQL 매퍼를 쓰든, 자바가 데이터베이스와 대화하는 최종 경로는 항상 JDBC입니다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
JDBC가 왜 필요한가 — DB 접근의 표준 인터페이스
JDBC(Java Database Connectivity) 란 자바에서 데이터베이스에 접근하기 위한 표준 API입니다.
왜 "표준"이 중요한지 생각해볼게요. 세상에는 MySQL, PostgreSQL, Oracle, MariaDB 등 수많은 데이터베이스가 있습니다. 만약 DB마다 접근 방식이 완전히 다르다면 어떻게 될까요?
- MySQL용 코드, PostgreSQL용 코드, Oracle용 코드를 각각 따로 작성해야 합니다
- DB를 교체하면 데이터 접근 코드를 전부 다시 짜야 합니다
- 라이브러리마다 사용법이 달라서 학습 비용이 엄청나게 늘어납니다
JDBC는 이 문제를 해결합니다. "자바에서 DB에 접근하려면 이 인터페이스를 따라라" 라는 표준을 정해놓고, 각 DB 벤더가 그 표준에 맞는 드라이버를 제공하는 구조예요.
학습할 라이브러리도 하나, 코드도 그대로 — DB 호환성 문제를 인터페이스 한 층으로 깔끔하게 분리한 셈입니다. 덕분에 운영 중인 서비스가 MySQL에서 PostgreSQL로 옮겨가더라도 데이터 접근 코드는 거의 손대지 않아도 돼요.
JDBC 아키텍처 — Driver, Connection, Statement, ResultSet
JDBC의 핵심 구성요소 네 가지를 알아야 전체 흐름이 보입니다. 호출 흐름은 이렇게 한 줄로 흐릅니다.
[Java 앱] → [JDBC API] → [JDBC Driver] → [DB 서버]
(java.sql) (벤더 제공) (TCP/IP)
JDBC를 사용하는 전체 흐름은 이렇습니다: (1) DriverManager에 드라이버 등록 → (2) Connection 획득 → (3) Statement 생성 → (4) SQL 실행 → (5) ResultSet에서 데이터 추출 → (6) 자원 해제.
각 구성요소의 역할을 정리하면 다음과 같습니다.
| 구성요소 | 역할 | 핵심 인터페이스 |
|---|---|---|
| Driver | DB와의 실제 통신을 담당하는 드라이버 | java.sql.Driver |
| Connection | DB와의 연결 세션을 나타냄 | java.sql.Connection |
| Statement | SQL 문을 DB로 전송하고 실행 | java.sql.Statement |
| ResultSet | SQL 실행 결과를 담는 커서 | java.sql.ResultSet |
이 네 가지가 JDBC의 뼈대입니다. 이제 코드로 하나씩 살펴볼게요.
기본 CRUD — DriverManager로 시작하기
가장 기본적인 JDBC 사용법을 살펴볼게요. MySQL을 기준으로 작성했지만, 드라이버와 URL만 바꾸면 다른 DB에서도 동일하게 동작합니다.
연결하기
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class JdbcBasic {
public static void main(String[] args) {
// JDBC URL 형식: jdbc:DB종류://호스트:포트/DB이름
String url = "jdbc:mysql://localhost:3306/mydb";
String user = "root";
String password = "1234";
try {
// DriverManager를 통해 커넥션 획득
Connection conn = DriverManager.getConnection(url, user, password);
System.out.println("DB 연결 성공!");
// 사용 후 반드시 닫아야 한다
conn.close();
} catch (SQLException e) {
System.out.println("DB 연결 실패: " + e.getMessage());
}
}
}
DriverManager.getConnection()을 호출하면 내부적으로 이런 일이 벌어집니다.
- 등록된 드라이버 목록에서 URL에 맞는 드라이버를 찾습니다
- 해당 드라이버가 DB 서버에 TCP 연결을 맺습니다
- 인증(사용자, 비밀번호)을 수행합니다
- 연결된
Connection객체를 반환합니다
JDBC 4.0(Java 6) 이후로는 Class.forName("com.mysql.cj.jdbc.Driver") 같은 드라이버 로딩 코드를 쓸 필요가 없어요. 클래스패스에 드라이버 JAR만 있으면 자동으로 로딩됩니다.
데이터 조회 (SELECT)
import java.sql.*;
public class SelectExample {
public static void main(String[] args) throws SQLException {
String url = "jdbc:mysql://localhost:3306/mydb";
Connection conn = DriverManager.getConnection(url, "root", "1234");
// Statement 생성
Statement stmt = conn.createStatement();
// SQL 실행 → ResultSet 반환
ResultSet rs = stmt.executeQuery("SELECT id, name, email FROM users");
// ResultSet 순회
while (rs.next()) {
int id = rs.getInt("id"); // 컬럼명으로 조회
String name = rs.getString("name");
String email = rs.getString("email");
System.out.println(id + " | " + name + " | " + email);
}
// 역순으로 닫기 (열린 순서의 반대)
rs.close();
stmt.close();
conn.close();
}
}
데이터 삽입/수정/삭제 (INSERT, UPDATE, DELETE)
Statement stmt = conn.createStatement();
// executeUpdate()는 영향받은 행 수를 반환한다
int rows = stmt.executeUpdate(
"INSERT INTO users (name, email) VALUES ('홍길동', 'hong@email.com')"
);
System.out.println(rows + "행 삽입됨");
executeQuery()는 SELECT에, executeUpdate()는 INSERT/UPDATE/DELETE에 사용합니다.
| 메서드 | 용도 | 반환 타입 |
|---|---|---|
executeQuery() | SELECT | ResultSet |
executeUpdate() | INSERT, UPDATE, DELETE, DDL | int (영향받은 행 수) |
execute() | 모든 SQL | boolean (ResultSet 여부) |
PreparedStatement — SQL Injection 방어
위에서 본 Statement에는 치명적인 문제가 있습니다. 사용자 입력을 SQL에 직접 넣으면 SQL Injection 공격에 노출돼요.
// ❌ 절대 이렇게 하면 안 된다
String userInput = "'; DROP TABLE users; --";
String sql = "SELECT * FROM users WHERE name = '" + userInput + "'";
stmt.executeQuery(sql);
// 실행되는 SQL: SELECT * FROM users WHERE name = ''; DROP TABLE users; --'
// → users 테이블이 삭제된다!
PreparedStatement 는 이 문제를 구조적으로 해결합니다. SQL 구문과 파라미터를 분리해서 처리하기 때문에, 사용자 입력이 절대로 SQL 구문의 일부로 해석되지 않아요.
// ✅ PreparedStatement 사용 — 안전하다
String sql = "SELECT * FROM users WHERE name = ? AND email = ?";
// ?는 플레이스홀더 — 나중에 값을 바인딩한다
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, userName); // 첫 번째 ?에 값 바인딩 (1부터 시작)
pstmt.setString(2, userEmail); // 두 번째 ?에 값 바인딩
ResultSet rs = pstmt.executeQuery();
PreparedStatement의 장점을 정리하면 다음과 같습니다.
- **SQL Injection 방어 **: 파라미터가 값으로만 처리되어 SQL 구문 조작이 불가능합니다
- ** 성능 향상 **: SQL을 미리 컴파일(precompile)하므로, 같은 구문을 반복 실행할 때 더 빠릅니다
- ** 가독성 **: 문자열 연결 대신 ?로 파라미터 위치가 명확해요
- ** 타입 안전성 **:
setInt(),setString()등 타입별 메서드로 바인딩합니다
Statement와 PreparedStatement의 핵심 차이는 SQL Injection 방어 와 프리컴파일 입니다. 실제로 Statement를 직접 쓸 이유는 없어요.
PreparedStatement로 INSERT 예제
// INSERT — ?에 값을 바인딩하면 된다
String insertSql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)";
PreparedStatement pstmt = conn.prepareStatement(insertSql);
pstmt.setString(1, "김자바");
pstmt.setString(2, "kim@java.com");
pstmt.setInt(3, 25);
int rows = pstmt.executeUpdate(); // 영향받은 행 수 반환
UPDATE, DELETE도 동일한 패턴이에요. SQL만 바꾸고 ?에 값을 바인딩하면 됩니다.
트랜잭션 — setAutoCommit, commit, rollback
트랜잭션이란 "전부 성공하거나, 전부 실패하거나" 를 보장하는 작업 단위입니다. 대표적인 예가 계좌 이체예요.
A 계좌에서 10만원 출금 → B 계좌에 10만원 입금
출금은 성공했는데 입금이 실패하면? 10만원이 증발합니다. 이걸 막으려면 두 작업을 하나의 트랜잭션으로 묶어야 해요.
JDBC는 기본적으로 Auto Commit 모드입니다. 즉, SQL 문 하나하나가 실행될 때마다 자동으로 커밋돼요. 수동으로 트랜잭션을 관리하려면 Auto Commit을 꺼야 합니다.
Connection conn = DriverManager.getConnection(url, user, password);
try {
// 1. Auto Commit 끄기
conn.setAutoCommit(false);
PreparedStatement withdraw = conn.prepareStatement(
"UPDATE accounts SET balance = balance - ? WHERE id = ?"
);
withdraw.setInt(1, 100000); // 10만원
withdraw.setInt(2, 1); // A 계좌
withdraw.executeUpdate();
PreparedStatement deposit = conn.prepareStatement(
"UPDATE accounts SET balance = balance + ? WHERE id = ?"
);
deposit.setInt(1, 100000); // 10만원
deposit.setInt(2, 2); // B 계좌
deposit.executeUpdate();
// 2. 두 작업 모두 성공하면 커밋
conn.commit();
System.out.println("이체 성공");
} catch (SQLException e) {
// 3. 하나라도 실패하면 롤백
conn.rollback();
System.out.println("이체 실패, 롤백 완료: " + e.getMessage());
} finally {
// Auto Commit 복원
conn.setAutoCommit(true);
conn.close();
}
추가로 Savepoint를 사용하면 트랜잭션 중간에 부분 롤백도 가능합니다. conn.setSavepoint("name")으로 저장점을 만들고, conn.rollback(savepoint)로 해당 지점까지만 되돌릴 수 있어요.
운영에서 한 가지 더: 트랜잭션 범위를 서비스 계층 전체로 너무 크게 잡지 마세요. 외부 API 호출 같은 느린 작업까지 트랜잭션 안에 들어가면 락 경쟁이 심해지고 커넥션 점유 시간도 길어져 풀 전체가 마를 수 있습니다.
ResultSet 다루기
ResultSet은 SQL 실행 결과를 행 단위로 순회하는 커서입니다. 처음에는 첫 번째 행 ** 앞 **을 가리키고 있어서, next()를 호출해야 첫 번째 행으로 이동해요.
[시작] → [행1] → [행2] → [행3] → [끝]
↑
최초 위치 (첫 번째 행 앞)
데이터 타입 매핑
Java 타입과 SQL 타입 사이에는 매핑 규칙이 있습니다.
| SQL 타입 | Java 타입 | getter 메서드 |
|---|---|---|
INT, INTEGER | int | getInt() |
BIGINT | long | getLong() |
VARCHAR, CHAR | String | getString() |
DOUBLE, FLOAT | double | getDouble() |
BOOLEAN | boolean | getBoolean() |
DATE | java.sql.Date | getDate() |
TIMESTAMP | java.sql.Timestamp | getTimestamp() |
BLOB | byte[] | getBytes() |
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
// 컬럼명으로 조회 (권장 — 가독성이 좋다)
int id = rs.getInt("id");
String name = rs.getString("name");
// 컬럼 인덱스로 조회 (1부터 시작)
int id2 = rs.getInt(1);
String name2 = rs.getString(2);
// NULL 처리 — wasNull() 사용
int age = rs.getInt("age");
if (rs.wasNull()) {
System.out.println("age는 NULL입니다");
}
}
컬럼명으로 조회하는 것이 인덱스보다 권장됩니다. 컬럼 순서가 바뀌어도 코드가 깨지지 않기 때문이에요.
리소스 관리 — try-with-resources
JDBC를 쓸 때 가장 실수하기 쉬운 부분이 ** 리소스 해제 **입니다. Connection, Statement, ResultSet 모두 사용 후 반드시 닫아야 해요. 안 닫으면 어떻게 될까요?
- **Connection 누수 **: DB 커넥션이 계속 쌓여서 결국 DB가 새 연결을 거부합니다
- ** 메모리 누수 **: GC가 수거하지 못하는 네이티브 리소스가 쌓입니다
- ** 장애 **: 서비스 전체가 DB에 접근하지 못하는 상황이 발생합니다
Java 7 이전에는 finally 블록에서 일일이 close()를 호출해야 했는데, close 자체도 예외를 던질 수 있어서 코드가 매우 지저분해졌어요. try-with-resources(AutoCloseable 객체를 try 블록이 끝날 때 자동으로 닫아주는 문법, Java 7+)가 이 문제를 깔끔하게 해결합니다.
// Connection, Statement, ResultSet 모두 AutoCloseable을 구현한다
try (
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement pstmt = conn.prepareStatement(
"SELECT * FROM users WHERE id = ?"
)
) {
pstmt.setInt(1, 1);
// ResultSet도 try-with-resources 안에서 관리
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
// → try 블록을 벗어나면 자동으로 close() 호출
// → 역순으로 닫힌다: ResultSet → PreparedStatement → Connection
try-with-resources를 사용하면 리소스 누수를 구조적으로 방지할 수 있습니다. JDBC 코드를 작성할 때는 반드시 이 패턴을 사용하세요.
커넥션 풀 — 왜 필요한가
여기까지 배운 방식에는 근본적인 성능 문제가 있습니다. 매 요청마다 DriverManager.getConnection()을 호출하면 다음 과정이 반복돼요.
- TCP 3-way handshake (네트워크 왕복)
- DB 인증 (사용자/비밀번호 검증)
- 커넥션 객체 생성
- SQL 실행
- 커넥션 종료 (TCP 연결 해제)
커넥션을 하나 만드는 데 보통 ** 수십 밀리초 **가 걸립니다. 웹 서비스에서 초당 1,000개 요청이 들어온다면? 매번 커넥션을 새로 만들고 버리면 DB가 버티지 못해요.
** 커넥션 풀(Connection Pool)**은 이 문제를 해결합니다.
커넥션 풀의 이점을 정리하면 다음과 같습니다.
- ** 성능 향상 **: 커넥션 생성/종료 비용을 줄여줍니다
- ** 자원 관리 **: 최대 커넥션 수를 제한하여 DB 과부하를 방지합니다
- ** 안정성 **: 커넥션 유효성 검사, 타임아웃 관리 등을 자동으로 처리해요
HikariCP — 가장 빠른 커넥션 풀
Spring Boot의 기본 커넥션 풀이 HikariCP 입니다. "Light(빛)"이라는 일본어에서 이름을 따왔고, 실제로 가장 빠른 커넥션 풀로 알려져 있어요.
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class HikariExample {
public static void main(String[] args) throws Exception {
// HikariCP 설정
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
config.setUsername("root");
config.setPassword("1234");
// 풀 설정
config.setMaximumPoolSize(10); // 최대 커넥션 수
config.setMinimumIdle(5); // 최소 유휴 커넥션 수
config.setConnectionTimeout(30000); // 커넥션 획득 대기 시간 (ms)
config.setIdleTimeout(600000); // 유휴 커넥션 유지 시간 (ms)
config.setMaxLifetime(1800000); // 커넥션 최대 생존 시간 (ms)
// DataSource 생성
HikariDataSource ds = new HikariDataSource(config);
// 풀에서 커넥션을 꺼내 사용
try (Connection conn = ds.getConnection();
PreparedStatement pstmt = conn.prepareStatement(
"SELECT * FROM users WHERE id = ?"
)) {
pstmt.setInt(1, 1);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
}
}
// try-with-resources로 닫으면 풀에 반환된다 (실제로 닫히지 않음)
// 애플리케이션 종료 시 풀 닫기
ds.close();
}
}
HikariCP 주요 설정 값
| 설정 | 기본값 | 설명 |
|---|---|---|
maximumPoolSize | 10 | 풀의 최대 커넥션 수 |
minimumIdle | maximumPoolSize | 유지할 최소 유휴 커넥션 수 |
connectionTimeout | 30000 (30초) | 커넥션 획득 대기 최대 시간 |
idleTimeout | 600000 (10분) | 유휴 커넥션이 풀에서 제거되기까지의 시간 |
maxLifetime | 1800000 (30분) | 커넥션의 최대 생존 시간 |
maximumPoolSize를 너무 크게 잡으면 DB에 부하가 걸리고, 너무 작게 잡으면 대기 시간이 길어집니다. HikariCP 공식 문서에서는 다음 공식을 권장해요.
최적 풀 사이즈 ≈ (CPU 코어 수 x 2) + 유효 디스크 수
예를 들어 4코어 서버에 디스크 1개면 (4 × 2) + 1 = 9개 정도가 적당합니다. 물론 실제로는 부하 테스트를 통해 조정해야 해요.
운영에서 풀이 모자라 보일 때도 사이즈부터 늘리지 마세요. 슬로우 쿼리 하나가 커넥션을 오래 점유해서 풀이 마르는 경우가 더 흔합니다. ** 풀 사이즈 튜닝 전에 쿼리 튜닝과 인덱스부터 의심 **하는 게 순서예요.
DataSource — DriverManager를 대체하는 표준
지금까지 두 가지 방법으로 커넥션을 얻었습니다.
// 1. DriverManager 방식 — 매번 새 커넥션 생성
Connection conn = DriverManager.getConnection(url, user, password);
// 2. DataSource 방식 — 커넥션 풀에서 꺼내기
Connection conn = dataSource.getConnection();
** 실제로는 항상 DataSource를 사용합니다.** 그 이유는 다음과 같아요.
| DriverManager | DataSource | |
|---|---|---|
| 커넥션 풀 | 지원하지 않음 | 지원 |
| 분산 트랜잭션 | 지원하지 않음 | 지원 가능 |
| 설정 변경 | 코드 수정 필요 | 외부 설정으로 변경 가능 |
| 성능 | 매번 새 커넥션 생성 | 커넥션 재사용 |
| 사용 위치 | 학습/테스트용 | 실제/프로덕션 |
Spring에서는 application.yml에 설정하면 HikariCP DataSource가 자동으로 구성됩니다.
# Spring Boot application.yml 설정 예시
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
JPA와의 관계 — JDBC 위에 쌓인 추상화 계층
JPA와 JDBC의 관계를 한 장의 그림으로 정리하면 이렇습니다.
핵심은 JPA도 결국 내부에서 JDBC를 사용한다 는 것이다.
em.persist(user)→ Hibernate가 INSERT SQL을 생성 → JDBC PreparedStatement로 실행em.find(User.class, 1)→ Hibernate가 SELECT SQL을 생성 → JDBC ResultSet에서 데이터 추출- 트랜잭션 → JDBC의
setAutoCommit(false),commit(),rollback()
그래서 JPA에서 문제가 발생하면 결국 JDBC 레벨까지 내려가서 디버깅해야 할 때가 있다. 슬로우 쿼리 분석, 커넥션 풀 튜닝, 트랜잭션 격리 수준 설정 등은 JDBC를 이해해야 제대로 할 수 있다.
MyBatis도 마찬가지다. SQL을 직접 작성하지만, 실행은 JDBC를 통해 이루어진다. 결국 JPA든 MyBatis든 Spring JDBC Template이든, 전부 JDBC 위에 쌓인 추상화일 뿐이다.
주의할 점
Connection 누수는 서비스 전체를 멈춘다
JDBC 리소스(Connection, Statement, ResultSet)를 닫지 않으면 커넥션이 계속 쌓여서 결국 DB가 새 연결을 거부한다. try-with-resources를 반드시 사용 해야 한다. finally 블록에서 수동으로 닫는 방식은 close() 자체가 예외를 던질 수 있어서 코드가 복잡해진다.
maximumPoolSize를 너무 크게 잡으면 역효과
커넥션 풀 크기를 무작정 늘리면 DB에 부하가 집중된다. HikariCP 공식 문서의 권장 공식은 (CPU 코어 수 x 2) + 유효 디스크 수다. 4코어 서버에 디스크 1개면 약 9개가 적당하다. 실제로는 부하 테스트를 통해 조정해야 한다.
Statement 대신 PreparedStatement를 써야 하는 이유
Statement로 사용자 입력을 SQL에 직접 넣으면 SQL Injection에 노출된다. SELECT * FROM users WHERE name = ' + userInput + ' 형태의 코드에 '; DROP TABLE users; --를 넣으면 테이블이 삭제된다. PreparedStatement는 SQL 구문과 파라미터를 분리하므로 이 공격을 구조적으로 차단 한다.
실무에서는 이렇게 씁니다
자바에서 DB를 다루는 일반적인 스택을 정리하면 이렇습니다.
- 기본 조합: Spring Boot + HikariCP + JPA/Hibernate 가 사실상 디팩토 스탠다드입니다
- 복잡한 동적 쿼리는 JPA + QueryDSL 혹은 MyBatis 로 보완합니다
- JDBC는 튜닝/디버깅/로우레벨 제어가 필요할 때 직접 내려갑니다 — 슬로우 쿼리 분석, 커넥션 풀 모니터링, 트랜잭션 격리 수준 조정 등
JPA를 쓰더라도 결국 그 아래는 JDBC입니다. ORM 추상화에 가려진 SQL과 커넥션 흐름이 보일 때 비로소 운영 단계에서 생기는 문제를 진단할 수 있어요.
정리
| 개념 | 핵심 정리 |
|---|---|
| JDBC | 자바에서 DB에 접근하기 위한 표준 인터페이스 (java.sql 패키지) |
| Driver | DB 벤더가 제공하는 JDBC 구현체, 실제 통신 담당 |
| Connection | DB와의 연결 세션, TCP 연결 포함 |
| PreparedStatement | SQL 프리컴파일 + 파라미터 바인딩으로 SQL Injection 방어 |
| ResultSet | SELECT 결과를 순회하는 커서, next()로 이동 |
| ** 트랜잭션** | setAutoCommit(false) -> 작업 -> commit()/rollback() |
| try-with-resources | JDBC 리소스 자동 해제, Connection 누수 방지 |
| ** 커넥션 풀** | 커넥션을 미리 만들어두고 재사용, HikariCP가 사실상 표준 |
| DataSource | DriverManager를 대체하는 커넥션 제공 인터페이스, 풀 기능 포함 |
| JPA/Hibernate | JDBC 위에 쌓인 ORM 추상화 계층, 내부에서 JDBC 사용 |