애플리케이션 코드
│ java.sql.* 인터페이스만 사용 (Connection, PreparedStatement, ResultSet ...)
▼
DriverManager ──또는── DataSource (풀)
│ URL 의 "jdbc:h2:" 접두어로 드라이버 선택
▼
org.h2.Driver (구현체) ← META-INF/services/java.sql.Driver 에 등록 → ServiceLoader 가 자동 로딩
│
▼
DB 엔진 (H2 / PostgreSQL / MySQL ...)| 구성 요소 | 역할 | 비고 |
|---|---|---|
DriverManager |
URL 에 맞는 드라이버에 connect() 위임 |
JDBC 4 부터 Class.forName 불필요. ServiceLoader 가 자동 등록 |
DataSource |
커넥션을 "얻는 방법"을 추상화. 풀링, 분산 트랜잭션은 이 인터페이스 뒤에서 | 실무 코드는 항상 DataSource 를 주입받음. 구현은 HikariCP |
Connection |
DB 세션 하나. 트랜잭션 경계(autoCommit, commit, rollback)와 격리 수준을 가짐 | 비싸다(TCP + 인증 + 세션 메모리). 풀에서 빌려 씀 |
Statement |
SQL 문자열을 그대로 실행 | DDL, 고정 SQL 에만. 사용자 입력 결합 금지 |
PreparedStatement |
? 자리를 값으로 바인딩해 실행 |
SQL 과 데이터를 분리 → 인젝션 방어 + 실행 계획 캐시 |
ResultSet |
조회 결과 커서. next() 로 한 행씩 전진 |
전체를 메모리에 올리지 않음(fetchSize 만큼 가져옴). 열린 동안 커넥션 점유 |
H2 URL 은 모드에 따라 다릅니다.
| URL | 의미 |
|---|---|
jdbc:h2:mem:shop |
인메모리. 마지막 커넥션이 닫히면 DB 삭제 |
jdbc:h2:mem:shop;DB_CLOSE_DELAY=-1 |
인메모리. JVM 종료까지 유지 (풀에서 커넥션이 전부 반납돼도 데이터 유지) |
jdbc:h2:./data/shop |
파일 모드. ./data/shop.mv.db 생성 |
jdbc:h2:tcp://host:9092/shop |
서버 모드. 여러 프로세스가 공유 |
이 레슨은 잔여 파일이 남지 않도록 인메모리 + DB_CLOSE_DELAY=-1 만 씁니다.
DriverManager.getConnection() 은 매번 새 세션을 만듭니다. 원격 DB 라면 TCP 핸드셰이크 + TLS + 인증 + 세션 초기화로 수십 ms, 인메모리 H2 조차 1~2ms 가 듭니다. 초당 1,000 요청이면 커넥션 생성만으로 CPU 한 코어가 사라집니다.
풀 없음: 요청 → [생성 2ms] → 쿼리 0.1ms → [해제] → 요청 → [생성 2ms] → ...
풀 사용: 기동 시 [생성 × 8] → 요청 → 빌림 0.01ms → 쿼리 0.1ms → 반납 → 요청 → 빌림 ...풀의 원리는 단순합니다. 미리 만든 커넥션을 큐에 넣어 두고, getConnection() 은 큐에서 꺼내고, close() 는 진짜 닫는 대신 큐에 되돌립니다(변형 4 에서 30줄로 구현). 실무는 HikariCP 를 씁니다 — 누수 감지, 유효성 검사, 타임아웃, 메트릭이 있기 때문입니다. 이 레슨은 H2 내장 JdbcConnectionPool 을 씁니다. 핵심 규칙 두 가지:
close() 를 빠뜨리면 풀에서 커넥션이 사라진다(누수). try-with-resources 가 유일하게 안전한 방법.Statement: "SELECT * FROM member WHERE name = '" + input + "'"
input = ' OR '1'='1 → SELECT * FROM member WHERE name = '' OR '1'='1' ← 조건이 항상 참
PreparedStatement: "SELECT * FROM member WHERE name = ?" + setString(1, input)
DB 는 SQL 구조를 먼저 파싱(컴파일)하고, 값은 나중에 '데이터'로 받음 → 따옴표는 그냥 문자인젝션 방어 외에 성능 이유도 있습니다. DB 는 SQL 을 파싱하고 실행 계획을 세우는데, PreparedStatement 는 같은 SQL 문자열(? 포함)이 반복될 때 그 계획을 재사용합니다. 문자열 결합은 값마다 다른 SQL 이 되어 매번 파싱합니다. 반면 ? 를 쓸 수 없는 자리(테이블명, 컬럼명, ORDER BY 방향)는 화이트리스트로 검증한 뒤 결합해야 합니다.
| DB 타입 | 권장 getter | 주의 |
|---|---|---|
| BIGINT / INT | getLong / getInt |
NULL 이면 0 반환. 직후 wasNull() 로 구분 |
| VARCHAR | getString |
NULL → null |
| DECIMAL | getBigDecimal |
돈을 getDouble 로 읽지 말 것 (0.1 + 0.2 ≠ 0.3) |
| DATE | getObject(col, LocalDate.class) |
getDate() 는 java.sql.Date(타임존 함정). JDBC 4.2 부터 java.time 직접 지원 |
| TIMESTAMP | getObject(col, LocalDateTime.class) |
타임존이 필요하면 OffsetDateTime.class |
| BOOLEAN | getBoolean |
NULL → false. wasNull 필요 |
| 임의 | getObject(i) |
메타데이터 기반 범용 덤프에 |
getObject(col, Class<T>) 는 드라이버가 변환을 책임지므로 java.sql.Date → LocalDate 같은 수동 변환 코드가 사라집니다. 바인딩도 setObject(i, LocalDate) 로 대칭입니다.
| 배치 04 시뮬레이션 | JDBC | 비고 |
|---|---|---|
db.begin() |
conn.setAutoCommit(false) |
JDBC 에 "begin" 은 없다. autoCommit 을 끄면 다음 SQL 부터 트랜잭션 |
db.put() |
ps.executeUpdate() |
undo 로그 + 행 락 |
db.get() |
같은 커넥션의 SELECT |
자기 변경은 보임 |
db.commit() |
conn.commit() |
redo 로그 fsync, 락 해제 |
db.rollback() |
conn.rollback() |
undo 로 원복, 락 해제 |
| 세이브포인트 | conn.setSavepoint() / conn.rollback(sp) |
트랜잭션 안에서 부분 취소 |
| 커넥션 끊김 | 미커밋 자동 롤백 | 풀 반납 시에도 미커밋이면 롤백됨(HikariCP 기본) |
autoCommit 이 true(기본)면 SQL 하나가 트랜잭션 하나입니다. "재고 UPDATE 후 주문 INSERT" 를 autoCommit 상태로 실행하면 UPDATE 는 이미 커밋된 뒤라 INSERT 실패 시 되돌릴 수 없습니다.
| 수준 | Dirty Read | Non-repeatable Read | Phantom Read | 비고 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 발생 | 발생 | 발생 | 미커밋 데이터가 보임. 거의 안 씀 |
| READ_COMMITTED | 방지 | 발생 | 발생 | PostgreSQL, Oracle, H2 기본. 같은 트랜잭션에서 두 번 읽으면 다를 수 있음 |
| REPEATABLE_READ | 방지 | 방지 | (MySQL 은 대부분 방지) | MySQL InnoDB 기본 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 완전 직렬화. 락/재시도 비용 큼 |
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE) 로 커넥션 단위로 바꿀 수 있지만, 풀에 반납할 때 원복해야 합니다. 배치는 보통 기본 수준을 쓰고, "읽고-검사하고-갱신" 경합은 SELECT ... FOR UPDATE 로 행을 잠가 해결합니다(예제 5 의 재고).
건별: ps.executeUpdate() × 100,000 → DB 왕복 100,000회
배치: ps.addBatch() × 1000 → ps.executeBatch() → 왕복 100회 (1000건씩 묶어 전송)addBatch() 는 드라이버 버퍼에 파라미터 세트를 쌓고 executeBatch() 가 한 번에 보냅니다. 원격 DB 에서 왕복 지연(RTT)이 0.5ms 면 건별 10만 건은 왕복만 50초, 배치는 0.05초입니다. 인메모리 H2 는 왕복이 없어 차이가 작게 나오지만(예제 7), 원리는 같습니다.
MySQL 은 rewriteBatchedStatements=true 옵션을 줘야 드라이버가 INSERT ... VALUES (...),(...),(...) 하나로 재작성해 진짜 빨라집니다 — 옵션 없이는 배치가 건별과 같습니다. 배치 크기는 500~5,000 이 일반적이고, 버퍼가 메모리에 쌓이므로 무한히 키우지 않습니다.
열기: Connection → PreparedStatement → ResultSet
닫기: ResultSet → PreparedStatement → Connection (역순. try-with-resources 가 자동으로 보장)SQLException 은 checked 예외이고, 두 가지 식별자를 가집니다.
| 식별자 | 예 | 용도 |
|---|---|---|
getSQLState() |
23505 UNIQUE 위반, 23503 FK 위반, 42S02 테이블 없음, 40001 데드락 |
표준(SQL:2003). 앞 두 자리가 클래스: 23 무결성, 42 문법, 08 커넥션, 40 롤백 |
getErrorCode() |
H2 23505, PostgreSQL 없음, Oracle ORA-00001 → 1 |
벤더 고유 |
실무 헬퍼는 SQLException 을 런타임 예외로 감싸되 SQLState 를 보존해, 호출자가 "중복 이메일"(사용자 오류)과 "테이블 없음"(프로그램 버그)을 구분하게 합니다. Spring 의 DataAccessException 계층(DuplicateKeyException, DataIntegrityViolationException)이 이 번역을 대신합니다.