JDBC — 자바가 DB와 말하는 가장 낮은 층
JPA도, MyBatis도, Spring Data도 — 결국 그 밑바닥에서는 JDBC로 DB와 말한다. JDBC(Java Database Connectivity)는 *자바 표준 라이브러리(java.sql)*가 정의한 DB 접근의 공통 인터페이스다. 핵심은 인터페이스는 표준, 구현은 드라이버라는 점이다 — Connection·Statement·ResultSet은 표준 규약이고, PostgreSQL·MySQL·Oracle 각각의 *드라이버(jar)*가 그 규약을 구현한다. 그래서 우리는 같은 코드로 다른 DB를 쓸 수 있다(드라이버만 갈아 끼우면). JPA의 영속성 컨텍스트도, HikariCP의 커넥션 풀도 이 네 객체 위에서 돌아가니 — 토대를 알면 그 위층의 사고까지 바닥부터 보인다.
네 개의 핵심 객체와 그 생애
JDBC로 쿼리 하나를 날리는 흐름은 네 객체의 짧은 생애다 — Connection을 얻고 → Statement를 만들고 → 실행해 ResultSet을 받고 → 닫는다.
String sql = "SELECT id, name FROM member WHERE email = ?";
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, email); // 1번 ? 자리에 email 바인딩
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { // 커서를 한 행씩 전진
long id = rs.getLong("id");
String name = rs.getString("name");
}
}
} // try-with-resources가 역순으로 close
여기서 *옛날 방식인 DriverManager.getConnection*은 부를 때마다 새 물리 연결을 맺어 느리다. 실무에서는 거의 항상 DataSource(커넥션 풀이 구현)에서 이미 맺어 둔 연결을 빌려 쓴다. 그리고 Connection은 DB와의 한 세션이라 — 비싸고 한정된 자원이다(이 한 문장이 뒤의 커넥션 풀·누수 이야기 전체의 씨앗이다).
Statement vs PreparedStatement — 성능과 보안이 갈리는 지점
쿼리를 실행하는 객체는 둘이다. Statement는 완성된 SQL 문자열을 그대로 보내고, PreparedStatement는 ? 자리표시자가 있는 SQL을 미리 보내 컴파일해 두고, 값만 따로 바인딩한다. 이 차이가 성능과 보안 두 가지를 가른다.
성능 — PreparedStatement는 SQL을 한 번 파싱·컴파일해 실행 계획을 캐시해 두고, 다음부터는 값만 바꿔 재사용한다. 같은 쿼리를 반복하면 매번 새로 파싱하는 Statement보다 빠르다. (출처: PreparedStatement vs Statement.)
보안 — 이게 더 중요하다. 사용자 입력을 문자열로 이어 붙이면 SQL 인젝션에 뚫린다.
// ❌ 취약 — 입력을 SQL에 직접 이어 붙임
String sql = "SELECT * FROM member WHERE name = '" + name + "'";
// name 에 ' OR '1'='1 이 들어오면 → WHERE name = '' OR '1'='1' → 전체 노출
// ✅ 안전 — PreparedStatement 파라미터 바인딩
String sql = "SELECT * FROM member WHERE name = ?";
ps.setString(1, name); // 입력은 '값'으로만 취급되어 쿼리 구조를 못 바꾼다
핵심 원리는 — *PreparedStatement는 쿼리의 *구조(파싱된 트리)를 먼저 확정하고, 그 뒤 바인딩 값은 데이터로만 채운다는 것이다. 그래서 입력에 ' OR '1'='1이 와도 그것이 통째로 하나의 문자열 값으로 취급될 뿐, 쿼리의 의도를 바꾸지 못한다. (출처: OWASP SQL Injection Prevention.)
그래서 실무 규칙은 단순하다 — 사용자 입력이 들어가는 쿼리는 무조건 PreparedStatement(파라미터 바인딩)로 짠다. 문자열 연결로 SQL을 만드는 코드는 코드 리뷰에서 즉시 잡아야 할 신호다. (테이블·컬럼명처럼 바인딩이 안 되는 부분은 화이트리스트 검증으로 막는다.)
Connection은 비싸다 — 커넥션 풀
Connection 하나를 새로 맺는 건 TCP 연결 + 인증 + 세션 설정이라 수~수십 밀리초가 든다. 요청마다 새로 맺으면 — 그 비용이 매번 들고, 동시 연결 수도 폭발한다. 그래서 커넥션 풀이 미리 N개를 맺어 두고 빌려주고 반납받는다. 자바 진영의 사실상 표준이 HikariCP다(Spring Boot 기본).
여기서 가장 자주 헷갈리는 한 가지 — 풀에서 빌린 Connection에 close()를 부르면 물리 연결이 닫히는 게 아니라 풀로 반납된다. 그래서 *반드시 close()(반납)*해야 하고, 안 하면 커넥션 누수가 된다 — 빌린 채 안 돌려준 연결이 쌓이다 풀이 고갈되면, 새 요청이 연결을 못 얻어 멈춘다. 막는 법은 두 가지다. 첫째, try-with-resources로 예외가 나도 반드시 닫는다(위 코드처럼). 둘째, HikariCP의 leakDetectionThreshold(예: 2초 이상 안 돌려주면 경고 로그와 스택트레이스)로 누수 코드의 위치를 찾는다. (출처: HikariCP Connection Leaks.)
풀 크기는 *기본 maximumPoolSize = 10*인데 — 무조건 크게 잡으면 빨라지는 게 아니다. 커넥션이 많아지면 DB가 더 많은 동시 연결을 처리하느라 컨텍스트 스위칭·락 경합으로 오히려 느려진다. 적정값은 DB의 코어·디스크 수에 묶이는 작은 수(흔히 수~수십)이고, 애플리케이션 스레드 수보다 작게 잡는 게 보통이다 — 느린 쿼리가 커넥션을 오래 쥐면 풀이 마르니, 풀 크기 이전에 쿼리를 빠르게 만드는 게 먼저다. (출처: HikariCP pool sizing.)
트랜잭션 — autocommit을 끄는 순간
JDBC Connection은 기본이 autocommit = true라 — 매 SQL이 곧바로 커밋된다. 그래서 여러 SQL을 한 단위로 묶으려면 autocommit을 끄고 직접 커밋/롤백한다.
try (Connection con = dataSource.getConnection()) {
con.setAutoCommit(false); // 수동 트랜잭션 시작
try {
// ... 출금 UPDATE, 입금 UPDATE (둘 다 되거나 둘 다 안 되거나)
con.commit(); // 둘 다 성공해야 확정
} catch (Exception e) {
con.rollback(); // 하나라도 실패하면 전부 취소
throw e;
}
}
격리수준도 Connection 단위로 정한다 — con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED)처럼. JDBC는 READ_UNCOMMITTED·READ_COMMITTED·REPEATABLE_READ·SERIALIZABLE 네 수준을 상수로 노출하며, 높을수록 이상 현상(더티/반복불가/팬텀 읽기)을 막지만 동시성은 준다. (출처: JDBC Isolation Levels.) — 실무에서는 이 수동 트랜잭션·autocommit·격리수준을 직접 다루기보다, Spring의 @Transactional이 대신 setAutoCommit(false)·commit·rollback을 호출해 준다. 즉 우리가 트랜잭션 편에서 본 모든 것이 결국 이 JDBC 호출로 번역된다.
대량 처리 — fetch size와 배치
조회가 클 때 — ResultSet은 커서라, 전체를 한 번에 메모리로 끌어오지 않고 fetch size만큼씩 DB에서 가져온다. 기본 fetch size는 작아서(드라이버마다 다르나 흔히 10) 수백만 행을 읽으면 왕복이 너무 잦다. ps.setFetchSize(1000)처럼 키우면 왕복이 줄어 빨라진다 — 단 너무 키우면 한 번에 들고 오는 행이 많아 메모리를 먹으니 균형을 잡는다(일부 드라이버는 스트리밍 모드를 따로 켜야 진짜 행 단위로 흘려보낸다). (출처: JDBC fetch size.)
쓰기가 클 때 — INSERT를 한 건씩 보내면 매번 네트워크 왕복이라 느리다. 배치로 묶는다.
String sql = "INSERT INTO log(msg) VALUES(?)";
try (PreparedStatement ps = con.prepareStatement(sql)) {
for (String msg : messages) {
ps.setString(1, msg);
ps.addBatch(); // 큐에 쌓기
if (++i % 1000 == 0) ps.executeBatch(); // 1000건마다 한 번에 전송
}
ps.executeBatch(); // 남은 것 전송
}
addBatch()로 쌓고 executeBatch()로 한 번에 전송하면 왕복 횟수가 확 준다. 배치 크기는 너무 작으면 효과가 적고, 너무 크면 메모리·실패 시 롤백 비용이 커서 대략 50~100(상황에 따라 수백)이 권장된다. (출처: JDBC Batch Processing.) — JPA에서 본 hibernate.jdbc.batch_size가 바로 이 JDBC 배치를 켜는 설정이다.
JDBC 위의 추상들 — 보일러플레이트를 걷어내다
순수 JDBC는 try-with-resources·예외 변환·ResultSet 매핑이 매번 반복돼 지루하고 실수가 잦다. 그래서 그 위에 추상이 올라간다.
JdbcTemplate(Spring) — 커넥션 열기·닫기·예외 변환을 대신해 주고, 우리는 SQL과RowMapper(행→객체 변환)만 준다. 반복 코드를 걷어내되 SQL은 직접 쓰는 중간 지점이라, 복잡한 쿼리·성능 민감 구간에서 JPA보다 선호되기도 한다.- JPA/Hibernate — 객체-테이블 매핑·영속성 컨텍스트·지연 로딩까지 얹어 JDBC를 거의 안 보이게 한다. 하지만 그 밑에서 결국 JDBC로 SQL을 날리고 커넥션 풀에서 연결을 빌린다 — 그래서 N+1·커넥션 고갈 같은 사고는 JDBC 층을 알아야 근본 원인이 보인다.
정리하면, JDBC는 자바가 DB와 말하는 표준 인터페이스로 — Connection·Statement·ResultSet의 짧은 생애로 쿼리가 돈다. PreparedStatement는 사전 컴파일로 빠르고, 파라미터 바인딩으로 SQL 인젝션을 막는다(사용자 입력엔 무조건 이것). Connection은 비싸 커넥션 풀(HikariCP)로 빌려 쓰되 *반드시 반납(try-with-resources)*해 누수를 막고, 풀 크기는 무작정 키우지 않는다. 트랜잭션은 autocommit을 끄고 commit/rollback하며 격리수준을 정하는데, 실무에선 @Transactional이 이 JDBC 호출을 대신한다. 대량은 fetch size·배치로 왕복을 줄인다. 그리고 그 위의 JdbcTemplate·JPA는 이 토대를 감싼 추상일 뿐 — 사고가 터지면 답은 늘 한 층 아래, JDBC에 있다. 이 토대 위에서, 다음은 그 추상의 대표 격인 JPA다.