관리 메뉴

나구리의 개발공부기록

JDBC 이해 - JDBC 개발(등록 / 조회 / 수정 / 삭제) 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 1편 - 데이터 접근 핵심 원리

JDBC 이해 - JDBC 개발(등록 / 조회 / 수정 / 삭제)

소소한나구리 2024. 9. 11. 15:07

  출처 : 인프런 - 스프링 DB 1편 데이터 접근 핵심 원리 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard


1. JDBC 개발 - 등록

  • H2 데이터 베이스를 실행 해놓고 member 테이블을 미리 만들어두고 시작해야함(이전 강의 참고)

1) Member - domain

package hello.jdbc.domain;

@Data
public class Member {

    private String memberId;
    private int money;

    // 기본 생성자
    public Member() {}

    // 매개변수가 있는 생성자
    public Member(String memberId, int money) {
        this.memberId = memberId;
        this.money = money;
    }
}

2) MemberRepositoryV0

(1) 커넥션 획득

  • getConnection() : 이전에 만들어둔 DBConnectionUtil을 통해서 데이터 베이스 커넥션을 획들

(2) save() - SQL 전달

  • sql : 데이터 베이스에 전달할 SQL을 작성 
  • con.prepareStatement(sql) : 데이터베이스에 전달할 SQL과 파라미터로 전달할 데이터들을 준비함
  • pstmt.setString(1, member.getMemberId()) : SQL의 첫번째 ?에 값을 지정, 문자이므로 setString을 사용
  • pstmt.executeUpdate() : Statement를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달함, 리턴 값으로 int를 반환하는데 영향받은 DB의 row수를 반환함 ->  여기 예제에서는 하나의row를 등록했으므로 1이 반환됨

** 리소스 정리

  • 쿼리를 실행하고 나면 리소스를 꼭 정리해야하며, 리소스를 사용한 역순으로 정리해야 함
  • 해당 예제에서는 Connection을 먼저 획득하고 PreparedStatement를 만들었기 때문에 반환할 때는 역순으로PreparedStatement를 먼저 종료하고 그 다음에 Connection을 종료,
  • 여기선 사용하지 않았지만 ResultSet은 결과를 조회할 때 사용함
  • 예외가 발생하든 발생하지 않은 항상 수행되어야 하므로 finally 구문에 주의해서 작성해야 하며 해당 부분을 놓치게 되면 커넥션이 끊어지지 않고 계속 유지되는 문제가 발생하는데 이런 것을 리소스 누수라고함 -> 결과적으로 커넥션 부족으로 장애가 발생할 수 있음
  • PreparedStatement -> Statement의 자식타입, ?(물음표)를 통한 파라미터 바인딩을 가능하게 해주고 SQL Injection 공격을 예방하려면 꼭 PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야함
  • 자세한 내용은 검색,( SQLInjection  - 악의적 쿼리문을 삽입해서 정보를 탈취하거나 오류를 발생)
package hello.jdbc.repository;

// 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;
        /*
        Statement 를 상속받은 PreparedStatement 인터페이스
        Statement : SQL을 그대로 입력
        PreparedStatement : 파라미터를 바인해서 SQL을 입력, 기능이 더 많음
         */
        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 { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
            close(con, pstmt, null);
        }
    }

    /**
     * 연결을 닫아주는 메서드를 별도로 추출
     * 연결을 닫아줄때 예외가 터질 수도 있어서 한번더 try - catch 로 잡아줘야함
     * 보통 이런 형태로 작성함
     */
    private void close(Connection con, Statement stmt, ResultSet rs) {
        if (con != null) {
            try {
                con.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }

        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }

        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }

    }

    // 커넥션 연결 메서드 추출
    private Connection getConnection() {
        return DBConnectionUtil.getConnection();
    }
}

 

(3) MemberRepositoryV0Test

  • 해당 테스트를 진행 후 H2 데이터베이스에 조회 쿼리문을 날려보면 값이 저장됨
  • 이 테스트는 두번 실행하면 PK 중복 오류가 발생하니 delete from member;로 데이터를 날리고 다시 테스트 하거나, "memberV0"의 값을 변경하면서 테스트 하면 됨
package hello.jdbc.repository;

class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void save() throws SQLException {
        Member member = new Member("memberV0", 10000);
        repository.save(member);
    }
}

2. JDBC 개발 - 조회

1) MemberRepositoryV0 - findById() 메서드 추가

 

(1) findById()

  • rs = pstmt.executeQuery() : 데이터 변경 시에는 exeCuteUpdate()를 사용하지만 데이터를 조회할 때는 executeQuery()를 사용, 쿼리 결과를 ResultSet에 담아서 반환함

(2) ResultSet

    • ResultSet의 구조는 아래 그림과 같은 구조인데 보통 Select 쿼리의 결과가 순서대로 들어가게 됨
    • 내부에 있는 커서(cursor)를 이동해서 다음 데이터를 조회하는데 rs.next()를 호출하면 커서가 다음으로 이동하고, 최초의 커서는 데이터를 가리키고 있지 않기 때문에 최초 한번은 호출해야 데이터를 조회할 수 있음
    • rs.next()의 결과가 true 면 데이터가 있다는 뜻이고 false면 더이상 커서가 가리키는 데이터가 없다는 뜻

ResultSet 구조

  • 이렇게 조회된 데이터를 가지고 member.setMemberId(), member.setMonet()로 조회후 member를 반환
  • finById()는 회원 하나를 조회하는 것이 목적이므로 조회 결과가 항상 1건이므로 if을 사용했지만 다수의 건일경우 while문을 사용
/// ...
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();  // 쿼리 결과를 반환
        if (rs.next()) {            // 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);
    }
}
/// ...

2) MemberRepositoryV0Test - 멤버 조회 테스트 추가

  • 기존 save() 메서드를 curd()로 이름을 변경하고 조회 테스트 코드를 작성
  • 롬복의 @Data가 toString()을 오버라이딩해서 보여주기 때문에 로그의 값이 참조값이 아닌 실제 값이 보여지게 됨
  • isEqualTo(): findMember.equals(member)를 비교 -> 롤복의 @Data가 해당 객체의 모든 필드를 사용하도록 equals()를 오버라이딩 하기 때문
  • 이 테스트도 마찬가지로 동일한 값으로 중복 테스트하면 PK 중복 오류가 발생하니, 값을 삭제하거나 memberId값을 바꿔서 테스트를 진행
package hello.jdbc.repository;

@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void crud() throws SQLException {
        // save
        Member member = new Member("memberV2", 10000);
        repository.save(member);

        // findById
        Member findMember = repository.findById(member.getMemberId());
        // @Data 롬복이 toString()을 Override 해서 로그의 결과가 참조값이 아닌 실제 값이 출력됨
        log.info("findMember={}", findMember);
        
        // == 비교를 하면 false(참조값 비교), equals()는 true(값 비교)
        // isEqualTo()가 true인 이유는 @Data가 객체의 모든 필드 값을 사용하도록 equals()를 Override 함
        Assertions.assertThat(findMember).isEqualTo(member);
    }
}

3. JDBC 개발 - 수정, 삭제

1) MemberRepositoryV0 - update(), delete() 메서드 추가

  • 데이터를 변경하는 쿼리 : executeUpdate() 이용
  • 쿼리 결과를 int로 반환(반영된 row의 수)
// ...
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 { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
        close(con, pstmt, null);
    }
}

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);   // 해당 인덱스에 값 저장
        int resultSize = pstmt.executeUpdate();       // 실행
        log.info("resultSize={}", resultSize);
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    } finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
        close(con, pstmt, null);
    }
}
//...

 

2) MemberRepositoryV0Test - 멤버 수정, 삭제 테스트 추가

  • save시 회원 데이터의 money에 값을 지정해놓고, update()에서 값을 변경해서 테스트진행 후 값이 변경되어있는지 검증
  • delete()는 해당 값이 삭제된 후 조회를 하게 되면 NoSuchElementException 예외가 터지는 것을 활용하여 assertThatThrownBy로 해당 예외가 발생했는지 확인해서 검증을 하면 됨
package hello.jdbc.repository;

@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void crud() throws SQLException {
        // save
        Member member = new Member("member80", 10000);
        
        // ...

        // update
        repository.update(member.getMemberId(), 50000);
        Member updatedMember = repository.findById(member.getMemberId());
        assertThat(updatedMember.getMoney()).isEqualTo(50000);

        // delete
        repository.delete(updatedMember.getMemberId());
        // NoSuchElementException 이터지면 검증 OK
        assertThatThrownBy(() -> repository.findById(member.getMemberId()))
                .isInstanceOf(NoSuchElementException.class);
    }
}

 

** 참고

  • sava() ~ delete()까지 테스트 코드를 작성하면 이제 db에 값을 지우지 않아도 계속 반복적으로 테스트가 성공하지만 좋은 방법은 아님
  • 예를들어 중간에 알수 없는 이유로 update()까지만 동작하고 예외가 발생해서 멈추게 되면, delete()는 실행되지 않은채 테스트가 종료 되는데 db에 테스트 값이 들어간채로 종료되기 때문에 다시 테스트를 시작할 때 에러가 발생되고, 그러면 DB에 가서 직접 데이터를 지워야 하는 일이 발생함
  • 이러한 문제는 트랜잭션을 활용하면 해결할 수 있음