[JDBC]
1. JDBC 이해
1. JDBC 이해
JDBC 등장 이유
JDBC는 JAVA Database Connectivity로 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API이고 데이터베이스에서 자료를 쿼리하거나 업데이트하는 방법을 제공한다.
보통 애플리케이션을 개발할 때에는 중요 데이터를 대부분 데이터 베이스에 보관한다.
클라이언트가 애플리케이션 서버를 통하여 데이터를 저장하거나 조회하면 애플리케이션 서버는 데이터 베이스를 어떻게 사용하느냐
일반적인 사용 방법은 이런 식인데
- 커넥션 연결은 주로 TCP/IP를 사용하여 연결한다.
- SQL 전달은 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통하여 DB에 전달을 하고
- 결과 응답은 DB가 전달된 SQL을 수행하고 그 결과를 응답하는 것이다. 애플리케이션 서버는 응답 결과를 활용한다.
하지만 문제는 각각의 데이터베이스마다(My SQL, Oracle 등) 사용 방법이 모두 다르다.
만약 데이터베이스를 다른 종류로 변경을 하려면 서버에 개발된 데이터베이스 사용 코드도 함께 변경 해야하고,
개발자가 각각의 데이터베이스 마다 사용 방법을 새로 터득해야한다는 문제점이 있다.
이런 문제를 해결하기 위해 JDBC라는 자바 표준이 등장을 한 것이다.
JDBC는 대표적으로 3가지 기능을 표준 인터페이스로 정의해서 제공한다.
- java.sql.Connection : 연결
- java.sql.Statement : SQL 담은 내용
- java.sql.ResultSet : SQL 요청 응답
하지만 인터페이스만으로는 기능이 동작을 하지 않고 각각의 DB 벤더(회사)에서 자신의 DB에 맞도록 구현해 라이브러리로 제공을 하는데 이것을 JDBC 드라이버라고 한다. (MySQL -> MySQL JDBC 드라이버, Oracle DB -> Oracle JDBC 드라이버) 고로 다른 종류의 데이터베이스로 변경을 하고 싶으면 JDBC 구현 라이브러리만 변경을 하면 된다!
2. JDBC와 최신 데이터 접근 기술
JDBC는 오래된 기술이라 사용 방법도 복잡하다. 하지만 현재는 JDBC를 편리하게 사용할 수 있는 다양한 기술들이 있다. 대표적으로 SQL Mapper와 ORM 기술인데
SQL Mapper는 SQL 응답 결과를 객체로 편리하게 변환해주고 JDBC의 반복 코드를 제거해준다는 장점이 있지만,
개발자가 직접 SQL을 작성해야한다는 단점이 있다. 예로는 스프링 Jdbc Template와 MyBatis가 있다.
ORM 기술은 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술로 SQL을 동적을 만들어주기 때문에 반복적인 SQL을 개발자가 직접 작성하지 않아도 된다. 예로는 JPA, 하이버네이트, 이클립스링크가 있다.
3. 데이터베이스 연결
애플리케이션과 데이터베이스를 연결하려하는데 H2 데이터베이스 서버를 먼저 실행해두어야 한다.
[ConnectionConst.java]
//DB 접속에 필요한 기본 정보를 편리하게 사용하기 위해 상수로 만들었다.
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 = "1234";
}
[DBConnectionUtil.java]
@Slf4j
public class DBConnectionUtil {
//JDBC 사용 하여 실제 데이터베이스에 연결
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("get connection={}, class={}", connection, connection.getClass());
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
DB에 연결하기 위해 JDBC가 제공하는 DriverManager.getConnection() 메소드를 사용하는데 이렇게 하면 라이브러리에 있는 DB 드라이버를 찾아 해당 드라이버가 제공하는 커넥션을 반환해준다.
static으로 선언한 메소드는 객체를 별도로 생성할 필요 없이 바로 사용할 수 있기에 (new DBConnectionUtil(); 할 필요X)
getConnection()을 static 메소드로 선언
[DBConnectionUtilTest.java]
@Slf4j
public class DBConnectionUtilTest {
@Test
void connection() {
Connection connection = DBConnectionUtil.getConnection();
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 DB 드라이버가 제공하는 H2 전용 커넥션
H2 전용 커넥션도 JDBC 표준 커넥션 인터페이스인 java.sql.Connection 인터페이스를 구현하고 있다.
4. JDBC DriverManager 연결 이해
DriverManager 커넥션 요청 흐름
DriverManager : 라이브러리에 등록된 DB 드라이버 관리, 커넥션 획득하는 기능 제공
1. 애플리케이션 로직에서 커넥션이 필요하면 DriverManger.getConnection()을 호출
2. DriverManager는 라이브러리에 등록된 드라이버 목록 자동으로 인식 후 순서대로 정보를 넘겨 커넥션 획득 가능한지 확인
- URL( jdbc:h2:tcp://localhost/~/test)
- 접속에 필요한 추가 정보(이름, 비밀번호)
- 만약 URL이 jdbc:h2로 시작했는데 MySQL이 먼저 실행된다면 본인이 처리할 수 없다는 결과를 반환하게 되고 다음 드라이버에게 순서 넘어간다.
3. 커넥션 구현체가 클라이언트에 반환
5. JDBC 개발 - 등록
JDBC를 사용하여 회원(Member) 데이터를 DB에 관리하는 기능 개발하기 전에 member 테이블 미리 만들어두어야한다.
[schema.sql]
drop table member if exists cascade;
create table member (
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
[Member.java]
@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 테이블에 데이터 저장하고 조회할 때 사용한다.
[MemberRepositoryV0.java] - 회원 등록
/*
* JDBC - DriverManager 사용
* */
@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;
/*
* PreparedStatement는 SQL Injection을 방어할 수 있다. -> ?로 바인딩
* */
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());//SQL 첫번째 ?에 값을 지정, 문자이므로 setString 사용
pstmt.setInt(2, member.getMoney()); //SQL 두번째 ?에 값 지정, Int형 숫자이므로 setInt 지정
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally{
//시작과 역순으로 close
/*
pstmt.close(); //만약 Exception이 터지면 다음 코드도 호출 안 될 수 있음
con.close();
*/
close(con, pstmt, null); //리소스 정리는 반드시 필수로 해줘야함!
}
}
/*
* 해제는 rs -> pstmt -> con 으로
* */
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 static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
- 커넥션 획득 : getConnection()을 통해 이전에 만들어둔 DBConnectionUtil을 통해 DB커넥션 획득한다.
- save() - SQL 전달
- sql : DB에 전달할 SQL 정의
- con.prepareStatement(sql) : DB에 전달할 SQL과 파라미터로 전달할 데이터 준비
- pstmt.executeUpdate() : Statement를 통해 준비된 SQL 커넥션을 통해 실제 DB에 전달한다. executeUpdate()는 int를 반환하는데 영향 받은 DB row 수 반환 -> 하나의 row 등록했기에 1 반환
- 리소스 정리 : 예외가 발생하든 안하든 항상 수행되어야 하므로 finally구문에 반드시 작성해주어야한다. 놓치게 되면 커넥션 끊어지지 않고 계속 유지되는 문제 발생(리소스 누수)
[MemberRepositoryV0Test.java] - 회원 등록
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
//save
Member member = new Member("memberV0", 10000);
repository.save(member);
}
}
6. JDBC 개발 -조회
JDBC 통해 이전에 저장한 데이터를 조회하는 기능을 개발
[MemberRepositroyV0.java] - 회원 조회 추가
/*
* 조회 (회원 하나 조회)
* */
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_id 데이터를 String 타입으로 반환
member.setMoney(rs.getInt("money"));
//money 데이터를 Int 타입으로 반환
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, rs);
}
}
rs = pstmt.executeQuery()에서 데이터를 변경할 때는 executeUpdate()를 사용하지만, 데이터 조회할 때에는 executeQuery()를 사용한다. 결과를 ResultSet에 담아서 반환한다.
ResultSet은 보통 select 쿼리의 결과가 순서대로 들어간다. select member_id, money라고 한다면 member_id, money라는 이름으로 데이터가 저장된다. 또한 내부에 있는 커서를 이동해서 다음 데이터를 조회할 수 있다.
rs.next() 메소드는 커서가 다음으로 이동하는데 참고로 최초의 커서는 데이터를 가리키고 있지 않아 이 메소드를 최초 한 번 호출해야 데이터를 조회할 수 있다.
rs.next()의 결과가 true면 커서 이동 결과 데이터가 있다는 뜻이고 false면 더이상 커서가 가리키는 데이터가 없다는 뜻이다.
findById()는 회원 하나 조회하는 것이 목적이므로 결과가 항상 1건이다. 그래서 while 대신 if문을 사용한다.
[MemberRepositroyV0Test.java]-회원 조회 추가
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
//save
Member member = new Member("memberV0", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
}
}
실행 결과를 보면
MemberRepositoryV0Test - findMember=Member(memberId=memberV0, money=10000)
member 객체의 참조 값이 아닌 실제 데이터가 보이는데 이는 lombok의 @Data가 toString()을 오버라이딩하여 보여주기 때문이다.
이 Test는 2번 실행하면 PK 중복 오류가 뜨는데 delete from member쿼리로 데이터를 삭제한 다음에 다시 실행해야 한다.
7. JDBC 개발 - 수정, 삭제
[MemberRepositoryV0.java] - 회원 수정 추가
/*
* 회원 수정
* */
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.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
executedUpdate()는 쿼리 실행하고 영향 받은 row수를 반환한다. 여기서는 하나의 데이터만 변경하기 때문에 1이 반환된다.
[MemberRepositoryV0Test.java] - 회원 수정 추가
//update : money: 10000 -> 20000
repository.update(member.getMemberId(), 20000);
Member updateMember = repository.findById(member.getMemberId());
assertThat(updateMember.getMoney()).isEqualTo(20000);
회원 데이터의 money를 10000에서 20000으로 변경하고 다시 조회해서 변경 되었는지 검증한다.
[MemberRepositoryV0.java] - 회원 삭제 추가
/*
* 회원 삭제
* */
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);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
[MemberRepositoryV0Test.java] - 회원 수정 추가
//delete
repository.delete(member.getMemberId());
assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
회원 삭제 후 findById()를 통해서 조회를 한다. 회원이 없으므로 NoSuchElementException이 발생하는데 assertThatThrownBy는 오류가 발생해야 검증에 성공한다.