[ Spring ] 데이터베이스 접근 기술 JDBC - 근본은 변하지 않는다
도입부 - 왜 JDBC를 알아야하지?
프로그래밍 공부를 하면서 느끼는 것은 외부적으로 기술 사용 방법도 중요하지만, 내부 동작원리를 이해하는 것이 중요함을 느낀다. 작년 부트캠프에서 스프링부트로 웹 애플리케이션을 뚝딱(뭐 뚝딱은 아니지만,,) 만들면서 발생한 여러가지 문제들을 이해하려고 했으나, 그 당시 java와 spring에 대한 지식 기반이 얕아서 발생한 문제들을 이해하지 못했다. java를 공부하고 spring 내부의 동작 방식을 하나씩 공부한 후에야 이전에 이해하지 못했던 문제들의 원인을 이해할 수 있었다. 그냥 기술을 가져다가 쓰는 것보다는 내부적으로 어떻게 동작하는지 이해할 때, 그 기술을 이해하는 정도와 숙련도가 깊어진다. 이는 곧 문제 발생시 해결할 수 있는 능력이 높아진다.
데이터베이스를 쉽게 접근할 수 있도록 도와주는 SQL Mapper 와 ORM 기술이 있다. 대표적으로 MyBatis,JPA등이 있다. 이런 기술들 내부에서는 모두 JDBC를 사용한다. 따라서 JDBC를 직접 사용하지는 않더라도 JDBC가 어떻게 동작하는지 기본 원리를 알아두어야 한다. 그래야 문제가 발생했을 때 근본적인 문제를 찾아서 해결할 수 있기 때문이다.
JDBC 동작 원리
애플리케이션이 JDBC를 통해 데이터베이스와 연결되는 과정은 다음과 같다.
1. 각 데이터베이스의 드라이버들이 존재한다. 드라이버들은 데이터베이스 서버와 커넥션은 맺는다.
- 드라이버들은 JDBC 표준 인터페이스에 맞춰서 구동이 된다.
- 애플리케이션은 JDBC 표준 인터페이스에 맞게 코드를 작성하면 된다.
데이터베이스와 직접 연결시킨 코드를 봐보자. 필자는 h2데이터 베이스를 이용하는 것이다.
데이터베이스 연결을 위해 필요한 데이터들
먼저 데이터베이스를 연결하기 위해서는 공통적으로 URL,USERNAME,PASSWORD가 필요하다. 이 3가지를 상수 값으로 정의해 둔 ConnectionConst를 만든다.
// 추상 클래스로 만드는 이유는 직접 인스턴스화가 불가능하게 만들기 위함
// 이는 곧 ConnectionConst는 상수값을 저장하기 위한 용도임을 나타냄
public abstract class ConnectionConst {
public static final String URL = "jdbc:h2:tcp://localhost/~/test";
public static final String USERNAME ="sa";
public static final String PASSWORD = "";
}
데이터베이스에 연결!
@Slf4j
public class DBConnectionUtil {
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL,USERNAME,PASSWORD);
log.info("get connection={}, class={}",connection,connection.getClass());
return connection;
} catch (Exception e) {
throw new IllegalStateException();
}
}
}
어떻게 위의 코드가 데이터베이스와 연결이 되는걸까? JDBC가 제공하는 DriverManager 덕분이다. DriverManager.getConnection(URL,USERNAME,PASSWORD) 작성하면, 데이터베이스 드라이버를 찾는다. 찾은 H2데이터베이스 드라이버가 작동해서 실제 데이터베이스와 커넥션을 맺고 커넥션을 반환한다.
이제 연결 테스트를 해보자.
@Slf4j
public class DBConnectionUtilTest {
@Test
public void connection(){
Connection connection = DBConnectionUtil.getConnection();
Assertions.assertThat(connection).isNotNull();
}
}
=== 실행 결과 ===
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
실행 결과를 보면 class=class org.h2.jdbc.JdbcConnection 부분을 확인할 수 있다. 이것이 바로 H2 데이터베이스 드라이버가 제공하는 H2 전용 커넥션이다.
JDBC는 java.sql.Connection 표준 커넥션 인터페이스를 정의한다. H2 데이터베이스 드라이버는 JDBC Connection 인터페이스를 구현한 org.h2.jdbc.JdbcConnection 구현체를 제공한다. 이 구현체가 커넥션이다.
DriverManager 커넥션 요청 과정을 자세하게!
JDBC가 제공하는 DriverManager는 라이브러리에 등록된 DB 드라이버들을 관리하고, 커넥션을 획득하는 기능을 제공한다.
- 애플리케이션 로직에서 DriverManager.getConnection()을 호출한다.
- DriverManager는 라이브러리에 등록된 드라이버 목록을 자동으로 인식한다. 목록에 있는 드라이버들에게 순서대로 다음 정보를 넘겨서 커넥션을 획득할 수 있는지 확인한다.
- URL,이름,비밀번호 등 접속에 필요한 추가 정보
- 각각의 드라이버는 URL 정보를 체크해서 본인이 처리할 수 있는 요청인지 확인한다. jdbc:h2로 시작하면 이것은 h2 데이터베이스에 접근하기 위한 규칙이다. 따라서 H2 드라이버는 본인이 처리할 수 있으므로 실제 데이터베이스에 연결해서 커넥션을 획득하고 이 커넥션을 클라이언트에 반환한다. 반면 URL이 jdbc:h2로 시작했는데 MySQL 드라이버가 먼저 실행되면 이 경우 본인이 처리할 수 없다는 결과를 반환하게 되고, 다음 드라이버에게 순서가 넘어간다.
- 획득한 커넥션 구현체가 클라이언트에게 반환된다.
자 그럼 이제 획득한 커넥션 구현체를 가지고 뭘 할 수 있을까? 획득한 커넥션에 SQL을 입력하여 실제 데이터베이스에 전달하도록 할 수 있다.
JDBC를 이용한 간단한 개발
멤버아이디와 돈을 관리하는 간단한 기능을 개발할 것이다.
@Data
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
}
Member 저장
드디어 JDBC를 활용해서 클라이언트로부터 받은 데이터를 데이터베이스에 저장하는 코드를 만들것이다. 바로 코드로 봐보자.
@Slf4j
public class MemberRepositoryV0 {
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();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con,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(con != null){
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
private Connection getConnection(){
return DBConnectionUtil.getConnection();
}
}
설명하기 전 PreparedStatement 객체를 알아야한다. PreparedStatement 객체는 간단히 말해서 커넥션에 sql문도 전달해주고 slq문을 전달 받은 커넥션이 데이터베이스 sql문을 전달하도록 해주는 역할을 한다.
- save 요청을 받으면 DBConnectionUtil.getConnection() 으로 데이터베이스와 연결된 커넥션을 가져온다.
- PreparedStatement 객체인 pstmt에 커넥션을 통해 sql문 전달
- slq문에 필요한 데이터들을 pstmt.set으로 넣어준다.
- pstmt.executeUpdate() 로 커넥션이 가진 sql문을 데이터베이스에 전달하게 한다.
- close(con, pstmt, null); 으로 리소르를 반드시 정리해야 한다. 왜냐하면 커넥션이 끊어지지 않고 계속 유지될 경우 커넥션 부족으로 장애가 발생할 수 있기 때문이다. 리소스 반환 순서는 PreparedStatement를 먼저 종료하고, 그 다음에 Connection을 종료하면 된다.
테스트 코드에서 확인해보자. H2 데이터베이스가 연결이 되어있어야 한다.
@Slf4j
public class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException{
// save
Member member = new Member("test", 10000);
repository.save(member);
}
}
=== 실행 결과 ===
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[0x7FFF5CB57FA4] ANOMALY: use of REX.w is meaningless (default operand size is 64)
H2데이터베이스에 저장 되었으며, 로그에도 에러가 없다. 실행 로그에서 get connection=conn0 이 부분을 보면 conn0라는 커넥션이 데이터베이스와 연결 되어 있음을 알 수 있다.
Member 조회
저장한 멤버를 조회하는 JDBC 코드를 짜보자.
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
// 데이터베이스서 쿼리 실행한 결과를 받을 객체
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
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, pstmt, null);
}
}
조회의 경우 데이터를 등록,수정,삭제가 아니기 때문에 pstmt.executeQuery()를 하면된다.데이터베이스에서 조회 후의 결과를 받을 객체가 필요한데, 그 역할을 하는 것이 바로 ResultSet 객체이다. pstmt.executeQuery() 실행 후 받은 데이터를 ResultSet 객첵가 받는다.
=== 테스트 시 h2데이터베이스에서 delete from member 실행하고 해야함 ===
@Slf4j
public class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
// save
Member member = new Member("test", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
Assertions.assertThat(findMember).isEqualTo(member);
}
}
=== 실행 결과 ===
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[main] INFO hello.jdbc.repository.MemberRepositoryV0Test -- findMember=Member(memberId=test, money=10000)
테스트는 에러 없이 실행 되었다. 이 때 살펴볼 것은 커넥션 이름이다. repository에 save요청 때 생긴 커넥션은 conn0 이고 findById 요청을 생긴 커넥션은 conn1 이다. 다른 커넥션을 쓰고 있음을 알 수 있다. 이것은 추후 다룰 커넥션 풀에 대한 내용과 이어지기 때문에 미리 언지를 둔 것이다.
Member 수정,삭제
수정과 삭제 로직은 거의 비슷하다
public void update(String memberId,int money) throws SQLException{
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}",resultSize);
} catch (SQLException e) {
log.info("error",e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void delete(String memberId) throws SQLException{
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
} catch (SQLException e) {
log.info("error",e);
throw e;
} finally {
close(con, pstmt, null);
}
}
pstmt.executeUpdate(); 시 업데이트 된 갯수를 반환한다.
테스트 코드를 봐보자
@Slf4j
public class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
public void crud() throws SQLException {
// save
Member member = new Member("test", 10000);
repository.save(member);
// findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
Assertions.assertThat(findMember).isEqualTo(member);
// update : money 1000->2000
repository.update(member.getMemberId(), 20000);
Member updatedMember = repository.findById(member.getMemberId());
Assertions.assertThat(updatedMember.getMoney()).isEqualTo(20000);
// delete
repository.delete(member.getMemberId());
Assertions.assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
}
}
=== 실행 결과 ===
[main] INFO hello.jdbc.repository.MemberRepositoryV0Test -- findMember=Member(memberId=test, money=10000)
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn2: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[main] INFO hello.jdbc.repository.MemberRepositoryV0 -- resultSize=1
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn3: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn4: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
[main] INFO hello.jdbc.connection.DBConnectionUtil -- get connection=conn5: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class org.h2.jdbc.JdbcConnection
정리
JDBC로 기본적인 CURD 코드를 만들어 보았다. 서버와 데이터베이스가 어떻게 연결이 되고 SQL문을 어떻게 전송되는지 JDBC 동작 원리를 공부함으로써 연동 방식을 이해하게 되었다.
참고
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/