1. Connection Pool이란?
데이터베이스 커넥션을 획득할 때는 다음과 같은 과정을 거친다.
- 사용자가 요청을 보낸다.
- 애플리케이션 로직은 DB Driver를 통해 커넥션을 조회한다.
- DB 드라이버는 DB와 "TCP/IP" 커넥션을 연결한다.
- DB 드라이버는 3 way handshake 이후 connection이 연결되었다면, ID, PW, 부가정보 등을 전달한다.
- DB는 받은 정보로 내부 인증을 완료하고, 세션을 생성한다.
- DB는 커넥션 생성이 완료되었다는 응답을 보낸다.
- DB 드라이버는 커넥션 객체를 생성해 클라이언트에게 반환한다.
결국 위와 같은 과정을 거쳐 커넥션이 반환된다. 하지만 이 과정은 너무 복잡하고, 시간도 많이 소요된다. 이런 문제를 해결하기 위해서 커넥션을 미리 생성하고, 사용하는 커넥션 풀이라는 방식을 사용한다. 커넥션 풀은 이름 그대로 커넥션을 관리하는 풀이다. 이를 위해서 플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해 풀에 보관한다. 얼마나 보관할지는 서비스 특징, 서버 스펙마다 다르지만 보통 10개이다. 대략적인 구조는 다음과 같다.
그러면, 이 커넥션들은 모두 DB와 TCP/IP로 커넥션이 되어있는 상태며 DB의 세션도 다 만들어져 있다. 따라서 이전과 같이 복잡한 과정없이 즉시 SQL을 DB로 전달할 수 있게 된다. 커넥션 풀을 사용하는 경우는 다음과 같은 과정을 거친다.
- 사용자가 요청한다.
- 커넥션 풀에서 커넥션을 조회한다.
- 가지고 있는 커넥션 중 하나를 반환한다.
만약 커넥션을 모두 사용했다면, 해당 커넥션을 다시 커넥션 풀에 다시 넣어주면 된다. 커넥션 풀은 오픈소스 커넥션 풀이 많아 그 중 하나를 보통 사용한다. 대표적인 커넥션 풀 오픈소스로는 "commons-dbcp2", "tomcat-jdbc pool", "HikariCP" 등이 있다. 최근에는 "HikariCP"를 주로 사용한다. 스프링 부트 2.0부터는 기본 커넥션 풀로 "HikariCP"를 제공한다.
2. DataSource란?
우리는 지금까지 JDBC로 개발할 때, DB Driver를 활용하여 신규 커넥션을 생성하고, 해당 커넥션을 반환해주는 방식을 사용했다. 그럼, 위에서 설명한 커넥션 풀을 사용하기 위해서는 어떻게 해야할까? 만약 애플리케이션 로직에서 DriverManager를 사용하고 있다가, HikariCP와 같은 커넥션 풀을 사용하려면 커넥션을 획득하는 애플리케이션 코드도 함께 변경해야 할 것이다. 왜냐하면, 의존 관계가 DriverManager가 아닌 HikariCP로 변경되기 때문이다. 이러한 문제를 보완하기 위해 DataSource가 등장한다.
- 자바는 위에서 설명한 문제를 해결하기 위해 javax.sql.DataSource 인터페이스를 제공한다.
- DataSource는 커넥션 획득 방법을 추상화한 인터페이스이다.
- DataSource의 핵심 기능은 커넥션 조회이다.
따라서 우리는 DataSource 인터페이스에 의존하도록 로직을 작성하고, 커넥션 풀 구현 기술을 사용하고 싶다면, 해당 구현체로 바꿔주기만 하면 된다. 하지만 DriverManager는 DataSource 인터페이스를 사용하지 않아 DriverManager를 직접 사용해야 한다. 이를 해결하기 위해 스프링에서는 DriverManager도 DataSource를 통해 사용할 수 있도록 DriverManagerDataSource라는 DataSource 구현 클래스를 제공한다.
3. DataSource 예제1 - Driver Manager
먼저 기존의 DriverManager로 커넥션을 가져오는 코드를 살펴보자. 아래와 같이 DriverManager.getConnection()을 통해 커넥션을 연결할 수 있다.
@Slf4j
public class ConnectionTest {
@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());
}
}
이 경우 로그를 위와 같이 찍어보면, 얻어온 con1과 con2가 서로 다른 것을 확인할 수 있다.
이번에는 DataSource에서 제공하는 DriverManagerDataSource를 사용해보자. 아래와 같이 DataSource 인스턴스를 하나 만들고 dataSource.getConnection()을 사용하면 된다.
@Test
void dataSourceDriverManager() throws SQLException {
DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
useDataSource(dataSource);
}
private void useDataSource(DataSource dataSource) throws SQLException {
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();
}
이 방식 또한 내부에서 DriverManager를 사용하기 때문에 항상 새로운 커넥션을 생성한다.
- 위에서 DriverManagerDataSource를 DataSource로 받을 수 있는 이유는 DriverManagerDataSource의 조상이 DataSource이기 때문이다.
- DriveManager는 getConnection을 할 때 항상 URL, USERNAME, PASSWORD를 전달해야 했지만 이 방식은 그렇지 않다. 즉, 설정과 사용을 분리했다. 이와 같이 설정과 사용을 분리하면 설정 부분에서 URL, USERNAME, PASSWORD에 변경이 일어나면 설정 부분만 변경하면 된다. 사용 부분 역시 설정을 신경쓰지 않고 getConnection()만 호출해 사용하면 된다. (애플리케이션 개발시 보통 설정은 한 곳에서 수행하고, 사용은 수 많은 곳에서 한다.)
4. DataSource 예제2 - Connetion Pool
이번에는 DataSource로 Connection Pool을 사용해보자. 우리는 HikariCP 커넥션 풀을 사용할 것이다.
@Test
void dataSourceConnectionPool() throws SQLException {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(10);
dataSource.setPoolName("Pool Example");
useDataSource(dataSource);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
- 커넥션 풀에 커넥션을 채우는 것은 상대적으로 오래 거리는 작업이다. 따라서 애플리케이션을 실행할 때 별도의 쓰레드를 사용해 커넥션 풀을 채워 실행 시간에 영향을 주지 않도록 한다. 이때 별도의 쓰레드에서 동작하므로 테스트가 먼저 종료되어 버릴 수 있기 때문에 위와 같이 Thread.sleep을 통해 대기 시간을 준 것이다.
- 만약 커넥션 풀에 커넥션이 생성되기 전에 getConnection 요청을 한다면 커넥션 풀에 커넥션이 채워질 때까지 기다린다. 또한 10개가 넘어가는 (최대 커넥션 개수에서 넘어가는) 요청을 보내면, 커넥션 한 개가 종료될 때까지 계속 기다린다.
5. DataSource 적용
아래는 기존에 사용한 DriverManager를 DataSource로 변경한 코드다.
@Slf4j
public class MemberRepositoryV1 {
private final DataSource dataSource;
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "INSERT INTO member_jdbc(member_id, money) VALUES (?, ?)";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, member.getMemberId());
ps.setInt(2, member.getMoney());
ps.executeUpdate();
return member;
}
catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member_jdbc where member_id = ?";
// try, catch 구문 때문에 밖에 선언한다.
Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, memberId);
// ps.executeUpdate(): insert 등 데이터를 변경할 때 사용.
// ps.executeQuery(): select로 데이터 조회시 사용.
rs = ps.executeQuery();
// rs.next()를 해줘야 실제 데이터부터 시작함.
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, ps, rs);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "UPDATE member_jdbc SET money=? WHERE member_id=?";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setInt(1, money);
ps.setString(2, memberId);
int resultSize = ps.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
public void delete(String memberId) throws SQLException {
String sql = "DELETE FROM member_jdbc WHERE member_id=?";
Connection con = null;
PreparedStatement ps = null;
try {
con = getConnection();
ps = con.prepareStatement(sql);
ps.setString(1, memberId);
ps.executeUpdate();
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, ps, null);
}
}
private void close(Connection con, Statement st, ResultSet rs) throws SQLException {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(st);
JdbcUtils.closeConnection(con);
}
public Connection getConnection() throws SQLException {
Connection connection = dataSource.getConnection();
return connection;
}
}
- 위와 같이 JdbcUtils를 사용하면 이전에 ResultSet, Statement, Connection 객체를 close할 때 사용했던 긴 코드를 간단하게 작성할 수 있다.
- getConnection부분만 변경되었고 save, update 등 기존 메서드들은 변경하지 않고 사용할 수 있다. (커넥션을 가져올 때만 DataSource를 사용하지 이후에는 그대로 Connection 객체를 사용하기 때문이다.)
'BackEnd > Spring' 카테고리의 다른 글
[Spring DB-1] 문제 해결 - 트랜잭션 (0) | 2024.05.07 |
---|---|
[Spring DB-1] 트랜잭션 이해 (0) | 2024.05.05 |
[Spring DB-1] JDBC 이해 (0) | 2023.11.15 |
[Spring MVC-2] 메시지, 국제화 (0) | 2023.11.10 |
[Spring MVC-2] 타임리프 - 스프링 통합과 폼 (0) | 2023.09.27 |