땃쥐네

[Spring DB] 순수 JDBC 기술로 CRUD 본문

Spring

[Spring DB] 순수 JDBC 기술로 CRUD

ttasjwi 2022. 9. 18. 16:34

이전 글

- [Spring DB] 커넥션과 DataSource

 

이 글은 위의 글에서 이어집니다.


프로젝트 설정

 

```groovy
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// test에서 Lombok 사용
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}
```

이전 글에서 다룬 설정을 다시 짚고 넘어가겠습니다.

 

1. 이전에 작성한 글 [Spring DB] 커넥션과 DataSource에서 사용한 설정 그대로 사용합니다. 커넥션 획득이 어떻게 추상화되었는 지 확인하려면 위 글을 확인하실 필요가 있습니다.

 

2. 여기서 데이터 베이스 관련해서 중요한 부분은 spring-boot-starter-jdbc(spring-data-jdbc를 사용할 경우 spring-data-jdbc를 사용하시면 될 것) 및 데이터베이스 드라이버입니다.

 

3. 의존 라이브러리에 JDBC API 또는 Spring Data Jdbc가 등록되어 있어야 합니다.

 

4. 테스트에서 lombok을 사용하려면, 마지막 두 줄을 추가해줘야합니다.


데이터베이스 접근 설정

이전 글([Spring DB] 커넥션과 DataSource)에서 사용한 설정 을 그대로 사용합니다.

 


테이블 생성

DROP TABLE book if exists cascade;

CREATE TABLE book
(
    book_id   bigint not null auto_increment,
    name varchar(20) not null,
    price     integer not null default 0,
    primary key (book_id)
);

h2 데이터베이스에서 테이블을 생성해야합니다.

여기서는 book 테이블을 생성했고, auto increment를 통해 매번 식별자를 db에서 자동 생성하도록 했습니다.

SELECT * FROM book;

생성 후 select 쿼리를 돌려 테이블이 제대로 생성됐는 지 확인해보시면 됩니다.


Book 클래스 생성

package com.ttasjwi.jdbc.book.domain;

// import문 생략

@Getter
@ToString(of = {"id", "name", "price"})
public class Book {

    private Long id;
    private String name;
    private int price;

    @Builder(access = AccessLevel.PUBLIC)
    private Book(Long id, String name, int price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Book(String name, int price) {
        this(null, name, price);
    }

    public void initId(Long id) {
        this.id = id;
    }

}

롬복을 이용해 Book  클래스를 생성합니다.

 

- 생성은 name, price를 통해 생성자로 생성하는 방식 및 Builder를 통해 생성하는 방식 두 가지를 사용합니다.

- 추후 id 초기화를 위한 메서드 initId 메서드를 생성합니다. JPA와 같은 기술을 사용하면 이런 메서드는 사용하지 않아도 됩니다.

- 편의를 위해 모든 getter를 열어두겠습니다.


BookRepositoryV1 생성

V1 특징

- DataSource 인터페이스를 통해 커넥션을 획득합니다.

- SQLException이 발생하면 외부 계층에 throw합니다. 즉 이 리포지토리를 호출하는 외부 계층은 Jdbc 기술 예외인 SQLException을 알게(의존하게) 됩니다.

- 트랜잭션을 적용하지 않습니다.

기본 의존관계, 어노테이션

@Slf4j
@RequiredArgsConstructor
public class BookRepositoryV1 {

    private final DataSource dataSource;
	
    // 생략
}

DataSource 의존

- DataSource 인터페이스를 의존하도록 하고, 이를 생성자를 통해 주입하도록 합니다.

- 생성자를 통해 주입되고 인터페이스를 의존하고 있기 때문에, 이후 DataSource 구현체가 변경되더라도  코드를 변경하지 않아도 됩니다.

롬복

- 로깅을 위해 @Slf4j 어노테이션을 추가했습니다.

- @RequiredArgsConstructor는 final이 붙은 필드를 확인하여 이들을 기반으로 생성자를 만듭니다.


JDBC 기술의 흐름

커넥션 획득 -> PreparedStatement(Statement) 준비 -> 실제 DB에 쿼리 -> 결과 반환 -> 커넥션 종료(DriveManager 사용 시)/반납(커넥션 풀 사용 시), 리소스 정리

 

순수 JDBC 기술 사용은 위와 같은 흐름으로 진행됩니다. 하나씩 차근차근 살펴보겠습니다.


Book 등록

    public void save(Book book) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            String sql =
                    "INSERT INTO book (name, price)" +
                    " VALUES (?, ?)";

            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

            pstmt.setString(1, book.getName());
            pstmt.setInt(2, book.getPrice());

            pstmt.executeUpdate();
            rs = pstmt.getGeneratedKeys();

            if (rs.next()) {
                book.initId(rs.getLong("book_id"));
            } else {
                throw new SQLException("Book 등록 후 식별자 조회 실패");
            }
        } catch (SQLException e) {
            log.error("DB 예외 발생!", e);
            throw e;
        } finally {
            close(con, pstmt, rs); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
        }
    }

커넥션 획득

con = dataSource.getConnection();
    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get Connection : {}, class = {}", con, con.getClass());
        return con;
    }

datasource를 통해 커넥션을 획득합니다. 여기서는 커넥션을 획득할 때 매번 로그를 찍어보고 싶기에, 별도의 private 메서드로 분리해서 로그를 찍어보겠습니다.

 

DriverManagerDataSource 방식에서는 매번 DB와 TCP/IP 물리적 커넥션을 맺고, DB에 DBSession을 생성한 뒤 물리적 커넥션을 기반으로 Connection 객체가 생성되는 작업을 같이 수행됩니다.

 

커넥션 풀링 방식에서는 DataSource 구현체 생성 시점에서 이미 커넥션이 다 맺어져있고, 저희는 커넥션 풀에서 물리적 커넥션을 하나 가져와 이를 기반으로 ConnectionProxy 객체를 받아옵니다. 자세한 내용은 이후 커넥션 풀 관련 내용을 포스팅할 때 다루겠습니다.

 

SQL 작성

String sql =
        "INSERT INTO book (name, price)" +
        " VALUES (?, ?)";

- PreparedStatement 방식에서는 ?를 통해 위치 기반으로 파라미터를 전달합니다.

- 원래 옛날 기술에서는 Statement를 사용하였고, 이 방식은 ?에 파라미터를 전달하는 방식이 아닌, 문자열 결합을 이용해 파라미터를 그대로 넘기는 방식이였습니다. 이 방식은 SQLInjection에 매우 취약하므로 사용하지 말아야합니다. '?'를 통해 파라미터를 '순서대로' 지정하면 됩니다.

- SQL을 읽기 편하게, 줄을 나누고 +를 통해 결합하여 확인하도록 했는데, 한 칸 띄어 쓰는 것을 잊지 말도록 합시다. 그렇지 않을 경우 띄어써야할 부분이 붙게되고 문법 오류가 발생하게됩니다.

 

PreparedStatement 준비

pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

pstmt.setString(1, book.getName());
pstmt.setInt(2, book.getPrice());

- sql을 준비하고, 이후 DB에서 생성된 식별자를 반환받을 수 있도록 설정합니다.

- Statement.RETURN_GENERATED_KEYS : 기본키 생성을 DB에 위임했는데 이를 받아오려면 전달해야합니다.

- 필요한 파라미터를 타입을 지정해서 전달합니다. 순서에 주의해야합니다.

- PreparedStatement는 Statement의 하위 인터페이스입니다. 하위 인터페이스인 PreparedStatement를 사용하는 이유는 ?에  파라미터를 전달하는 setXXX 메서드가 PreparedStatement 방식이기 떄문입니다.

- PreparedStatement는 SQLInjection 에 안전합니다.

 

SQL 실행

pstmt.executeUpdate();

- PreparedStatement에서 준비한 설정들을 Connection을 통해 DB Session에 전달하여, 실제로 쿼리를 실행합니다.

- 등록, 수정, 삭제는 executeUpdate()를 사용하면 됩니다.

 

ResultSet 반환 받기

rs = pstmt.getGeneratedKeys();

- DB에 기본키 생성을 위임했기 때문에 생성된 식별자를 반환받아야합니다.

- 앞에서 PreparedStatement를 통해 기본키 생성 결과를 반환받는다고 선언했기 때문에 받아올 수 있는 것입니다.

 

ResultSet에서 기본키 받아오기

if (rs.next()) {
    book.initId(rs.getLong("book_id"));
} else {
    throw new SQLException("Book 등록 후 식별자 조회 실패");
}

- 후술하겠지만, ResultSet에는 결과들이 목록의 형태로 전달되는데, rs.next() 를 통해 커서를 다음 위치로 이동시키고 데이터가 존재하면 true를 반환합니다. 커서? 이런 말이 익숙하지 않을 수 있는데,  아래의 조회부분에서 추가적으로 다룰 것입니다.

- rs.next()가 true일 때는 rs에서 식별자 "book_id"를 받아와서, 저장하는 book 인스턴스의 id 필드를 초기화하도록 했습니다.

 

SQLException 처리

} catch (SQLException e) {
    log.error("DB 예외 발생!", e);
    throw e;
}

순수 JDBC 기술 사용 시, SQLException이라는 체크 예외가 발생하게 되는데 이를 catch하거나, 외부 계층으로 throw 해야합니다. 일단은 예외가 발생했다는 것만 로그로 남기고 외부 계층으로 throw하도록 하겠습니다. (이를 올바르게 처리하는 방법은 이후 포스팅에서 다룰 것입니다.)

 

- DB에서 오류가 발생하면, 실제 SQLException에는 해당 DB에서의 에러코드가 같이 전달됩니다. 이 역시 이후 포스팅에서 다룰 예정입니다.

 

리소스 정리

} finally {
    close(con, pstmt, rs); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
}
    private void close(Connection con, Statement stmt, ResultSet rs) {
        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                log.info("ResultSet close 실패!", e);
            }
        }

        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.info("Statement Close 실패!", e);
            }
        }

        if (con != null) {
            try {
                con.close();
            } catch (SQLException e) {
                log.info("Connection Close 실패!", e);
            }
        }
    }

- 데이터 접근에 사용한 리소스를 반드시 정리해야합니다.

- Connection -> Statement -> ResultSet 순서대로 생성했으므로, 역순으로 ResultSet, Statement, Connection 순으로 정리해야합니다.

- 데이터베이스와 연결 후 커넥션을 정리하지 않으면 DB에는 불필요하게 DB Session이 남아있게 되는 문제가 발생합니다. 이는 성능 이슈를 야기시킬 수 있는데요. 커넥션 정리는 이런 관점에서 매우 중요한 작업입니다.

- DriverManagerDataSource 방식에서는 실제로 물리적 커넥션을 끊어버립니다.

- HikariCP와 같은 커넥션 풀링 방식에서는 물리적 커넥션을 커넥션 풀에 반환하고, ConnectionProxy 객체를 제거합니다.

 

private void close(Connection con, Statement stmt, ResultSet rs) {
    JdbcUtils.closeResultSet(rs);
    JdbcUtils.closeStatement(stmt);
    JdbcUtils.closeConnection(con);
}

그런데 이렇게 매번 커넥션을 정리하는 코드를 매번 작성하는건 엄청 귀찮습니다. 스프링은 JdbcUtils 클래스를 통해, 리소스 정리 관련 편의 메서드를 제공합니다. 위의 코드는 이렇게 세줄로 변경해서 사용하면 됩니다.

 

여기까지 Book 등록 로직을 설명했습니다. 이제 조회/수정/삭제를 다뤄보겠습니다.


Book 단건 조회

public Optional<Book> findById(Long id) throws SQLException {
    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        String sql =
                "SELECT book_id, name, price" +
                        " FROM book" +
                        " WHERE book_id = ?";

        con = dataSource.getConnection();
        pstmt = con.prepareStatement(sql);

        pstmt.setLong(1, id);

        rs = pstmt.executeQuery();

        if (rs.next()) {
            Book findBook = Book.builder()
                    .id(rs.getLong("book_id"))
                    .name(rs.getString("name"))
                    .price(rs.getInt("price"))
                    .build();
            return Optional.of(findBook);
        }
        return Optional.empty();
    } catch (SQLException e) {
        log.error("DB 예외 발생!", e);
        throw e;
    } finally {
        close(con, pstmt, rs); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
    }
}

반환타입 Optional

- java 8에서 추가된 기능인 Optional을 사용하여 반환합니다. null을 Optional.empty()로 안전하게 감싸서 반환할 수 있습니다. Java 8의 핵심 기능 중 하나이므로 모르신다면 학습이 필요합니다.

 

단건 조회 SQL

String sql =
        "SELECT book_id, name, price" +
                " FROM book" +
                " WHERE book_id = ?";

- 조회이므로 SELECT문을 작성합니다.

- 줄을 나눠 쓸 경우, 띄어쓰기를 잊지 맙시다...!

 

조회 쿼리 실행

rs = pstmt.executeQuery();

- 조회 시에는 executeQuery()를 통해 조회 결과를 ResultSet으로 받아와야합니다.

- (cf) 등록, 수정, 삭제 시에는 executeUpdate() 를 호출합니다.

 

단건 조회 결과를 객체 바인딩하여 반환하기

if (rs.next()) {
    Book findBook = Book.builder()
            .id(rs.getLong("book_id"))
            .name(rs.getString("name"))
            .price(rs.getInt("price"))
            .build();
    return Optional.of(findBook);
}
return Optional.empty();

- 식별자를 통해 조회하므로 조회 결과가 0개 또는 1개일겁니다.

- rs.next()를 통해 커서를 한 칸 이동시켜서 결과가 있으면 결과를 객체에 바인딩하여 Optional에 감싸 반환합니다.

- 결과가 없으면 null 대신 optional.empty()를 통해 반환합니다. 이를 통해 외부 계층에서는 optional을 통해 데이터를 얻어오기에 null을 좀 더 안전하게 처리할 수 있습니다.

- 지금은 테이블이 1개고, 필드가 몇 개 없지만 필드가 여러개 생기거나, 조인을 해야한다면 바인딩해야하는 데이터가 어마어마하게 많아지므로 빌더 패턴을 통해 무슨 데이터를 어느 필드에 바인딩하는지 확인하기 쉽게 하였습니다.

- 여기서 파라미터로 넘기는 "book_id", "name", "price"는 Book 객체의 필드명이 아닌, 앞에서 SQL에서 지정한 조회 칼럼명입니다.


Book 여러 건 조회

    public List<Book> findAll() throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            String sql =
                    "SELECT book_id, name, price" +
                            " FROM book";

            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);

            rs = pstmt.executeQuery();

            List<Book> books = new ArrayList<>();
            while (rs.next()) {
                Book findBook = Book.builder()
                        .id(rs.getLong("book_id"))
                        .name(rs.getString("name"))
                        .price(rs.getInt("price"))
                        .build();
                books.add(findBook);
            }
            return books;

        } catch (SQLException e) {
            log.error("DB 예외 발생!", e);
            throw e;
        } finally {
            close(con, pstmt, rs); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
        }
    }

모든 데이터를 조회하는 기능입니다.

 

실무에서 모든 데이터를 싹 가져오는 쿼리를 작성할 일은 없을겁니다. 데이터가 100만건 이상 될 수도 있는데 그걸 메모리에 전부 올려버리면 WAS가 죽죠...

 

여기서는 여러 데이터를 resultSet에서 조회하는 법을 정리하기 위해 이 코드를 작성했습니다.

 

SQL 작성

String sql =
        "SELECT book_id, name, price" +
                " FROM book";

- 모든 데이터를 조회하므로 WHERE 구를 지정하지 않았습니다.

 

여러건 조회 결과를 객체에 바인딩하여 반환하기

List<Book> books = new ArrayList<>();
while (rs.next()) {
    Book findBook = Book.builder()
            .id(rs.getLong("book_id"))
            .name(rs.getString("name"))
            .price(rs.getInt("price"))
            .build();
    books.add(findBook);
}
return books;

- while문을 돌면서, 커서가 아무 것도 가리키지 않을 때까지 데이터를 조회하고, 바인딩하여 리스트에 추가합니다.

- 아무 것도 데이터가 조회되지 않으면 결과적으로 빈 리스트가 반환됩니다.

- 아까부터 계속 rs.next() 의 작동방식을 뭉뚱그려 설명했는데, 여기서 설명하도록 하겠습니다.


ResultSet을 통해 데이터를 얻어오는 원리

위 그림은 ResultSet을 통해 데이터를 얻어오는 방식을 그림으로 설명한 것입니다.

ResultSet은 내부적으로 Cursor가 있는데, 이 Cursor는 최초 상태일 때는 아무 것도 가리키고 있지 않습니다.

 

rs.next()를 호출하면 다음으로 커서를 이동시키고, 가리키는 행의 데이터 존재여부를 true(값이 있을 때), false(값이 없을때)로 반환합니다. 커서를 이동시키고 그 지점에서 데이터를 조회하면 되는 것입니다.

 

보통, 결과가 0-1건이면 "if 문"으로 감싸 처리하고, 0건 또는 여러건이면 "while 문"으로 처리하면 됩니다.


Book 수정

    public void update(Long id, String updateName, int updatePrice) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            String sql =
                    "UPDATE book"+
                    " SET name = ?, price = ?"+
                    " WHERE book_id = ?";

            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);

            pstmt.setString(1, updateName);
            pstmt.setInt(2, updatePrice);
            pstmt.setLong(3, id);

            int resultSize = pstmt.executeUpdate();
            log.info("Book 수정! -> resultSize = {}", resultSize);
        } catch (SQLException e) {
            log.error("DB 예외 발생!", e);
            throw e;
        } finally {
            close(con, pstmt, null); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
        }
    }

Book을 수정하는 기능입니다. update 문을 사용했고, 이 로직에서는 ResultSet이 필요 없으니 ResultSet은 다루지 않고 close 메서드에도 null을 전달합니다.

 

수정 쿼리 실행

int resultSize = pstmt.executeUpdate();

- 등록, 수정, 삭제는 executeUpdate()를 사용하면 됩니다.

- 이 때 반환 타입이 int인데 반환 결과는 영향 받은 행의 갯수를 의미합니다.


Book 단건 삭제

    public void delete(Long id) throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            String sql =
                    "DELETE FROM book"+
                    " WHERE book_id = ?";

            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);

            pstmt.setLong(1, id);

            int resultSize = pstmt.executeUpdate();
            log.info("Book 삭제! -> resultSize = {}", resultSize);
        } catch (SQLException e) {
            log.error("DB 예외 발생!", e);
            throw e;
        } finally {
            close(con, pstmt, null); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
        }
    }

Book을 삭제하는 기능입니다. delete 문을 사용했고, 이 로직에서는 ResultSet이 필요 없으니 ResultSet은 다루지 않고 close 메서드에도 null을 전달합니다.

 

쿼리를 실행하는 방식은 update와 같습니다.


Book 전체 삭제

public void deleteAll() throws SQLException {
    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        String sql = "DELETE FROM book";

        con = dataSource.getConnection();
        pstmt = con.prepareStatement(sql);

        int resultSize = pstmt.executeUpdate();
        log.info("Book 일괄 삭제! -> resultSize = {}", resultSize);
    } catch (SQLException e) {
        log.error("DB 예외 발생!", e);
        throw e;
    } finally {
        close(con, pstmt, null); // 작업을 마치면 커넥션을 반드시 제거/반환 해야한다.
    }
}

- 실무에서는 절대 쓰지 않을 아주 위험한 기능입니다.

- 테이블의 모든 데이터가 삭제됩니다!

- 이후 테스트의 편의를 위해 만들었습니다.


V1 테스트 - 테스트 환경 준비

package com.ttasjwi.jdbc.book.repository;

// import문 대부분 생략

import static com.ttasjwi.jdbc.util.ConnectionConstant.*;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
class BookRepositoryV1Test {

    BookRepositoryV1 repository;

    @BeforeEach
    void setUp() {
        DataSource dataSource = new DriverManagerDataSource(URL, USER, PASSWORD);
        repository = new BookRepositoryV1(dataSource);
    }
    
    @AfterEach
    void clear() throws SQLException {
        repository.deleteAll();
    }
}

- 리포지토리는 DataSource를 의존하는데, 여기에서는 구현체로 DriverManagerDataSource를 외부에서 주입합니다.

- import static 문에서, 이전 글에서 등록한 ConnectionConstant 클래스의 내용을 등록합니다.

- import static 문에서 asserj의 Assertions.assertThat 을 등록합니다.

- @BeforeEach를 통해 매번 테스트를 실행하기 전에, 리포지토리를 생성합니다.

- @AfterEach에서 deleteAll 쿼리를 통해 매번 테스트가 수행된 후, 테이블의 모든 데이터를 제거합니다.


등록, 식별자 조회 테스트

코드 작성

@Test
@DisplayName("책 등록 -> 같은 책이 찾아져야함")
void save() throws Exception {
    // given
    Book saveBook = new Book("test", 10000);

    // when
    repository.save(saveBook);
    log.info("saveBook = {}", saveBook);
    Book findBook = repository.findById(saveBook.getId()).get();
    log.info("findBook = {}", findBook);
    
    // then
    assertThat(findBook.getId()).isEqualTo(saveBook.getId());
    assertThat(findBook.getName()).isEqualTo(saveBook.getName());
    assertThat(findBook.getPrice()).isEqualTo(saveBook.getPrice());
}

Book을 save하고, 식별자를 통회 조회하여 같은 데이터가 찾아지는 지 확인합니다.

 

순수 jdbc 기술에서는 equals를 통해 비교할 수 없는데 그 이유는 저희가 equals를 오버라이드하지 않았고(동등성 정의x), 매번 다른 객체가 생성되기 때문입니다. 그래서 필드를 하나하나 getter로 꺼내서 각각 동등성을 비교했습니다.

 

JPA를 사용하면 같은 트랜잭션에서는 같은 영속성 컨텍스트에서 캐싱하고 조회하기 때문에 동일한 객체임이 보장됩니다.

 

실행

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook = Book(id=1, name=test, price=10000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
findBook = Book(id=1, name=test, price=10000)

- 로그를 통해 눈으로 확인하면 같은 데이터임을 알 수 있습니다.

- DriverManagerDataSource를 사용하므로 매번 물리적 커넥션을 생성하고 종료함을 확인할 수 있습니다.

- AssertJ를 통한 테스트가 통과됩니다.


전체 조회 테스트

코드

@Test
@DisplayName("책 여러건 등록 후 findAll 조회 -> 모두 찾아져야함")
void findAllTest() throws SQLException {
    // given
    Book saveBook1 = new Book("book1", 10000);
    Book saveBook2 = new Book("book2", 20000);
    Book saveBook3 = new Book("book3", 30000);

    repository.save(saveBook1);
    log.info("saveBook1 = {}", saveBook1);
    repository.save(saveBook2);
    log.info("saveBook2 = {}", saveBook2);
    repository.save(saveBook3);
    log.info("saveBook3 = {}", saveBook3);

    // when
    List<Book> findBooks = repository.findAll();
    for (Book findBook : findBooks) {
        log.info("findBook = {}", findBook);
    }

    // then
    assertThat(findBooks.size()).isEqualTo(3);
    assertThat(findBooks).extracting("name").containsExactly("book1", "book2", "book3");
    assertThat(findBooks).extracting("price").containsExactly(10000, 20000, 30000);
}

- 책을 3개 save한 후, findAll로 모두 찾아서 조회한 뒤 같은 데이터가 찾아지는 지 확인합니다.

- 검증부의 extracting은 리스트의 내용물 각각을 자바 빈에서 동일한 이름의 필드를 찾아 변환합니다.

- 검증부의 containsExactly는 해당 순서대로 제대로 저장되어 있는 지 확인합니다. 기본적으로 식별자 순서대로 조회되기 때문에 위 순서대로 조회됩니다.

 

실행

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook1 = Book(id=2, name=book1, price=10000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook2 = Book(id=3, name=book2, price=20000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn2: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook3 = Book(id=4, name=book3, price=30000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn3: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
findBook = Book(id=2, name=book1, price=10000)
findBook = Book(id=3, name=book2, price=20000)
findBook = Book(id=4, name=book3, price=30000)

- 로그를 통해 눈으로 확인하면 같은 데이터들이 조회됨을 확인할 수 있습니다.

- DriverManagerDataSource를 사용하므로 매번 물리적 커넥션을 생성하고 종료함을 확인할 수 있습니다.

- AssertJ를 통한 테스트가 통과됩니다.


Update 테스트

코드

@Test
@DisplayName("책 등록, 변경 후 조회 -> 변경된 책이 찾아져야함")
void updateAndFind() throws Exception {
    // given
    Book saveBook = new Book("original", 10000);
    repository.save(saveBook);
    log.info("saveBook = {}", saveBook);

    // when
    repository.update(saveBook.getId(), "updated", 20000);
    Book findBook = repository.findById(saveBook.getId()).get();
    log.info("findBook = {}", findBook);
    
    // then
    assertThat(findBook.getId()).isEqualTo(saveBook.getId());
    assertThat(findBook.getName()).isEqualTo("updated");
    assertThat(findBook.getPrice()).isEqualTo(20000);
}

- 책을 등록 후 같은 식별자의 책의 이름, 가격을 수정한 뒤 다시 조회합니다.

- 조회 결과물이 변경된 데이터인지 검증합니다.

 

실행

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook = Book(id=5, name=original, price=10000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
Book 수정! -> resultSize = 1

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn2: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
findBook = Book(id=5, name=updated, price=20000)

- 로그를 통해 눈으로 확인하면 변경된 데이터가 조회됨을 확인할 수 있습니다.

- DriverManagerDataSource를 사용하므로 매번 물리적 커넥션을 생성하고 종료함을 확인할 수 있습니다.

- AssertJ를 통한 테스트가 통과됩니다.


Delete 테스트

코드

    @Test
    @DisplayName("delete -> 같은 식별자의 책이 삭제되야함")
    void deleteTest() throws SQLException {
        // given
        Book saveBook = new Book("book", 10000);
        repository.save(saveBook);
        log.info("saveBook = {}", saveBook);

        // when
        repository.delete(saveBook.getId());
        Book findBook = repository.findById(saveBook.getId()).orElse(null);
        log.info("findBook = {}", findBook);

        // then
        assertThat(findBook).isNull();
    }

- 책을 등록 후, 삭제하여 같은 식별자로 조회 시 찾아지지 않음을 검증합니다.

실행 결과

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook = Book(id=8, name=book, price=10000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
Book 삭제! -> resultSize = 1

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn2: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
findBook = null

- 로그를 통해 눈으로 확인하면 같은 데이터가 삭제됨을 확인할 수 있습니다.

- DriverManagerDataSource를 사용하므로 매번 물리적 커넥션을 생성하고 종료함을 확인할 수 있습니다.

- AssertJ를 통한 테스트가 통과됩니다.


DeleteAll 테스트

코드

    @Test
    @DisplayName("책 여러건 등록 후 deleteAll -> 모두 삭제 되야함")
    void deleteAllTest() throws SQLException {
        // given
        Book saveBook1 = new Book("book1", 10000);
        Book saveBook2 = new Book("book2", 20000);
        Book saveBook3 = new Book("book3", 30000);

        repository.save(saveBook1);
        log.info("saveBook1 = {}", saveBook1);
        repository.save(saveBook2);
        log.info("saveBook2 = {}", saveBook2);
        repository.save(saveBook3);
        log.info("saveBook3 = {}", saveBook3);

        // when
        repository.deleteAll();
        List<Book> findBooks = repository.findAll();
        log.info("findBooks = {}", findBooks);

        // then
        assertThat(findBooks).isEmpty();
    }

- 책 3개를 등록 후 deleteAll로 일괄 삭제합니다.

- findAll 조회 결과 아무 것도 없는 지 확인합니다.

실행

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn0: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook1 = Book(id=15, name=book1, price=10000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn1: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook2 = Book(id=16, name=book2, price=20000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn2: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
saveBook3 = Book(id=17, name=book3, price=30000)

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn3: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
Book 일괄 삭제! -> resultSize = 3

Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/jdbc_ex]
get Connection : conn4: url=jdbc:h2:tcp://localhost/~/jdbc_ex user=SA, class = class org.h2.jdbc.JdbcConnection
findBooks = []

- 로그를 통해 눈으로 확인하면 모든 데이터가 삭제됨을 확인할 수 있습니다.

- DriverManagerDataSource를 사용하므로 매번 물리적 커넥션을 생성하고 종료함을 확인할 수 있습니다.

- AssertJ를 통한 테스트가 통과됩니다.


한계

지금까지 순수 Jdbc 기술을 통해 등록, 조회, 수정, 삭제 하는 법을 확인했습니다.

저희가 작성한 코드는 정말 많은 문제를 가지고 있는데 이를 살펴봅시다.

 

개발자가 작성해야하는 공통된 코드가 너무 많다.

위에서 작성한 코드들을 보시면 아시겠지만, Jdbc 기술을 사용할 때는 개발자가 공통적으로 작성해야하는 코드가 너무 많습니다. 회원 전체 조회하는 로직 자체만 놓고 보면 거의 30줄을 작성해야합니다!

 

커넥션을 획득하고, PreparedStatement 준비하고, ResultSet으로 결과를 가져오고, 객체에 바인딩하고, 체크 예외인 SQLException이 발생하기 때문에 try-catch문으로 감싸서 처리해야하고, 커넥션 사용 후 리소스 정리도 일일이 번거롭게 해줘야합니다.

 

실제로 약간씩 차이가 있는건 결국 sql 작성하는거랑, 파라미터 전달하는 부분, 그리고 결과 객체 바인딩하는 부분인데 그 부분을 제외하면 거의 다 똑같은 코드를 매번 써야합니다. 이 짓을 매번 하는 것은 유지 보수 면에서도 좋지 않고 시간도 너무 오래 걸립니다.

 

이는 순수 Jdbc 자체의 문제입니다. 이런 점 때문에 요즘은 jdbc 기술을 거의 잘 쓰지 않고, JdbcTemplate, MyBatis 또는 Spring Data JPA를 주로 사용합니다.

 

Jdbc 기술인 SQLException이 발생하고, 처리하지 않으면 외부에서 Jdbc 예외를 알게 된다.

자세히 보시면 SQLException이 발생하는 것을 확인할 수 있는데요.

 

SQLException은 체크예외입니다. 반드시 catch로 감싸서 처리하거나, 외부 계층에 throws를 통해 떠넘겨야하죠. throws를 통해 떠넘기게 되면 이 메서드를 호출하는 측에서는 반드시 SQLException을 알아야하는 문제가 발생합니다. 외부 계층이 데이터베이스 접근 기술 중 하나에 불과한 순수 jdbc 기술에 의존적인 코드가 되는 것입니다. SQLException은 특정 기술의 예외에 불과한 예외인데, 외부 계층이 특정 기술의 예외에 의존적인 코드가 됩니다.

 

나중에 순수 jdbc 기술 말고 다른 데이터베이스 접근기술을 사용할 때는 SQLException 말고 다른 예외가 발생하거나, 런타임 예외가 던져져서 별도로 예외를 감쌀 필요가 없을텐데... 데이터베이스 접근기술을 변경하면 외부 계층도 코드 수정이 불가피해집니다. 가령 Service 계층, 테스트 코드가 있다면 이쪽에서도  SQLException을 알아야합니다. 그리고 Repository 인터페이스가 있다면 이쪽에서도 throws로 SQLException을 선언해야합니다.

 

트랜잭션이 적용되지 않았다.

현재 기술에는 데이터베이스 트랜잭션이 적용되어 있지 않습니다.

 

트랜잭션은 여러개의 SQL의 모음인데, 여러 SQL이 실행되는 도중 하나의 SQL이라도 실패하면 전체가 리셋되어야함이 보장됩니다. 트랜잭션이 생소하시다면 이전에 작성한 [DataBase] 트랜잭션 및 트랜잭션의 4대 특성(ACID)  포스팅을 참고하시면 됩니다. 현재 코드에서는 트랜잭션이 적용되지 않았으므로, 모든 SQL마다 다른 커넥션에서 진행되고, 도중에 하나라도 잘못되면 전체가 롤백되지 않는 문제가 발생합니다.

 

만약 계좌 이체 도중에, 돈을 지불하는 측은 돈이 빠져나갔는데 도중에 예외가 발생해서 돈을 지불받는 측에 돈이 들어오지 않는 문제가 발생하더라도 현재로서는 이를 방어할 수 없는겁니다.

 

테스트 코드에서도 트랜잭션이 적용되지 않아, 매번 수동으로 데이터를 삭제하고 있다.

우리 테스트 코드는 매번 DB의 상태에 영향을 끼치고 있습니다. 실제로 테스트를 진행할 때마다 Book의 식별자 번호가 계속 증가하는 것을 확인할 수 있습니다.

 

테스트를 할 때마다 데이터를 등록하고, @AfterEach를 통해 데이터를 실제로 매번 초기화하고 있기 때문입니다. 매번 테스트 실행 후 영향이 없던게 아니라, 실제 데이터가 추가된 기록이 남고 삭제되어서 테스트가 정상 동작하고 있던겁니다.

//    @AfterEach
//    void clear() throws SQLException {
//        repository.deleteAll();
//    }

실제로 @AfterEach의 코드를 주석처리하고 findAll 테스트 부분을 두번 실행하면 두번째 테스트 코드가 실패합니다. 테스트를 실행할 때마다 테이블의 데이터를 모두 지워야만 테스트를 여러번 실행해도 실패하지 않습니다.

 

그리고 @AfterEach를 살려두더라도, 테스트 도중에 실패하게되면 마지막 @AfterEach가 실행되지 않은 채 끝나기에 DB에 데이터가 남게 됩니다.

 

테스트를 실행할 때 트랜잭션을 걸고, 매번 테스트가 끝나기 직전에 롤백시켜버려야만 테스트 전후의 데이터베이스 상태가 항상 같고, 테스트를 반복 실행해도 문제가 없어집니다.


연관 학습 키워드

- 커넥션

- Spring의 데이터 접근계층 예외 추상화

- 데이터 접근 계층의 트랜잭션 사용법

- 스프링의 트랜잭션 추상화

- 다른 데이터베이스 접근 기술 : JdbcTemplate, MyBatis, Spring Data JPA


 

Comments