Spring

[인프런/스프링 DB 1편] 1. JDBC

주니어주니 2023. 6. 17. 23:51

 

1. JDBC 이해 

 

 

(1) 클라이언트, 애플리케이션 서버, DB

 

 

클라이언트 -> 애플리케이션 서버 -> DB

 

 

 

(2) 애플리케이션 서버 <-> DB

 

 

 

(3) JDBC 

 

원래 DB 마다 DB 사용 코드, 설정 방법 등이 다 달랐음

-> JDBC 표준 인터페이스 제공 : 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 표준 인터페이스

 

 

 

JDBC 는 아래 기능을 표준 인터페이스로 정의해서 제공 -> 이 표준 인터페이스를 구현해서 개발

  • Connection - 연결
  • Statement - SQL을 담은 내용
  • ResultSet - SQL 요청 응답

 

(4) JDBC 드라이버

 

JDBC 드라이버 : JDBC 인터페이스를 각 DB에 맞도록 구현해서 라이브러리로 제공  

 

 

  • JDBC 등장 
    -> 다른 DB로 변경하고 싶을 때 -> 드라이버(JDBC 구현 라이브러리)만 변경하면 됨
    -> 애플리케이션 서버의 JDBC 연결 코드는 그대로 유지
    -> 하지만, SQL은 해당 DB에 맞도록 변경 !  

 


 

2. JDBC 접근 기술 

 

(1) JDBC 직접 사용

  • 아주 복잡

 

 

(2) SQL Mapper

  • 장점 : JDBC를 편리하게 사용하도록 도와줌
    • SQL 응답 결과를 객체로 편리하게 변환
    • JDBC의 반복 코드 제거 
    • SQL만 직접 작성하면 나머지 번거로운 일을 SQL Mapper가 대신 해결해줌
  • 단점 : 개발자가 SQL을 직접 작성
  • 대표 기술 : 스프링 JdbcTemplate, MyBatis

 

 

(3) ORM 기술

  • 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술
  • SQL 직접 작성 X , 하지만 깊이있게 학습해야 함 
  • 객체에 설정정보를 작성해서 JPA에 전달하면 SQL을 동적으로 만들어 실행
  • 각 데이터베이스마다 다른 SQL을 사용하는 문제도 해결해줌
  • 구현 기술 : 하이버네이트, 이클립스 링크

 

 


 

3. 데이터베이스 연결

 

※ H2 데이터베이스 서버 먼저 실행해둘 것 ! (h2 파일 -> bin -> h2.bat 실행)

 

 

(1) ConnectionConst - 데이터베이스 접속 정보를 상수로 등록

  • URL : 저대로 작성하는게 규약임
package hello.jdbc.connection;

public abstract class ConnectionConst {
    public static final String URL = "jdbc:h2:tcp://localhost/~/test";
    public static final String USERNAME = "sa";
    public static final String PASSWORD = "";
}

 

 

(2) DBConnectionUtil - 데이터베이스 연결 코드

package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;

@Slf4j
public class DBConnectionUtil {

    public static Connection getConnection() {
        try {
            Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
            log.info("get connection={}, class={}", connection, connection.getClass());
            return connection;
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    }
}
  • JDBC가 제공하는 DriverManager.getConnection() 사용 
    -> 라이브러리에 등록되어 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션 반환
    -> 우리는 H2 db를 등록했음 -> H2 데이터베이스 드라이버 작동 -> 데이터베이스와 커넥션 -> 그 결과 반환

 

 

(3) DBConnectionUtilTest - 연결 테스트 

package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.Connection;

import static org.assertj.core.api.Assertions.*;

@Slf4j
class DBConnectionUtilTest {

    @Test
    void connection() {
        Connection connection = DBConnectionUtil.getConnection();
        assertThat(connection).isNotNull();
    }
}

테스트 결과

DBConnectionUtil -- get connection=conn0: 
url=jdbc:h2:tcp://localhost/~/test user=SA, 
class=class org.h2.jdbc.JdbcConnection

 

class=class org.h2.jdbc.JdbcConnection

: H2 데이터베이스 드라이버가 제공하는 H2 전용 커넥션

-> JDBC 표준 커넥션 인터페이스인 java.sql.Connection 인터페이스를 구현하고 있음

 

 

 

(4) JDBC DriverManager 동작

 

 

 

  • JDBC는 java.sql.Connection 표준 인터페이스를 정의함 
  • H2 데이터베이스 드라이버는 JDBC Connection 인터페이스를 구현한 org.h2.jdbc.JdbcConnection 구현체 제공

 

 

* DriverManager 커넥션 요청 흐름 

 

JDBC가 제공하는 DriverManager : 라이브러리에 등록된 DB 드라이버들 관리, 커넥션 획득하는 기능 제공 

 

  1. 애플리케이션 로직에서 DriverManager.getConnection() 호출 -> 커넥션 요청
  2. DriverManager는 라이브러리에 등록된 드라이버 목록 자동으로 인식
    -> 드라이버들에세 순서대로 아래 정보를 넘겨서 커넥션 획득할 수 있는지 확인
    • URL ( jdbc:h2:tcp://localhost/~/test ) 
    • USERNAME , PASSWORD 등 접속에 필요한 추가 정보
    • 각 드라이버는 URL 정보를 체크해서 본인이 처리할 수 있는 요청인지 확인
      ex) jdbc:h2 ~~ : h2 드라이버가 처리할 수 있으므로 실제 데이터베이스에 연결해서 커넥션 획득, 클라이언트에 반환
      MySQL 드라이버가 먼저 실행되면 -> 처리할 수 없다는 결과 반환 -> 다음 드라이버로 넘어감
  3. 커넥션 구현체가 클라이언트에 반환

 

 

* build.gradle 

 

현재 H2 데이터베이스 드라이버만 라이브러리에 등록되어있음 -> H2 커넥션 제공받음

-> H2 커넥션은 JDBC가 제공하는 java.sql.Connection 인터페이스를 구현하고 있음

 

 

 


4. JDBC 개발 - JDBC 직접 연결

 

4-0. 테이블, 샘플 데이터 생성 

 

1) h2.bat 실행 -> h2 콘솔 접속

jdbc:h2:~/test (최초 한번) -> 연결 

 

2) Member 테이블생성 

drop table member if exists cascade;
create table member (
    member_id varchar(10),
    money integer not null default 0,
    primary key (member_id)
);

 

 

3) 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;
    }
}

 

 

 

4-1. 등록 (Create)

 

1) MemberRepositoryV0 - 회원 등록 ( save() ) 

package hello.jdbc.repository;

import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;

import java.sql.*;

/**
 * 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;				// finally에서도 호출해야 하기 때문에 밖에 따로 선언
        PreparedStatement pstmt = null;

        try {
            con = getConnection();                  	// 커넥션 획득 -> 메소드로 추출
            pstmt = con.prepareStatement(sql);      	// sql에 파라미터 바인딩
            pstmt.setString(1, member.getMemberId());   // sql의 첫번째 ?에 문자 저장
            pstmt.setInt(2, member.getMoney());         // sql의 두번째 ?에 숫자 저장
            pstmt.executeUpdate();                  	// 실행 (sql을 커넥션을 통해 데이터베이스에 전달)
            return member;
        } catch (SQLException e) {
            log.error("db error", e);               	// 로그를 찍기 위해 예외 잡음
            throw e;                                
        } finally {
            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();
    }
}

 

(1) 커넥션 획득

  • getConnection() : 만들어둔 DBConnectionUtil을 사용해 데이터베이스 커넥션 획득 

 

 

(2) save() - SQL 전달 

  • sql : 데이터베이스에 전달할 SQL 정의
  • con.prepareStatement(sql) : 데이터베이스에 전달할 SQL과 파리미터로 전달할 데이터 준비
    • Statement : SQL을 그대로 전달, PreparedStatement : 파라미터 바인딩한 SQL 전달
    • PreparedStatement 
      • Statement의 자식 타입, ?를 통한 파라미터 바인딩
      • SQL Injection 공격 예방하기 위해 그냥 얘를 써라 (파라미터 바인딩을 써야 파라미터를 데이터로 인식)
  • pstmt.executeUpdate() : Statement를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달
    • executeUpdate()는 int를 반환 -> 영향받은 DB row 수 반환 (예 - 하나 등록하면 1 반환)

 

 

(3) 리소스 정리 

  • 사용 : Connection 획득 -> Connection을 통해 PreparedStatement 만듦
  • 반환 : PreparedStatement 종료 -> Connection 종료 (역순)
  • 쿼리 실행 후에는 리소스 정리 꼭 필요 !
    • 예외가 발생하든 말든 항상 수행되어야 하므로 finally 구문에 작성
    • 이 부분을 놓치면 커넥션이 끊어지지 않고 계속 유지되는 문제 (리소스 누수) -> 커넥션 부족으로 인한 장애 발생

 

 

* 예외

- 로그를 찍기 위해 예외 잡음 

- 다시 예외를 던져주지 않으면 예외가 발생해도 여기서 예외를 먹어버리기 때문에 save()를 호출한 곳에서 정상 처리 되었다고 생각해버림 -> 데이터가 저장되지 않았는데 오류 없는 문제

catch (SQLException e) {
    log.error("db error", e);               
    throw e;  
}

 

 

2) MemberRepositoryV0Test - 테스트코드로 데이터베이스에 회원 등록

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;

import static org.junit.jupiter.api.Assertions.*;

class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

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

 

 

 

 

 

4-2. 조회 (Read) 

 

1) MemberRepositoryV0 - 회원 조회 ( findById() )

@Slf4j
public class 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();

            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);
        }

    }

    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();
    }
}

 

(1) findById() - 쿼리 실행 

  • sql : 데이터 조회를 위한 select SQL 준비 
  • rs = pstmt.executeQuery()
    • 데이터 등록, 변경 -> executeUpdate()
    • 데이터 조회 -> executeQuery() -> 조회 결과를 ResultSet에 담아서 반환

 

(2) ResultSet 

  • ResultSet에는 select 쿼리의 결과가 순서대로 들어감
    • select member_id, money -> member_id, money라는 이름으로 데이터 저장
    • select * -> 테이블의 모든 컬럼 지정
  • ResultSet 내부에 있는 커서(cursor)를 이동해서 다음 데이터 조회 
  • rs.next() : 커서가 다음으로 이동. 최초의 커서는 데이터 가리키지 X
    • rs.next() = true : 커서의 이동 결과 데이터가 있다 
    • rs.next() = false : 커서가 가리키는 데이터가 없다
  • rs.getString("member_id") : 현재 커서가 가리키는 위치의 member_id 데이터를 String 타입으로 반환
  • rs.getInt("money") : 현재 커서가 가리키는 위치의 money 데이터를 Int 타입으로 반환
  • 보통 rs를 조회할 때는 while()로 조회하지만, findById()에서는 회원 하나를 조회하기 때문에 if 사용

 

 

 

2) MemberRepositoryV0Test - 회원 조회 테스트

@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

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

        // findById
        Member findMember = repository.findById(member.getMemberId());
        log.info("findMember={}", findMember);
        assertThat(findMember).isEqualTo(member);
    }

}

  • Member 객체의 @Data 설정
    • toString() 을 적절히 오버라이딩 -> findMember의 참조값이 아니라 실제 데이터로 이쁘게 보임
    • equals, hashcode 를 적절히 오버라이딩
      (원래 객체의 equal 비교는 주소값을 비교하는데, 얘를 오버라이딩하면 string의 equal 비교처럼 값을 비교할 수 있도록 오버라이딩 가능 -> @Data가 객체의 값을 비교하도록 오버라이딩을 해주는 것)
      -> findMember == member : false (동일한 객첸지 비교)
      -> findMember equals member : true (값이 같은지 비교 - isEqualTo() 는 내부에서 equals 비교를 해줌 ) 

 

 

 

4-3. 수정, 삭제 (Update, Delete) 

 

1) MemberRepositoryV0 - 수정 

  • executeUpdate() : 쿼리 실행 후 영향받은 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 {
        close(con, pstmt, null);
    }
}

 

 

2) 테스트 

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;
import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@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());
        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);

    }

}

 

* V2 생성 -> 10000 를 20000 으로 수정 

 

 

 

 

 

3) MemberRepositoryV0 - 삭제 

public void delete(String memberId) throws SQLException {
    String sql = "delete 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);
    }
}

 

 

4) 테스트 

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;
import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void crud() throws SQLException {
        // save
        Member member = new Member("memberV3", 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);
    }

}

 

* V3 생성 -> 삭제 

: 회원 삭제 후 findById() 로 조회 -> findById() 메소드에서 객체 없을 때 NoSuchElementException 발생하도록 만들었음

-> 회원이 없으니까 예외 발생 -> assertThatThrownBy() - 해당 예외 발생하면 검증 성공