Spring DB : DB 테스트

    이 글은 인프런 김영한님의 강의를 복습하며 작성한 글입니다. 

    DB 연동

    데이터 접근 기술은 DB에 접근해서 데이터를 잘 저장하고 조회할 수 있는지 확인하는 것이 필요하다. ItemRepositoryTest를 통해서 테스트를 진행한다.

    어플리케이션용 application.properties와 테스트용 application.properties에는 각각 Profile에 대한 값이 설정되어있다. 각각의 Profile에 따라 다르게 동작할 수 있다. 그리고 DB 설정 정보를 어떻게 하느냐에 따라 다르게 동작할 수 있다. 

     

    @SpringBootTest

    • @SpringBootTest어노테이션이 존재하는 클래스는 테스트를 실행하면 @SpringBootApplication 어노테이션을 검색한다.
    • @SpringBootApplication에서 설정된 값을 읽어와서, 테스트에서 사용한다. 

    위의 내용을 정리하면 스프링 컨테이너에 등록된 빈을 불러와서 테스트에 사용한다는 것으로 이해할 수 있다. 즉, 스프링 컨테이너에 의존성이 생기게 된다. 

     

    테스트 실행 →  findItems 테스트 코드에서 문제 발생

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);
    
        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);
    
        //둘 다 없음 검증 (  / MaxPrice도 없는 경우 모든 Item이 다 나와야한다 -> 즉 3개가 나와야한다)
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);
    
        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);
    
        //maxPrice 검증
        test(null, 10000, item1);
    
        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }
    • findItems()의 코드는 다음과 같음

    테스트를 실행해보면 findItems() 테스트에서 테스트가 실패하게 된다. 

    에러 코드를 확인해보면 3개의 값만 저장되어 있어야 하는데, 2개의 값이 더 저장되었다는 것이다. 위의 테스트 코드를 살펴보면 3개의 아이템만 저장한다. 이렇게 테스트가 실패한 이유는 앞에서 실행되었던 테스트 결과가 다른 테스트에 영향을 주기 때문으로 이해를 할 수 있다. 즉, item2 / itemA라는 애들이 앞선 테스트 결과로 DB에 저장되어있던 것이 영향을 준 것이다. 

     

    DB 테스트 코드의 필요성

    테스트에서 중요한 것은 격리성이다. 테스트와 테스트는 서로에게 영향을 주지 않아야 하고, 이를 위해서 각 테스트는 서로 격리되어야 한다. 

     

    DB 분리

    어플리케이션 서버 / 테스트가 동일한 DB를 사용하고 있으므로 테스트에서 문제가 발생할 수 있다. 예를 들어 어플리케이션에서 사용했던 값이 DB에 남아있는 경우, 테스트 코드에 영향을 줄 수 있다. 따라서 이 문제를 해결하기 위해 DB 분리를 고려할 수 있다. 

    1. DB 새로 생성

    다음 주소로 테스트 DB(jdbc:h2:~/testcase)를 만들어준다. 이 때, TCP를 붙일 경우 DB가 만들어지지 않는다. 따라서 위와 같이 URL에 입력한 후 "연결"을 클릭하면 DB가 만들어진다. 

    2. DB 설정 정보

    test 폴더 아래에 있는 application.properties에 다음과 같이 DB 설정 정보를 업데이트 해준다. 

    DB 분리 후 다시 테스트를 실행해도 동일한 이유로 findItems()는 실패한다. 이 때의 실패한 이유는 여전히 updateItem() / save()의 실행 결과가 테스트 DB에 남아있기 때문이다. 이것을 해결 하기 위해서는 테스트 이후 매번 데이터를 삭제 해주어야 한다. 

    데이터를 삭제해주는 방법은 매 테스트마다 DELETE SQL을 사용하는 방법이 있다. 그렇지만 매번 SQL문을 작성하는 것은 번거롭기 때문에 Transaction 아래에서 테스트를 실행하면 좀 더 손쉽게 테스트 이후 데이터를 삭제할 수 있다. 

     

    테스트의 중요한 원칙

    • 테스트는 다른 테스트와 격리되어야 한다.
    • 테스트는 반복해서 실행할 수 있어야 한다. 

    위의 원칙을 지킬 수 있도록 매 테스트가 끝난 후, 테스트의 결과가 DB에 남아있지 않도록 해야한다. 

     

     

    데이터 롤백

    트랜잭션과 롤백 전략

    • 테스트 사이에 데이터를 격리하기 위해 도움을 주는 것이 "트랜잭션"이다. 
    • 테스트가 끝나고 나서 트랜잭션을 강제로 롤백하면 데이터가 깔끔하게 제거된다. 뿐만 아니라 예외가 발생하더라도, 커밋하지 않았기 때문에 데이터가 반영되지 않는다. 

     

    @BeforeEach / @AfterEach를 이용한 트랜잭션 적용

    • @BeforeEach : 각각의 테스트 케이스를 실행하기 직전에 호출됨. 따라서 여기서 트랜잭션을 시작하면 됨.
    • @AfterEach : 각각의 테스트 케이스가 완료된 직후에 호출됨. 따라서 여기서 트랜잭션을 롤백하면 됨. 

    Jdbc에서 사용하는 Query들은 결국 Connection을 사용해서 DB에 접근한다. 이 때, 사용하는 Connection은 트랜잭션 매니저가 참조하는 트랜잭션 동기화 매니저에 있는 Connection이다. 따라서 트랜잭션이 자동으로 동기화 된다. 

    @Autowired
    PlatformTransactionManager transactionManager;
    TransactionStatus transaction;
    • 먼저 트랜잭션 동기화를 위한 트랜잭션 매니저를 주입받는다.
    • 트랜잭션을 클래스 전체에서 사용하기 위해 클래스 변수로 선언한다.
    @BeforeEach
    void beforeEach() {
        transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
    }
    • @BeforeEach에서 클래스 변수에 대한 transaction을 만들어서 주입해줌.
    • 이 때, transaction은 트랜잭션 매니저의 트랜잭션 동기화 매니저 커넥션을 가져옴. 
    @AfterEach
    void afterEach() {
        // MemoryItemRepository의 경우 제한적 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    
        // 트랜잭션 롤백
        transactionManager.rollback(transaction);
    }
    • @AfterEach에서 테스트가 끝날 때, 자동으로 트랜잭션의 데이터를 롤백해줌.
    • 테스트 도중 예외가 발생할 경우, 자동으로 트랜잭션이 롤백되므로 원하는 목적을 달성할 수 있음. 

    @BeforeEach / @AfterEach를 사용하면 다음과 같이 테스트 데이터를 초기화 할 수 있다. 그렇지만 트랜잭션 및 롤백 처리를 위해서 불필요한 코드 작성이 많아진다. 스프링은 @Transactional 어노테이션을 이용해서 다음 기능을 간소화 시켜준다. 

     

     

    @Transactional

    앞서 @BeforeEach / @AfterEach를 이용해서 테스트 데이터를 초기화 했었다. 불필요한 코드 작성이 많아지는데 @Transactional을 이용하면 조금 더 깔끔하게 해결할 수 있다. 

    • 스프링이 제공하는 @Transactional은 로직이 성공적으로 수행되면 커밋하도록 동작한다.
    • @Transacation을 테스트에서 사용하면 테스트를 트랜잭션 안에서 실행하고, 테스트가 끝나면 트랜잭션을 자동으로 롤백시킨다.

    트랜잭션이 적용된 테스트의 동작 방식은 아래를 참고할 수 있다. 

    1. 테스트에 @Transactional이 있으면 테스트 메서드나 클래스에 있으면 먼저 트랜잭션을 시작함.
    2. 테스트 로직을 실행한다. 테스트가 끝날 때 까지 모든 로직은 트랜잭션 안에서 수행된다.
      • 트랜잭션은 기본적으로 전파되기 때문에 리포지토리에서 사용하는 JdbcTemplate도 같은 트랜잭션을 사용한다. 
    3.  @Transactional이 테스트에 있으면 테스트가 끝날 때, 트랜잭션을 강제로 롤백시킨다. 

    정리하면 @Transacational을 이용하면 자동으로 트랜잭션을 걸어서 테스트를 실행해주고, 끝날 때는 RollBack 처리를 해준다는 것이다.

    사용을 위해서는 다음과 같이 @Transactional만 클래스 위에 붙여주면 된다. 

     

     

    @Transactional 정리

    • 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공함
    • 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 걱정이 없음. 이 경우 트랜잭션을 커밋하지 않기 때문이다.
    • 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않는 장점이 있음.
    • @Transactional 때문에 다음 원칙을 아주 편리하게 지킬 수 있음. 
      • 테스트는 다른 테스트와 격리되어야 한다.
      • 테스트는 반복해서 실행할 수 있어야 한다. 

     

    @Commit

    필요에 따라 DB에 데이터가 들어갔는지 확인해야할 경우 @Commit 어노테이션을 붙여주면 된다. 이 경우, 롤백되지 않고 커밋이 되기 때문에 값이 실제로 DB에 들어간다. 비슷한 기능으로는 @RollBack(value = "false")를 붙여주면 된다.

     

    임베디드 모드 DB

    테스트 케이스 실행하기 위해서 별도의 DB를 설치하고 운영하는 것은 상당히 번잡한 작업이다. 단순히 테스트를 검증할 용도로만 사용하기 때문에 테스트가 끝나면 DB 데이터를 모두 삭제해도 됨. 그리고 DB 자체를 삭제해도 된다.

    *임베디드 모드*

    H2 DB는 자바로 개발되어 있고, JVM 안에서 메모리 모드로 동작하는 특별한 기능을 제공함. 그래서 어플리케이션을 실행할 때 H2 DB도 JVM 메모리에 포함해 함께 실행할 수 있다. DB를 어플리케이션에 내장해서 함께 실행하기 때문에 "임베디드 모드"라고 한다. 쉽게 이야기 해서 임베디드 모드는 JVM 메모리를 함께 사용하는 라이브러리처럼 동작한다. 

     

    임베디드 모드 직접 사용 방법

    @Bean
    @Profile("test")
    public DataSource dataSource() {
       log.info("메모리 데이터베이스 초기화");
       DriverManagerDataSource dataSource = new DriverManagerDataSource();
       dataSource.setDriverClassName("org.h2.Driver"); // H2 DB Driver를 지정해줌
       dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1"); // mem은 메모리 모드로 사용
       dataSource.setUsername("sa");
       dataSource.setPassword("");
       return dataSource;
    }
    • 스프링 빈 등록
      • 프로필이 "test"인 경우에만 데이터 소스를 스프링 빈으로 등록해서 사용함. 
      • 스프링은 기본적으로 application.properties의 설정값을 보고 DataSource를 등록해줌. 이렇게 스프링 빈으로 등록하는 경우 수동 등록한 DataSource만 스프링 빈으로 등록됨. 
    spring.profiles.active=test
    spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
    spring.datasource.username=sa
    spring.datasource.password=
    
    # jdbc Template Log
    logging.level.org.springframework.jdbc=debug
    • Application.properties 설정
      • jdbc:h2:mem:db : 이 부분이 중요함. 데이터 소스를 만들 때, 이렇게만 적으면 임베디드 모드로 동작하는 H2 DB를 사용할 수 있음.
      • DB_CLOSE_DELAY=-1 : 임베디드 모드에서는 DB 커넥션이 모두 끊어지면 DB도 종료되는데 그것을 방지하는 설정임. 

     

    임베디드 모드 테이블 생성

    위의 설정을 하고 실행할 경우 다음과 같은 에러가 발생한다. 실제 DB에는 이전에 Table을 만들어두었지만 메모리 DB에는 테이블을 만들어 준 적이 없기 때문에 이런 에러가 발생한다. 따라서 테스트를 실행하기 전에 테이블을 먼저 생성해줘야 한다.  JdbcTemplate을 이용해 테이블을 생성할 수도 있지만, 스프링 부트는 이런 기능을 편리하게 제공해준다. 

     

    *스프링 부트 - 기본 SQL 스크립트를 사용해서 DB를 초기화 하는 기능 제공*

    JDBC나 JdbcTemplate을 직접 사용해서 테이블을 생성하는 DDL을 호출해도 되지만 불편하다. 스프링부트는 SQL 스크립트를 실행해서 어플리케이션 로딩 시점에 DB를 초기화 하는 기능을 제공해준다. 이 기능을 이용하기 위해서는 다음을 조치하면 된다. 

    drop table if exists item CASCADE;
    create table item
    (
        id bigint generated by default as identity,
        item_name varchar(10),
        price integer,
        quantity integer,
        primary key (id)
    );
    • 'src/test/resources/schema.sql'을 생성함(위치와 파일 이름이 반드시 맞아야 함)
    • 이렇게 만들어주면 테스트 실행 전 테이블을 만들어 주고, 테스트가 실행된다. 

    다음 위치에 schema.sql을 설정해두면 된다. 

    설정해두면 스프링부트가 테스트를 실행할 때 다음과 같이 테이블을 만들어주는 것을 확인할 수 있다. 다만 반드시 파일명과 파일의 위치를 지켜야 한다. 

     

    스프링 부트와 임베디드 모드

    스프링 부트는 DB에 대한 별다른 설정이 없는 경우, 임베디드 DB를 사용한다. 

    @Bean
    @Profile("test")
    public DataSource dataSource() {
       log.info("메모리 데이터베이스 초기화");
       DriverManagerDataSource dataSource = new DriverManagerDataSource();
       dataSource.setDriverClassName("org.h2.Driver"); // H2 DB Driver를 지정해줌
       dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1"); // mem은 메모리 모드로 사용
       dataSource.setUsername("sa");
       dataSource.setPassword("");
       return dataSource;
    }
    • 먼저 위의 스프링 빈 등록 코드를 삭제함.

    src/test/application.properties

    • DB 접근 설정도 주석처리 함.

    위처럼 DataSource 수동 빈등록을 삭제하고, Application.properties에 DB 설정 정보를 삭제하게 되면 DB로 접근하는 모든 설정 정보가 사라지게 된다. 스프링 부트는 DB에 접근하는 설정 정보가 없는 경우 임베디드 모드로 자동 접근하도록 도와준다.

     

    정리

    • 테스트를 실행할 때는 반드시 다음 두 가지를 만족해야한다.
      • 테스트는 서로 격리 되어야 한다.
      • 테스트는 반복해서 실행될 수 있어야 한다. 
    • 위의 규칙을 지키기 위해 어플리케이션 DB / 테스트 DB 분리를 고려할 수 있음. 
    • 스프링 부트는 DB 설정 정보를 등록하지 않을 경우, 임베디드 모드로 DB를 등록해줌. 이 말은 JVM 메모리를 DB처럼 사용한다는 것이다. 즉, 어플리케이션과 테스트의 DB가 분리됨.
    • 테스트의 실행 결과가 DB에 남는 경우 다른 테스트에 영향을 줄 수 있고, 자신에게도 영향을 줄 수 있음. 따라서 DB에 테스트 결과를 남기면 안됨.
      • 테스트 한 후, 데이터를 반드시 롤백해야 함.  
      • @BeforeEach / @AfterEach를 이용해 트랜잭션을 시작 / 롤백할 수 있음.
      • @Transactional을 이용해서 자동으로 롤백할 수 있음. 

    'Spring > Spring DB' 카테고리의 다른 글

    Spring DB : JPA  (0) 2023.01.23
    Spring DB : MyBatis  (0) 2022.07.08
    Spring DB : JdbcTemplate  (0) 2022.06.11
    Spring DB : JdbcTemplate을 이용한 반복 문제 해결  (0) 2022.05.19
    Spring DB : Spring의 예외 추상화 이해  (0) 2022.05.19

    댓글

    Designed by JB FACTORY