공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 04 / 22

JDBC 기초와 트랜잭션 (H2)

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

3절의 Main 은 하나의 DB 위에서 기능을 훑었습니다. 여기서는 각각 단일 파일로 실행되는 네 가지 변형으로 원리를 다른 각도에서 다시 씁니다. 모두 javac -cp "lib/h2-2.3.232.jar" 로 컴파일하고 인메모리 H2 를 씁니다.

변형 1: 세이브포인트 — 청크 안에서 실패 건만 취소하고 나머지를 커밋

배치 04 레슨 연습 문제 1 의 "건별 세이브포인트"를 실제 JDBC Savepoint 로 옮깁니다. CHECK (balance >= 0) 제약이 잔액 부족을 SQLException(SQLState 23513) 으로 알려 주고, rollback(sp) 는 그 건의 두 UPDATE 만 되돌린 채 트랜잭션을 유지합니다.

java
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 을 거부하므로 세이브포인트 없이는 이 패턴이 불가능합니다.

변형 2: 커서 스트리밍 — 20만 행을 fetchSize 로 흘려보내며 CSV 내보내기

ResultSet 은 커서이므로 20만 행을 전부 힙에 올리지 않아도 됩니다. setFetchSize(1000) 은 "한 번에 1,000 행씩만 가져오라"는 힌트이고, ResultSetMetaData 로 컬럼을 몰라도 헤더와 값을 범용으로 씁니다. 배치 02 레슨(스트리밍 파일 IO)의 DB 판입니다.

java
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 를 줬으니 안전하다"가 아니라 드라이버 문서를 확인해야 합니다.

변형 3: MERGE 로 멱등 정산 — 두 번 실행해도 같은 결과

배치 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 입니다.

java
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 행만 보내는 편이 수백 배 빠릅니다. "데이터가 있는 곳에서 계산한다"는 원칙입니다.

변형 4: 30줄 커넥션 풀 — close() 를 반납으로 바꾸는 프록시

2.2 절의 풀 원리를 BlockingQueue 와 java.lang.reflect.Proxy 로 구현합니다. borrow() 는 큐에서 꺼내고, 반환된 프록시의 close() 는 실제로 닫는 대신 autoCommit 을 원복하고 큐에 되돌립니다. 큐가 비면 타임아웃까지 기다리다 SQLTimeoutException — HikariCP 의 connectionTimeout 이 정확히 이것입니다.

java
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), 격리 수준·읽기 전용 플래그 원복.

응용 변형 예제
  • 변형 1: 세이브포인트 — 청크 안에서 실패 건만 취소하고 나머지를 커밋
  • 변형 2: 커서 스트리밍 — 20만 행을 fetchSize 로 흘려보내며 CSV 내보내기
  • 변형 3: MERGE 로 멱등 정산 — 두 번 실행해도 같은 결과
  • 변형 4: 30줄 커넥션 풀 — close() 를 반납으로 바꾸는 프록시
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)