framework/spring

2. connectionPool 과 datasource 이해

wooweee 2023. 9. 22. 15:18
728x90

 

1. connectionPool의 이해

db connection 획득 과정

  • connection을 새로 만드는 것은 과정이 복잡하고 시간도 많이 소모된다.
    - app과 db의 connection 맺는 과정에서 3 way handshake가 수행되기 때문

  • 문제점
    • DB , app server에서도 tcp/ip 컨넥션을 새로 생성하기 위한 리소스를 매번 사용
    • 고객이 app을 사용할 때, SQL을 실행하는 시간, connection을 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 준다.
  • 해결방안
    • connection을 미리 생성해두고 사용하는 connection pool 방법 이용

 

 

  • connection pool
    • app이 시작되는 시점에 connection pool은 필요한 만큼 connection을 미리 확보해서 pool에 보관한다.
    • default로 10개를 보관
    • pool에 있는 connection은 tcp/ip로 db와 connection이 연결되어 있는 상태이기 때문에 언제든지 즉시 sql을 db에 전달 가능하다

 

 

  • 사용 1
    • 새로운 connection을 획득하지 않고 이미 생성되어 있는 connection을 객체 참조로 그냥 가져다 사용하면 된다.
  • 사용 2
    • connection을 모두 사용하고 나면 connection을 종료하는 것이 아니라, 다음에 다시 사용할 수 있도록 해당 connection을 그대로 connection pool에 반환
    • connection을 종료하는 것이 아니라 connection이 살아있는 상태로 pool에 반환

 

  • 정리
    • DB 보호 효과 : server당 최대 connection 수 제한
    • 이점이 매우 커서 실무에서는 항상 기본으로 사용
    • 대표적 connection 오픈 소스
      1. connons-dbcp2
      2. tomcat-jdbc pool
      3. HikariCP : spring boot 에서 주로 사용, jdbc 사용시, jdbc에서 자동으로 HikariCP를 import한다.

 

2. DataSource 이해

  • connection 획득 방법 2가지
    1. DriverManager : connection pool 사용 안함
    2. connection pool : connection pool 사용

 

 

  • connection 방법 변경시 문제점
    • 의존관계 때문에 DriverManager -> HIkariCP 로 변경시 app code를 건들여야되는 문제점이 발생

  • 해결 방법
    • 추상화를 이용한 DI 원리 이용
    • DataSource 인터페이스 이용

 

 

connection을 획득하는 방법을 추상화

 

  • DataSource
    • 자바에서 DataSource 인터페이스 제공
    • DataSource는 connection을 획득하는 방법을 추상화 하는 인터페이스
    • 핵심 기능은 connection 조회
    • DataSource.getConnection()

 

public interface DataSource {
    Connection getConnection() throws SQLException;
}

 

  • 정리
    • 대부분의 connection pool은 DataSource interface를 구현
    • 이제 app code에서는 DataSource interface에 의존하도록 app logice을 작성
    • DriverManger: datasource를 사용하지 않는데 해당 문제점을 해결하기 위해 spring은 DriverManager도 DataSource를 통해서 사용할 수 있도록 DriverManagerDataSource 구현체 class 를 제공한다.
    • application logic이 변경하지 않아도 된다.

 

3. DataSource ConnectionTest

 

package hello.jdbc.connection;

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

@Slf4j
public class ConnectionTest {

    @Test
    @DisplayName("dataSource interface 사용안 한 DriverManger")
    void drivermanager() throws SQLException {
        // 1.등록 2. 연결이 동시에 이루어짐
        Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD); // connection 할 때마다 설정 params 넘겨야 함
        Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);

        log.info("connection={}, class={}", con1, con1.getClass()); // connection=conn0:
        log.info("connection={}, class={}", con2, con2.getClass()); // connection=conn1:
    }

    @Test
    @DisplayName("dataSource interface 사용한 DriverManger 구현체인 DriverMangerDataSource")
    void dataSourceDriverManger() throws SQLException{
        // 1. 설정
        // spring이 제공하는 DriverManger
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        // DriverMangerDataSource의 부모인 DataSource 다형성 이용
        DataSource dataSource1 = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        useDataSource(dataSource);
        /* 결과
        * connection=conn0: connection=conn1:
        * datasource interface를 이용해서 connection을 했을 뿐, datasource를 사용했다고 connectionPool이 적용되는 것은 아니다.
        * 대신, dataSource를 사용하는 시점에서는 params를 받을 필요가 없다. - private void useDataSource() 명시
        * 이로써 설정과 사용의 분리를 할 수 있다.
        *  */
    }

    @Test
    @DisplayName("dataSource interface 사용한 구현체인 HikariDataSource, connectionPool 이용 ")
    void dataSourceConnectionPool() throws SQLException, InterruptedException{
        // connection pooling : hikariProxyConnection(Proxy) -> JdbcConnection(target)

        // springboot의 jdbc 사용시, Hikari가 자동으로 import 된다.
        HikariDataSource dataSource = new HikariDataSource();
//        DataSource dataSource1 = new HikariDataSource(); // 물론 얘도 DataSource interface 상속 받음
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        dataSource.setMaximumPoolSize(10); // default 10개
        dataSource.setPoolName("MyPool"); // pool 이름

        useDataSource(dataSource); // 2. 호출
        Thread.sleep(1000); // pool에 connection 담는 것은 다른 쓰레드가 처리하기 때문에 test가 먼저 끝날 경우가 생김. 그럼 로그보기가 힘들다.
    }

    // 2. 호출, 연결
    // DataSource interface의 method를 이용한 connection 사용
    private void useDataSource(DataSource dataSource) throws SQLException{
        Connection con1 = dataSource.getConnection();
        Connection con2 = dataSource.getConnection();

        log.info("connection={}, class={}", con1, con1.getClass());
        log.info("connection={}, class={}", con2, con2.getClass());
    }
}

 

3.1. DriverManager

  • Drivermanager과 DriverMangerDataSource 차이점
    • params 의존 유무
      기존 drivermanger는 connection을 때마다 params가 필요하지만 DriverMangerDataSource는 몰라도 된다.

    • 설정과 사용의 분리
      1. 설정 : DataSource를 만들고 필요한 속성들을 사용해서 URL, USERNAME, PASSWORD 같은 부분을 입력하는 것
      2. 사용 : 설정은 신경쓰지 않고 DataSource의 getConnection()만 호출해서 사용

 

3.2. connection pool

  • HikariCP
    • connectionPool 사용
    • DataSource 인터페이스를 구현
    • setMaximumPoolSize(), setPoolName()은 작성 안해도 default로 설정된다.
    • connectionPool에 connection 생성과정은 app 실행 속도에 영향을 주지 않기 위해 별도의 Thread에서 작동한다.
      • 별도 쓰레드 이름 : pool이름 connection adder
      • 현 code에선 close()가 없기 때문에 MyPool - After adding stats (total=10, active=2, idle=8, waiting=0) 의 결과를 가지게 된다.
    • 더 자세한 내용 : https://github.com/brettwooldridge/HikariCP

 

4. DataSource 적용

  • 핵심 로직으로 DataSource interface를 이용해서 connection을 연결
  • 이제 Connection 방식이 DriverMangerDataSource 이든 HikariCP이든 변경이 되더라도 핵심 logic file을 변경하지 않아도 된다.
  • 생성자 주입 방식으로 connection 설정을 받는다.

  • repository : app logic
    1. DataSource DI 주입
      • 직접 만든 DBConnectionUtil 사용할 필요가 없다.
      • DataSource 표준 interface 덕분에  DriverManagerDataSource 에서 HikariDataSource 로 변경되어도 해당 코드를 변경하지 않아도 된다.
    2. JdbcUtils 편의 method
      • close()를 단순하게 작성 가능해진다.
  • DataSource 장점 : DI + OCP 지킬 수 있게 된다.

 

4.1. DataSource Interface를 이용해서 Connection을 수행한 Repository

package hello.jdbc.repository;

/**
 * JDBC - DriverManager 사용
 */
 
@Slf4j
public class MemberRepositoryV1 {

    // DataSource Interface 생성자 주입 받음
    // DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
    private final DataSource dataSource;

    // 생성자 생성
    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 1. 등록
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(? , ?)";

        Connection con = null;
        PreparedStatement pstmt = null; // sql params를 바인딩 받는 class

        try {
            con = getConnection(); // 1. connection 연결

            pstmt = con.prepareStatement(sql); // 2. db랑 소통
            pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
            pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
            pstmt.executeUpdate(); // db에서 실행됨

            return member;

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
        }

    }

    // 2. 조회
    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

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

    // 3. 수정
    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

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

    // 4. 삭제
    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

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

    // getConnection을 Datasource용으로 변경
    private Connection getConnection() throws SQLException{
        Connection con = dataSource.getConnection(); // 연결이 된다는 것이지 아직 연결된 db는 없다.

        /* * 연결된 설정 파일을 안만들었기 때문에 test 할 때 @BeforeEach에 dataSource를 만들어서 넣는다.
         * @BeforeEach
         * void before(){
         *     DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
         *     memberRepositoryV1 = new MemberRepositoryV1(dataSource);
         * }
         * */
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }

    // close() 예외 처리 - 더 간략한 방법으로 변경
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 utils
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con); // hika 머시기는 hika constpool에 감싸져있어서 그 안으로 들어간다.
    }
}

 

4.2.test

  • DriverMangerDataSource에서 HikariCP datasource 구현체로 변경을 해도 Repository를 건들이지 않아도 된다.
package hello.jdbc.repository;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
class MemberRepositoryV1Test {

    MemberRepositoryV1 repository;

    @BeforeEach // 각 test가 실행되기 직전에 호출
    void beforeEach() throws Exception {
        /* 1. 기본 DriverManager - 항상 새로운 Connection 획득, Connection pool이 없어서 호출마다 생성
        *
        * DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        * repository = new MemberRepositoryV1(dataSource); // 생성자 주입 수행
        * */

        /* 2. 변경. HikariDataSource로 dataSource 변경. Connection pool이 존재
        *
        * DI로 인해서 설정만 변경하면 app code를 건들일 필요가 없게 되었다.
        * */

        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        // connection pool 같은 경우는 default로 들어감

        repository = new MemberRepositoryV1(dataSource); // DriverMangerDataSource, HikariDataSource 모든 자손이기 때문에 다형성 DI 주입이 가능

        /* 정리
        * DriverManger : 호출마다 connect 생성
        * HikariDataSource : pool만 생성되고 같은 자원 계속 사용. close()시 연결이 끊기지 않고 pool로 돌아감
        * */
    }
    @Test
    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);
        // 다른 객체 member이지만 equals가 되는 이유는 @Data lombok 기능에서 equals를 오버라이딩 했기 때문
        assertThat(findMember).isEqualTo(member);

        // update: money: 10000 -> 20000
        repository.update(member.getMemberId(), 20000);
        Member updatedMember = repository.findById(member.getMemberId());
        assertThat(updatedMember.getMoney()).isEqualTo(20000);

        // delete
        repository.delete(member.getMemberId());
        assertThatThrownBy(()->repository.findById(member.getMemberId()))
                .isInstanceOf(NoSuchElementException.class);

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}