3절의 Main 은 하나의 DB 위에서 기능을 훑었습니다. 여기서는 각각 단일 파일로 실행되는 네 가지 변형으로 원리를 다른 각도에서 다시 씁니다. 모두 javac -cp "lib/h2-2.3.232.jar" 로 컴파일하고 인메모리 H2 를 씁니다.
배치 04 레슨 연습 문제 1 의 "건별 세이브포인트"를 실제 JDBC Savepoint 로 옮깁니다. CHECK (balance >= 0) 제약이 잔액 부족을 SQLException(SQLState 23513) 으로 알려 주고, rollback(sp) 는 그 건의 두 UPDATE 만 되돌린 채 트랜잭션을 유지합니다.
import java.sql.*;
public class SavepointChunk {
public static void main(String[] args) throws Exception {
try (Connection c = DriverManager.getConnection("jdbc:h2:mem:sp", "sa", "")) {
try (Statement st = c.createStatement()) {
st.execute("CREATE TABLE account(id VARCHAR(10) PRIMARY KEY, balance BIGINT NOT NULL CHECK (balance >= 0))");
st.execute("INSERT INTO account VALUES ('A',1000),('B',1000),('C',1000)");
}
String[][] transfers = {{"T1","A","B","100"},{"T2","B","C","200"},{"T3","C","A","99999"},{"T4","A","C","50"}};
c.setAutoCommit(false);
int ok = 0, failed = 0;
try (PreparedStatement out = c.prepareStatement("UPDATE account SET balance = balance - ? WHERE id = ?");
PreparedStatement in = c.prepareStatement("UPDATE account SET balance = balance + ? WHERE id = ?")) {
for (String[] t : transfers) {
Savepoint sp = c.setSavepoint(t[0]); // 건 단위 되돌림 지점
try {
long amt = Long.parseLong(t[3]);
out.setLong(1, amt); out.setString(2, t[1]); out.executeUpdate(); // CHECK(balance >= 0) 가 잔액 부족을 막음
in.setLong(1, amt); in.setString(2, t[2]); in.executeUpdate();
ok++;
} catch (SQLException e) {
c.rollback(sp); // 이 건의 변경만 취소, 트랜잭션은 유지
failed++;
System.out.println(" skip " + t[0] + " (SQLState " + e.getSQLState() + ")");
}
}
}
c.commit();
try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery("SELECT id, balance FROM account ORDER BY id")) {
StringBuilder sb = new StringBuilder();
long sum = 0;
while (rs.next()) { sb.append(rs.getString(1)).append('=').append(rs.getLong(2)).append(' '); sum += rs.getLong(2); }
System.out.println("커밋: 성공 " + ok + "건, 제외 " + failed + "건 -> " + sb + "(총합 " + sum + ")");
}
}
}
}
// 출력:
// skip T3 (SQLState 23513)
// 커밋: 성공 3건, 제외 1건 -> A=850 B=900 C=1250 (총합 3000)잔액 검사를 자바 if 가 아니라 DB CHECK 제약에 맡긴 점에 주목하세요. 동시 트랜잭션이 끼어들어도 DB 가 마지막 방어선이 됩니다. 단, PostgreSQL 은 트랜잭션 안에서 SQL 오류가 나면 세이브포인트로 되돌리기 전까지 모든 후속 SQL 을 거부하므로 세이브포인트 없이는 이 패턴이 불가능합니다.
ResultSet 은 커서이므로 20만 행을 전부 힙에 올리지 않아도 됩니다. setFetchSize(1000) 은 "한 번에 1,000 행씩만 가져오라"는 힌트이고, ResultSetMetaData 로 컬럼을 몰라도 헤더와 값을 범용으로 씁니다. 배치 02 레슨(스트리밍 파일 IO)의 DB 판입니다.
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.sql.*;
public class CursorExport {
public static void main(String[] args) throws Exception {
Path out = Files.createTempFile("export", ".csv");
try (Connection c = DriverManager.getConnection("jdbc:h2:mem:exp", "sa", "")) {
try (Statement st = c.createStatement()) {
st.execute("CREATE TABLE sale(id BIGINT AUTO_INCREMENT PRIMARY KEY, merchant VARCHAR(10), amount DECIMAL(12,2), sold_at DATE)");
st.execute("INSERT INTO sale(merchant, amount, sold_at) SELECT 'M-' || MOD(X, 7), X * 100, DATEADD('DAY', MOD(X, 30), DATE '2025-01-01') FROM SYSTEM_RANGE(1, 200000)");
}
long t0 = System.nanoTime(), rows = 0;
c.setAutoCommit(false); // 일부 DB(PostgreSQL)는 커서 스트리밍에 트랜잭션 필요
try (PreparedStatement ps = c.prepareStatement("SELECT * FROM sale ORDER BY id",
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
BufferedWriter w = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) {
ps.setFetchSize(1000); // 한 번에 1000행씩만 가져오라는 힌트 (전체를 메모리에 올리지 않음)
try (ResultSet rs = ps.executeQuery()) {
ResultSetMetaData m = rs.getMetaData();
int n = m.getColumnCount();
for (int i = 1; i <= n; i++) w.write((i > 1 ? "," : "") + m.getColumnLabel(i).toLowerCase());
w.newLine();
while (rs.next()) { // 컬럼을 몰라도 메타데이터로 범용 순회
for (int i = 1; i <= n; i++) {
Object v = rs.getObject(i);
w.write((i > 1 ? "," : "") + (v == null ? "" : v.toString()));
}
w.newLine();
rows++;
}
}
}
c.commit();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("%,d행 내보내기 %d ms, 파일 %,d bytes%n", rows, ms, Files.size(out));
try (BufferedReader r = Files.newBufferedReader(out)) { System.out.println(r.readLine()); System.out.println(r.readLine()); }
} finally { Files.deleteIfExists(out); }
}
}
// 출력 (시간은 환경에 따라 다름):
// 200,000행 내보내기 5792 ms, 파일 6,777,818 bytes
// id,merchant,amount,sold_at
// 1,M-1,100.00,2025-01-02드라이버별로 스트리밍 조건이 다릅니다. PostgreSQL 은 autoCommit=false + fetchSize > 0 일 때만, MySQL 은 fetchSize = Integer.MIN_VALUE 또는 useCursorFetch=true 일 때만 실제로 스트리밍합니다.
조건을 안 맞추면 드라이버가 전체 결과를 먼저 메모리에 받아 OOM 이 납니다. "fetchSize 를 줬으니 안전하다"가 아니라 드라이버 문서를 확인해야 합니다.
배치 04 변형 4 의 "upsert 절대값" 전략을 실제 SQL 로 옮깁니다. settle += amount 대신 매출 테이블에서 합계를 다시 계산해 MERGE ... KEY(...) 로 덮어쓰므로 재시작·재실행에 안전하고, 늦게 도착한 매출도 다시 돌리면 반영됩니다.
표준 SQL 은 MERGE INTO ... USING ... WHEN MATCHED, PostgreSQL 은 INSERT ... ON CONFLICT DO UPDATE, MySQL 은 INSERT ... ON DUPLICATE KEY UPDATE 입니다.
import java.sql.*;
public class MergeSettlement {
static void settle(Connection c) throws SQLException {
c.setAutoCommit(false);
try (Statement st = c.createStatement()) {
// 상대값 갱신(settle += amount)이 아니라 입력에서 절대값을 다시 계산해 MERGE → 몇 번 실행해도 같다
st.executeUpdate("""
MERGE INTO settlement(merchant, settle_date, total, cnt)
KEY(merchant, settle_date)
SELECT merchant, sold_at, SUM(amount), COUNT(*) FROM sale WHERE sold_at = DATE '2025-01-02' GROUP BY merchant, sold_at
""");
c.commit();
} catch (SQLException e) { c.rollback(); throw e; }
finally { c.setAutoCommit(true); }
}
static String dump(Connection c) throws SQLException {
try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery("SELECT merchant, total, cnt FROM settlement ORDER BY merchant")) {
StringBuilder sb = new StringBuilder();
while (rs.next()) sb.append(rs.getString(1)).append(':').append(rs.getBigDecimal(2).toPlainString()).append('/').append(rs.getInt(3)).append(' ');
return sb.toString().trim();
}
}
public static void main(String[] args) throws Exception {
try (Connection c = DriverManager.getConnection("jdbc:h2:mem:merge", "sa", "")) {
try (Statement st = c.createStatement()) {
st.execute("CREATE TABLE sale(id BIGINT AUTO_INCREMENT PRIMARY KEY, merchant VARCHAR(10), amount DECIMAL(12,2), sold_at DATE)");
st.execute("CREATE TABLE settlement(merchant VARCHAR(10), settle_date DATE, total DECIMAL(14,2), cnt INT, PRIMARY KEY(merchant, settle_date))");
st.execute("INSERT INTO sale(merchant, amount, sold_at) VALUES ('M-1',10000,DATE '2025-01-02'),('M-2',5000,DATE '2025-01-02'),('M-1',3000,DATE '2025-01-02'),('M-3',7000,DATE '2025-01-02'),('M-2',1000,DATE '2025-01-03')");
}
settle(c); String once = dump(c);
settle(c); String twice = dump(c);
System.out.println("1회: " + once);
System.out.println("2회: " + twice + " -> " + (once.equals(twice) ? "멱등 OK" : "이중 정산!"));
try (Statement st = c.createStatement()) { st.execute("INSERT INTO sale(merchant, amount, sold_at) VALUES ('M-1', 500, DATE '2025-01-02')"); } // 늦게 도착한 매출
settle(c);
System.out.println("추가 매출 후 재정산: " + dump(c));
}
}
}
// 출력:
// 1회: M-1:13000.00/2 M-2:5000.00/1 M-3:7000.00/1
// 2회: M-1:13000.00/2 M-2:5000.00/1 M-3:7000.00/1 -> 멱등 OK
// 추가 매출 후 재정산: M-1:13500.00/3 M-2:5000.00/1 M-3:7000.00/1집계를 자바 루프가 아니라 DB 의 GROUP BY 한 문장으로 한 점도 중요합니다. 20만 행을 가져와 자바에서 더하는 것보다 DB 가 집계해 7 행만 보내는 편이 수백 배 빠릅니다. "데이터가 있는 곳에서 계산한다"는 원칙입니다.
2.2 절의 풀 원리를 BlockingQueue 와 java.lang.reflect.Proxy 로 구현합니다. borrow() 는 큐에서 꺼내고, 반환된 프록시의 close() 는 실제로 닫는 대신 autoCommit 을 원복하고 큐에 되돌립니다. 큐가 비면 타임아웃까지 기다리다 SQLTimeoutException — HikariCP 의 connectionTimeout 이 정확히 이것입니다.
import java.lang.reflect.Proxy;
import java.sql.*;
import java.util.concurrent.*;
public class MiniPool implements AutoCloseable {
private final BlockingQueue<Connection> idle;
MiniPool(String url, int size) throws SQLException {
idle = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) idle.add(DriverManager.getConnection(url, "sa", ""));
}
/** 빈 커넥션이 없으면 timeout 까지 대기 (HikariCP 의 connectionTimeout) */
Connection borrow(long timeoutMs) throws SQLException, InterruptedException {
Connection raw = idle.poll(timeoutMs, TimeUnit.MILLISECONDS);
if (raw == null) throw new SQLTimeoutException("풀 고갈: " + timeoutMs + "ms 내 커넥션 없음");
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (p, m, a) -> {
if (m.getName().equals("close")) { // 진짜 닫지 않고 상태 원복 후 반납
raw.setAutoCommit(true);
idle.add(raw);
return null;
}
return m.invoke(raw, a); // 나머지는 실제 커넥션에 위임
});
}
int idleCount() { return idle.size(); }
@Override public void close() throws SQLException { for (Connection c : idle) c.close(); }
public static void main(String[] args) throws Exception {
try (MiniPool pool = new MiniPool("jdbc:h2:mem:pool;DB_CLOSE_DELAY=-1", 2)) {
System.out.println("초기 idle=" + pool.idleCount());
try (Connection c1 = pool.borrow(100)) {
c1.setAutoCommit(false);
System.out.println("1개 대여 후 idle=" + pool.idleCount() + ", c1.autoCommit=" + c1.getAutoCommit());
try (Connection c2 = pool.borrow(100)) {
System.out.println("2개 대여 후 idle=" + pool.idleCount());
try { pool.borrow(200); } catch (SQLTimeoutException e) { System.out.println("3번째 대여: " + e.getMessage()); }
}
System.out.println("c2 close(반납) 후 idle=" + pool.idleCount());
}
System.out.println("전부 반납 후 idle=" + pool.idleCount() + ", 반납된 커넥션 autoCommit=" + pool.borrow(10).getAutoCommit());
}
}
}
// 출력:
// 초기 idle=2
// 1개 대여 후 idle=1, c1.autoCommit=false
// 2개 대여 후 idle=0
// 3번째 대여: 풀 고갈: 200ms 내 커넥션 없음
// c2 close(반납) 후 idle=1
// 전부 반납 후 idle=2, 반납된 커넥션 autoCommit=true"3번째 대여" 가 실무의 풀 고갈 장애입니다. 커넥션 2개짜리 풀에서 두 스레드가 커넥션을 쥔 채 외부 API 를 3초씩 기다리면, 세 번째 요청은 connectionTimeout 후 예외입니다. 해결은 풀을 키우는 게 아니라 커넥션을 쥔 시간을 줄이는 것입니다.
HikarCP 가 여기에 더하는 것: 반납 시 미커밋 트랜잭션 롤백, 유효성 검사(isValid), 누수 감지(leakDetectionThreshold), 격리 수준·읽기 전용 플래그 원복.