스프링 DB 1편

Park sang woo·2023년 2월 28일
0

📖 H2 Database 설정

  1. localhost:8082, URL 앞 부분 localhost로 변경.
  2. JDBC URL에서 최초의 한 번은 직접 파일로 바로 접근을 해야 해서 jdbc:h2:~/test로 해서 연결을 한다. 그리고 파일이 생성되고 나면 이제 jdbc:h2:tcp://localhost/~/test로 연결.





📖 JDBC

문제점 1. 데이터베이스를 다른 종류의 데이터베이스로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다.

문제점 2. 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 연결, SQL 전달, 그 결과를 응답받는 방법을 새로 학습해야 한다.

이러한 문제들을 해결하기 위한 자바 표준으로 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API이다. (데이터베이스에서 자료를 쿼리하거나 업데이트하는 방법을 제공한다.)

JDBC 표준 인터페이스
java.sql.Connection - 연결
java.sql.Statement - SQL을 담은 내용
java.sql.ResultSet - SQL 요청 응답

우리는 이 표준 인터페이스만 사용해서 개발하면 된다.

JDBC 드라이버 : 인터페이스만 있다고해서 기능이 동작하지 않기 때문에 JDBC 인터페이스를 각각의 DB벤더(회사)에서 자신의 DB에 맞도록 루현하여 라이브러리로 제공하는 것.
ex) MySQL DB에 접근할 수 있는 것 -> MySQL JDBC 드라이버.
Oracle이라면 Oracle JDBC 드라이버.






📖 데이터베이스 연결

데이터베이스에 연결하려면 JDBC가 제공하는 DriverManager.getConnection(..)을 사용하면 된다. 그러면 라이브러리에 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해준다.
여기서는 H2 데이터베이스 드라이버가 작동해서 실제 데이터베이스와 커넥션을 맺고 그 결과를 반환해준다.

@Slf4j
public class DBConnectionUtil {
    //Connection이 JDBC 표준 인터페이스가 제공하는 커넥션.
    public static Connection getConnection() {
        //url, username, password 넣어주기
        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); //런타임 Exception
        }
    }
}

결과 : get Connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection

실행하면 getConnection(..)을 호출하는데 Connection은 인터페이스로 구현체가 나온다. 여기서 구현체는 외부 라이브러리의 org.h2.jdbc.JdbcConnection를 반환해준다.
H2 드라이버를 사용하고 있기 때문에 커넥션 인터페이스의 구현체인 H2 데이터베이스가 구현한 구현체를 제공한다.

class=class org.h2.jdbc.JdbcConnection 부분이 H2 데이터베이스 드라이버가 제공하는 H2 전용 커넥션이고 이 커넥션은 JDBC 표준 커넥션 인터페이스인 java.sql.Connection 인터페이스를 구현하고 있다.

즉 H2 데이터베이스 드라이버는 JDBC Connection 인터페이스를 구현한 org.h2.jdbc.JdbcConnection 구현체를 제공.






📖 JDBC 등록

JDBC를 사용해서 데이터를 데이터베이스에 관리하려면 테이블을 미리 만들어둬야 한다.
H2 데이터베이스 실행해서 테이블 생성.

create table member(
	member_id varchar(10),
    money integer not null default 0,
    primary key (member_id)
)

생성했으면 테이블의 데이터를 저장하도록 클래스 생성.

@Data
public class Member {
    private String memberId;
    private int money; //회원이 가지고 있는 금액

    public Member() {
    }

    public Member(String memberId, int money) {
        this.memberId = memberId;
        this.money = money;
    }
}

이제 실제 JDBC를 통해서 회원 객체를 데이터베이스에 저장하는 코드를 작성

/**
 * JDBC - DriverManager 사용
 */
@Slf4j
public class MemberRepositoryV0 {
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";

        //Connection이 있어야 연결을 함.
        Connection con = null;
        //PreparedStatement로 데이터베이스에 쿼리를 날림.
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId()); //sql에 대한 파라미터 바인딩을 해준다. 즉 ?를 채워줌.
            pstmt.setInt(2, member.getMoney());
            //실행
            pstmt.executeUpdate(); // 바로 위에 준비된게 실제 데이터베이스에 실행이 된다.

            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally{
            close(con, pstmt, null); //항상 호출이 보장되도록 close는 finally에 위치.
            
            //Connection은 외부 리소스를 쓰는 것인데 닫지 않으면 연결이 계속 유지가 된다.
        }

    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        if (rs != null) { //rs는 결과를 조회할 때 사용.
            try {
                rs.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }
        // 만약 pstmt.close()에서 문제가 생겨서 con을 닫지 못하면 문제가 발생하므로
        // try~catch로 잡아야 한다.

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

    private static Connection getConnection() {
        return DBConnectionUtil.getConnection();
    }
}

이렇게 만들면 JDBC에 저장하는 기능이 완료가 된다. (보면 JDBC 코드가 지저분하다는 것을 알 수 있다.)

📌 리소스 정리
쿼리를 실행하고 나면 리소스를 정리해야 하는데 여기서 Connection, PreparedStatement를 사용했다. 리소스 정리는 항상 역순으로 Connection을 먼저 흭득하고 Connection을 통해 PreparedStatement를 만들었기 때문에 리소스를 반환할 때는 PreparedStatement를 먼저 종료하고, 그 다음에 Connection을 종료하면 된다.

그래서 리소스 정리를 반드시 해야 했고 예외가 발생하든 하지 않든 항상 수행 되어야 하므로 finally 구문에 주의해서 넣어줘야 했다. 이 부분을 놓치면 커넥셔닝 끊어지지 않고 계속 유지가 되어 문제가 발생한다. 이 문제를 리소스 누수라고 한다. (결과적으로 커넥션 부족으로 장애가 발생.)


PreparedStatement는 Statement의 자식 타입으로 쿼리에서 ?를 통한 파라미터 바인딩을 가능하게 해준다. SQL Injection 공격을 예방하려면 PreparedStatement를 통한 파라미터 바인딩 방식을 사용.


테스트 코드에서 JDBC로 회원을 데이터베이스에 등록

class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

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

실행하고 H2 데이터베이스 가면 데이터가 저장되는 것을 볼 수 있다.
그런데 한 번더 실행하면 예외가 발생한다. -> Primary Key가 Member_id로 잡혀있는데 같은 값이 들어왔기 때문이다. 그럼 다른 값으로 넣어주면 된다.






📖 JDBC 조회

//데이터 조회
    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);

            // executeUpdate() 는 데이터 변경할 때.
            // select는 executeQuery()를 사용.
            // Select 쿼리의 결과를 담고있는 통인 ResultSet을 반환해준다.
            rs = pstmt.executeQuery();
            // 값을 꺼낸다
            if (rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.getInt("money"));
                return member;
                // next() 호출하면 데이터가 유무를 확인한다.

            } else { //데이터 없는 경우
                throw new NoSuchElementException("member not found memberId = " + memberId);
            }

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally{
            close(con, pstmt, rs);
        }

    }

ResultSet은 내부에 이쓴ㄴ 커서를 이동해서 다음 데이터를 조회할 수 있다. select 쿼리의 결과가 순서대로 들어간다.
Select 쿼리의 결과를 담고있는 통이라 생각하면 된다.

rs.next() : 호출하면 커서가 다음으로 이동한다. 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next()를 최초 한 번은 호출해야 데이터를 조회할 수 있다.
rs.next()의 결과가 true면 커서의 이동 경로가 데이터가 있다는 뜻. false면 더 이상 커서가 가리키는 데이터가 없다는 뜻.

rs.getString("member_id") : 현재 커서가 가리키고 있는 위치의 member_id 데이터를 String 타입으로 반환한다.






📖 JDBC 수정, 삭제

수정

// 수정
    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이 반환된다. 데이터가 없으면 0이 반환.

ex) 회원이 100인데 모든 회원의 데이터를 한 번에 수정했다면 update sql 수행 시 결과는 100이 된다.



삭제

//삭제
    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{
            close(con, pstmt, null);
        }
    }

보면 try~catch부터 중복된 부분들을 볼 수 있다. 그래서 SQL Mapper 같은 것들이 나온 것이고 쿼리도 JPA를 사용하는 것이다.






📖 커넥션 풀 이해

커넥션을 새로 만드는 과정은 매우 복잡하고 시간도 많이 소모된다. DB는 물론 애플리케이션 서버에서도 TCP/IP 커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 한다.
진짜 문제는 고객이 애플리케이션을 사용할 때 SQL을 실행하는 시간 뿐만 아니라 컨넥션을 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 준다.

그래서 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법을 사용한다.
커넥션 풀은 커넥션을 관리하는 풀이다.

커넥션 풀의 연결. (항상 실무에서는 기본으로 사용)

🔍. 애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다. 보통 서비스의 특징과 서버 스펙에 따라 달리 보관되지만 기본값은 보통 10개다.

🔍. 커넥션 풀에 들어있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 SQL을 DB로 전달할 수 있다.

🔍. 애플리케이션 로직에서 이제는 DB 드라이버를 통해서 새로운 커넥션을 흭득하는 것이 아니다.
커넥션 풀을 통해 이미 생성되어 있는 커넥션을 객체 참조로 그냥 가져다 쓰면 된다.

🔍. 커넥션 풀에 커넥션을 요청하면 커넥션 풀은 자신이 가지고 있는 커넥션 중에 하나를 반환한다.


성능과 사용의 편리함 측면에서 최근에는 hikariCP를 주로 사용한다. 스프링 부트 2.0부터는 기본 커넥션 풀로 hikariCP를 제공한다. 실무에서도 레거시 프로젝트가 아닌 이상 대부분 hikariCP를 사용한다.






📖 DataSource 이해






📖 DataSource - DriverManager






📖 DataSource - 커넥션 풀






📖 DataSource 적용

profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글