[DB] JDBC 실습 (구현 및 테스트)
아래 포스팅에서 만든 H2 DataBase를 사용해서 실습을 진행할 예정입니다.
https://silver-programmer.tistory.com/entry/JDBC%EB%9E%80
테이블 생성 및 DTO 정의
schema.sql
아래처럼 task라는 테이블을 생성합니다. task 테이블의 필드는 task_id, category, priority로 구성했습니다.
DROP TABLE IF EXISTS task CASCADE;
CREATE TABLE task (
task_id VARCHAR(10),
category ENUM('STUDY', 'SELF_DEVELOPMENT', 'LEISURE') NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (task_id)
);
Category.class
category는 Enum 클래스로 구현하였습니다.
package hello.jdbc.domain;
public enum Category {
STUDY,
SELF_DEVELOPMENT,
LEISURE;
public String getValue(){
return this.name();
}
public static Category getCategory(String value){
try{
return Category.valueOf(value);
}catch(IllegalArgumentException e){
return null;
}
}
}
Task.class
Task 객체 클래스 입니다. Task를 생성할 때 category 값이 필요합니다. 위의 Category 클래스에서 category를 Enum으로 정의했는데, 인자로 들어온 category 값이 Category 클래스 안에 있는 유효한 값인지 확인하는 코드를 추가하였습니다.
또한 category를 getter 로 반환할 때, Category 클래스에 정의되어 있는 이름으로 반환하도록 재정의하였습니다.
package hello.jdbc.domain;
import hello.jdbc.common.Category;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Task {
String taskId;
Category category;
int priority;
// Task 생성 (팩토리 메서드)
public static Task create(String taskId, String categoryValue, int priority) {
Category category = Category.getCategory(categoryValue);
if (category == null) {
throw new IllegalArgumentException("Invalid Category");
}
return new Task(taskId, category, priority);
}
// getter 재정의 (Enum 이름이 반환되도록)
public String getCategory() {
return category.getValue();
}
}
Task 등록
먼저 JDBC를 사용해서 위에서 만든 Task 객체를 DataBase에 저장하는 코드를 구현해 보겠습니다.
TaskRepository.class
package hello.jdbc.repository;
import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Task;
import lombok.extern.slf4j.Slf4j;
import java.sql.*;
@Slf4j
public class TaskRepository {
public Task save(Task task) {
// DB에 전달할 SQL 정의
String sql = "insert into task(task_id, category, priority) values(?, ?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection(); // DBConnectionUtil 통해 DB 커넥션 획득
// DB에 전달할 SQL과 파라미터((?,?,?) 부분)로 전달할 데이터 준비
pstmt = con.prepareStatement(sql);
pstmt.setString(1, task.getTaskId()); // 첫번째 ? 값에 지정 (String 형)
pstmt.setString(2, task.getCategory()); // 두번째 ? 값에 지정 (String 형)
pstmt.setInt(3, task.getPriority()); // 세번째 ? 값에 지정 (int 형)
pstmt.executeUpdate(); // 실제 DB에 전달
// executeUpdate() 은 int 를 반환하는데 영향받은 DB row 수를 반환한다 (여기서는 1)
return task;
} catch (SQLException e) {
log.error("db error", e);
throw new RuntimeException(e);
} finally {
// 리소스 정리!! 꼭!! (메모리 누수로 이어질 수 있음)
close(con, pstmt, null);
}
}
public Connection getConnection() {
return DBConnectionUtil.getConnection();
}
public 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);
}
}
}
}
- con.prepareStatement(sql) : 데이터베이스에 전달할 SQL과 파라미터로 전달할 데이터들을 준비합니다.
- pstmt.executeUpdate() : Statement 를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달합니다. 참고로 executeUpdate() 은 int를 반환하는데 영향받은 DB row 수를 반환합니다
쿼리를 실행하고 나면 리소스를 꼭 정리를 해야 하는데, 리소스 정리는 항상 역순으로 해야 합니다.
리소스 생성 순서: Connection → PreparedStatement
리소스 정리 순서: PreparedStatement → Connection
TaskRepositoryTest.class
데이터를 저장하는 위 코드를 테스트 해 봅시다.
package hello.jdbc.repository;
import hello.jdbc.domain.Category;
import hello.jdbc.domain.Task;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
class TaskRepositoryTest {
TaskRepository taskRepository;
@Test
void crudTest() {
taskRepository = new TaskRepository();
Task task = new Task("1", Category.STUDY, 1);
taskRepository.save(task);
}
}
위 테스트 코드 작성 후, 데이터베이스에서 select * from task 쿼리를 실행하면 데이터가 저장된 것을 확인할 수 있습니다.
참고로! 이 테스트를 2번 실행하면 PK 중복 오류가 발생합니다. 저장된 데이터를 삭제 후에 다시 실행해야 test 가 성공합니다.
Task 조회
이제 저장된 Task를 조회하는 코드를 작성해봅니다.
public Task findById(String taskId) {
String sql = "select * from task where task_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, taskId);
rs = pstmt.executeQuery(); // 조회할 때는 executeQuery!! -> ResultSet에 결과 반환
if (rs.next()) {
return Task.create(rs.getString("task_id"), rs.getString("category"), rs.getInt("priority"));
} else throw new NoSuchElementException("task not found task_id = " + taskId);
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
close(con, pstmt, rs);
}
}
- 데이터를 조회할 때는 저장할 때와 다르게 executeQuery() 를 사용하는 것을 확인할 수 있습니다. 이 메서드로 ResultSet에 조회된 결과를 반환합니다.
ResultSet
ResultSet에는 select 쿼리의 결과가 순서대로 저장됩니다.
예를 들어서 select col1, col2라고 지정하면 col1, col2라는 이름으로 데이터가 저장됩니다. 참고로 select * 을 사용하면 테이블의 모든 컬럼을 다 저장합니다.
ResultSet 내부에는 커서(cursor)가 있어서 이를 이용해 다음 데이터를 조회할 수 있습니다.
코드에 있는 rs.next() 를 호출하여 다음으로 이동합니다. 참고로 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next()를 최초 한 번은 호출해야 데이터를 조회할 수 있습니다.
rs.next()의 결과가 true 면 커서의 이동 결과 데이터가 있다는 뜻이고 false 이면 더 이상 커서가 가리키는 데이터가 없다는 뜻입니다.
rs.getString("task_id") : 현재 커서가 가리키고 있는 위치의 task_id 데이터를 String 타입으로 반환합니다. rs.getInt("priority") : 현재 커서가 가리키고 있는 위치의 priority 데이터를 int 타입으로 반환한다.
참고로 위 코드의 findById()는 task 하나를 조회하는 것이 목적이므로, while(rs.next()) 대신 if 문을 사용하였습니다.
테스트 코드 추가
TaskRepositoryTest.class 파일의 crud() 메서드에 조회 로직 테스트 코드를 추가해 봅시다.
@Test
void crudTest() {
taskRepository = new TaskRepository();
Task task = new Task("1", Category.STUDY, 1);
taskRepository.save(task);
Task findTask = taskRepository.findById("1");
log.info("findTask={}", findTask); // @Data 가 toString() 을 적절히 오버라이딩 해서 보여준다
assertThat(findTask).isEqualTo(task);
}
실행결과는 아래와 같습니다.
findTask=Task(taskId=1, category=STUDY, priority=1)
- 실행 결과 저장된 task 객체를 보여주는 것을 확인할 수 있습니다.
- 참고로 실행 결과에 task 객체의 참조 값이 아니라 실제 데이터가 보이는 이유는 lombok의 @Data 가 toString()을 적절히 오버라이딩 해서 보여주기 때문입니다.
- isEqualTo() : findTask.equals(task)를 비교합니다. 결과가 참인 이유는 lombok의 @Data는 해당 객체의 모든 필드를 사용하도록 equals()를 오버라이딩 하기 때문입니다.
Task 수정 및 삭제
수정과 삭제는 등록과 비슷합니다. 등록, 수정, 삭제처럼 데이터를 변경하는 쿼리는 executeUpdate() 를 사용하면 됩니다.
// 수정
public void updatePriority(String taskId, int priority) {
String sql = "update task set priority = ? where task_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, priority);
pstmt.setString(2, taskId);
int resultSize = pstmt.executeUpdate();
// executeUpdate() 는 쿼리 실행하고 영향받은 row 수 반환
// 하나의 데이터만 변경하므로 1 반환
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw new RuntimeException(e);
} finally {
close(con, pstmt, null);
}
}
// 삭제
public void delete(String taskId) {
String sql = "delete from task where task_Id = ?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, taskId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw new RuntimeException(e);
} finally {
close(con, pstmt, null);
}
}
- executeUpdate() 는 쿼리를 실행하고 영향받은 row 수를 반환하는데, 여기서는 하나의 데이터만 변경하기 때문에 결과로 1이 반환됩니다. 만약 task가 10개이고, 모든 task의 데이터를 한 번에 수정하는 update sql을 실행하면 결과는 10이 됩니다.
테스트 작성
@Test
void crudTest() {
// save
taskRepository = new TaskRepository();
Task task = new Task("1", Category.STUDY, 1);
taskRepository.save(task);
// findById
Task findTask = taskRepository.findById("1");
log.info("findTask={}", findTask); // @Data 가 toString() 을 적절히 오버라이딩 해서 보여준다
assertThat(findTask).isEqualTo(task);
// update
taskRepository.updatePriority(task.getTaskId(), 2);
Task updateTask = taskRepository.findById("1");
assertThat(updateTask.getPriority()).isEqualTo(2); // 결과가 변경된 것을 확인할 수 있다.
// delete
taskRepository.delete("1");
assertThatThrownBy(() -> taskRepository.findById("1")).isInstanceOf(NoSuchElementException.class);
}
- 마지막 delete 부분 코드를 보면 exception 이 발생하는 것을 검증하고 있습니다. task를 삭제한 다음 findById()를 통해서 조회하면 결과가 없기 때문에 NoSuchElementException 이 발생하고, assertThatThrownBy는 해당 예외가 발생해야 검증에 성공합니다.
이제 마지막에 task를 삭제하기 때문에 테스트가 정상 수행되면, 이제부터는 같은 테스트를 반복해서 실행할 수 있습니다. 물론 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없습니다! 이 부분은 트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있는데 이 부분은 다른 포스팅에서 작성해 보도록 하겠습니다.