서버에서 DB를 이용하기 위해 위와 같은 세 가지 절차를 거쳐야한다. TCP/IP를 이용하여 DB와의 커넥션을 획득한 후 커넥션을 통해 SQL 명령을 전달하고 DB는 전달된 SQL을 수행하여 서버에 결과를 응답한다.
문제는 이 세 과정이 DB마다 구현 방법이 다르다는 것이다.
DB가 변경된다면 서버 어플리케이션에서 개발된 데이터베이스를 사용하는 코드도 함께 변경해야하며 개발자가 각각의 DB마다 1(커넥션 연결),2(SQL전달),3(결과응답)단계에 대한 부분을 새로 학습하여 코드를 수정해야한다.
이러한 문제를 해결하기위해 JDBC라는 자바 표준이 등장한다.
JDBC(java database connectivity)는 자바로 하여금 DB에 접속할 수 있도록 하는 자바 API이다. 이 표준 인터페이스는 하위 인터페이스를 제공하는데, Connection(연결), Statement(SQL을 담은 내용), ResultSet(SQL 요청에 대한 응답결과) 3가지 기능을 표준 인터페이스로 제공한다.
각 DB벤더들은 자신들의 DB에 맞추어 이 인터페이스들을 구현해서 라이브러리로 제공한다.
이 구현체를 JDBC Driver라고 한다.
JDBC의 등장으로 많은 것이 편리해졌다.
DB와의 연결, DB에 SQL쿼리를 날리는 것, SQL쿼리의 결과를 받아오는 것과 같은 일들이 JDBC 인터페이스로 하여금 추상화되어 손쉽게 처리할 수 있게되었다.
하지만 여전히 DB마다 SQL문이 달라질 수 있는 등의 문제가 존재한다.
(JPA와 같은 ORM기술들은 이에 대한 훌륭한 해결책이 될 수 있다.)
DriverManager는 Jdbc가 제공하는 Resolver이며 라이브러리에 있는 여러 DB구현체 중에 적절한 구현체를 찾아 적용해준다.
간단한 실습을 목적으로 H2 데이터베이스를 사용하도록 해보겠다.
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
커넥션을 얻기 위해 DriverManager로 DB커넥션을 위의 코드처럼 가져올 수 있다.
DriverManager는 URL로 하여금 구현체(어떤 DB인지 판단한다)를 찾는다.
아래의 테스트 코드에서 반환된 Connection 구현체가 h2에서 구현한 드라이버로부터 온 것을 확인할 수 있다.
// test code
@Test
void connection() throws SQLException {
// DBConnectionUtil에 DriverManager.getConnection(URL, USERNAME, PASSWORD); 작성
Connection connection = DBConnectionUtil.getConnection();
assertThat(connection).isNotNull();
}
// 결과
[Test worker] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
Jdbc의 구현체로 각 DB벤더의 드라이버가 존재한다.
위의 H2 DB를 사용한 예제에서, 우리의 Driver에는 H2Connection, H2Statement, H2ResultSet가 존재한다.
이는 위에서 언급한 것에 따라, JDBC의 Connection, Statement, ResultSet 인터페이스의 구현체이다.
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();
// prepareStatement -> Statement와 달리 동적으로 쿼리문을 구성할 수 있음.
pstmt = con.prepareStatement(sql);
// "?"에 동적으로 sql문 요소 배치
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
// 쿼리문 실제 DB에 적용
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
PreparedStatement는 Statement의 자식 타입이다.
미리 생성한 String sql에 설정된 ? 부분은 동적으로 값을 배치할 목적을 가진다.
PreparedStatement는 ?를 통해 파라미터 바인딩을 가능하게 해준다.
또한 SQL Injection 공격을 예방하기 위해서도 PreparedStatement를 통한 바인딩 방식을 사용해야한다.
SQL Injection
만약 ?를 통한 파라미터 바인딩이 아니라 단순히 String데이터를 동적으로 조합하여 SQL문을 만들 수도 있을 것이다. String 데이터로 sql을 전달할 경우 SQL Injection 공격에 취약해진다. 예를 들어 웹에서 사용자 입력 폼에 sql문을 넣는다고 생각해보자. 만약 서버에서 이 입력 데이터를 그대로 String 데이터에 더해서 사용할 경우
SELECT * FROM users WHERE username = 'user' AND password = 'pass';와 같이 조합되어 DB로 전달될 수 있다. 이 user 부분에"admin' -- "로 전달할 경우, 여기서 --는 SQL에서 주석을 의미하므로, 비밀번호 검증 부분이 무시될 수 있다. 결과적으로 username이 admin인 사용자로 로그인할 수 있게 될 수 있다.ResultSet
위의 코드에서 나타나지 않은 ResultSet을 사용해보도록 하자.
public Member save(Member member) throws SQLException { String sql = "insert into member(member_id, money) values (?, ?)"; Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; // 자동 생성 키를 받아오기 위한 ResultSet try { con = getConnection(); // 자동 생성 키를 반환받기 위한 옵션 설정 pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); int affectedRows = pstmt.executeUpdate(); if (affectedRows == 0) { throw new SQLException("회원 등록 실패: 영향을 받은 행이 없습니다."); } // 자동 생성된 키 값을 ResultSet을 통해 받아옴 rs = pstmt.getGeneratedKeys(); if (rs.next()) { // 첫 번째 컬럼에 생성된 ID가 있다고 가정하고 할당 member.setId(rs.getLong(1)); } else { throw new SQLException("회원 등록 실패: ID를 받아올 수 없습니다."); } return member; } catch (SQLException e) { log.error("db error", e); throw e; } finally { // 자원을 생성된 순서의 역순으로 닫기: ResultSet → PreparedStatement → Connection close(con, pstmt, rs); } }리소스 정리
쿼리를 완료하고 나면 반드시 리소스를 정리해야한다.
정리는 역순으로 진행해야한다. 여기서는 ResultSet, Connection , PreparedStatement순으로 사용했기에 이에 대해 역순으로 종료시켜야 한다.
private void close(Connection con, PreparedStatement pstmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException ex) {
log.error("ResultSet 닫기 실패", ex);
}
}
if (pstmt != null) {
try {
pstmt.close();
} catch (SQLException ex) {
log.error("PreparedStatement 닫기 실패", ex);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException ex) {
log.error("Connection 닫기 실패", ex);
}
}
}
리소스 정리는 예외가 발생하든 하지 않든 항상 수행되어야 하므로 주의해서 finally 구문에 작성해야한다. 만약 리소스 정리가 이루어지지 않을 경우 리소스 누수가 발생할 수 있다.
위의 save()가 호출될 때마다(update, delete등 다른 기능들 또한 동일하다) connection 객체를 만들어내고 있다.
TCP/IP 통신을 사용하게 되기 때문에 일반적인 자바 코드보다 커넥션을 만드는 행위는 비용이 발생한다.
만약 DB와 관련된 모든 행위마다 커넥션 연결을 진행해야한다면 이는 큰 비용이 될 것이다.
서버는 이러한 문제를 해결하기위해 커넥션 객체 여러개를 미리 생성해놓고 관리하는 ConnectionPool 방식을 도입할 수 있다.
커넥션 풀이라는 공간을 만들어 미리 커넥션을 여러개 생성해놓고 필요할 때 가져다 쓰고 다 쓰면 되돌려놓는 방식이다.
이 커넥션 풀에 존재하는 커넥션들은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태로 계속 유지되기 때문에 서버는 DB와의 상호작용을 위해 커넥션을 생성하는 과정을 기다릴 필요가 없다.
커넥션 유지를 위한 자원을 조금 내어주고 속도적인 이점을 가져오는 것이다.
(커넥션을 유지하려면 해당 커넥션을 담고 있는 객체(메모리, 네트워크 소켓, 기타 시스템 자원)가 상시적으로 유지되어야 하기 때문이다.)
적절한 커넥션 풀 숫자는 서비스의 특징과 서버, DB 스펙에 따라 다르기 때문에 성능테스트가 필요한 사항이며 커넥션이 무한정 생성되는 것을 막아 DB를 보호하는 효과도 있다.
이러한 커넥션 풀의 구현 방법은 간단해보이지만 여러 편리한 기능을 제공하는 오픈소스를 사용한다.
많은 구현 오픈소스가 존재했지만 현재에는 HikariCP로 사실상 통일되었다.
커넥션을 획득하는 방법 또한 자바는 이를 추상화하여 제공한다.
DataSource는 커넥션을 획득하기 위한 방법에 대한 인터페이스이다.

또한 기존의 DriverManager를 직접 이용하는 것(적절한 DB벤더 구현체를 찾고 커넥션을 얻는 등의 작업) 또한 DataSource를 이용하는 방법으로 바꿀 수 있다.
커넥션 풀 방식을 적용하는 것 또한 가능하다.
개발자는 내부의 커넥션 획득 로직은 몰라도 되며, DataSource를 통해 커넥션 풀에서 커넥션을 획득할 수 있다.
DriverManager만을 사용하면 커넥션을 얻기 위해 커넥션을 새로 생성하고 커넥션을 종료하여 생성한 커넥션을 없앴다면 DataSource로 하여금 커넥션 풀을 구성하고 커넥션을 미리 여러개 생성해놓고 커넥션을 생성하지 않고 풀에서 빌림으로써 커넥션 비용 문제를 해결할 수 있다.
테스트 코드를 구성해서 DataSource를 이용해보자.
// TEST CODE
@Slf4j
class MemberRepositoryV1Test {
MemberRepositoryV1 repository;
@BeforeEach
void beforeEach() {
// 기본 DriverManager
// DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// 커넥션 풀링
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setPassword(PASSWORD);
dataSource.setUsername(USERNAME);
repository = new MemberRepositoryV1(dataSource);
}
@Test
void crud() throws SQLException {
Member member = new Member("memberV2", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
//update
repository.update("memberV2", 5000);
Member memberV2 = repository.findById("memberV2");
assertThat(memberV2.getMoney()).isEqualTo(5000);
//remove
repository.remove("memberV2");
assertThatThrownBy(() -> repository.findById("memberV2"))
.isInstanceOf(NoSuchElementException.class);
}
}
// repository에서의 getConnection 로직
private Connection getConnection() throws SQLException {
Connection con = dataSource.getConnection();
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
repository는 생성 시점에 dataSource를 생성자로 받게 된다.
이 생성자에 dataSource를 넘겨줄 때 원하는 구현체(커넥션 풀 방식, driverManager방식...)만 넘겨준다.
repository기능을 사용할 때 connection을 얻는 방식을 결정하게 된다.
DataSource는 설정과 사용을 분리하는 효과도 가지는데, DriverManager.getConnection(URL, USERNAME, PASSWORD)에서는 커넥션을 적용하는 시점에 URL, USERNAME, PASSWORD를 전달하기 때문에 연결마다 이를 전달해주어야 한다.
하지만 DataSource는 생성 시점에 URL, USERNAME, PASSWORD가 구축되기 때문에 커넥션을 얻을 사용자는 DB의 정보를 모른채 getConnection()만으로 커넥션을 얻을 수 있게 된다.
실제로는 DataSource또한 커넥션을 생성해서 풀에 넣어놓는 작업에서 DriverManager를 사용하게 된다.