JDBC란?
JDBC (Java Database Connectivity)
자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API이다.
JDBC 등장 배경
서버에서 DB를 사용하기 위해서는 일반적으로 TCP/IP를 사용해서 커넥션을 연결하고, SQL 전달 후 수행 결과를 받는 과정이 있다.
많은 종류의 데이터베이스가 존재하기 때문에, 서버에서 사용하는 DB를 변경하려면 각 DB의 커넥션 연결, SQL 전달, 결과 응답 받는 방법을 학습하고 서버 코드를 일일이 수정해줘야 한다.
이를 해결하기 위해 자바 표준으로 JDBC 인터페이스를 정의했다.
그리고 각 DB를 만든 회사가 JDBC 인터페이스를 준수해서 해당 DB에 맞는 DB 드라이버(JDBC 드라이버)를 구현해 제공한다.
그래서 우리는 JDBC 인터페이스 사용법만 학습하면, JDBC 드라이버를 구현한 DB는 모두 사용할 수 있고, 중간에 DB를 수정한다고 해도 동일한 인터페이스를 사용하기 때문에 서버 코드를 수정할 필요가 없다.
JDBC 드라이버
JDBC 인터페이스를 각각의 DB 벤더(회사)에서 자신 의 DB에 맞도록 구현해서 라이브러리로 제공하는데, 이것을 JDBC 드라이버라 한다.
예를 들면, MySQL DB는 MySQL JDBC 드라이버로, Oracle DB는 Oracle JDBC 드라이버로 접근 가능하다.
SQL Mapper, ORM
JDBC 등장 후에 Mybatis와 같은SQL Mapper
의 등장으로 SQL 응답 결과를 객체로 변환해 주는 등 JDBC를 더 편리하게 사용할 수 있게 됐다.
하지만,ANSI SQL
(SQL 표준)이 존재함에도 불구하고, 각 DB마다 사용하는 데이터타입이나 SQL이 약간씩 달라서 쿼리 자체는 DB에 따라 따로 작성해야 했다.
이후, RDB 테이블과 매핑해주는 ORM(Object Relational Mapping)이 등장했다.
ORM은 SQL을 동적으로 작성하는 등 편리한 기능을 제공하는데, 나중에 자세히 살펴보겠다.
JDBC 사용
JDBC는 DB 사용을 위해 다음과 같은 표준 인터페이스를 제공한다.
java.sql.Connection
: DB 연결java.sql.Statement
: SQL 내용java.sql.ResultSet
: SQL 응답 결과
1. 커넥션 연결
1
2
3
4
5
6
7
8
9
10
11
12
public static final String URL = "jdbc:h2:tcp://localhost/~/test";
public static final String USERNAME = "sa";
public static final String PASSWORD = "";
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
JDBC에서 제공하는 DriverManager
는 DB 드라이버들을 관리하고, 커넥션을 획득한다.
커넥션을 얻기 위해서는 DriverManager
의 getConnection()
메소드를 호출해야 한다.
DriverManager의 커넥션 요청 과정은 다음과 같다.
- 커넥션이 필요할 때, DriverManager의 getConnection() 메소드를 호출한다.
- DriverManager가 라이브러리에 등록된 드라이버 목록을 인식하고, 각 드라이버들에게 순차적으로 연결하려는 DB의 URL을 넘겨 커넥션을 획득할 수 있는지 확인한다.
URL이 jdbc:h2로 시작하면 h2 드라이버가, jdbc:mysql로 시작하면 MySQL 드라이버가 커넥션 획득을 시도한다.
- 획득한
Connection
구현체를 반환한다.
2. SQL 실행
1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private String memberId;
private int age;
}
@Data
@Data
어노테이션은@Getter
,@Setter
,@RequiredArgsConstructor
,@ToString
,@EqualsAndHashCode
어노테이션을 모두 포함한다.
@Setter 남용이나 @RequiredArgsConstructor 사용 관련 문제로 @Data 사용을 지양해야 한다는 의견이 있다.
member 테이블이 있고, 테이블에 위와 같은 정보들이 저장된다고 하자.
이때, 위에 정의해 둔 getConnection() 메소드를 활용해, 새로운 회원을 insert 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, age) values (?, ?)";
Connection conn = null;
PreparedStatement pstmt = null;
try {
conn = getConnection(); // 1. 커넥션 획득
// 2. SQL과 파라미터를 설정해 실행할 쿼리를 준비한다.
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getAge());
// 3. Statement를 통해 준비된 쿼리를 DB에 전달해 실행한다.
pstmt.executeUpdate(); // 영향받은 row의 수를 int로 반환받을 수 있다.
return member;
} catch (SQLException e) {
throw e;
} finally {
close(conn, pstmt, null); // 4. 리소스 정리
}
}
SQL의 실행은 위와 같은 방식으로 진행된다.
리소스 정리 부분은 잠시 뒤 살펴보자.
PreparedStatement
PreparedStatement
는Statement
의 자식 타입인데,?
를 통한 파라미터 바인딩을 가능하게 해준다.
SQL Injection 공격을 예방하려면 PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야 한다.
3. 응답 결과 조회
이번에는 회원 데이터를 조회해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection(); // 1. 커넥션 획득
// 2. 실행할 쿼리 준비
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 3. 쿼리를 실행해 결과를 반환 받음
if (rs.next()) {
String foundId = rs.getString("member_id");
int foundAge = rs.getInt("age");
return new Member(foundId, foundAge);
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
throw e;
} finally {
close(conn, pstmt, rs); // 4. 리소스 정리
}
}
데이터를 변경할 때는 executeUpdate()
를 사용했지만, 데이터를 조회할 때는 executeQuery()
를 사용한다.
그리고, 그 결과를 ResultSet으로 반환받는다.
ResultSet은 내부에 있는 커서를 이동해 데이터를 조회할 수 있다.
ResultSet에는 next()
외에도 커서를 뒤로 옮기는 previous()
등 몇몇 커서 위치 이동 메소드가 존재한다.
next()
나 previous()
는 반환값으로 boolean 타입 데이터를 주는데, 해당 위치의 커서가 가리키는 데이터의 존재 여부를 나타낸다.
4. 리소스 정리
쿼리를 실행하고 나면 리소스를 정리해야만 한다.
만약 리소스를 잘 정리하지 않으면, 커넥션이 계속 살아있거나 데이터가 메모리에 남아있을 수 있다.
이는 서버나 DB의 메모리 등의 리소스 누수를 초래하게 되고, 결과적으로 커넥션 부족 혹은 메모리 부족으로 장애가 발생할 수 있다.
중간에 예외가 발생해도 꼭 리소스를 정리해야 하기 때문에, finally
부분에서 리소스 정리 작업을 수행해줘야 한다.
위에서 살펴본 예시 코드들에서도 finally에 close()
메소드를 호출한 것을 볼 수 있다.
아래의 코드는 close()
메소드를 구현한 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void close(Connection conn, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
리소스의 정리 순서는 선언의 역순인 ResultSet
⇨ Statement
⇨ Connection
으로 진행된다.
Connection
을 close하면 Statement
도 자동으로 반환시키는 방식과 같이 동작하는 경우도 있지만, 각 DB 드라이버의 구현 방식에 따라 다르게 동작할 수도 있기 때문에, 위 순서대로 정리해주는게 옮다.
매번 위와 같이 코드를 작성하는게 번거롭기 때문에, JDBC에서 위와 유사한 방식으로 동작하는 리소스 정리 메소드를 제공해준다.
1
2
3
4
5
private void close(Connection conn, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(conn);
}