이번에는 JDBC를 활용해서 간단한 등록, 조회, 수정, 삭제를 개발하고 테스트 코드까지 만들어보겠습니다.
shema.sql
drop table member if exists cascade;
create table member (
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
Member
package hello.jdbc.domain;
import lombok.Data;
@Data
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
}
회원의 ID와 해당 회원이 소지한 금액을 표현하는 단순한 클래스입니다. 앞서 만들어둔 member 테이블에 데이터를 저장하고 조회할 때 사용합니다.
가장 먼저 JDBC를 사용해서 이렇게 만든 회원 객체를 데이터베이스에 저장해보겠습니다.
MemberRepositoryV0 - 회원 등록
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
/**
* 저장
*/
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?, ?)";
Connection con = null; //연결
PreparedStatement pstmt = null; //데이터베이스에 쿼리 전송
try {
con = getConnection(); //커넥션 획득
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId()); //파라미터 바인딩
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
/* 외부 리소스를 사용하는 것이기 때문에 실제 TCP, IP 커넥션에 걸려서 사용하는 것이다.
커넥션을 안 닫아주면 열린 상태로 계속 떠다닌다.
아래 '리소스 정리'에서 설명한다.*/
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
private static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
커넥션 획득
getConnection() : 이전에 만들어둔 DBConnectionUtil를 통해서 데이터베이스 커넥션을 획득한다.save() - SQL 전달
sql : 데이터베이스에 전달할 SQL을 정의한다. 여기서는 데이터를 등록해야 하므로 insert sql을 작성
con.prepareStatement(sql) : 데이터베이스에 전달할 SQL과 파라미터로 전달할 데이터들을 준비한다.
sql : insert into member(member_id, money) values(?, ?)"
pstmt.setString(1, member.getMemberId()) : SQL의 첫번째 ? 에 값을 지정한다. 문자이므로 setString을 사용한다.
pstmt.setInt(2, member.getMoney()) : SQL의 두번째 ? 에 값을 지정한다. Int형 숫자이므로 setInt 를 지정한다.
pstmt.executeUpdate() : Statement를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달한다. 참고로 executeUpdate()은 int 를 반환하는데 영향받은 DB row 수를 반환한다. 여기서는 하나 의 row를 등록했으므로 1을 반환한다.
executeUpdate()
int executeUpdate() throws SQLException;
리소스 정리
쿼리를 실행하고 나면 리소스를 정리해야 한다. 여기서는 Connection, PreparedStatement를 사용했다. 리소스를 정리할 때는 항상 역순으로 해야한다. Connection을 먼저 획득하고 Connection을 통해 PreparedStatement를 만들었기 때문에 리소스를 반환할 때는 PreparedStatement를 먼저 종료하고, 그 다음에 Connection을 종료하면 된다. 참고로 여기서 사용하지 않은 ResultSet 은 결과를 조회할 때 사용한다. 조금 뒤에 조회 부분에서 알아보자.
주의
리소스 정리는 꼭! 해주어야 한다. 따라서 예외가 발생하든, 하지 않든 항상 수행되어야 하므로 finally 구문에 주의해서 작성해야한다. 만약 이 부분을 놓치게 되면 커넥션이 끊어지지 않고 계속 유지되는 문제가 발 생할 수 있다. 이런 것을 리소스 누수라고 하는데, 결과적으로 커넥션 부족으로 장애가 발생할 수 있다.
참고
PreparedStatement는Statement의 자식 타입인데,?를 통한 파라미터 바인딩을 가능하게 해준다. 참고로 SQL Injection 공격을 예방하려면PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야 한다.
이제 테스트 코드를 사용해서 JDC로 회원을 데이터베이스에 등록해보겠습니다.
MemberRepositoryV0Test - 회원 등록
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
//save
Member member = new Member("memberV5", 10000);
repository.save(member);
}
실행 결과
데이터베이스에서 select * from member 쿼리를 실행하면 데이터가 저장된 것을 확인할 수 있습니다.

참고로 이 테스트를 2번 실행하면 PK 중복 오류가 발생합니다. 이 경우 delete from member 쿼리로 데이터를 삭제한 다음에 다시 실행하면 됩니다.
PK 중복 오류
[Test worker] ERROR hello.jdbc.repository.MemberRepositoryV0 -- db error
org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.PRIMARY_KEY_8 ON PUBLIC.MEMBER(MEMBER_ID) VALUES ( /* 3 */ 'memberV0' )"; SQL statement:
insert into member(member_id, money) values (?, ?) [23505-224]
이번에는 JDBC를 통해 이전에 저장한 데이터를 조회하는 기능을 개발해보겠습니다.
MemberRepositoryV0 - 회원 조회
/**
* 조회
**/
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); //select 쿼리의 결과를 담고 있음.
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
findById() - 쿼리 실행
sql : 데이터 조회를 위한 select SQL을 준비한다.rs = pstmt.executeQuery() 데이터를 변경할 때는 executeUpdate()를 사용하지만, 데이터를 조회할 때는 executeQuery()를 사용한다. executeQuery()는 결과를 ResultSet에 담아서 반환한다. executeQuery()
ResultSet executeQuery() throws SQLException;
ResultSet
ResultSet 은 다음과 같이 생긴 데이터 구조이다. 보통 select 쿼리의 결과가 순서대로 들어간다.
select member_id, money라고 지정하면 member_id, money라는 이름으로 데이터 가 저장된다.select *을 사용하면 테이블의 모든 컬럼을 다 지정한다.ResultSet 내부에 있는 커서( cursor )를 이동해서 다음 데이터를 조회할 수 있다.
rs.next() : 이것을 호출하면 커서가 다음으로 이동한다. 참고로 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next() 를 최초 한번은 호출해야 데이터를 조회할 수 있다.
rs.getInt("money") : 현재 커서가 가리키고 있는 위치의 money 데이터를 int 타입으로 반환한다.
ResultSet 결과 예시

참고로 이 ResultSet 의 결과 예시는 회원이 2명 조회되는 경우입니다.
1-1 에서 rs.next() 를 호출한다.
1-2 의 결과로 cursor 가 다음으로 이동한다. 이 경우 cursor 가 가리키는 데이터가 있으므로 true 를반환한다.
2-1 에서 rs.next() 를 호출한다.
2-2 의 결과로 cursor 가 다음으로 이동한다. 이 경우 cursor 가 가리키는 데이터가 있으므로 true 를반환한다.
3-1 에서 rs.next() 를 호출한다.
3-2 의 결과로 cursor 가 다음으로 이동한다. 이 경우 cursor 가 가리키는 데이터가 없으므로 false 를반환한다.
findById()에서는 회원 하나를 조회하는 것이 목적입니다. 따라서 조회 결과가 항상 1건이므로 while 대 신에 if 를 사용합니다. 다음 SQL을 보면 PK인 member_id를 항상 지정하는 것을 확인할 수 있습니다.
SQL: select * from member where member_id = ?
이제 테스트도 작성해보겠습니다.
회원 조회 테스트 추가
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
//save
Member member = new Member("memberV5", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
}
실행 결과
MemberRepositoryV0Test - findMember=Member(memberId=memberV0, money=10000)
수정과 삭제는 등록과 비슷합니다. 등록, 수정, 삭제처럼 데이터를 변경하는 쿼리는 executeUpdate()를 사용하면됩니다.
*MemberRepositoryV0 - 회원 수정
/**
* 수정
*/
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize = {}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
executeUpdate()는 쿼리를 실행하고 영향받은 row 수를 반환합니다. 여기서는 하나의 데이터만 변경하기 때문에 결과로 1이 반환됩니다. 만약 회원이 100명이고, 모든 회원의 데이터를 한번에 수정하는 update sql을 실행하면 결과는 100이 됩니다.
회원 수정 테스트 추가
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
//save
Member member = new Member("memberV5", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
//update: money: 10000 -> 20000
repository.update(member.getMemberId(), 20000);
Member updateMember = repository.findById(member.getMemberId());
assertThat(updateMember.getMoney()).isEqualTo(20000);
}
회원 데이터의 money를 10000 -> 20000으로 수정하고, DB에서 데이터를 다시 조회해서 20000으로 변경 되었는지 검증합니다.
실행 로그
MemberRepositoryV0 - resultSize=1
pstmt.executeUpdate()의 결과가 1인 것을 확인할 수 있습니다. 이것은 해당 SQL에 영향을 받은 로우 수가 1개라는 뜻입니다.
데이터베이스에서 조회하면 memberV0의 money가 20000으로 변경된 것을 확인할 수 있다.
select * from member;
MemberRepositoryV0 - 회원 삭제
/**
* 삭제
*/
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
쿼리만 변경되고 내용은 이전과 거의 같습니다.
회원 삭제 테스트 추가
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
//save
Member member = new Member("memberV5", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
//update: money: 10000 -> 20000
repository.update(member.getMemberId(), 20000);
Member updateMember = repository.findById(member.getMemberId());
assertThat(updateMember.getMoney()).isEqualTo(20000);
//delete
repository.delete(member.getMemberId());
assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class); //예외가 터져야 검증 성공
}
}
회원을 삭제한 다음 findById()를 통해서 조회합니다. 회원이 없기 때문에 NoSuchElementException이 발생합니다. assertThatThrownBy는 해당 예외가 발생해야 검증에 성공합니다.
참고
마지막에 회원을 삭제하기 때문에 테스트가 정상 수행되면, 이제부터는 같은 테스트를 반복해서 실행할 수 있습니다. 물론 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없습니다.
트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있는데, 자세한 내용은 뒤에서 설명하겠습니다!
출처
스프링 DB 1편 - 데이터 접근 핵심 원리(김영한)
글의 내용과 자료는 대부분 위 강의의 자료를 사용합니다.
내용을 정리하는 깃허브 링크