공공부하자개발 · 영어 학습 노트
자바
실무 확장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정리‹ 이전다음 ›

3. 코드 예제

java-src/extension/04_jdbc/ 에서 실행합니다. H2 jar 가 클래스패스에 있어야 합니다.

text
javac -encoding UTF-8 -cp "..\..\lib\*" *.java
java -Dstdout.encoding=UTF-8 -cp "..\..\lib\*;." Main

스키마는 회원/상품/주문/결제 네 테이블입니다.

sql
CREATE TABLE member (
    id        BIGINT AUTO_INCREMENT PRIMARY KEY,
    name      VARCHAR(50)  NOT NULL,
    email     VARCHAR(100) NOT NULL UNIQUE,
    grade     VARCHAR(10)  NOT NULL DEFAULT 'BRONZE',
    joined_at DATE         NOT NULL,
    balance   DECIMAL(15,2) NOT NULL DEFAULT 0,
    memo      VARCHAR(200)
);
CREATE TABLE product (
    id    BIGINT AUTO_INCREMENT PRIMARY KEY,
    name  VARCHAR(100) NOT NULL,
    price DECIMAL(12,2) NOT NULL,
    stock INT NOT NULL CHECK (stock >= 0)
);
CREATE TABLE orders (
    id         BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id  BIGINT NOT NULL REFERENCES member(id),
    product_id BIGINT NOT NULL REFERENCES product(id),
    qty        INT NOT NULL,
    amount     DECIMAL(12,2) NOT NULL,
    status     VARCHAR(20) NOT NULL,
    ordered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE payment (
    id       BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    amount   DECIMAL(12,2) NOT NULL,
    method   VARCHAR(20) NOT NULL
);

예제 1: DataSource, 스키마 초기화, 드라이버 로딩, 커넥션 비용

Db 는 H2 내장 풀로 DataSource 를 만들고, 스키마 스크립트를 RunScript 로 실행합니다.

세미콜론 분할과 주석 처리를 직접 하지 않아도 됩니다(다른 DB 라면 ; 로 split 해 Statement.execute 반복, 또는 Spring 의 ScriptUtils). 드라이버는 Class.forName 없이 이미 등록되어 있고, 같은 인메모리 DB 에 2,000 번 접속할 때 풀이 얼마나 빠른지 잽니다.

java
public class Db {
    public static final String URL = "jdbc:h2:mem:shop;DB_CLOSE_DELAY=-1";

    public static DataSource dataSource() {
        JdbcConnectionPool pool = JdbcConnectionPool.create(URL, "sa", "");   // H2 내장 간단 풀. 실무는 HikariCP
        pool.setMaxConnections(8);
        return pool;
    }

    public static void initSchema(DataSource ds) throws SQLException {
        try (Connection c = ds.getConnection()) {
            RunScript.execute(c, new StringReader(SCHEMA));                  // 세미콜론 구분 스크립트 실행
        }
    }
}

// Main.driverAndConnection
DriverManager.drivers().forEach(d -> System.out.println("등록된 드라이버: " + d.getClass().getName()));
try (Connection c = DriverManager.getConnection(Db.URL, "sa", "")) {
    System.out.println("DriverManager 커넥션: " + c.getMetaData().getDatabaseProductName()
            + " " + c.getMetaData().getDatabaseProductVersion() + ", autoCommit=" + c.getAutoCommit());
}
long t0 = System.nanoTime();
for (int i = 0; i < 2000; i++) try (Connection c = DriverManager.getConnection(Db.URL, "sa", "")) {}
long direct = (System.nanoTime() - t0) / 1_000_000;
t0 = System.nanoTime();
for (int i = 0; i < 2000; i++) try (Connection c = ds.getConnection()) {}
long pooled = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("커넥션 2000회: DriverManager %d ms, 풀 %d ms%n", direct, pooled);
// 출력:
// 등록된 드라이버: org.h2.Driver
// DriverManager 커넥션: H2 2.3.232 (2024-08-11), autoCommit=true
// 커넥션 2000회: DriverManager 3742 ms, 풀 183 ms

인메모리 DB 조차 커넥션 하나에 ~1.8ms 입니다. 네트워크 너머의 DB 라면 이 차이는 수십 배가 됩니다. autoCommit=true 가 기본이라는 점도 기억하세요.

예제 2: Statement 인젝션 시연 → PreparedStatement 로 방어

로그인 폼에 ' OR '1'='1 이 들어왔을 때, 문자열 결합은 조건을 항상 참으로 만들어 전원을 반환하고, 바인딩은 그 문자열을 이름으로 가진 회원(없음)을 찾습니다.

java
String input = "' OR '1'='1";
try (Connection c = ds.getConnection()) {
    String sql = "SELECT COUNT(*) FROM member WHERE name = '" + input + "'";
    try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery(sql)) {
        rs.next();
        System.out.println("Statement 문자열 결합: " + sql);
        System.out.println("  -> " + rs.getLong(1) + "건 (조건이 항상 참이 되어 전원 노출)");
    }
    try (PreparedStatement ps = c.prepareStatement("SELECT COUNT(*) FROM member WHERE name = ?")) {
        ps.setString(1, input);                                  // 값은 SQL 이 아니라 '데이터'로 전달
        try (ResultSet rs = ps.executeQuery()) {
            rs.next();
            System.out.println("PreparedStatement 바인딩: name = ? [" + input + "]");
            System.out.println("  -> " + rs.getLong(1) + "건 (따옴표가 그냥 문자)");
        }
    }
}
// 출력:
// Statement 문자열 결합: SELECT COUNT(*) FROM member WHERE name = '' OR '1'='1'
//   -> 3건 (조건이 항상 참이 되어 전원 노출)
// PreparedStatement 바인딩: name = ? [' OR '1'='1]
//   -> 0건 (따옴표가 그냥 문자)

'; DROP TABLE member; -- 같은 입력이면 테이블이 사라집니다. 사용자 입력이 SQL 에 닿는 모든 경로를 ? 로 바꾸는 것이 유일한 방어입니다. 이스케이프 함수로 막으려는 시도는 인코딩·DB 방언 차이로 반드시 뚫립니다.

예제 3: ResultSet 타입 매핑 — LocalDate, BigDecimal, wasNull

joined_at 을 LocalDate 로 직접 받고, balance 는 BigDecimal, memo 는 NULL 이 있습니다. LENGTH(memo) 를 getInt 로 읽으면 NULL 이 0 으로 나오는데, 이것이 "메모 길이 0" 인지 "메모 없음"인지는 wasNull() 만이 알려 줍니다.

java
try (Connection c = ds.getConnection();
     PreparedStatement ps = c.prepareStatement("SELECT id, name, joined_at, balance, memo, LENGTH(memo) AS memo_len FROM member ORDER BY id");
     ResultSet rs = ps.executeQuery()) {
    while (rs.next()) {
        long id = rs.getLong("id");
        LocalDate joined = rs.getObject("joined_at", LocalDate.class);   // java.sql.Date 를 거치지 않고 바로
        BigDecimal balance = rs.getBigDecimal("balance");                // 돈은 double 이 아니라 BigDecimal
        String memo = rs.getString("memo");                              // NULL → null
        int memoLen = rs.getInt("memo_len");                             // 원시형 getter 는 NULL 을 0 으로 돌려준다
        boolean lenNull = rs.wasNull();                                  // 직전 getXxx 가 NULL 이었는지
        System.out.printf("id=%d name=%s joined=%s(%s) balance=%s memo=%s memo_len=%d wasNull=%b%n",
                id, rs.getString("name"), joined, joined.getDayOfWeek(), balance.toPlainString(), memo, memoLen, lenNull);
    }
}
// 출력:
// id=1 name=김철수 joined=2024-01-15(MONDAY) balance=150000.50 memo=VIP 후보 memo_len=6 wasNull=false
// id=2 name=이영희 joined=2024-06-01(SATURDAY) balance=20000.00 memo=null memo_len=0 wasNull=true
// id=3 name=박민수 joined=2025-03-20(THURSDAY) balance=0.00 memo=null memo_len=0 wasNull=true

ResultSet 은 열려 있는 동안 커넥션을 점유하는 커서입니다. while (rs.next()) 안에서 다른 DB 호출이나 외부 API 를 부르지 말고, 필요한 값을 객체로 뽑은 뒤 닫고 처리합니다.

예제 4: SimpleJdbc 헬퍼 + RowMapper + DAO CRUD + 생성 키

JDBC 코드의 80% 는 "커넥션 얻기 → prepare → 바인딩 → 실행 → 순회 → 닫기" 의 반복입니다. SimpleJdbc 는 그 반복을 query/update/inTransaction 세 메서드에 담습니다.

RowMapper<T> 는 "행 하나 → 객체" 함수형 인터페이스이고, MemberDao 는 그 위에서 CRUD 를 짧게 씁니다. insert 는 RETURN_GENERATED_KEYS 로 AUTO_INCREMENT 키를 돌려받습니다.

java
@FunctionalInterface
public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; }

public class SimpleJdbc {
    public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
        try (Connection c = ds.getConnection()) { return query(c, sql, mapper, args); }
        catch (SQLException e) { throw new DataAccessException(sql, e); }
    }
    public static <T> List<T> query(Connection c, String sql, RowMapper<T> mapper, Object... args) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement(sql)) {
            bind(ps, args);
            try (ResultSet rs = ps.executeQuery()) {
                List<T> out = new ArrayList<>();
                while (rs.next()) out.add(mapper.map(rs));
                return out;
            }
        }
    }
    public static long insertReturningKey(Connection c, String sql, Object... args) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            bind(ps, args);
            ps.executeUpdate();
            try (ResultSet keys = ps.getGeneratedKeys()) {
                if (!keys.next()) throw new SQLException("생성 키 없음: " + sql);
                return keys.getLong(1);
            }
        }
    }
    private static void bind(PreparedStatement ps, Object... args) throws SQLException {
        for (int i = 0; i < args.length; i++) ps.setObject(i + 1, args[i]);   // LocalDate, BigDecimal, null 모두 setObject
    }
}

public class MemberDao {
    public record Member(long id, String name, String email, String grade, LocalDate joinedAt, BigDecimal balance, String memo) {}
    public static final RowMapper<Member> MAPPER = rs -> new Member(
            rs.getLong("id"), rs.getString("name"), rs.getString("email"), rs.getString("grade"),
            rs.getObject("joined_at", LocalDate.class), rs.getBigDecimal("balance"), rs.getString("memo"));

    public long insert(String name, String email, LocalDate joinedAt) {
        return jdbc.inTransaction(c -> SimpleJdbc.insertReturningKey(c,
                "INSERT INTO member(name, email, joined_at) VALUES (?,?,?)", name, email, joinedAt));
    }
    public Optional<Member> findById(long id) { return jdbc.queryOne("SELECT * FROM member WHERE id = ?", MAPPER, id); }
    public int updateGrade(long id, String grade) { return jdbc.update("UPDATE member SET grade = ? WHERE id = ?", grade, id); }
    public int delete(long id) { return jdbc.update("DELETE FROM member WHERE id = ?", id); }
}

// Main.crud
MemberDao dao = new MemberDao(jdbc);
long id = dao.insert("최지훈", "choi@example.com", LocalDate.of(2025, 9, 1));
System.out.println("insert 생성 키: " + id);
System.out.println("findById: " + dao.findById(id).orElseThrow());
System.out.println("updateGrade 영향 행: " + dao.updateGrade(id, "SILVER"));
System.out.println("변경 후 grade: " + dao.findById(id).orElseThrow().grade());
System.out.println("delete 영향 행: " + dao.delete(id) + ", 재조회: " + dao.findById(id));
// 출력:
// insert 생성 키: 4
// findById: Member[id=4, name=최지훈, email=choi@example.com, grade=BRONZE, joinedAt=2025-09-01, balance=0.00, memo=null]
// updateGrade 영향 행: 1
// 변경 후 grade: SILVER
// delete 영향 행: 1, 재조회: Optional.empty

executeUpdate() 의 반환값(영향 행 수)을 확인하는 습관이 중요합니다. UPDATE ... WHERE id = ? 가 0 을 반환하면 "없는 id" 이고, 조용히 넘어가면 나중에 찾기 어렵습니다.

예제 5: 주문 트랜잭션 — 재고 차감 + 주문 + 결제의 원자성

inTransaction 은 커넥션 하나를 빌려 setAutoCommit(false), 람다 실행, commit(), 예외면 rollback(), 마지막에 setAutoCommit(true) 로 원복하고 반납합니다.

OrderService.placeOrder 는 그 안에서 SELECT ... FOR UPDATE 로 재고 행을 잠근 뒤 네 SQL 을 실행합니다. 재고 부족(자바 예외)이든 FK 위반(SQLException)이든 이미 실행된 UPDATE 까지 전부 취소됩니다.

java
public <T> T inTransaction(TxCallback<T> work) {
    Connection c = null;
    try {
        c = ds.getConnection();
        c.setAutoCommit(false);                       // BEGIN
        T result = work.doInTx(c);
        c.commit();
        return result;
    } catch (Exception e) {
        if (c != null) try { c.rollback(); } catch (SQLException ignore) {}
        if (e instanceof RuntimeException re) throw re;
        if (e instanceof SQLException se) throw new DataAccessException("transaction", se);
        throw new RuntimeException(e);
    } finally {
        if (c != null) try { c.setAutoCommit(true); c.close(); } catch (SQLException ignore) {}   // 풀 반납 전 상태 원복
    }
}

public long placeOrder(long memberId, long productId, int qty, String payMethod) {
    return jdbc.inTransaction(c -> {
        List<Object[]> rows = SimpleJdbc.query(c, "SELECT price, stock FROM product WHERE id = ? FOR UPDATE",   // 행 잠금
                rs -> new Object[]{rs.getBigDecimal("price"), rs.getInt("stock")}, productId);
        if (rows.isEmpty()) throw new IllegalArgumentException("상품 없음: " + productId);
        BigDecimal price = (BigDecimal) rows.get(0)[0];
        int stock = (int) rows.get(0)[1];
        if (stock < qty) throw new OutOfStockException("재고 부족: product=" + productId + " stock=" + stock + " < qty=" + qty);

        SimpleJdbc.update(c, "UPDATE product SET stock = stock - ? WHERE id = ?", qty, productId);
        BigDecimal amount = price.multiply(BigDecimal.valueOf(qty));
        long orderId = SimpleJdbc.insertReturningKey(c,
                "INSERT INTO orders(member_id, product_id, qty, amount, status) VALUES (?,?,?,?,'PAID')", memberId, productId, qty, amount);
        SimpleJdbc.update(c, "INSERT INTO payment(order_id, amount, method) VALUES (?,?,?)", orderId, amount, payMethod);   // 여기서 실패해도 전부 롤백
        return orderId;
    });
}

// Main.orderTx
System.out.println("초기: " + svc.snapshot(c, 1));
long orderId = svc.placeOrder(1, 1, 2, "CARD");
System.out.println("주문 성공 id=" + orderId + " -> " + svc.snapshot(c, 1));
try { svc.placeOrder(2, 1, 10, "CARD"); }
catch (OrderService.OutOfStockException e) { System.out.println("주문 실패: " + e.getMessage()); }
System.out.println("실패 후: " + svc.snapshot(c, 1) + " (아무것도 바뀌지 않음)");
try { svc.placeOrder(999, 1, 1, "CARD"); }                                   // 없는 회원 → FK 위반은 3번째 SQL 에서
catch (SimpleJdbc.DataAccessException e) { System.out.println("FK 위반: SQLState=" + e.sqlState + " -> 재고 차감까지 롤백"); }
System.out.println("FK 실패 후: " + svc.snapshot(c, 1));
// 출력:
// 초기: stock=5, orders=0, payments=0
// 주문 성공 id=1 -> stock=3, orders=1, payments=1
// 주문 실패: 재고 부족: product=1 stock=3 < qty=10
// 실패 후: stock=3, orders=1, payments=1 (아무것도 바뀌지 않음)
// FK 위반: SQLState=23506 -> 재고 차감까지 롤백
// FK 실패 후: stock=3, orders=1, payments=1

세 번째 케이스가 트랜잭션의 존재 이유입니다. 재고 UPDATE 는 성공했고, 그 다음 INSERT 가 FK 로 실패했습니다. autoCommit 이었다면 재고 3 → 2 가 이미 확정된 뒤였을 것입니다.

예제 6: 격리 수준 — 미커밋 변경은 다른 커넥션에 보이지 않는다

커넥션 A 가 insert 하고 커밋하지 않은 동안 커넥션 B 가 세면, READ_COMMITTED 에서는 A 의 행이 보이지 않습니다. 배치 04 의 "pending 은 자기에게만 보인다"가 실제 DB 에서 그대로 성립합니다.

java
try (Connection a = ds.getConnection(); Connection b = ds.getConnection()) {
    System.out.println("기본 격리 수준: " + isolationName(a.getTransactionIsolation()));
    a.setAutoCommit(false);
    SimpleJdbc.update(a, "INSERT INTO member(name, email, joined_at) VALUES ('홍길동','hong@example.com', CURRENT_DATE)");
    long inA = SimpleJdbc.query(a, "SELECT COUNT(*) FROM member", rs -> rs.getLong(1)).get(0);
    long inB = SimpleJdbc.query(b, "SELECT COUNT(*) FROM member", rs -> rs.getLong(1)).get(0);
    System.out.println("A 가 insert 후 미커밋: A 에서 보이는 건수=" + inA + ", B 에서 보이는 건수=" + inB);
    a.rollback();
    inB = SimpleJdbc.query(b, "SELECT COUNT(*) FROM member", rs -> rs.getLong(1)).get(0);
    System.out.println("A rollback 후: B 에서 보이는 건수=" + inB);
    a.setAutoCommit(true);
}
// 출력:
// 기본 격리 수준: READ_COMMITTED
// A 가 insert 후 미커밋: A 에서 보이는 건수=4, B 에서 보이는 건수=3
// A rollback 후: B 에서 보이는 건수=3

예제 7: 배치 insert 10만 건 — 건별 executeUpdate vs addBatch/executeBatch

같은 커넥션, 같은 트랜잭션에서 10만 건을 두 방식으로 넣고 시간을 잽니다. 1,000 건마다 executeBatch() 로 버퍼를 비웁니다.

java
final int N = 100_000;
String sql = "INSERT INTO bulk_log(seq, body) VALUES (?, ?)";
try (Connection c = ds.getConnection()) {
    c.setAutoCommit(false);
    long t0 = System.nanoTime();
    try (PreparedStatement ps = c.prepareStatement(sql)) {
        for (int i = 0; i < N; i++) { ps.setInt(1, i); ps.setString(2, "row-" + i); ps.executeUpdate(); }   // 건별 왕복
    }
    c.commit();
    long single = (System.nanoTime() - t0) / 1_000_000;

    SimpleJdbc.update(c, "TRUNCATE TABLE bulk_log");
    t0 = System.nanoTime();
    try (PreparedStatement ps = c.prepareStatement(sql)) {
        for (int i = 0; i < N; i++) {
            ps.setInt(1, i); ps.setString(2, "row-" + i);
            ps.addBatch();                                            // 드라이버 버퍼에 쌓기
            if ((i + 1) % 1000 == 0) ps.executeBatch();               // 1000건 단위로 전송
        }
        ps.executeBatch();                                            // 꼬리
    }
    c.commit();
    long batch = (System.nanoTime() - t0) / 1_000_000;
    long count = SimpleJdbc.query(c, "SELECT COUNT(*) FROM bulk_log", rs -> rs.getLong(1)).get(0);
    System.out.printf("건별 executeUpdate: %d ms, addBatch/executeBatch(1000): %d ms, 적재 %,d건%n", single, batch, count);
    c.setAutoCommit(true);
}
// 출력 (측정값은 환경에 따라 다름):
// 건별 executeUpdate: 4732 ms, addBatch/executeBatch(1000): 2615 ms, 적재 100,000건

인메모리 H2 는 네트워크 왕복이 없어 약 1.8배 차이에 그칩니다. 원격 PostgreSQL/MySQL(rewriteBatchedStatements=true)에서는 10~50배가 일반적입니다. 배치의 이득은 왕복 지연에 비례한다는 것을 기억하세요.

예제 8: 청크 커밋을 실제 DB 로 — 실패 청크만 롤백

배치 04 레슨의 TransferBatch 골격입니다. 500 건을 100 건 청크로 나눠 청크마다 executeBatch() + commit(), 3번째 청크의 50번째 건에서 검증 실패가 나면 rollback() 으로 그 청크의 미커밋 50 건만 취소됩니다.

java
try (Connection c = ds.getConnection()) {
    c.setAutoCommit(false);
    int chunkSize = 100, total = 500, committed = 0, rolledBack = 0;
    for (int start = 0; start < total; start += chunkSize) {
        int chunkNo = start / chunkSize + 1;
        try (PreparedStatement ps = c.prepareStatement("INSERT INTO bulk_log(seq, body) VALUES (?, ?)")) {
            for (int i = start; i < start + chunkSize; i++) {
                if (chunkNo == 3 && i == start + 50) throw new IllegalStateException("seq " + i + " 검증 실패");
                ps.setInt(1, i); ps.setString(2, "chunk-" + chunkNo); ps.addBatch();
            }
            ps.executeBatch();
            c.commit();                                               // 커밋 포인트
            committed++;
            System.out.println("  청크 " + chunkNo + " 커밋");
        } catch (Exception e) {
            c.rollback();                                             // 이 청크의 미커밋 변경만 취소
            rolledBack++;
            System.out.println("  청크 " + chunkNo + " 롤백 (" + e.getMessage() + ")");
        }
    }
    c.setAutoCommit(true);
    long count = SimpleJdbc.query(c, "SELECT COUNT(*) FROM bulk_log", rs -> rs.getLong(1)).get(0);
    System.out.printf("커밋 %d청크, 롤백 %d청크, 적재 %d건 (청크 3 의 50건은 없음)%n", committed, rolledBack, count);
}
// 출력:
//   청크 1 커밋
//   청크 2 커밋
//   청크 3 롤백 (seq 250 검증 실패)
//   청크 4 커밋
//   청크 5 커밋
// 커밋 4청크, 롤백 1청크, 적재 400건 (청크 3 의 50건은 없음)

시뮬레이션과 다른 점 하나: 실제 JDBC 는 rollback() 없이 다음 청크로 넘어가도 막지 않습니다. 실패 청크의 절반이 다음 청크와 함께 커밋되는 사고를 catch 의 rollback() 이 막습니다(실수 4).

예제 9: 메타데이터 — DatabaseMetaData 와 ResultSetMetaData 범용 덤프

DatabaseMetaData 는 DB/드라이버 정보와 테이블 목록을, ResultSetMetaData 는 "지금 조회한 결과의 컬럼 이름과 타입"을 줍니다. 후자로 컬럼을 모르는 임의 SQL 을 표로 찍는 범용 덤프를 만들 수 있습니다(관리 도구, CSV 내보내기의 기본).

java
DatabaseMetaData md = c.getMetaData();
System.out.println("DB: " + md.getDatabaseProductName() + " " + md.getDatabaseProductVersion()
        + ", 드라이버: " + md.getDriverName() + " " + md.getDriverVersion()
        + ", 배치 지원=" + md.supportsBatchUpdates() + ", 세이브포인트=" + md.supportsSavepoints());
try (ResultSet t = md.getTables(null, "PUBLIC", "%", new String[]{"BASE TABLE"})) {
    StringBuilder sb = new StringBuilder("테이블:");
    while (t.next()) sb.append(' ').append(t.getString("TABLE_NAME"));
    System.out.println(sb);
}

static void dump(Connection c, String sql) throws SQLException {
    try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery(sql)) {
        ResultSetMetaData m = rs.getMetaData();
        int n = m.getColumnCount();
        StringBuilder head = new StringBuilder();
        for (int i = 1; i <= n; i++) head.append(String.format("%-26s", m.getColumnLabel(i) + "(" + m.getColumnTypeName(i) + ")"));
        System.out.println(head);
        while (rs.next()) {
            StringBuilder row = new StringBuilder();
            for (int i = 1; i <= n; i++) row.append(String.format("%-26s", rs.getObject(i)));
            System.out.println(row);
        }
    }
}
// 출력:
// DB: H2 2.3.232 (2024-08-11), 드라이버: H2 JDBC Driver 2.3.232 (2024-08-11), 배치 지원=true, 세이브포인트=true
// 테이블: BULK_LOG MEMBER ORDERS PAYMENT PRODUCT
// ID(BIGINT)                NAME(CHARACTER VARYING)   PRICE(DECIMAL)            STOCK(INTEGER)
// 1                         노트북                       1200000.00                3
// 2                         마우스                       25000.00                  100

예제 10: 예외 — SQLState 로 제약 위반과 프로그램 버그 구분

같은 SQLException 이라도 SQLState 클래스가 다릅니다. 23xxx 는 사용자 데이터 문제(중복, 참조 무결성, CHECK), 42xxx 는 SQL/객체 오류(개발자 버그)입니다. 헬퍼가 예외를 감쌀 때 이 값을 보존해야 호출자가 분기할 수 있습니다.

java
try {
    jdbc.update("INSERT INTO member(name, email, joined_at) VALUES ('중복', 'kim@example.com', CURRENT_DATE)");
} catch (SimpleJdbc.DataAccessException e) {
    System.out.println("UNIQUE 위반: SQLState=" + e.sqlState + ", errorCode=" + e.errorCode
            + (e.sqlState.startsWith("23") ? " -> 무결성 제약(23xxx): 사용자 오류로 처리" : ""));
}
try {
    jdbc.update("UPDATE product SET stock = stock - 1000 WHERE id = 2");
} catch (SimpleJdbc.DataAccessException e) {
    System.out.println("CHECK 위반 : SQLState=" + e.sqlState + ", errorCode=" + e.errorCode);
}
try {
    jdbc.query("SELECT * FROM no_such_table", rs -> rs.getString(1));
} catch (SimpleJdbc.DataAccessException e) {
    System.out.println("테이블 없음: SQLState=" + e.sqlState + ", errorCode=" + e.errorCode + " -> 프로그램 버그");
}
// 출력:
// UNIQUE 위반: SQLState=23505, errorCode=23505 -> 무결성 제약(23xxx): 사용자 오류로 처리
// CHECK 위반 : SQLState=23513, errorCode=23513
// 테이블 없음: SQLState=42S02, errorCode=42102 -> 프로그램 버그

H2 는 errorCode 를 SQLState 와 비슷하게 맞춰 두었지만, PostgreSQL 은 errorCode 가 0 이고 Oracle 은 ORA- 번호입니다. 이식성이 필요하면 SQLState 만 봅니다.

예제 11: 페이징 — LIMIT/OFFSET vs 키셋

OFFSET 은 "앞의 N 행을 읽고 버리기"라 뒤 페이지로 갈수록 느려지고, 도중에 행이 삽입/삭제되면 중복·누락이 생깁니다. 키셋은 "마지막으로 본 id 다음부터"라 항상 인덱스 범위 탐색 한 번이고 배치의 커서 순회에 알맞습니다.

java
public List<Member> pageByOffset(int page, int size) {
    return jdbc.query("SELECT * FROM member ORDER BY id LIMIT ? OFFSET ?", MAPPER, size, page * size);
}
public List<Member> pageByKeyset(long lastId, int size) {
    return jdbc.query("SELECT * FROM member WHERE id > ? ORDER BY id LIMIT ?", MAPPER, lastId, size);
}

// Main.paging (회원 7명 추가 후)
System.out.println("OFFSET  page0: " + names(dao.pageByOffset(0, 4)));
System.out.println("OFFSET  page1: " + names(dao.pageByOffset(1, 4)));
long lastId = 0;
for (int page = 0; page < 2; page++) {
    List<MemberDao.Member> rows = dao.pageByKeyset(lastId, 4);
    System.out.println("KEYSET  after " + lastId + ": " + names(rows));
    lastId = rows.get(rows.size() - 1).id();
}
// 출력:
// OFFSET  page0: [1:김철수, 2:이영희, 3:박민수, 7:회원0]
// OFFSET  page1: [8:회원1, 9:회원2, 10:회원3, 11:회원4]
// KEYSET  after 0: [1:김철수, 2:이영희, 3:박민수, 7:회원0]
// KEYSET  after 7: [8:회원1, 9:회원2, 10:회원3, 11:회원4]

id 4, 5, 6 이 비어 있는 것에 주목하세요. 4 는 예제 4 에서 삭제했고, 5 와 6 은 예제 5·6 에서 롤백된 INSERT 가 소비한 AUTO_INCREMENT 값입니다. 시퀀스는 트랜잭션과 무관하게 전진하므로 "id 가 연속이다"를 전제하는 코드는 잘못입니다. 키셋 페이징이 id > ? 를 쓰는 이유이기도 합니다.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다. 외부 jar 를 쓰는 레슨은 java-src/lib 를 클래스패스에 넣습니다.

cd java-src\extension\04_jdbc
javac -encoding UTF-8 *.java && java Main

:: 외부 jar 가 필요한 레슨
javac -encoding UTF-8 -cp "..\..\lib\*;." *.java && java -cp "..\..\lib\*;." Main
코드 예제
  • 예제 1: DataSource, 스키마 초기화, 드라이버 로딩, 커넥션 비용
  • 예제 2: Statement 인젝션 시연 → PreparedStatement 로 방어
  • 예제 3: ResultSet 타입 매핑 — LocalDate, BigDecimal, wasNull
  • 예제 4: SimpleJdbc 헬퍼 + RowMapper + DAO CRUD + 생성 키
  • 예제 5: 주문 트랜잭션 — 재고 차감 + 주문 + 결제의 원자성
  • 예제 6: 격리 수준 — 미커밋 변경은 다른 커넥션에 보이지 않는다
  • 예제 7: 배치 insert 10만 건 — 건별 executeUpdate vs addBatch/executeBatch
  • 예제 8: 청크 커밋을 실제 DB 로 — 실패 청크만 롤백
  • 예제 9: 메타데이터 — DatabaseMetaData 와 ResultSetMetaData 범용 덤프
  • 예제 10: 예외 — SQLState 로 제약 위반과 프로그램 버그 구분
  • 예제 11: 페이징 — LIMIT/OFFSET vs 키셋
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제