Spring Boot

[DB] JDBC 실습 (구현 및 테스트)

작은별._. 2024. 8. 3. 13:13
728x90

아래 포스팅에서 만든 H2 DataBase를 사용해서 실습을 진행할 예정입니다.

https://silver-programmer.tistory.com/entry/JDBC%EB%9E%80

 

JDBC란?

서버에서는 데이터를 데이터베이스에 저장하고 있습니다. 그리고 데이터베이스에는 MySQL, Oracle과 같이 다양한 종류가 있죠. 여기서 문제가 발생합니다. 첫째로, 특정 데이터베이스를 사용하다

silver-programmer.tistory.com

 


테이블 생성 및 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 * 을 사용하면 테이블의 모든 컬럼을 다 저장합니다.

 

 

https://www.tutorialspoint.com.cach3.com/java-resultset-previous-method-with-example.html#google_vignette

 

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를 삭제하기 때문에 테스트가 정상 수행되면, 이제부터는 같은 테스트를 반복해서 실행할 수 있습니다. 물론 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없습니다! 이 부분은 트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있는데 이 부분은 다른 포스팅에서 작성해 보도록 하겠습니다.

728x90
반응형