데이터베이스 커넥션 풀 최적값을 찾아가는 여정(1/2)

안녕하세요. 코딩 신생아 입니다. 

최근 정보를 자동 업로드화하는 알고리즘을 짜는 중, 커넥션 풀 개수를 고려하게 되어, 이에 대해 찾아보았습니다. 해당 과정중 데이터베이스 커넥션 풀 관련 재미있는 글을 읽게 되어 최적의 커넥션 개수와 메모리는 성능 테스트를 통해 찾아낸다는 부분을 보고 이를 판단하는 "최적의 커넥션 풀 크기" 실험을 해보려고 합니다.

데이터베이스 커넥션

 

 

우선 데이터베이스 커넥션 풀에 대해 알아보기 이전에 데이터 베이스 커넥션이 무엇인지 알아보자.

데이터베이스 연결의 생애주기는 아래와 같다.

  • 데이터베이스 드라이버를 사용해 데이터베이스 연결
  • 데이터 읽기/쓰기를 위한 TCP소켓 열기
  • 소켓을 통한 데이터 읽기/쓰기
  • 연결 종료
  • 소켓 닫기

데이터베이스를 연결하고, 해제하는 과정은 비용이 많이 들어가므로 반복하지 않는 것이 좋다. 이를 네트워크 관점, DBMS 관점에서 생각해보자.

 

 

네트워크 관점

DBMS와 통신하기 위해 커넥션은 TCP/IP 통신을 한다. TCP/IP의 연결 과정은 3-way handshake 로 다음과 같이 이루어진다.

 

  • 서버 프로세스(DB)가 OS에게 포트 번호에 통신 요청이 오면 자신에게 전달해 달라하며 기다린다. LISTEN
  • 클라이언트가 통신 상대인 서버측 OS에게 경로 오픈을 의뢰하며 SYN 패킷을 전송한다.
  • 서버측 소켓은 ACK+SYN 패킷을 응답한다.
  • 클라이언트도 다시 ACK 패킷으로 응답하며 서버의 새로운 소켓이 생성되며 연결된다. ESTABLISHED

 

위 연결은 유지되고 끊어질때는  4-way handshake  로 끊어지므로, 반복할수록 비효율적이다.

네트워크 tcp통신 더 자세히 정리(추후)

 

 

DBMS (MYSQL) 관점

 

 

MySQL 같은 데이터베이스는 멀티스레드 서버이고, 높은 동시성 처리가 필요한 시스템에서 사용된다. 스레드당 하나의 클라이언트를 컨텍스트 스위칭을 하며 담당하는데, 스레드를 매 연결마다 생성하면 부담이 되어 스레드 풀 개념을 사용한다. 

SQLite와 같은 싱글 스레드 데이터베이스의 경우 동시접속이 불가하지만, MySQL, MongoDB는 멀티 스레드이므로, 실제 동시접속이 늘었을때, 스레드 생성으로 인해 지연이 발생할 수 있다.

MySQL과 MongoDB 차이(추후)

 

 

결론

 

 

이러한 반복비용을 고려하여 데이터베이스 커넥션 풀(DBCP Database Connection Pool)을 사용한다. 

 

DB와 미리 connection을 해놓은 객체들을 pool에 저장해두었다가, 클라이언트 요청이 오면 connection을 빌려주고, 처리가 끝나면 다시 connection을 반납받아 pool에 저장하는 방식을 말한다.

 

미리 생성한 connection 객체를 가져다 쓰기 때문에 위에서 말한 연결 시간이 반복적으로 소비되지 않는다.

 

간단한 DBCP 구현해서 성능 비교

 

그렇다면 실제로 반복적인 connection 생성 vs 미리 만든 connection 가져다쓰기 성능을 비교해보자.

사용기술은 아래와 같다. 

Spring boot 3.xx

MongoDB 5.xx

 

몽고디비 연결

 

spring:
  application:
    name: mongo
  data:
    mongodb:
      uri: mongodb+srv://<유저>:<비번>@<데이터베이스명>.mu8gr.mongodb.net/<데이터베이스명>
  autoconfigure:
    exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

 

몽고디비와 스프링을 연결할때는 mysql 과 같은 rdbms 관계형 데이터베이스와의 연결과 다르게 autoconfigure exclude를 설정해야한다.

 

스프링 부트는 기본적으로 관계형 데이터베이스 연결을 위한 jdbc설정을 자동으로 수행하려고 하기 때문에 JDBC 관련 자동 설정을 제외해서 몽고디비와 연결을 이루어지도록 한다고 한다. 몽고디비는 자체 커넥션 풀을 가지고 있어서 jdbc에서는 HikariCP 와 같은 커넥션 풀 오픈소스를 사용하는데 이를 안 사용한다고 한다. HikariCP 에 대한 내용(추후)

 

DatasourceConfig

 

우선 DatasourceConfig라는 클래스를 만들어 

디비 커넥션 풀을 조절하는 사이즈를 하드코딩으로 정하도록 하였다.

 

 

package hyeri.dbpool.config;

import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.internal.MongoClientImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DatasourceConfig {

    // 커넥션 풀 크기를 변수로 저장
    @Value("${spring.data.mongodb.connection-pool-size:10}")
    private int maxConnectionPoolSize;

    @Bean
    public MongoClient mongoClient() {
        ConnectionString connectionString = new ConnectionString("db정보 uri");

        MongoClientSettings settings = MongoClientSettings.builder()
                .applyConnectionString(connectionString)
                .applyToConnectionPoolSettings(builder ->
                        builder.maxSize(maxConnectionPoolSize) // 커넥션 풀 크기 설정
                )
                .build();

        return MongoClients.create(settings);
    }

    @Bean
    public int mongoConnectionPoolSize() {
        // 커넥션 풀 크기를 반환
        return maxConnectionPoolSize;
    }

    // 커넥션 풀의 상태를 반환하는 메서드
    public String getConnectionPoolStats(MongoClient mongoClient) {
        if (mongoClient instanceof MongoClientImpl) {
            MongoClientImpl clientImpl = (MongoClientImpl) mongoClient;
            // 커넥션 풀 상태 정보 조회
            return clientImpl.getClusterDescription().toString();
        }
        return "커넥션 풀 상태를 조회할 수 없습니다.";
    }
}

 

 

이렇게 정하고 @Bean 으로 최대 커넥션 풀 크기 값이 변수에 자동으로 주입되도록 하였다. 이때, 스레드의 개수도 몽고디비의 커넥션 풀 개수와 동일하도록 value값으로 설정했다. 

 

package hyeri.dbpool.tester;

import hyeri.dbpool.tester.entity.TravelCand;
import hyeri.dbpool.tester.repository.TravelCandRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Service
public class TravelCandService {

    private final TravelCandRepository travelCandRepository;
    private final int threadPoolSize;

    public TravelCandService(TravelCandRepository travelCandRepository, @Value("${spring.data.mongodb.connection-pool-size:10}") int threadPoolSize) {
        this.travelCandRepository = travelCandRepository;
        this.threadPoolSize = threadPoolSize;
    }

    public long insertLATicket(int amount) {

        ExecutorService executorService = Executors.newFixedThreadPool(this.threadPoolSize);

        List<Future<?>> futures = new ArrayList<>();

        Instant start = Instant.now();//=====================start time

        for (int i = 0; i < amount; i++) {
            final int index = i;
            futures.add(executorService.submit(() -> {
                TravelCand travelCand = new TravelCand("L.A. Ticket " + index);
                travelCandRepository.save(travelCand);
            }));
        }


        // 모든 작업이 끝날 때까지 대기
        futures.forEach(f -> {
            try {
                f.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        Instant end = Instant.now(); //=====================end time


        executorService.shutdown(); // 스레드 풀 종료

        return Duration.between(start, end).toMillis();
    }
}

 

결과 반환 controller

 

데이터 값 삽입을 하는 시간 동안의 시간을 측정해서 이를 controller부분에서 반환하도록 하였다. 

 

@GetMapping("/info/insert")
    @ResponseBody
    public String testInsert() {
        long timeTaken = travelCandService.insertLATicket(100);
        String poolStats = datasourceConfig.getConnectionPoolStats(datasourceConfig.mongoClient());

        return "100개 데이터 삽입 시간: " + timeTaken + " ms\n" + "커넥션 풀 상태: " + poolStats;
    }

 

 

결과 비교

 

결과들은 잘 몽고디비에 들어가고

 

etc-image-0

 

커넥션 풀이 1, 10인 경우 소요시간을 비교하면,

 

1인 경우에는 100개 데이터 삽입 시간: 2695 ms

10인 경우에는 100개 데이터 삽입 시간: 6242 ms

가 걸린다. 

 

당연한 결과이다. 커넥션 풀이 1인 경우, 다른 요청은 커넥션이 반환될 때까지 기다려야 한다. 따라서 동시성이 매우 낮아져 성능이 저하된다.

하지만 커넥션 풀이 10인 경우, 스레드의 개수를 10으로 설정하였기 때문에, 각 스레드가 각각의 커넥션을 할당받아 동시에 데이터베이스에 삽입 작업을 수행할 수 있다. 따라서 커넥션 대기 시간이 줄어들어, 동시성이 높아지고 빠르게 처리가 가능하다.

 

그렇다면 커넥션 풀은 클 수록 좋을까? 

 

아니다. DB 부하를 고려해서 최적의 값을 찾는 것이 중요한데, DB 과부하, 성능 저하를 고려해서 최적의 커넥션 풀 값을 찾는 것이 중요하다. 이는 시스템 부하 테스트를 통해 찾을 수 있고, 

 

동시성을 고려해서 부하 테스트와 함께 최적의 커넥션 풀 값을 찾는 글을 쓸 예정이다.

 

 

후기

 

이 실험을 하기 위해 몽고디비를 처음 써봤는데, 추가로 몽고디비와 관련해서 찾아보다가 

몽고디비에 커넥션을 획득할때, INFO로그로 커넥션 획득 로그가 나오는 부분을 DEBUG레벨로 조정하도록 오픈소스 활동하신 분의 글을 봤다. 

내가 사용한 몽고디비는 버전이 5.xx인데 글을 보면 4.xx부터 적용된다고 하니,

새로운 커넥션 연결을 할때, INFO레벨에서 로그가 안 찍히는 것을 보니 적용된 버전임을 확인할 수 있었다. 신기했다.

 

 

https://tech.kakaopay.com/post/junior-opensource/

 

주니어 개발자의 오픈소스 활동 이야기 | 카카오페이 기술 블로그

처음 사용해 보는 기술을 이용해 회사 업무를 진행하다 오픈소스에 기여하게 된 경험을 이제 만 1년 정도 된 주니어 서버 개발자의 입장에서 이야기합니다.

tech.kakaopay.com