스프링 DB-커넥션 풀
커넥션 풀
위 그림과 같이 DB와 커넥션을 생성하는 것은 상당히 복잡한 과정이 필요하다. 정확한 과정은 다음과 같다.
- 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회한다.
- DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 물론 이 과정에서 3 way handshake 같은 TCP/IP 연결을 위한 네트워크 동작이 발생한다.
- DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW와 기타 부가정보를 DB에 전달한다.
- DB는 ID, PW를 통해 내부 인증을 완료하고, 내부에 DB 세션을 생성한다.
- DB는 커넥션 생성이 완료되었다는 응답을 보낸다.
- DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다.
따라서 커넥션을 새로 생성하는 것은 리소스를 매번 소모할 뿐만 아니라, 응답 시간도 상당히 소요되기 때문에 고객에게 좋지 않은 사용경험을 줄 수 있다.
이러한 문제를 해결하기 위해 나온 것이 커넥션 풀이다.
커넥션 풀은 어플리케이션이 시작하는 시점에 커넥션을 필요한만큼 미리 확보한 후 풀에 저장하여 관리한다.
커넥션 풀에 저장된 커넥션들은 TCP/IP로 DB와 계속해서 연결되어있는 상태이기에 언제든 SQL을 DB로 전송할 수 있다.
커넥션 풀을 사용하면 이전과 같이 DriverManager를 통해 커넥션을 생성하지 않는다. 대신 커넥션 풀에서 이미 연결되어 있는 커넥션을 객체 참조로 가져다 쓰면 된다.
커넥션 풀에 커넥션을 요청하면 풀에서 가지고 있는 대기 커넥션 중 하나를 반환한다.
커넥션 풀에서 받아온 커넥션의 사용이 종료되면 연결을 종료하는 대신 커넥션 풀에 반환한다.
이러한 커넥션 풀은 다양한 이점이 있기에 실무에선 기본으로 사용하고, 직접 구현할 수도 있지만 편리하고 성능이 뛰어난 오픈소스가 여러 가지 있기에 보통 오픈소스를 사용한다.
오픈 소스엔 여러 가지가 있지만 HikariCP가 성능 및 편리함 면에서 우위에 있으므로 보통은 HikariCP를 사용한다. 스프링 부트 2.0에서도 기본 커넥션 풀로 사용한다.
DataSource
커넥션을 획득하는 데에는 다양한 방법이 존재한다. 이전과 같이 DriverManager를 사용할 수도 있고, 커넥션 풀에서 받아와도 된다.
그런데 만약 커넥션을 DriverManager로 받다가 커넥션 풀을 받는 방식으로 변경한다면 어떻게 될까?
기존에 DriverManager에 의존하던 어플리케이션의 코드를 HikariCP를 사용하도록 변경해주어야 하기 때문에 코드를 같이 수정해주어야 한다.
이러한 문제를 해결하기 위해 나온 것이 자바의 DataSource이다. 커넥션을 획득하는 방법을 추상화하였기 때문에 우리는 어플리케이션 코드를 DataSource에 의존하여 커넥션을 받아오도록 구현하고, 만약 커넥션을 받아오는 방식에 변경이 있어도 구현체만 갈아끼우면 된다.
대부분의 커넥션 풀은 DataSource 인터페이스를 이미 구현해두었으므로 그것을 사용하면 된다.
이제 이 DataSource를 사용하는 방식을 테스트 코드에서 사용해보자.
@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());
}
@Test
void dataSourceDriverManager() throws SQLException {
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());
}
기존 DriverManager를 사용하여 커넥션을 얻는 코드와, DriverManagerDataSource를 사용하여 DataSource로 커넥션을 받아오는 방식의 두 가지 코드이다.
이 코드에서 기존 방식과의 차이점을 찾아볼 수 있는데, 기존 DriverManager를 사용할 경우엔 커넥션을 획득할 때마다 URL, USERNAME 등의 파라미터가 전달되어야 했다.
하지만 DataSource를 사용하면 처음 DataSource 객체를 생성할 때에만 파라미터가 들어가고, 커넥션을 획득할 때에는 dataSource.getConnection() 메서드만 호출하면 된다.
이를 설정과 사용의 분리라고 하는데, 이렇게 분리가 이루어지면 DataSource를 사용하는 곳에서는 URL, USERNAME 등의 파라미터에 의존하지 않아도 된다. 쉽게 말해 Repository가 파라미터를 제외한 DataSource에만 의존하게 된다.
이번에는 DriverManager 대신 위에 설명한 HikariCP를 사용한 DataSource로 커넥션을 받아와보자.
@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);
}
위와 같이 각각의 메서드로 데이터소스의 파라미터들을 설정해주고 사용하면 된다.
어플리케이션의 실행 속도에 영향을 주지않기 위해 커넥션을 받아오는 과정은 별도의 쓰레드에서 진행되기 때문에, sleep(1000)으로 딜레이를 주지 않으면 테스트 과정이 먼저 끝나버린다.
별도의 설정을 통해 HikariCP의 로그를 확인하면 풀 이름, 풀 크기, 사용중인 커넥션과 대기중인 커넥션 수 등을 로그에서 확인할 수 있다.
잘 동작하는 것을 확인했으니 이제 기존 예제를 DataSource를 사용하는 방식으로 리팩토링 해보자.
final DataSource dataSource;
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
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("getConnection={}, class={}",con,con.getClass());
return con;
}
이젠 직접 만든 DBConnectionUtil을 사용하는 대신 외부에서 DataSource를 주입받아 사용한다. 또한 close도 직접 구현한 로직 대신 스프링에서 JDBC를 편하게 다룰 수 있도록 제공하는 JdbcUtils를 사용하여 연결을 닫는다.
이제 위 코드가 제대로 작동하는지 테스트해보자.
@Slf4j
class MemberRepositoryV1Test {
MemberRepositoryV1 repository;
@BeforeEach
void beforeEach() {
//DriverManagerDataSource dataSource=new DriverManagerDataSource(URL,USERNAME,PASSWORD);
HikariDataSource dataSource=new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
repository=new MemberRepositoryV1(dataSource);
}
@Test
void crud() throws SQLException {
Member member = new Member("memberV6", 10000);
repository.save(member);
Member byId = repository.findById(member.getMemberId());
log.info("find member={}",byId);
log.info("member!=findmember {}",member==byId);
//equals는 true
assertThat(byId).isEqualTo(member);
repository.update(member.getMemberId(),20000);
Member updated = repository.findById(member.getMemberId());
assertThat(updated.getMoney()).isEqualTo(20000);
repository.delete(member.getMemberId());
assertThatThrownBy(()->repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
}
}
beforeEach() 메서드로 각 테스트 전에 새로 리포지토리를 생성하도록 하였고, 리포지토리의 각 기능들을 테스트하였다. DriverManagerDataSource를 사용하면 각 커넥션들이 항상 새로운 커넥션을 생성하여 받아오는 것을 확인할 수 있고, HikariDataSource를 사용하면 커넥션 풀에서 커넥션 0번을 재사용하는 것을 로그로 확인할 수 있다.
이제 리포지토리는 DataSource 인터페이스에만 의존하기 때문에 구현체는 원하는대로 갈아치울 수가 있다. 이것이 DataSource를 사용하는 장점 중 하나이다.(DI + OCP)
정리
커넥션 풀이 무엇인지, 커넥션 풀과 데이터 소스의 작동 원리 및 사용 이유와 어떻게 사용하는 지에 대해 알 수 있었다. 다음 포스팅에서는 스프링에서의 트랜잭션에 대해 알아보도록 하겠다.