INFLEARN

[스프링 DB 1편 - 데이터 접근 핵심 원리] 2. 커넥션풀과 데이터소스 이해

ch010104 2026. 7. 7. 15:28

1. 커넥션 풀 이해

1.1 데이터베이스 커넥션을 매번 획득할 때의 한계

기본적인 데이터베이스 커넥션 획득 프로세스는 요청이 올 때마다 다음과 같은 매우 복잡한 단계를 거쳐 매번 새로운 커넥션을 생성합니다.

커넥션 매번 획득 시 6단계 상세 프로세스

  1. 커넥션 조회: 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회합니다.
  2. TCP/IP 연결: DB 드라이버는 데이터베이스(DB)와 TCP/IP 커넥션을 연결합니다. 이 과정에서 TCP/IP 3-way handshake와 같은 네트워크 동작이 발생합니다.
  3. 인증 정보 전달: TCP/IP 커넥션이 연결되면 DB 드라이버는 ID, PW 및 기타 데이터베이스 접속에 필요한 부가정보를 DB에 전달합니다.
  4. DB 내부 인증 및 세션 생성: DB는 전달받은 ID, PW를 검증하여 내부 인증을 완료하고, 해당 커넥션이 사용할 내부 DB 세션을 생성합니다.
  5. 응답 전송: DB는 커넥션 생성이 완료되었다는 응답을 보냅니다.
  6. 커넥션 객체 생성 및 반환: DB 드라이버는 물리적인 커넥션 연결을 추상화한 커넥션 객체를 생성하여 클라이언트(애플리케이션)에 반환합니다.

매번 신규 커넥션을 생성할 때 발생하는 진짜 문제점

  • 리소스 및 시간 소모: 커넥션을 새로 만드는 과정은 네트워크 통신 및 DB 세션 생성 등의 부하로 인해 시간과 리소스가 크게 소모됩니다.
  • 응답 속도 저하: 데이터베이스 종류 및 환경마다 다르지만(MySQL 계열은 수 $ms$ 정도로 비교적 빠르나, 다른 데이터베이스는 수십 $ms$ 이상 소모되기도 함), 매 요청마다 커넥션 생성 시간이 추가되면 최종 사용자에게 느린 응답 속도로 이어져 좋지 않은 사용자 경험을 주게 됩니다.

1.2 커넥션 풀(Connection Pool)의 개념과 동작 원리

매번 커넥션을 새로 생성하는 문제를 해결하기 위해, 애플리케이션이 시작되는 시점에 커넥션을 미리 확보해두고 관리하는 공간을 커넥션 풀(Connection Pool)이라고 합니다.

[애플리케이션 시작] ──> [커넥션 풀 초기화 (기본값 보통 10개)] ──> [DB와 TCP/IP 연결 유지 상태로 보관]

1) 커넥션 풀 초기화 및 연결 상태

  • 애플리케이션을 시작하는 시점에 커넥션 풀은 설정된 개수(기본값은 보통 10개)만큼 커넥션을 미리 확보하여 풀에 보관합니다.
  • 이때 풀에 보관된 커넥션들은 이미 DB와 TCP/IP로 연결이 완료되어 있고, 인증 및 세션 생성이 완료된 상태입니다. 따라서 즉시 SQL을 DB에 전달할 수 있습니다.

2) 커넥션 풀의 사용 (Acquire & Release)

  • 커넥션 획득 (Acquire)
    애플리케이션 로직은 더 이상 DB 드라이버를 통해 새 커넥션을 획득하지 않고, 커넥션 풀에서 이미 생성되어 있는 커넥션 객체의 참조를 가져다 사용합니다. 커넥션 풀은 요청을 받으면 자신이 관리하는 커넥션 중 하나를 반환합니다.

  • 커넥션 반환 (Release)
    애플리케이션 로직은 커넥션을 모두 사용한 후 커넥션을 종료(close)하는 것이 아니라, 살아있는 상태 그대로 커넥션 풀에 반환합니다. 반환된 커넥션은 재사용 가능한 상태(Idle)로 복귀합니다.

3) 커넥션 풀의 주요 이점

  • 성능 극대화: 커넥션을 새로 생성하는 비용이 완전히 사라져 응답 속도가 비약적으로 향상됩니다.
  • DB 보호 기능: 서버당 최대 커넥션 수를 제한할 수 있으므로, 갑작스러운 트래픽 폭주 시에도 DB에 무한정 연결이 생성되어 DB가 다운되는 현상을 방지할 수 있습니다.
  • 실무 표준: 얻을 수 있는 이점이 매우 크기 때문에 실무에서는 항상 기본으로 사용됩니다.

4) 대표적인 오픈소스 커넥션 풀

  • commons-dbcp2
  • tomcat-jdbc pool
  • HikariCP (현재 실무 표준 및 스프링 부트 2.0 이상 기본 커넥션 풀로 제공됨. 성능, 사용 편의성, 안정성 면에서 검증됨)

2. DataSource 이해

2.1 커넥션을 획득하는 방법의 변경 시 발생하는 문제

기본적으로 커넥션을 얻는 방법은 여러 가지가 존재합니다.

  • JDBC DriverManager를 직접 사용하여 매번 신규 커넥션을 생성하는 방법
  • 다양한 오픈소스 커넥션 풀(DBCP2, HikariCP 등)을 사용하는 방법

만약 애플리케이션 로직이 특정 커넥션 획득 방식에 직접 의존하게 되면, 획득 기술을 변경할 때 치명적인 문제가 생깁니다.

  • 의존관계 변경 및 코드 수정 필수: 예를 들어 DriverManager를 사용하다가 성능 개선을 위해 HikariCP로 변경하면, 커넥션을 획득하는 모든 애플리케이션 코드를 수정해야 합니다. 각각의 사용법과 API가 다르기 때문입니다.

2.2 DataSource 인터페이스를 통한 추상화

자바에서는 이러한 문제를 해결하기 위해 커넥션을 획득하는 방법을 추상화한 javax.sql.DataSource 인터페이스를 제공합니다.

DataSource 인터페이스의 핵심 API

package javax.sql;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Wrapper;

public interface DataSource extends CommonDataSource, Wrapper {
    Connection getConnection() throws SQLException;
}

(기타 부가 기능들이 있으나, 커넥션 조회 기능 하나가 핵심입니다.)

구현체의 표준화 및 동작 정리

  • 대부분의 커넥션 풀(DBCP2, HikariCP 등)은 이미 DataSource 인터페이스를 구현해 두었습니다.
  • 따라서 개발자는 구체적인 커넥션 풀 구현체에 직접 의존하지 않고, 오직 DataSource 인터페이스에만 의존하도록 애플리케이션 로직을 작성하면 됩니다.
  • 커넥션 풀 기술을 변경하고 싶다면, 애플리케이션 코드를 건드릴 필요 없이 의존성 주입(DI) 설정 부분에서 구현체만 갈아 끼우면 됩니다. (OCP 원칙 준수)

DriverManager와 DriverManagerDataSource

  • 전통적인 JDBC DriverManager는 DataSource 인터페이스를 구현하지 않습니다. 따라서 이를 직접 사용하다가 DataSource 기반 커넥션 풀로 넘어가려면 코드 수정이 불가피합니다.
  • 스프링은 이 문제를 해결하기 위해 DriverManager도 DataSource를 통해 사용할 수 있도록 DriverManagerDataSource라는 구현 클래스를 제공합니다.

3. DataSource 예제 1 - DriverManager

전통적인 DriverManager 방식과 스프링이 제공하는 DriverManagerDataSource 방식을 비교 테스트해봅니다.

3.1 ConnectionTest - 드라이버 매니저 및 데이터소스 드라이버 매니저 추가

package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

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

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

@Slf4j
public class ConnectionTest {

    // 1. 기존 DriverManager를 사용하여 매번 새로운 커넥션을 직접 획득하는 방식
    @Test
    void driverManager() throws SQLException {
        Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
        Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);

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

    // 2. 스프링이 제공하는 DataSource 구현체인 DriverManagerDataSource를 사용하는 방식
    @Test
    void dataSourceDriverManager() throws SQLException {
        // DriverManagerDataSource는 항상 새로운 커넥션을 획득합니다.
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        useDataSource(dataSource);
    }

    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.2 실행 결과 비교

1) driverManager() 실행 결과

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

2) dataSourceDriverManager() 실행 결과

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
connection=conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection

3.3 핵심 차이점: 설정과 사용의 분리

두 방식에는 파라미터 전달 및 구조 면에서 매우 결정적인 차이가 존재합니다.

// DriverManager 사용 방식 (호출할 때마다 정보 전달)
DriverManager.getConnection(URL, USERNAME, PASSWORD);
DriverManager.getConnection(URL, USERNAME, PASSWORD);

// DataSource 사용 방식 (객체 생성 시점에만 정보 전달)
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
dataSource.getConnection();
dataSource.getConnection();
  • 설정: DataSource를 생성하고 URL, USERNAME, PASSWORD와 같은 접속 정보를 입력하는 행위입니다. 이 설정 정보는 대개 한 곳에 모여 관리됩니다.
  • 사용: 복잡한 설정 정보는 신경 쓰지 않고, 단순히 dataSource.getConnection()만 호출하여 사용하면 됩니다.

설정과 사용의 분리가 주는 이점

  • 객체를 설정하는 부분과 사용하는 부분이 완전히 분리됩니다.
  • Repository와 같은 비즈니스 로직 클래스는 DataSource만 주입(DI)받아서 단순히 getConnection()만 호출하면 되며, 데이터베이스 접속 경로(URL)나 계정 정보와 같은 세부 설정을 몰라도 됩니다.
  • 향후 접속 정보가 변경되더라도 Repository 코드는 전혀 영향을 받지 않고 설정 부만 수정하면 되므로 변경 유연성이 극대화됩니다.

4. DataSource 예제 2 - 커넥션 풀

이번에는 DataSource의 구현체인 HikariDataSource를 사용하여 커넥션 풀을 생성하고 커넥션을 가져오는 예제를 살펴봅니다.

4.1 ConnectionTest - 데이터소스 커넥션 풀 추가

package hello.jdbc.connection;

import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

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

@Slf4j
public class ConnectionTest {

    // useDataSource 메서드는 이전 장과 동일하게 사용됩니다.
    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());
    }

    @Test
    void dataSourceConnectionPool() throws SQLException, InterruptedException {
        // 커넥션 풀링: Hikari ProxyConnection (Proxy) -> JdbcConnection (Target)
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        dataSource.setMaximumPoolSize(10); // 최대 풀 사이즈 지정
        dataSource.setPoolName("MyPool");  // 풀 이름 지정

        useDataSource(dataSource);

        // 커넥션 풀에서 커넥션이 완전히 생성되는 로그를 확인하기 위해 대기 시간 제공
        Thread.sleep(1000);
    }
}
  • HikariDataSource 사용: HikariCP 커넥션 풀의 핵심 클래스로, 내부적으로 DataSource 인터페이스를 구현하고 있습니다.
  • 별도 쓰레드 동작 방식: 커넥션 풀을 채우는 작업은 데이터베이스와의 TCP/IP 연결 등 다소 무거운 작업입니다. 애플리케이션 시작 단계에서 이를 메인 쓰레드가 동기적으로 처리하면 실행 대기 시간이 길어집니다. 따라서 히카리 커넥션 풀은 별도의 쓰레드(MyPool connection adder)를 사용하여 백그라운드에서 풀을 채웁니다. * Thread.sleep(1000)의 이유: 백그라운드 쓰레드가 동작하는 도중에 메인 테스트 쓰레드가 먼저 종료되는 것을 방지하기 위해 대기 시간을 할당한 것입니다.

4.2 실행 로그 분석

# 1. 커넥션 풀 설정 정보 출력
[Test worker] INFO com.zaxxer.hikari.HikariDataSource - MyPool - Starting...
[Test worker] INFO com.zaxxer.hikari.HikariConfig - MyPool - configuration:
[Test worker] INFO com.zaxxer.hikari.HikariConfig - maximumPoolSize................................10
[Test worker] INFO com.zaxxer.hikari.HikariConfig - poolName................................"MyPool"

# 2. 커넥션 풀 전용 쓰레드가 백그라운드에서 커넥션을 최대치(10개)까지 채움
[MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - Added connection conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
[MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - Added connection conn1: url=jdbc:h2:tcp://localhost/~/test user=SA
[MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - Added connection conn2: url=jdbc:h2:tcp://localhost/~/test user=SA
...
[MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - Added connection conn9: url=jdbc:h2:tcp://localhost/~/test user=SA

# 3. 커넥션 풀에서 커넥션 2개 획득 (반환하지 않고 사용 중인 상태)
[Test worker] INFO hello.jdbc.connection.ConnectionTest - connection=HikariProxyConnection@446445803 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection
[Test worker] INFO hello.jdbc.connection.ConnectionTest - connection=HikariProxyConnection@832292933 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection

# 4. 풀 상태 출력 (전체=10, 활성/사용중=2, 대기/유휴=8, 대기중인스레드=0)
[Test worker] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - After adding stats (total=10, active=2, idle=8, waiting=0)

로그 상세 분석

  • HikariProxyConnection: 커넥션 풀에서 커넥션을 획득하면 실제 물리 커넥션(conn0, conn1 등)을 바로 반환하는 것이 아니라, 이를 감싸고 있는 프록시 객체(HikariProxyConnection)를 반환합니다. 이는 사용 후 close()를 호출했을 때 물리적 종료가 아닌 풀 반환이 정상 동작하도록 가로채기 위함입니다.
  • 쓰레드 분리 풀링: MyPool connection adder라는 이름의 전용 백그라운드 쓰레드가 지정된 개수(10개)만큼 물리 커넥션을 미리 연결해두는 것을 확인할 수 있습니다.
  • 풀 현황 분석: 10개의 커넥션 중 2개를 획득하고 닫지 않았으므로 active=2, idle=8 상태를 유지하게 됩니다.

4.3 스프링 부트 3.1 이상 로그 미출력 문제 해결 방법

스프링 부트 3.1부터는 내부 기본 로그 레벨이 빠르게 INFO로 잡히기 때문에 커넥션 풀 추가 과정의 세부 DEBUG 로그(MyPool connection adder)가 보이지 않을 수 있습니다. 이때는 아래의 설정 파일을 리소스 경로에 추가하면 해결됩니다.

  • 경로: src/main/resources/logback.xml
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %kvp - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 기본 로그 레벨을 DEBUG로 지정하여 히카리 풀 로그가 출력되도록 함 -->
    <root level="DEBUG">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

5. DataSource 적용

이제 실제 Repository 코드에 DataSource를 적용하여 데이터베이스 연결 관리를 수행합니다. 기존의 MemberRepositoryV0를 리팩토링한 MemberRepositoryV1을 작성하고 검증해봅니다.

5.1 MemberRepositoryV1 클래스 소스 코드

이전 버전과 다르게 접속용 Util 클래스에 직접 의존하지 않고, 외부에서 표준 DataSource를 주입받아 사용하도록 구현합니다.

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * JDBC DataSource 사용, 스프링 제공 JdbcUtils 사용
 */
@Slf4j
public class MemberRepositoryV1 {

    private final DataSource dataSource;

    // 외부에서 DataSource를 주입받도록 함 (DI 적용)
    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

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

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

    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);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    // 자원 해제 편의 메서드: 스프링의 JdbcUtils 적극 활용
    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }
}

MemberRepositoryV1 핵심 변경 포인트

  1. 의존관계 주입 (DI): 외부에서 생성된 DataSource를 생성자를 통해 주입받아 사용합니다. 구현 클래스가 변경되더라도 Repository 내부 코드는 단 한 줄도 수정되지 않습니다.
  2. JdbcUtils 편의 메서드 사용: 스프링에서 제공하는 org.springframework.jdbc.support.JdbcUtils 클래스를 사용하여 리소스 반환 코드를 대폭 정돈했습니다.
    • closeConnection(), closeStatement(), closeResultSet() 등은 커넥션 풀을 고려하여 내부적으로 안전하게 예외 처리를 거쳐 연결을 정리/반환합니다.

5.2 MemberRepositoryV1Test 작성 및 검증

작성한 Repository가 다양한 DataSource 구현체와 결합하여 제대로 CRUD(등록, 조회, 수정, 삭제)가 수행되는지 단위 테스트로 검증합니다.

package hello.jdbc.repository;

import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

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

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
    void beforeEach() throws Exception {
        // [테스트 방식 1] 기본 DriverManagerDataSource 사용 (매번 새 커넥션 생성)
        // DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);

        // [테스트 방식 2] HikariCP 커넥션 풀 사용 (커넥션 풀링 최적화)
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD); // (주의: 오타로 poolName 등을 넣지 않도록 주의)

        repository = new MemberRepositoryV1(dataSource);
    }

    @Test
    void crud() throws SQLException, InterruptedException {
        log.info("start");

        // 1. Save (등록)
        Member member = new Member("memberV0", 10000);
        repository.save(member);

        // 2. FindById (조회)
        Member memberById = repository.findById(member.getMemberId());
        assertThat(memberById).isNotNull();

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

        // 4. Delete (삭제)
        repository.delete(member.getMemberId());
        assertThatThrownBy(() -> repository.findById(member.getMemberId()))
                .isInstanceOf(NoSuchElementException.class);
    }
}

5.3 테스트 실행 로그 분석 및 동작 분석

각 DataSource 구현체 설정에 따른 실제 실행 로그 및 커넥션 관리 특징을 정리합니다.

1) DriverManagerDataSource 테스트 동작 결과

get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
get connection=conn1: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
get connection=conn2: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
get connection=conn3: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
get connection=conn4: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
get connection=conn5: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
  • 분석: 저장, 조회, 수정 등의 호출이 일어날 때마다 conn0부터 conn5까지 계속해서 새로운 번호의 물리적 커넥션이 매번 생성되고 완전히 종료되는 것을 볼 수 있습니다.

2) HikariDataSource 테스트 동작 결과

get connection=HikariProxyConnection@xxxxxxxx1 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
get connection=HikariProxyConnection@xxxxxxxx2 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
get connection=HikariProxyConnection@xxxxxxxx3 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
get connection=HikariProxyConnection@xxxxxxxx4 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
get connection=HikariProxyConnection@xxxxxxxx5 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
get connection=HikariProxyConnection@xxxxxxxx6 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA
  • 분석: 물리적인 커넥션 번호가 모두 동일하게 conn0으로 찍힌 것을 볼 수 있습니다. 프록시 주소값(HikariProxyConnection@xxxxxxxx)은 다르지만, 이 프록시 객체들이 가리키는 실제 핵심 Target 커넥션은 동일한 conn0입니다.
  • 커넥션 재사용 메커니즘: 테스트는 단일 쓰레드에서 순차적으로 한 작업이 완전히 끝나고(리소스가 풀로 반환되고) 다음 작업이 시작되는 방식으로 흐릅니다. 따라서 반환된 conn0 커넥션을 다음 조회나 수정 로직에서 즉시 풀에서 재획득하여 연속적으로 재사용합니다.
  • 동시 요청 상황: 실제 웹 애플리케이션 환경처럼 여러 요청이 동시에 들어올 경우 멀티 쓰레드 구조에서 커넥션 풀의 여유 커넥션들을 골고루 분배받아 처리하는 상황을 관측할 수 있습니다.

6. 최종 요약 정리

  • 커넥션 풀: 매번 요청마다 DB 연결을 맺고 물리 커넥션을 지연 생성하는 고비용 문제를 해소하기 위해, 미리 수영장의 물처럼 연결 상태를 유지한 채 커넥션을 확보해 보관하는 고효율의 필수 실무 기술입니다. 성능, 편의성 면에서 HikariCP가 사실상 표준(Defacto Standard)으로 자리 잡았습니다.
  • DataSource 인터페이스: 커넥션을 획득하는 복잡하고 다채로운 방식을 규격화된 하나의 인터페이스로 추상화한 자바 표준 API입니다.
  • DI + OCP 실현: DriverManagerDataSource에서 최고 성능의 커넥션 풀링 기술인 HikariDataSource로 갈아끼우더라도, 이를 바라보는 MemberRepositoryV1 리포지토리 컴포넌트의 비즈니스 코드는 전혀 변하지 않는 다형성의 극대화를 실증하였습니다.