개발자로 후회없는 삶 살기

[문법] 커넥션과 데이터 소스 본문

[백엔드]/[spring | 학습기록]

[문법] 커넥션과 데이터 소스

몽이장쥰 2023. 5. 2. 00:56

서론

※ 과거에 기록한 내용에서 중요한 부분만 발췌하여 모두가 이해하기 쉽게 다시 서술한다.

 

본론

- 커넥션 풀 이해

드라이버 매니저를 쓰면 커낵션을 sql을 보낼 때마다 매번 커낵션을 가져와야합니다. 그리고 커낵션을 획득할 때 다음과 같은 복잡한 과정을 거칩니다.

 

1) 어플 로직은 db 드라이버(jdbc 드라이버)를 통해 커낵션을 조회
2) db 드라이버는 db와 tcp/ip 커넥션을 db와 맺음(3 way handshake)
3) db 드라이버는 tcp/ip 커넥션 연결되면 id, pw와 기타 부가정보를 db에 전달
4) db는 id, pw로 내부 인증을 하고 내부 db 세션을 인증된 사용자로 생성
5) db는 커넥션 생성 완료되었다는 응답
6) db 드라이버는 커낵션 객체를 생성해서 서버 로직에 반환

 

이렇게 커낵션을 새로 만드는 것은 과정도 복잡하고 시간도 많이 소모됩니다. 매번 커낵션을 새로 생성하는 것이 리소스를 소모하고 진짜 문제는 고객 응답 속도에 영향을 줍니다. 레포에서 매니저.getConnection을 할 때마다 매번 새로 커낵션을 새로 생성하는 것입니다. 이는 사용자에게 좋지 않은 경험을 줄 수 있습니다. 이런 문제를 한 번에 해결하는 아이디어가 바로 커넥션을 미리 생성해두고 재사용하는 커넥션 풀 방법입니다. 이름 그대로 커넥션을 관리하는 풀입니다.

 

-> 동작

1. 커넥션 풀 초기화

어플 시작 시점에 커넥션 풀에 "우리 어플이 최대 이 정도 커넥션을 쓰겠는데?" 정도만 풀에 생성해서 넣어놓습니다. 상황에 따라 다르지만 보통 10개입니다. 이것들은 10개가 모두 다 db 세션이 있고 다 tcp/ip 커넥션이 되어있습니다. 그래서 언제든지 즉시 sql을 db에 전달할 수 있습니다.

 

2. 사용 1

서버 로직에서 커넥션 풀에게 커넥션을 달라고 하면(getConntection = 풀의 커낵션 획득 방법이 getConnection입니다. 드라이버 매니저의 획득 방법은 getConnection(URL, USERNAME, PASSWORD)인 것입니다. 획득 방법의 구현 차이입니다.) 커넥션 풀이 이 커넥션을 반환하고 어플 로직은 이미 생성되어있는 커넥션을 객체 참조로(Connection con = dataSource.getConntection) 그냥 가져다 쓰면 됩니다.

3. 사용 2

서버 로직은 반환받은 커넥션을 가지고 sql을 db에 전달하고 그 결과를 받아서 처리하고 커넥션을 맺는 시간이 사라지고 sql을 수행하는 시간만 처리가 됩니다. 커넥션을 다 사용하고 나면 커넥션을 종료하는 것이 아니라 다음에 다시 사용할 수 있도록 풀에 반환합니다. 이때 커넥션을 닫는게 아니라 살아있는 상태로 반환합니다.

-> 정리

1. 적절한 풀 숫자는 스펙에 따라 다르기 때문에 성능 테스트를 통해서 정해야합니다.
2. 커넥션 수는 그 수 만큼만 db에 접근할 수 있게 하는 것입니다. 고객 입장에서는 장애겠지만 db에 무한정 연결이 생성되는 것을 막아 db를 보호합니다.
3. 실무에서는 커넥션 풀이 기본입니다.
4. 커넥션 풀은 Map에 미리 만들어 놓은 커낵션을 넣고 빼는 것으로 구현하는 것이 가능하나 오픈소스로 사용하는 것이 좋습니다. 실무에서는 hikaricp가 기본으로 스프링에서 사용됩니다.

 

- 데이터 소스 이해

어플리케이션 로직에서 커넥션을 획득하는 방법은 다양합니다. 드라이버 매니저에 직접 접근하여 항상 커넥션을 생성하는 방법도 있고 풀이 대신 만들어 준 것을 사용하는 것도 있습니다. 여기서도 중점은 그냥 커낵션을 생성하는 것입니다. 근데 풀이냐 매번 생성하냐 입니다.

 

-> 커낵션 풀 사용

이전에는 어플 로직에서 드라이버 매니저를 사용하다가 커낵션 풀이 좋으니깐 풀로 바꾸면 로직에서 코드를 바꿔야합니다. 드라이버에 접근하던 것을 커넥션 풀을 사용하도록 어플 코드를 변경해야합니다. 의존관계가 로직이 매니저를 보고 있다가 hikaricp로 변경되기 때문입니다. 그리고 커넥션 풀끼리도 dbcp2 풀을 쓰다가 hi 풀로 바꾸려고 해도 코드를 변경해야합니다.

 

그래서 커넥션을 획득하는 방법을 추상화하게 됩니다. "커넥션을 어떻게 얻을 것이냐"의 방법에 대해서 추상화를 해버립니다. 자바에서 이 문제를 해결하기 위해 javax.sql.datasource 라는 인터를 제공합니다.

데이터 소스는 커넥션을 획득하는 방법을 추상화하는 인터이고 이 핵심 기능은 커넥션 조회 하나입니다. getConnection 메서드를 가지고 "커넥션을 어떻게 획득할 것이냐" 구현은 dbcp를 하거나 드라이버 매니저를 하거나 hi를 하거나 상관이 없고 커넥션을 달라는 방법인 getConnection만 추상화한 것입니다. 추상화 해놓으면 구현체는 원하는 커넥션을 꺼내는 것으로 구현하면 되는 것입니다. 이것이 데이터 소스의 기능으로 "커넥션을 어떻게 획득 할 것이냐"입니다.

 

-> 정리

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

대부분의 커넥션 풀은 데이터 소스 인터에 이미 구현해 뒀고 따라서 개발자는 dbcp2 풀이나 hikaricp 풀에 직접 의존하는 것이 아니라 데이터 소스 인터페이스에만 의존하도록 어플리케이션 로직을 작성하면 되고 그러면 구현체는 원하는 것으로 바꿔치기 하면 됩니다.

그런데 드라이버 매니저는 데이터 소스를 사용하지 않아서 매니지는 직접 사용해야합니다. 따라서 매니저를 쓰다가 데이터 소스 기반의 커넥션 풀을 사용하도록 변경하면 관련 코드를 다 고쳐야합니다. 이런 문제를 해결하기 위해 스프링은 드라이버 매니저도 데이터 소스를 통해 사용할 수 있도록 드라이버 매니저 데이터 소스라는 데이터 소스 구현 클래스를 제공합니다. 이것을 사용하면 내부에서 데이터 소스 내부에서 드라이버 매니저를 써서 커넥션을 항상 생성하고 반환해주는 것입니다. 따라서 드라이버 매니저 데이터 소스를 쓰다가 커넥션풀을 사용하도록 코드를 변경해도 로직은 변경하지 않아도 됩니다.

 

- 데이터 소스 드라이버 매니저

@Test
void driverManager() throws SQLException {
    Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
    Connection connection2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);

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

기존에 매니저로 커낵션 얻는 것을 데이터 소스로 해보겠습니다. 이전에는 url과 이름 pw를 넣어서 커낵션을 획득했었습니다.

 

이렇게 하면 db에 연결하고 커낵션을 실제 얻게 되는데 객체와 타입을 찍어보면 서로 다른 커넥션이 실제 db와 연결하고 커낵션을 가져오게 됩니다.

 

@Test
void dataSourceDriverManager() throws SQLException {
    DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    useDataSource(dataSource);
}

private static void useDataSource(DataSource dataSource) throws SQLException {
    Connection connection = dataSource.getConnection();
    Connection connection1 = dataSource.getConnection();

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

이번엔 데이터 소스가 적용이된 드라이버 매니저를 사용해 보겠습니다. 얘도 내부에서 매니저를 쓰기에 항상 새로운 커낵션을 생성합니다. 드라이버는 바로 get했는데 그 전에 먼저 데이터 소스를 생성합니다. 이때는 get이 아닌 데이터 소스에  url, 이름, pw를 넣고 데이터 소스 부모 타입으로 반환합니다.

 

데이터 소스가 커넥션을 가져오는 것을 인터페이스화 한 것이라고 했는데 그러니 일단 커낵션을 가져오려면 구현체를 생성해야하고 구현체를 생생해서 데이터 소스 타입으로 선언한 후 그러면 그 구현체는 커넥션을 가져오는 방법 중 하나일 테니 데이터소스.get하면 커낵션이 반환되는 것입니다.

※ 인터페이스화 했다는 것은 일단 그 인터페이스 타입으로 생성하고 보는 것입니다. 그러면 그 생성한 객체는 그 역할을 하는 애들입니다. DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD); 도 일단 생성하고 얘의 역할은 커낵션을 가져오는 것이고 PreparedStatement pstmt = con.prepareStatement(sql); 얘도 일단 생성하고 이것의 역할은 sql을 전달하는 것입니다. 이제 그 생성한 구현체들이 각각 역할이 구현체마다 좀 다른 것입니다.

 

실제 이게 커낵션을 가져온 것인지 확인하는 메서드를 실행해보면 아까와 같은 결과가 나옵니다. 로그를 보면 새로운 커낵션을 만들었다고 나옵니다. 똑같은데 데이터 소스 인터로 커낵션을 가져온다는 차이가 있습니다.

 

더욱이 매니저는 get할 때마다 url, 이름, pw를 넣어줘야 했습니다. 근데 소스는 생성 시점에 넣어주고 사용할 때는 get에 파라미터 없이 호출하면 됩니다.

 

-> 설정과 사용의 분리
1) 설정

데이터 소스를 생성하고 필요한 속성들을 입력하는 것을 설정이라고 합니다. 설정과 관련된 속성들은 한 곳에 있는 것이 향후 변경에 더 유연하게 대처할 수 있습니다.

 

2) 사용

설정은 신경쓰지 않고 "난 데이터 소스가 뭔지 모르겠어" 근데 get만 호출해서 쓰면 됩니다. 

 

+ 추가 설명

이렇게 설정과 사용의 분리가 작아보이지만 큰 차이를 만들어 내는데 필요한 데이터를 데이터 소스가 만들어지는 시점에 미리 다 넣어두게 되면 데이터 소스를 사용하는 시점에는 그냥 dataSource.getConnection();만 호출하면 되므로 url, 이름 등 속성에 의존하지 않아도 되고 데이터 소스만 주입해서 사용하면 됩니다. 

 

> 결정적으로 레포는 데이터 소스에만 의존하고 이런 속성은 몰라도 되는 것입니다. 데이터 소스만 주입 받으면 그냥 dataSource.getConnection(); 하면 되는 것입니다. 따라서 설정은 한 곳에서 하고 사용은 수 많은 곳에서 해야합니다. 개발에서는 상수를 만들고 속성을 모아둬야 합니다.

 

- 데이터 소스 커넥션 풀

@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
    HikariDataSource dataSource = new HikariDataSource();
    dataSource.setJdbcUrl(URL);
    dataSource.setUsername(USERNAME);
    dataSource.setPassword(PASSWORD);
    dataSource.setMaximumPoolSize(10);
    dataSource.setPoolName("MyPool");

    useDataSource(dataSource);
    Thread.sleep(1000);
}

커넥션 풀링을 히카리를 써서 해볼 것입니다. 히카리 데이터 소스를 생성하고 선언합니다. 데이터 소스 타입으로 선언해도 되는데 셋팅할 게 남아서 일단 히카리 타입으로 설정할 때는 히카리 타입으로 써야합니다. 여기에도 url, pw, 이름을 생성시에 바로 넣어 설정과 사용을 분리합니다. 커낵션을 가져오는 것만 히카리를 사용하지 db는 h2이므로 규칙에 맞게 속성을 넣어줍니다.

> 풀 사이즈를 10개 넣고(기본값) 풀의 이름을 지정합니다. 그리고 이것을 아까 만든 useDatasource 메서드에 만든 데이터 소스를 넣습니다. 그러면 설정과 사용은 분리 되었기에 dataSource.get만 하면 커낵션을 획득할 수 있을 것입니다.

 

-> 설명

커넥션 풀에서 커넥션을 생성하는 작업은 tcp/ip 접속 때문에 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 작동합니다. 로그를 보면 풀에 커낵션이 생성된(Added) 것을 볼 수 있습니다.

 

로그를 확인해보면 url, 풀 크기 등을 확인할 수 있습니다. connection adder를 통해서 커낵션이 풀에 생성됩니다. 활성화 된 것이 2개되고 대기가 8개인 것이 지금 2개를 쓰고 있어서 그렇습니다.

 

커낵션 풀에서 커낵션을 획득하고 결과를 출력했는데 히카리가 만들어준 커낵션 객체입니다. 이 커낵션을 사용해서 나중에 pstmt에 적용할 것입니다.

 

만약 데이터 소스.get을 10개를 넘어가면 10개까지는 얻었는데 1개가 대기하고 있습니다. 그러면 풀이 확보될 때까지 블락이 됩니다. 30초 뒤에 커낵션이 끊겼는데 히카리에 풀에 들어갔을 때 커낵션 획득을 얼마정도 기다릴지 설정할 수 있습니다. 보통 짧게 잡는게 좋습니다.

 

- 데이터 소스 적용

@Slf4j
public class MemberRepositoryV1 {
    private final DataSource dataSource;

    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

만든 어플에 소스를 적용해 보겠습니다. 데이터 소스를 사용하려면 데이터 소스를 주입받아야합니다. 이렇게 해서 서버 로직은 데이터 소스에만 의존하고 get하게 되고 데이터 소스에 뭐가 들어오는 지도 모르게 로직을 짜게 됩니다. 어떤 방식의 데이터 소스를 사용할지는 외부에서 주입할 때 선택하게 되고 스프링은 기본으로 hicaricp 풀 방식의 데이터 소스를 넣어줍니다.

 

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

그리고 getConnection하는 메서드를 데이터 소스에서 get을 하고 이전에 드라이버에서 get해서 반환하던 것을 데이터 소스를 통해서 얻은 커낵션을 반환합니다. 

 

public void close(Connection con, Statement stmt, ResultSet rs) {
    JdbcUtils.closeResultSet(rs);
    JdbcUtils.closeStatement(stmt);
    JdbcUtils.closeConnection(con);
}

그리고 close 하는 것이 지금 너무 긴데 jdbcUtils라는게 있고 closeRs, stmt, con이 있습니다. 이것을 역순으로 닫으면 내부에 우리가 짠 코드가 다 작성되어있습니다. 이렇게 하면 끝입니다.

 

-> 테스트

@BeforeEach
void beforeEach() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    repository = new MemberRepositoryV1(dataSource);
}

beforeEach로 먼저 드라이버 매니저 데이터 소스를 주입합니다. 일단 이것을 써보고 나중에 풀을 테스트할 것입니다. 레포의 생성자가 데이터 소스를 받으니 생성자 인자로 넣으면 됩니다.

 

실행해보면 get 할때 1 2 3 4 5할 때 creating해서 계속 새로운 커낵션을 생성하고 db에 커낵션을 맺습니다. 드라이버 매니저를 썼기 때문이고 성능이 매우 느려질 것입니다.

 

- 커낵션 풀 사용

@BeforeEach
void beforeEach() {
    HikariDataSource dataSource = new HikariDataSource();
    dataSource.setJdbcUrl(URL);
    dataSource.setUsername(USERNAME);
    dataSource.setPassword(PASSWORD);
    dataSource.setMaximumPoolSize(10);
    dataSource.setPoolName("MyPool");

    repository = new MemberRepositoryV1(dataSource);
}

이제 커낵션 풀을 쓸 것입니다. 히카리를 생성하고 선언하고 set으로 속성을 설정하고 레포의 생성자로 넣습니다. 히카리를 사용할 때 설정할 때는 히카리 타입으로 하는 이유는 부모는 부모 것만 쓸 수 있는데 set하는 것들이 히카리에만 있습니다. 그러니 설정에서는 히카리 타입으로 하고 레포의 생성자에 의존관계 주입을 할 때 데이터 소스 타입으로 하면 모든 데이터 소스 구현체를 레포가 다 받을 수 있습니다.

 

-> 실행

로그를 보면 get 다음에 wrapping하고 다 0입니다. 그 이유가 save하고 find하고 커낵션을 close하면 풀은 연결을 닫는게 아니고 풀에 반환만 하는 것입니다. 그래서 계속 0번을 받고 풀에 반환하고 다시 0번을 받고 이렇게 제일 숫자가 작은 것을 받아서 그렇게 됩니다. 웹 어플에서는 계속 커낵션을 획득하려고 하니 다른 커낵션이 쓰입니다. 이렇게 풀을 써도 어플 로직은 바꾸지 않아도 됩니다. 이게 서버 로직이 데이터 소스 인터에만 의존하고 있기 때문입니다. 데이터 소스는 자바 표준으로 스프링이 hikari 풀 데이터 소스를 자동으로 빈 등록해줍니다.

Comments