땃쥐네

[Spring DB] 커넥션 풀을 통한 커넥션 획득 본문

Spring

[Spring DB] 커넥션 풀을 통한 커넥션 획득

ttasjwi 2022. 10. 2. 18:01

커넥션과 DataSource

해당 내용은 [Spring DB] 커넥션과 DataSource 에서 다뤘습니다. 간단하게 다시 정리해보겠습니다.

 

커넥션(Connection)

커넥션의 FQCN(Fully Qualified Class Name, 패키지를 포함하여 타입의 전체 이름)은 java.sql.connection 인터페이스로서, DBMS와의 물리적 연결을 추상화한 계층입니다.

 

데이터베이스 Driver를 통해 DB와 연결 후 그 연결정보를 담아 생성되는데 이 커넥션을 통해 우리는 DB에 SQL을 전달해 질의할 수 있습니다.

 

DataSource(DataSource)

java는 커넥션을 획득하는 방법을 DataSource 인터페이스로 표준화했습니다.


커넥션을 매번 획득하는 방식(DriveManager, DriveManagerDataSource)

커넥션 풀링 방법을 학습하기 앞서, 기존의 DriverManager, DriverManagerDataSource의 커넥션 획득 과정을 다시 확인해보겠습니다.

 

절차

DriverManager 방식은, 애플리케이션 로직에서 커넥션을 요청할 때마다, DB와 실제로 물리적인 커넥션을 맺고 애플리케이션에 커넥션을 반환해서 사용한 뒤 물리적 커넥션을 실제로 종료하는 방식입니다.

 

문제점

 

이 방식은 매번 수행 시 시간비용이 매우 비쌉니다. DB와 3way handshake와 같은 TCP/IP 커넥션을 맺는 네트워크 동작, DB 인증 과정, DB 내부 세션 생성 등의 비싼 로직을 매번 수행해야하는데, 결국 사용자 입장에서는 응답을 받기까지 그만큼 더 오래 걸리는 것처럼 느껴지게 됩니다.

 

그리고 매번 커넥션을 계속 생성하는 것에서 오는 위험성이 있습니다. 애플리케이션-DB 간의 커넥션마다, DB에는 DB 세션이 생성되고 DB입장에서는 큰 부하가 됩니다. 너무 과도한 수의 DB 세션이 생성되면 장애에 안전하지 못 하겠죠


미리 물리적 커넥션을 생성해두고 보관한 뒤, 빌려 쓰는 방식(커넥션 풀링)

애플리리케이션 로딩 시점 : 커넥션 풀에 커넥션 준비

커넥션 풀 방식은 애플리케이션 로딩 시점에 커넥션풀에, 커넥션을 미리 정해진 갯수만큼 생성해둡니다.

 

로딩 시점에 커넥션 여러개를 몰아서 생성하는 시간이 좀 길기야 하겠지만, 한번 생성해두면 그 이후로는 이런 비용을 들이지 않아도 됩니다.

 

이렇게 커넥션을 만들어두면, 앞으로 커넥션은 커넥션 풀에서 대여해서 사용하면 됩니다.

 

TCP/IP 커넥션을 항상 유지하고 있기에, 언제든 SQL을 바로 전달할 수 있다.

이렇게 만들어진 커넥션들은, 애플리케이션 로딩 중에 항상 커넥션 풀에서 관리되어지고 살아있습니다. 즉, 애플리케이션 로딩 내내 DB 측과 TCP/IP 커넥션을 통한 연결 관계가 계속 유지되는 것입니다. 이전에 BookRepository를 개발할 때 커넥션을 매번 생성해야했는데, 이제는 커넥션 풀에서 커넥션을 빌려오자마자 바로 SQL을 DB Session에 전달할 수 있습니다.

 

커넥션 대여 및 사용

이제 커넥션 풀을 통해 커넥션을 대여해서 사용하기 때문에 매번 커넥션을 생성하지 않습니다.

 

커넥션 풀에 커넥션을 요청하면, 커넥션 풀은 현재 사용되지 않고 있는 커넥션의 객체 참조를 알고 있는 객체, '프록시 커넥션'을 전달해줍니다. (모든 커넥션이 사용 중이라면 다시 반납되기 전까지 기다려야합니다.)

 

프록시 커넥션을 획득하여 SQL을 전달하면, 프록시 커넥션은 실제 물리적 커넥션을 통해 DB 세션에 SQL을 전달할 수 있습니다.

 

커넥션 반납

커넥션을 모두 사용하고 물리적 커넥션을 살아있는 상태로 유지해야합니다. 그래야 같은 커넥션을 재사용할 수 있고 매번 커넥션을 생성하지 않아도 되죠.

 

우리가 실제로 사용한 것은 Proxy Connection인데, 프록시 커넥션을 close시키면, 커넥션 풀과의 연결 관계가 끊어지면서 이 프록시 커넥션은 Closed 상태가 됩니다. (편의상 Closed Connection 이라 하겠습니다.) 더 이상 이 프록시 커넥션을 통해 SQL을 날리려고 시도해도 물리적 커넥션에 연결할 수 없습니다.

 

사용이 완료된 물리적 커넥션은 어떤 요청에서도 참조되지 않는 커넥션이 되고, 다시 다른 사용자의 새로운 요청이 들어왔을 때 사용 될 수 있는 상태가 됩니다.


커넥션의 적절한 갯수 설정이 중요

 

커넥션풀에서 보관하는 커넥션의 최대 갯수를 정하는 것은 백엔드 개발자들이 고민해야 할 중요한 문제 중 하나입니다.

 

너무 많은 커넥션을 생성해두면 DB, 서버 양쪽에 성능 문제가 생겨서 장애가 발생할 가능성이 있습니다.

너무 적은 커넥션을 생성해두면 커넥션이 일찍 고갈되고, 커넥션을 획득하지 못 한 고객들은 커넥션을 획득하기까지 그만큼 시간을 대기해야해서, UX 관점에서 악영향을 끼칠 수 있게 됩니다.

 

커넥션 풀에서 보관하는 커넥션의 최대 갯수에 관한 정답은 사실 정해져 있지 않습니다. 모두 제각각 다르기 때문입니다.

서비스의 특징, 애플리케이션 서버의 스펙, DB 서버의 스펙에 따라 가능한 갯수가 다를 수밖에 없습니다. 이는 실제 배포환경과 유사한 환경에서 성능 테스트를 여러번 거쳐서 정할 필요가 있습니다.

 

  • 커넥션 풀은 서버당 최대 커넥션 수를 제한할 수 있다.
  • DB에 무한정 연결이 생성되는 것을 막아주어서 DB를 보호하는 효과도 있다.

커넥션 풀 라이브러리

커넥션 풀은 개발자가 직접 구현할 수도 있기야 하겠지만, 이미 오픈소스로 공개된 좋은 커넥션 풀들이 많이 있기 때문에 이들을 사용하는 것이 추천됩니다. 

 

대표적인 오픈소스 커넥션 풀로는 tomcat dbcp, apache commons dbcp2, ... 등이 있는데 실제로 제일 많이 사용되는 것은 HikariCP DBCP입니다. 성능, 사용의 편리함, 안전성 측면에서 많은 사람들에게 이미 검증이 됐습니다. 커넥션 풀을 사용할 때는 별다른 고민 없이 HikariCP를 사용하면 됩니다.

 

Spring Boot  - HikariCP DBCP

실제로 SpringBoot에서는 2.x 이후부터, HikariCP DBCP를 기본 커넥션 획득방법으로 지정하였습니다. spring-boot-starter-jdbc 또는 spring-boot-starter-data-jpa를 사용할 때, 별 다른 설정을 하지 않으면, 스프링부트 로딩 시점에 DataSource 구현체로 HikariCP DBCP가 스프링 빈으로 등록되고, 주입되어 사용되어집니다.

 

물론 HikariCP 말고 다른 커넥션 획득방법을 사용하고 싶다면, 그것으로 수동 등록하시면 됩니다. 이 경우, 수동 등록한 것의 우선도가 더 높습니다.


실제 코드로 확인하기

라이브러리에 이미 등록되어 있는 HikariCP

저희는 spring-boot-starter-jdbc 를 gradle 의존관계에 추가해놨습니다. spring-boot-starter-jdbc가 추가되어 있으므로, HikariCP가 라이브러리에 추가되고 기본적으로 스프링 부트 애플리케이션을 로딩하면 DataSource에 HikaiDataSource가 빈으로 등록됩니다.

 

수동으로 HikariDataSource 생성

@Slf4j
public class HikariDataSourceTest {

    private DataSource dataSource;

    @BeforeEach
    void beforeEach() {
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setJdbcUrl(URL);
        hikariDataSource.setUsername(USER);
        hikariDataSource.setPassword(PASSWORD);
        hikariDataSource.setMaximumPoolSize(10);
        hikariDataSource.setPoolName("hikari pool");
        dataSource = hikariDataSource;
    }
}

수동으로 HikariDataSource를 생성할 때는 위와 같이, 클래스 선언 후, setXXX 메서드를 통해 접근 설정, 커넥션 풀의 최대 갯수, 커넥션 풀의 이름 등을 설정한 뒤 빈으로 등록하면 됩니다.

 

커넥션 풀링 로그로 확인

@Test
void hikariDataSource() throws SQLException {
    // 로직 1
    Connection con1 = dataSource.getConnection();
    log.info("con1 = {}", con1, con1.getClass());
    log.info("con1.class = {}", con1.getClass());

    // 로직 2
    Connection con2 = dataSource.getConnection();
    log.info("con2 = {}", con2, con2.getClass());
    log.info("con2.class = {}", con2.getClass());

    assertThat(con1).isNotNull();
    assertThat(con2).isNotNull();
}

dataSource로부터, 커넥션을 받아와서 로깅하는 코드입니다.

커넥션에 관한 정보, 그리고 실제 커넥션 인스턴스의 클래스명을 로깅합니다.

[main] DEBUG com.zaxxer.hikari.HikariConfig - MyPool - configuration:
...
[main] DEBUG com.zaxxer.hikari.HikariConfig - maximumPoolSize................................10

...
[main] DEBUG com.zaxxer.hikari.HikariConfig - poolName................................"ttasjwi pool"

로그를 순서대로 확인해보면, 우선 저희가 설정한대로 커넥션 풀이 생성되는 것을 확인할 수 있습니다.

 

[main] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn2: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn3: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn4: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn5: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn6: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn7: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn8: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - Added connection conn9: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA

그리고 별도의 스레드에서 저희가 지정한 최대 갯수에 해당하는 10개의 커넥션을 생성하는 것을 볼 수 있습니다.

 

커넥션을 생성하는 작업은 비용이 매우 비싼 작업이기 때문에 main 스레드에서 생성되지 않고 별도의 스레드에서 생성 로직을 수행합니다. 애플리케이션 로딩 시점에서는 커넥션 풀 준비 말고도 여러 초기화 작업을 많이 하기 때문이죠.

con1 = HikariProxyConnection@1204296383 wrapping conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
con1.class = class com.zaxxer.hikari.pool.HikariProxyConnection

con2 = HikariProxyConnection@567294307 wrapping conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
con2.class = class com.zaxxer.hikari.pool.HikariProxyConnection

[ttasjwi pool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - ttasjwi pool - After adding stats (total=10, active=2, idle=8, waiting=0)

실제 로그를 보면, 커넥션 2개를 풀에서 가져와서 사용하는 것을 볼 수 있습니다.

마지막 줄을 보시면, 총 10개에서, 2개의 커넥션이 사용중이고(저희가 반납을 따로 안 했으므로), 나머지 8개의 커넥션은 놀고(?) 있는 것을 볼 수 있어요!


프록시 커넥션 : 물리적 커넥션을 빌려와서, 감싼 가짜 프록시

 

방금 코드의 실행로그에서, 획득한 커넥션의 클래스명을 자세히 보시면, HikariProxyConnection이라고 되어 있습니다.

실제 런타임에, 애플리케이션 로직에서 획득하는 커넥션 구현체는 물리적 커넥션이 아니라 프록시 커넥션임을 확인할 수 있습니다.

 

    @Test
    void proxyConnection() throws SQLException {
        // 로직 1
        Connection con1 = dataSource.getConnection();
        log.info("con1 = {}", con1, con1.getClass());
        log.info("con1.class = {}", con1.getClass());
        assertThat(con1).isNotNull();
        JdbcUtils.closeConnection(con1);

        // 로직 2
        Connection con2 = dataSource.getConnection();
        log.info("con2 = {}", con2, con2.getClass());
        log.info("con2.class = {}", con2.getClass());
        assertThat(con2).isNotNull();
        JdbcUtils.closeConnection(con2);
    }

새로 코드를 작성해서 로그로 확인해봅시다.

아까 코드와 다르게 각 로직이 끝날 때, JdbcUtil.closeConnection을 호출하여 커넥션을 종료합니다.

 

프록시 커넥션의 close 메서드는 물리적 커넥션을 커넥션 풀에 반납하는 방식으로 구현되어 있어서, 호출 이후 프록시 커넥션이 close 상태가 될 뿐 물리적 커넥션은 사라지지 않습니다. 실제로 로그로 확인해볼까요?

15:16:40.154 [main] INFO com.ttasjwi.jdbc.datasource.HikariDataSourceTest - con1 = HikariProxyConnection@1204296383 wrapping conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
15:16:40.155 [main] INFO com.ttasjwi.jdbc.datasource.HikariDataSourceTest - con1.class = class com.zaxxer.hikari.pool.HikariProxyConnection

# 커넥션 풀에 'con1'을 반납하고 'con2'를 획득

15:16:40.388 [main] INFO com.ttasjwi.jdbc.datasource.HikariDataSourceTest - con2 = HikariProxyConnection@1759250827 wrapping conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA
15:16:40.389 [main] INFO com.ttasjwi.jdbc.datasource.HikariDataSourceTest - con2.class = class com.zaxxer.hikari.pool.HikariProxyConnection

첫번째로 얻어온 프록시 커넥션은 HikariProxyConnection@1204296383 이고

두번째로 얻어온 프록시 커넥션은 HikariProxyConnection@1759250827 로 서로 다릅니다.

 

하지만, 내부적으로는 'conn0'이라는 같은 물리적 커넥션을 감싸고 있는 것을 볼 수 있습니다.

 

첫 로직에서 물리적 커넥션(conn0) 을 커넥션 풀에서 가져와 사용 후 물리적 커넥션과 연결을 끊어 close 시킨뒤(편하게 반납이라고 하겠습니다.), 두번째 로직에서는 물리적 커넥션(conn0)을 가져와 사용 후 반납하는 것입니다. 실제로는 같은 물리적 커넥션이 재사용된 것이지요. 물론 이걸 감싼 프록시 커넥션 인스턴스는 다르겠지만요.


BookRepository 테스트에서, HikariDataSource 사용해보기

구현체 교체

    @BeforeEach
    void setUp() {
//        DataSource dataSource = new DriverManagerDataSource(URL, USER, PASSWORD);

        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USER);
        dataSource.setPassword(PASSWORD);

        repository = new BookRepositoryV1(dataSource);
    }

실제로 이전에 만들었던 BookRepositoryV1을 테스트 해봅시다.

HikariDataSource 클래스를 생성 및 설정 정보를 넣고 BookRepository에 주입하도록 합니다.

DataSource 구현체만 달라졌지, 내부에서는 DataSource를 의존하고 있기에 BookRepositoryV1 코드를 변경할 필요가 없습니다.

get Connection : HikariProxyConnection@485845532 wrapping conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
saveBook = Book(id=53, name=test, price=10000)

get Connection : HikariProxyConnection@1037854997 wrapping conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
findBook = Book(id=53, name=test, price=10000)

테스트 코드들을 실행해보면 전부 성공하는 것을 확인할 수 있고, 커넥션을 획득할 때 커넥션 풀에서 프록시 커넥션을 받아와 사용하는 것을 확인할 수 있습니다. 프록시 커넥션 인스턴스는 바뀌지만 내부적으로 'conn0'을 반납 후 재사용하는 것을 볼 수 있어요!


이상으로 포스팅을 마치겠습니다. 모두들 화이팅입니다!


참고자료

- 인프런 김영한 님 Spring DB 강의 1편

Comments