순수 자바에서 JDBC 쿼리 테스트 해보기

홍혁준·2023년 4월 7일
2

시작하며

이번 미션에서 처음으로 DB를 적용했습니다.

DB를 이번 미션에서 적용하면서, 가장 고민했던 것은 “테스트를 어떻게 할까?” 였습니다.

(이번 프롤로그는 일종의 JDBC 쿼리 테스트 회고입니다. 그냥 이렇게도 해봤구나 하면서 봐주시면 감사하겠습니다.)

테스트하기 어려웠던 이유

테스트는 멱등을 유지해야 합니다.
n번 실행해도 n번 같은 결과를 반환해야 하죠.

DB가 아닌 일반 코드에서는 이런 면이 크게 어렵진 않았습니다.

테스트를 실행할 때 자바 코드만 신경쓰면 됬고, 테스트를 종료하면 메모리가 전부 깨끗이 비우기에 이전 테스트가 다음 테스트에 영향을 주지도 않았죠.

하지만 데이터베이스 테스트는 다릅니다. 자바 코드외에 DB에도 의존적이기에, 신경써야할 것이 많죠.
저는 아래와 같은 문제점을 찾았습니다.

DB 테스트 환경

  • 자바 코드 외에 DB 서버와 연결에 대해 신경을 써야합니다.
  • 테스트 실행 전에, 테스트에 필요한 데이터를 DB에 세팅해야 합니다.
    다양한 테스트 케이스로 테스트하는 것에 대한 걸림돌이 됩니다.
  • 매번 테스트 실행 후 다음 테스트에 영향이 가지 않게, 테스트 실행 동안 데이터베이스에 적재된 데이터를 비워줘야 합니다.

값을 비워주지 않는다면, 테스트의 멱등이 깨집니다. 물론 값을 비워주지 않아도 되는 방향으로 테스트를 작성할 수 있지만, 한정적인 테스트밖에 작성하지 못합니다.

테스트하는 기능의 양방향 의존성

  • 테스트하는 기능(메서드)간의 양방향 의존성이 발생합니다.

데이터베이스에 값을 저장, 변경하는(update) 기능을 테스트하고, 기능이 유효한지 확인하려면 값을 조회(select)하는 메서드가 필요하고,
데이터베이스에 값을 조회하는(select) 기능을 테스트하고, 기능이 유효한지 확인하려면 값을 저장,변경(update)하는 메서드가 필요합니다.

시도해본 것들

DB 테스트 환경

프로덕션 DB, 테스트 DB 분리

일단 가장 먼저 한 것은 테스트DB를 프로덕션DB와 분리한 것이었습니다.

도메인 로직 테스트 같은 경우에는 외부 환경에 영향을 받지 않습니다. 자바 코드에만 영향을 받죠.

그렇기에, 테스트환경과 프로덕션 환경을 코드에서 분리가 가능했고, 테스트에서 어떠한 일을 하더라도 실제 프로덕션에 영향을 주지 않았습니다.

하지만 DB 테스트는 외부 환경(DB)에 영향을 주고, 영향을 받습니다.
만약, 프로덕션과 테스트에서 같은 DB에 접근하여 사용한다면, 프로덕션이 테스트에, 테스트가 프로덕션에 영향을 주고 받는 일이 생길 것입니다.

그래서, 가장 먼저 아래와 DB연결을 분리하였습니다.

public interface ConnectionGenerator {

    String OPTION = "?useSSL=true&serverTimezone=UTC";
    String USERNAME = "root";
    String PASSWORD = "root";

    Connection getConnection();
}

public class ConnectionGeneratorImpl implements ConnectionGenerator {

    private static final String SERVER = "localhost:13306";
    private static final String DATABASE = "chess-production";

    public Connection getConnection() {
        try {
            return DriverManager.getConnection("jdbc:mysql://" + SERVER + "/" + DATABASE + OPTION, USERNAME, PASSWORD);
        } catch (final SQLException e) {
            throw new DataBaseCanNotConnectException();
        }
    }
}

public class TestConnectionGenerator implements ConnectionGenerator {

    private static final String SERVER = "localhost:13307";
    private static final String DATABASE = "chess-test";

    public Connection getConnection() {
        try {
            final Connection connection = DriverManager.getConnection(
                    "jdbc:mysql://" + SERVER + "/" + DATABASE + OPTION, USERNAME, PASSWORD);
            connection.setAutoCommit(false);
            return connection;
        } catch (final SQLException e) {
            throw new DataBaseCanNotConnectException();
        }
    }
}

스키마가 완전히 동일한 두 데이터베이스를 만들었습니다.

프로덕션은 13306, 테스트는 13307 포트로 정의하여 접속하게 하였습니다.

이렇게 가장 먼저 프로덕션과 테스트가 서로에게 영향을 끼치지 않도록 DB를 분리하였습니다.

테스트 DB라는 일종의 샌드박스를 만들었습니다.

테스트 후 테스트 DB 롤백하기

사실, 순수 자바가 아닌 Spring에서는 @Transactional이라는 어노테이션을 이용하여, DB에 변경사항을 아주 손쉽게 롤백해줄 수 있습니다.

하지만, 아쉽게도.. JDBC에는 해당 기능을 제공해주지 않습니다.

그러면 어떻게 테스트 DB를 롤백할까?에 대해 고민하였습니다. 가장 먼저 떠오른 것은,
BeforeEach와 AfterEach를 사용해서, 테스트를 할 때마다 값을 지우는 것이었습니다.

@BeforeEach
void setUP(){
   //테스트에 필요한 값을 데이터베이스에 세팅하는 로직
}

@AfterEach
void tearDown(){
   //적재된 데이터베이스에 있는 값들을 전부 지우는 로직
}

사실 위와 같은 방법이 가장 깔끔하고, 현재 프로젝트 규모에선 가장 적절한 방식입니다.

하지만, 테스트 DB에 디폴트로 많은 데이터가 적재되어있는 경우 위와 같은 방식이 문제가 될 수 있습니다.
또 다음과 같은 문제 때문에 rollback을 구현하는 방향으로 리팩터링하였습니다.

만약 테스트 코드에서, Exception이 발생하여 테스트가 종료되는 경우, @AfterEach에 작성된 코드가 수행되지 않기에, 테스트 멱등이 깨질 수도 있습니다.

하지만 connection의 setAutoCommit을 false로 정의했다면, Connection이 비정상적으로 종료가 됬을 때, rolback을 해주기 때문에 이런 면에서 더 안정적입니다.

추가적으로 후술할 기능 의존성에서 한 가지 이점이 있습니다.

한번 아이디어가 떠올라서 이번 미션에서 구현해보았습니다.

먼저, JDBC Template을 정의합니다.

public interface JdbcTemplate {

    //값을 변경하는 쿼리를 실행시키는 메서드
    void executeUpdate(final String query, final Object... parameters);

    //값을 추가하고, AutoIncrement한 PK를 가져오기 위해 정의한 메서드
    <T> T executeUpdate(String query, RowMapper<T> rowMapper, Object... parameters);

    //값을 조회하는 쿼리를 실행시키는 메서드
    <T> T executeQuery(final String query, final RowMapper<T> rowMapper, final Object... parameters);
}

실제 프로덕션에서 사용하는 template

public class JdbcTemplateImpl implements JdbcTemplate {

    private static final ConnectionGenerator CONNECTION_GENERATOR = new ConnectionGeneratorImpl();

    @Override
    public void executeUpdate(final String query, final Object... parameters) {
        try (final Connection connection = CONNECTION_GENERATOR.getConnection();
             final PreparedStatement preparedStatement = connection.prepareStatement(query)) {
            for (int i = 1; i <= parameters.length; i++) {
                preparedStatement.setObject(i, parameters[i - 1]);
            }
            preparedStatement.executeUpdate();
        } catch (final SQLException e) {
            throw new QueryFailException();
        }
    }
... 다른 정의된 메서드들
}

테스트에서 사용하는 template

public class TestJdbcTemplate implements JdbcTemplate {

    private final Connection connection;

    public TestJdbcTemplate(final Connection connection) {
        this.connection = connection;
    }

    @Override
    public void executeUpdate(final String query, final Object... parameters) {
        try (final PreparedStatement preparedStatement = connection.prepareStatement(query)) {
            for (int i = 1; i <= parameters.length; i++) {
                preparedStatement.setObject(i, parameters[i - 1]);
            }
            preparedStatement.executeUpdate();
        } catch (final SQLException e) {
            throw new QueryFailException();
        }
    }
    
    public void rollBack() throws SQLException {
        connection.rollback();
        connection.close();
    }
}

위 처럼 test는 rollback이라는 메서드를 정의하고 이를 afterEach에서 호출해주는 방식으로 코드를 작성하였습니다.

testJdbcTemplate과 JdbcTemplateImpl에서 내부 코드의 중복이 발생합니다.
이러한 중복을 제거하고 싶다면, 크게 두 가지 방법이 있습니다.

  1. 프로덕션에서 try-with-resources를 포기하고, 똑같이 Connection을 필드로 갖는 방법이 있습니다.

  2. try문 안에 있는 메서드를 JdbcTemplate Interface에서 정의하고, 이를 파라미터로 넘기는 방식

    저는 프로덕션의 try-with-resources를 포기하기 싫었고, try문 안에 있는 메서드를 추출하여 파라미터로 넘기는 것이 오히려 더 이해하기 힘들다고 생각하였습니다.

그리고 JdbcTemplate같은 경우에 추가적인 변경여지가 없다고 생각하여서 위와 같이 작성하였습니다.

위와 같이 사용해서 아래처럼 테스트를 작성할 수 있었습니다.

private static final long TEST_GAME_ID = 1L;
private static final Board TEST_BOARD = new BoardFactory().createInitialBoard();
private static final Turn TEST_TURN = new Turn(Color.WHITE);

private TestJdbcTemplate testJdbcTemplate;
private DataBasePieceDao pieceDao;

@BeforeEach
void setUp() {
    testJdbcTemplate = new TestJdbcTemplate(CONNECTION_GENERATOR.getConnection());
    pieceDao = new DataBasePieceDao(testJdbcTemplate);
}

@AfterEach
void tearDown() throws SQLException {
    testJdbcTemplate.rollBack();
}

@Test
@DisplayName("보드를 불러오는 기능 테스트")
void test_loadBoard() {
    final Board loadedBoard = pieceDao.loadBoard(TEST_GAME_ID, TEST_TURN);

    assertThat(loadedBoard.getBoard())
            .containsAllEntriesOf(TEST_BOARD.getBoard());
} 

... 다른 기능 테스트들

이 방식이 완벽하게 최적화된 방식이라고는 말 못하겠지만, 저는 위와 같은 방식으로 이번 미션에서 DB 테스트를 구현해봤습니다.

테스트하는 기능의 양방향 의존성

결론부터 말하자면, 이 부분은 완벽히 해결하지 못했습니다.

Insert, Update와 같이 테이블에 값을 추가하거나 변경하는 기능을 테스트하려면 필연적으로 조회하는 메서드에 의존성이 생깁니다.

먼저 Insert와 Update로 값을 추가, 변경하고 나면 이 기능의 유효성을 확인하기 위해서는실제로 어떤 값이 추가되었는지 나 어떤 값이 변경되었는지를 검증할 것입니다.

순수 java에서는 getter로 값이 어떻게 변했는지 확인하여 유효성을 테스트하였지만,현재로서는 DB의 값에 접근할 수 있는 방법이 JDBC의 SQL 쿼리문밖에 없기에, 결국 다른 메서드에 의존성이 생기게 됩니다.

반대로 조회하는 메서드를 테스트할 때에는, 원하는 값을 셋팅해야 하기에, 상태를 변경하는(Insert,Update)하는 메서드에 의존성이 생깁니다.(java에서는 생성자로 원하는 값을 설정할 수 있지만, DB에서는 그렇지 못하니..)

결국 다음과 같은 합의점을 찾아냈습니다.
테스트 데이터베이스에 디폴트로 많은 값을 적재시켜두고, 조회하는 메서드를 다양하게 테스트하여 확실하게 검증합니다. 이 방법은 rollback으로 DB테스트를 하기에 가능한 방법입니다.

데이터베이스의 값을 바꾸는 기능에서 조회하는 기능의 의존은 어쩔수 없더라도,
데이터베이스의 값을 조회하는 기능에서 값을 바꾸는 기능의 의존성이 생기지 않도록, 초기 DB에 기본값을 세팅해두고 이 값들을 이용해 검증합니다.

마치며

이번에 처음으로 JDBC를 배웠고, Test 또한 배운 적이 얼마 되지 않았기에, 위와 같은 방법들을 시도해보았습니다.
개선해야할 점 또는 더 좋은 방법이 있으면 알려주시면 감사하겠습니다.

profile
끊임없이 의심하고 반증하기

2개의 댓글

comment-user-thumbnail
2023년 4월 8일

좋은 글 잘 보고 갑니다!

1개의 답글