String sql = "SELECT * FROM member WHERE email = '" + email + "'"; // 인젝션 + 매번 파싱
st.executeQuery(sql);예제 2 처럼 ' OR '1'='1 하나로 전원이 노출됩니다. 정수라서 안전하다는 생각도 틀립니다 — 1 OR 1=1 은 정수 자리에 그대로 들어갑니다.
✅ 모든 값은 ? 로 바인딩합니다. ? 를 쓸 수 없는 식별자(정렬 컬럼, 테이블명)는 화이트리스트로 검증합니다.
try (PreparedStatement ps = c.prepareStatement("SELECT * FROM member WHERE email = ?")) {
ps.setString(1, email);
...
}
// 정렬 컬럼처럼 바인딩이 안 되는 자리
Set<String> allowed = Set.of("id", "name", "joined_at");
if (!allowed.contains(sortCol)) throw new IllegalArgumentException("정렬 불가 컬럼: " + sortCol);
String sql = "SELECT * FROM member ORDER BY " + sortCol;Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery();
while (rs.next()) { ... }
c.close(); // 예외가 나면 여기 도달 못 함 → 풀에서 커넥션 하나가 영원히 사라짐수백 번 반복되면 풀이 고갈되어 서비스 전체가 멈춥니다. 커넥션만 닫고 ResultSet 을 안 닫으면 일부 드라이버는 서버 커서를 유지합니다.
✅ 세 자원 모두 try-with-resources 에 넣습니다. 역순 해제가 자동으로 보장됩니다.
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { // 바인딩 후에 열어야 하므로 별도 블록
while (rs.next()) { ... }
}
}try (Connection c = ds.getConnection()) { // autoCommit = true
update(c, "UPDATE product SET stock = stock - ? WHERE id = ?", qty, pid); // 즉시 커밋됨
update(c, "INSERT INTO orders ..."); // 여기서 실패해도 재고는 이미 차감
}"같은 커넥션이니까 같은 트랜잭션"이 아닙니다. autoCommit 이 켜져 있으면 SQL 하나하나가 독립 트랜잭션입니다.
✅ setAutoCommit(false) 로 시작하고 commit()/rollback() 을 명시하며, 반납 전 원복합니다. 예제 5 의 inTransaction 처럼 헬퍼에 가두면 빠뜨릴 수 없습니다.
jdbc.inTransaction(c -> {
SimpleJdbc.update(c, "UPDATE product SET stock = stock - ? WHERE id = ?", qty, pid);
SimpleJdbc.update(c, "INSERT INTO orders ...");
return null;
});for (List<Row> chunk : chunks) {
try {
for (Row r : chunk) insert(c, r);
c.commit();
} catch (Exception e) {
log.warn("청크 실패", e); // rollback 없음
}
}배치 04 시뮬레이션은 begin() 이 "이미 트랜잭션 진행 중"으로 막아 줬지만, JDBC 에는 begin() 이 없습니다. 실패 청크의 절반이 다음 청크의 commit() 에 묻어 들어갑니다.
✅ catch 에서 반드시 rollback(). PostgreSQL 은 오류 후 롤백 전까지 모든 SQL 을 거부하므로 더욱 필수입니다.
} catch (Exception e) {
c.rollback();
log.warn("청크 실패, 롤백", e);
}int discount = rs.getInt("discount_rate"); // NULL 이면 0
price = price * (100 - discount) / 100; // "할인 없음"과 "할인율 미정"을 구분 못 함원시형 getter 는 NULL 을 0/false 로 바꿉니다. "미정"을 "0%" 로 계산해 버리면 조용한 데이터 오류입니다.
✅ wasNull() 로 확인하거나, 래퍼 타입으로 받습니다.
Integer discount = rs.getObject("discount_rate", Integer.class); // NULL → null
if (discount == null) throw new IllegalStateException("할인율 미정: " + rs.getLong("id"));double amount = rs.getDouble("amount"); // 0.1 + 0.2 = 0.30000000000000004
java.sql.Date d = rs.getDate("joined_at"); // JVM 기본 타임존으로 변환 → 서버 타임존이 다르면 하루 밀림✅ BigDecimal 과 getObject(col, LocalDate.class).
BigDecimal amount = rs.getBigDecimal("amount");
LocalDate joined = rs.getObject("joined_at", LocalDate.class);
ps.setObject(1, LocalDate.now()); // 바인딩도 대칭try (Connection c = ds.getConnection()) {
List<Order> orders = query(c, ...);
for (Order o : orders) paymentApi.charge(o); // 외부 API 3초 × N 건 동안 커넥션 점유
update(c, ...);
}풀 크기 10 에 이런 요청 10개가 동시에 오면 11번째 요청부터 풀 고갈로 실패합니다. DB 는 한가한데 서비스가 멈추는 전형적 장애입니다.
✅ 커넥션은 SQL 실행 직전에 빌리고 직후에 반납합니다. 외부 호출은 트랜잭션 밖에서 하고, 결과를 다시 짧은 트랜잭션으로 기록합니다(배치 04 레슨 실수 5 참고).