java-src/extension/04_jdbc/ 에서 실행합니다. H2 jar 가 클래스패스에 있어야 합니다.
javac -encoding UTF-8 -cp "..\..\lib\*" *.java
java -Dstdout.encoding=UTF-8 -cp "..\..\lib\*;." Main스키마는 회원/상품/주문/결제 네 테이블입니다.
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
);Db 는 H2 내장 풀로 DataSource 를 만들고, 스키마 스크립트를 RunScript 로 실행합니다.
세미콜론 분할과 주석 처리를 직접 하지 않아도 됩니다(다른 DB 라면 ; 로 split 해 Statement.execute 반복, 또는 Spring 의 ScriptUtils). 드라이버는 Class.forName 없이 이미 등록되어 있고, 같은 인메모리 DB 에 2,000 번 접속할 때 풀이 얼마나 빠른지 잽니다.
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 가 기본이라는 점도 기억하세요.
로그인 폼에 ' OR '1'='1 이 들어왔을 때, 문자열 결합은 조건을 항상 참으로 만들어 전원을 반환하고, 바인딩은 그 문자열을 이름으로 가진 회원(없음)을 찾습니다.
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 방언 차이로 반드시 뚫립니다.
joined_at 을 LocalDate 로 직접 받고, balance 는 BigDecimal, memo 는 NULL 이 있습니다. LENGTH(memo) 를 getInt 로 읽으면 NULL 이 0 으로 나오는데, 이것이 "메모 길이 0" 인지 "메모 없음"인지는 wasNull() 만이 알려 줍니다.
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=trueResultSet 은 열려 있는 동안 커넥션을 점유하는 커서입니다. while (rs.next()) 안에서 다른 DB 호출이나 외부 API 를 부르지 말고, 필요한 값을 객체로 뽑은 뒤 닫고 처리합니다.
JDBC 코드의 80% 는 "커넥션 얻기 → prepare → 바인딩 → 실행 → 순회 → 닫기" 의 반복입니다. SimpleJdbc 는 그 반복을 query/update/inTransaction 세 메서드에 담습니다.
RowMapper<T> 는 "행 하나 → 객체" 함수형 인터페이스이고, MemberDao 는 그 위에서 CRUD 를 짧게 씁니다. insert 는 RETURN_GENERATED_KEYS 로 AUTO_INCREMENT 키를 돌려받습니다.
@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("최지훈", "[email protected]", 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 protected], grade=BRONZE, joinedAt=2025-09-01, balance=0.00, memo=null]
// updateGrade 영향 행: 1
// 변경 후 grade: SILVER
// delete 영향 행: 1, 재조회: Optional.emptyexecuteUpdate() 의 반환값(영향 행 수)을 확인하는 습관이 중요합니다. UPDATE ... WHERE id = ? 가 0 을 반환하면 "없는 id" 이고, 조용히 넘어가면 나중에 찾기 어렵습니다.
inTransaction 은 커넥션 하나를 빌려 setAutoCommit(false), 람다 실행, commit(), 예외면 rollback(), 마지막에 setAutoCommit(true) 로 원복하고 반납합니다.
OrderService.placeOrder 는 그 안에서 SELECT ... FOR UPDATE 로 재고 행을 잠근 뒤 네 SQL 을 실행합니다. 재고 부족(자바 예외)이든 FK 위반(SQLException)이든 이미 실행된 UPDATE 까지 전부 취소됩니다.
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 가 이미 확정된 뒤였을 것입니다.
커넥션 A 가 insert 하고 커밋하지 않은 동안 커넥션 B 가 세면, READ_COMMITTED 에서는 A 의 행이 보이지 않습니다. 배치 04 의 "pending 은 자기에게만 보인다"가 실제 DB 에서 그대로 성립합니다.
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같은 커넥션, 같은 트랜잭션에서 10만 건을 두 방식으로 넣고 시간을 잽니다. 1,000 건마다 executeBatch() 로 버퍼를 비웁니다.
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배가 일반적입니다. 배치의 이득은 왕복 지연에 비례한다는 것을 기억하세요.
배치 04 레슨의 TransferBatch 골격입니다. 500 건을 100 건 청크로 나눠 청크마다 executeBatch() + commit(), 3번째 청크의 50번째 건에서 검증 실패가 나면 rollback() 으로 그 청크의 미커밋 50 건만 취소됩니다.
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).
DatabaseMetaData 는 DB/드라이버 정보와 테이블 목록을, ResultSetMetaData 는 "지금 조회한 결과의 컬럼 이름과 타입"을 줍니다. 후자로 컬럼을 모르는 임의 SQL 을 표로 찍는 범용 덤프를 만들 수 있습니다(관리 도구, CSV 내보내기의 기본).
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같은 SQLException 이라도 SQLState 클래스가 다릅니다. 23xxx 는 사용자 데이터 문제(중복, 참조 무결성, CHECK), 42xxx 는 SQL/객체 오류(개발자 버그)입니다. 헬퍼가 예외를 감쌀 때 이 값을 보존해야 호출자가 분기할 수 있습니다.
try {
jdbc.update("INSERT INTO member(name, email, joined_at) VALUES ('중복', '[email protected]', 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 만 봅니다.
OFFSET 은 "앞의 N 행을 읽고 버리기"라 뒤 페이지로 갈수록 느려지고, 도중에 행이 삽입/삭제되면 중복·누락이 생깁니다. 키셋은 "마지막으로 본 id 다음부터"라 항상 인덱스 범위 탐색 한 번이고 배치의 커서 순회에 알맞습니다.
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 > ? 를 쓰는 이유이기도 합니다.