관리 메뉴

나구리의 개발공부기록

데이터 접근 기술 - 테스트, 데이터 베이스 연동 및 분리, 데이터 롤백, @Transactional, 임베디드 모드 DB, 스프링 부트와 임베디드 모드 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 2편 - 데이터 접근 활용

데이터 접근 기술 - 테스트, 데이터 베이스 연동 및 분리, 데이터 롤백, @Transactional, 임베디드 모드 DB, 스프링 부트와 임베디드 모드

소소한나구리 2024. 9. 19. 19:17

  출처 : 인프런 - 스프링 DB 2편 데이터 접근 핵심 원리 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2


1. 테스트 - 데이터베이스 연동

  • 데이터 접근 기술에 대해서 더 알아보기 전에 데이터베이스에 연동하는 테스트에 대해 먼저 알아보고 넘어가야 함(중요함)
  • 데이터 접근 기술을 실제 데이터베이스에 접근해서 데이터를 잘 저장하고 조회할 수 있느지 확인하는 것이 필요함
  • 메모리 DB로 테스트했던 ItemRepositoryTest를 통해서 테스트를 진행 

1) test - application.properties 수정

  • 테스트 케이스는 src/test에 있으므로 test/resources/application.properties에 데이터 베이스 연결 설정을 추가
spring.profiles.active=test

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa

#jdbcTemplate sql log
logging.level.org.springframework.jdbc=debug

2) ItemRepositoryTest - 테스트 실행

  • 테스트 코드 전체는 프로젝트 구조 설명 강의에 있음

(1) @SpringBootTest

  • 상위에 있는 @SpringBootApplication을 찾아서 설정으로 사용함
  • 현재는 설정이 @Import(JdbcTemplateV3Config.class) 이므로 테스트를 실행시 해당 설정을 사용하여 이전 강의에서 작성했던 JdbcTemplateItemRepositoryV3를 리포지토리로 사용함

(2) 테스트 실행

  • H2 데이터베이스 서버를 실행 후 테스트해보면 findItems() 메서드는 오류를 내면서 실패하는데, 코드를 살펴보면 상품을 3개를 저장하고 조회되면 item1, item2, item3을 반환하도록 기대했지만 실제로는 기대보다 더 많은 데이터가 조회 됨
@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);

    // 3개 이상이 조회되는 문제가 발생함
    test(null, null, item1, item2, item3);
   
    // ... 이하 검증 코드
}

 

(3) 실패 원인

  • TestDataInit도 프로필을 local 일때만 동작하도록 되어있으므로 test는 프로필이 test로 돌아가므로 해당 문제도 아님
  • 문제는 H2 데이터베이스에 이미 과거에 서버르 실행하면서 저장했던 데이터가 보관되어 있기 때문에 해당 데이터가 현재 테스트 데이터에 영향을 주고 있기 때문임
  • H2 데이터베이스 콘솔을 열어서 데이터를 확인해보면 데이터들이 계속 남아있는 것을 확인할 수 있으며, save(), updateItem() 테스트를 시도해서 성공하면 해당 데이터도 계속 저장되고 있는 것을 확인할 수 있음
  • 의도적으로 몇가지 테스트 데이터를 넣고 테스트 하는 겨우도 있지만 지금 findItems()인 경우에는 DB에 값이 아예 없다는 것을 가정하고 테스트를 진행 했지만 격리성이 지켜지지 않아 테스트가 실패하고 있음 -> 테스트는 격리성이 매우 중요하므로 DB를 분리해야함

2. 테스트 - 데이터베이스 분리

  • 로컬에서 사용하는 애플리케이션 서버와 테스트에서 같은 데이터베이스를 사용하고 있어 발생했던 문제를 테스트 전용 데이터베이스를 별도로 운영하여 문제를 해결
  • jdbc:h2:tcp://localhost/~/testcase 로 test케이스에서 사용하는 전용 데이터베이스를 추가

1) 데이터베이스 파일 추가하기

  • DB 서버 종료 후 재실행
  • 사용자명 sa, JDBC URL에 jdbc:h2:~/testcase (최초한번만) 입력
  • 터미널 home에 testcase.mv.db가 생성되었는지 확인
  • 이후 H2 데이터베이스 콘솔 접속은 jdbc:h2:tcp://localhost/~/testcase로 접속

2) 테이블 생성 및 접속 정보 변경

(1) item 테이블 생성

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)
);

 

(2) 접속 정보 변경

  • ~test/resources/application.properties의 H2 데이터베이스 접속 url 정보를 test -> testcase로 수정
spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase

3) 테스트 실행

  • 모두 변경 후 실행해보면 HikariPool-1 - Added connection conn0: url=jdbc:h2:tcp://localhost/~/testcase user=SA 처럼 로그에 접속정보가 testcase로 변경된 것을 알 수 있음

(1) findItems() 테스트 실행

  • 처음 findItems() 테스트를 단독으로 실행해보면 드디어 결과가 성공함
  • 그런데, 한번 다시 실행하면 테스트에 실패함
  • testcase DB에 접속해서 item 테이블을 확인해보면 처음 테스트를 실행할 때 저장된 데이터가 계속 남아있기 때문에 두번째 테스트에 영향을 준 것
  • 최초 테스트 문제 원인에 다른 테스트가 진행 되었을때의 결과가 DB에 저장되어 문제가 발생했던 것이 해결되지 않아 테스트 데이터가 오염이 되어버렸음
  • 이 문제를 해결하려면 각각의 테스트가 끝날 때 마다 해당 테스트에서 추가한 데이터를 삭제해야 다른 테스트에 영향을 주지 않음

(2) 테스트에서 매우 중요한 원칙

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

(3) 해결방안

  • 테스트가 끝날 때마다 추가한 데이터에 DELETE SQL을 사용해도 되지만 이방법도 궁극적인 해결책은 아님
  • 만약 테스트 과정에서 데이터를 이미 추가했는데 예기치 않은 상황으로 테스트가 실행되는 도중에 애플리케이션이 종료가 되어 버리며서 테스트 종료 시점에 DELETE SQL을 호출하지 못하면 똑같이 DB에 데이터가 남아있게 되므로 트랜잭션과 롤백을 활용해야함

3. 테스트 - 데이터 롤백

1) 트랜잭션과 롤백 전략

  • 테스트가 끝나고 나서 트랜잭션을 강제로 롤백해 버리면 데이터가 깔끔하게 제거됨
  • 테스트를 하면서 데이터를 이미 저장했는데 중간에 테스트가 실패해서 롤백을 호출하지 못해도 트랜잭션을 커밋하지 않았기 때문에 DB에 해당 데이터가 반영되지 않음
  • 트랜잭션 시작 -> 테스트 A 실행 -> 트랜잭션 롤백 -> 트랜잭션 시작 -> 테스트 B 실행 -> 트랜잭션 롤백 -> ... -> 테스트 끝 처럼 테스트 실행 직전에 트랜잭션을 시작하고, 각 테스트 실행 직후에 트랜잭션을 롤백하도록 하면 다음 테스트에 데이터로 인한 영향을 주지 않음

2) 테스트에 트랜잭션 추가

  • 테스트 실행 전, 후로 동작하는 @BeforeEach, @AfterEach를 활용하여 테스트에 트랜잭션과 롤백을 적용

(1) ItemRepositoryTest 코드 수정

  • @Autowired로 PlatformTransactionManeger를 주입받아서 사용하고 스프링부트는 자동으로 적절한 트랜잭션 매니저를 스프링 빈으로 등록해줌(o.s.jdbc.support.JdbcTransactionManager) 이 동작함
  • @BeforeEach: 각각의 테스트 케이스를 실행하기 직전에 호출하여 .getTransaction(new DefaultTransactionDefinition());으로 트랜잭션을 시작
  • @AfterEach: 각각의 테스트 케이스가 완료가 된 직후에 호출하여 .rollback(status)로 트랜잭션을 롤백함
  • 리포지토리는 각 테스트 메서드를 호출할 때 모두 트랜잭션 동기화 매니저에 있는 커넥션을 사용하기 하므로 모두 동일한 커넥션, 트랜잭션을 사용하게 되어 각각의 테스트를 트랜잭션 범위 안에서 실행 하고 복구할 수 있음
package hello.itemservice.domain;

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    PlatformTransactionManager transactionManager;
    TransactionStatus status;

    @BeforeEach
    void beforeEach() {
        // 트랜잭션 시작
        status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    }

    @AfterEach
    void afterEach() {
        // ... 메모리 DB관련 코드 생략

        // 트랜잭션 롤백
        transactionManager.rollback(status);
    }

    // ... 테스트 코드 동일
}

 

(2) 테스트 실행

  • 테스트에 영향을 주지 않도록 H2 데이터베이스 testcase의 item  테이블에 남아있는 기존 데이터를 깔끔하게 삭제 후 테스트 실행하면 모두 성공하는 것을 확인할 수 있음
  • 테스트 실행 후 DB에서 조회를해보면 데이터베이스에 데이터가 모두 삭제 된 것을 확인할 수 있음
  • 이제 반복적으로 테스트를 계속 실행해도 테스트가 통과됨

4. 테스트 - @Transactional

  • DB분리, 트랜잭션 롤백을 적용해서 테스트를 반복 시행해도 계속 성공되게 했지만 적용하기가 좀 불편한데, 이런 부분을 스프링이 제공하는 @Transactional 애노테이션 하나로 깔끔하게 해결할 수 있음

1) 테스트에서 @Transactional 적용

  • 적용해보기 위해서 이전에 테스트에 트랜잭션과 롤백을 위해 추가했던 코드들을 모두 주석 처리 진행 후 Test클래스에 @Transactional을 작성하면 끝
  • 테스트 진행전 testcase DB에 데이터가 있다면 삭제하고 테스트를 진행해보면, 트랜잭션을 적용하기위해 번거롭게 입력했던 코드들 없이 @Transactional 하나로 모든 테스트가 반복적으로 성공하게 됨
  • 테스트 후 DB에서 데이터베이스에 데이터를 확인해보면 롤백되어 데이터가 없는 것을 확인할 수 있음
  • 기존 @Transactional 사용하는 방법과 마찬가지로 클래스 전체에 적용하려면 클래스 레벨에, 특정 메서드에 적용하려면 특정 메서드에 @Transactional을 적용하면 됨
@Transactional
@SpringBootTest
class ItemRepositoryTest {
   // ... 기존 테스트 코드 동일(트랜잭션을 구성하기 위해 작성했던 코드는 모두 제거하거나 주석처리해야함)
}

2) 테스트에서의 @Transactional 원리

  • 원래 우리가 알던 @Transactional은 로직이 성공적으로 수행되면 커밋하도록 동작함
  • 그러나 테스트에서 사용하게 되면 특별하게 동작하도록 설계되어있는데, 스프링은 테스트를 트랜잭션안에서 실행하고 테스트가 끝나면 트랜잭션을 자동으로 롤백시켜 버림

(1) @Transactional이 적용된 findItems() 메서드의 테스트 동작 방식

@Transactional이 적용된 테스트 동작 방식

  1. 테스트에 @Transactional 애노테이션이 테스트 메서드나 클래스에 있으면 먼저 트랜잭션을 시작함
  2. 테스트 로직을 실행하고 테스트가 끝날 때 까지 모든 로직은 트랜잭션 안에서 수행됨(트랜잭션은 기본적으로 전파가 되어서 리포지토리에서 사용하는 JdbcTemplate도 같은 트랜잭션을 사용함)
  3. 테스트 실행 중 INSERT SQL을 사용해서 item1, item2, item3을 DB에 저장
  4. 검증을 위해서 SELECT SQL로 데이터를 조회 (SELECT SQL도 같은 트랜잭션을 사용하기 때문에 저장한 데이터를 조회할 수 있음, 다른 트랜잭션에서는 해당 데이터를 확인할 수 없음) 하고 assertThat()으로 검증이 끝남
  5. @Transactional이 테스트에 있으므로 테스트가 끝날때 트랜잭션을 강제로 롤백함
  6. 롤백에 의해 앞서 데이터베이스에 저장한 item1, item2, item3의 데이터가 제거됨

** 참고

  • 테스트 케이스의 메서드나 클래스에 @Transactional을 직접 붙여서 사용할 때만 이렇게 동작함
  • 트랜잭션을 테스트에서 시작하기 때문에 서비스, 리포지토리에 있는 @Transactional도 테스트에서 시작한 트랜잭션에 참여함
  • 트랜잭션 참여 부분은 이후 강의인 트랜잭션 전파에서 자세히 설명할 예정
  • 지금은 테스트에서 트랜잭션을 실행하면 테스트 실행이 종료될 때까지 테스트가 실행하는 모든 코드가 같은 트랜잭션 범위(같은 트랜잭션을 사용 == 같은 커넥션을 사용)에 들어간다고 이해하면 됨

(2) 정리

  • 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공하며 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 트랜잭션을 커밋하지 않았기 때문에 자동으로 롤백되어 걱정이 없음(보통 DB 커넥션이 끊어지면 자동으로 롤백되어 버림)
  • 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않음
  • 다른 테스트와 격리, 반복 테스트 실행 해야하는 원칙을 매우 편리하게 지킬 수 있게 됨

(3) 강제로 커밋

  • 가끔 실무를 하다보면 테스트에서도 롤백이 아닌 커밋하여 테스트 결과를 눈으로 확인하고 싶을 때도 있는데 그럴때에는 @Commit 이나, @Rollback(value = false)를 사용하면 테스트 종료 후 롤백이 아닌 커밋으로 동작함
  • 마찬가지로 클래스 레벨뿐만 아니라 특정 메서드에도 적용 가능함
@Commit or @Rollback(value = false)
@Transactional
@SpringBootTest
class ItemRepositoryTest {}

5. 테스트 - 임베디드 모드 DB

  • 테스트 케이스를 실행하기 위해서 별도의 DB를 설치하고 운영하는 것은 상당히 번잡한 작업이므로 단순히 테스트를 검증할 용도로만  DB를 사용한다면 테스트가 끝난뒤 데이터베이스의 데이터를 모두 삭제해도 되며 더 나아가서 데이터베이스 자체를 제거해도 됨

1) 임베디드 모드

  • H2 데이터베이스는 자바로 개발되어 있고 JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공하는데, 애플리케이션을 실행할 때 H2 데이터베이스도 해당 JVM 메모리에 포함되서 함께 실행할 수 있음
  • DB를 애플리케이션에 내장해서 함께 실행한다고 하여 임베디드 모드(Embedded mode)라고 함
  • 애플리케이션이 종료되면 임베디드모드로 동작하는 H2 데이터베이스도 함께 종료되고 데이터도 모두 사라짐(자바 메모리를 함께 사용하는 라이브러리 처럼 동작)

2) 임베디드 모드 직접 사용

(1) ItemServiceApplication에 @Bean 추가

  • @Profile("test")인 경우에 데이터 소스를 직접 스프링 빈으로 등록 -> 테스트 케이스에서만 사용
  • org:h2:mem:db; 이부분이 중요, 데이터 소스를 만들 때 이렇게 적으면 임베디드 모드(메모리 모드)로 동작하는 H2 데이터베이스를 사용할 수 있음
  • DB_CLOSE_DELAY=-1 설정은 임베디드 모드에서 데이터베이스 커넥션 연결이 모두 끊어지면 데이터베이스도 종료되는 것을 방지해주므로 입력해주는 것이 좋음
package hello.itemservice;

@Slf4j
//@Import(MemoryConfig.class)
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

    // ... 다른 코드 동일

	@Bean
	@Profile("test")
	public DataSource dataSource() {	// 프로필이 test인 경우에만 데이터 소스를 직접 등록
		log.info("메모리 데이터베이스 초기화");
		DriverManagerDataSource dataSource = new DriverManagerDataSource();
		dataSource.setDriverClassName("org.h2.Driver");				// h2 드라이버 지정
		dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");		// h2 DB를 mem(메모리)로 실행
		dataSource.setUsername("sa");
		dataSource.setPassword("");
		return dataSource;
	}
}

 

(2) 실행

  • 설정이 모두 끝나고 H2 DB가 켜져있다면 종료한 후 테스트를 실행 해보면, 오류가 발생하며 로그에서 ITEM 테이블이 없다고 뜸
  • 생각해보면 설정만 완료했지 메모리 DB에는 테이블을 만들어 준 적이 없으므로 테이블을 생성해 주어야함

발생한 오류

  • 수동으로 테이블을 생성할 수 있지만 스프링 부트가 해당 문제를 해결하는 편리한 기능을 제공함

(3) 스프링 부트 - 기본 SQL 스크립트를 사용해서 데이터베이스를 초기화

  • 메모리 DB는 애플리케이션이 종료될 때 함께 사라지기 때문에 애플리케이션 실행 시점에 데이터베이스 테이블도 새로 만들어 주어야함
  • JDBC나 JdbcTemplate을 직접 사용해서 테이블을 생성하는 DDL을 호출해도 되지만 너무 불편함
  • 스프링 부트는 SQL 스크립트를 실행해서 애플리케이션 로딩 시점에 데이터베이스르 초기화하는 기능을 제공함
  • ~src/test/resources 경로에 schema.sql 파일을 만들고 테이블을 생성하는 SQL를 작성
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)
);

 

** 참고

(4) 다시 테스트 실행

  • H2 데이터베이스를 띄우지 않았음에도 테스트가 모두 정상적으로 동작하는 것을 확인할 수 있음

6. 테스트 - 임베디드 모드 DB

  • 스프링부트는 개발자에게 정말 많은 편리함을 제공하는데 임베디드 데이터베이스 설정도 기본으로 제공함
  • 테스트시에는 위에서 해봣던 것 처럼 임베디드모드로 데이터베이스를 정말 많이 사용할텐데, 스프링부트는 데이터베이스에 대한 별다른 설정이 없으면 임베디드 데이터베이스를 사용함

1) 기존 메모리 DB 설정을 모두 주석처리

(1) ItemServiceApplication

  • test 프로필일때 데이터소스를 등록했던 @Bean 설정을 모두 주석처리하거나 제거
/*
@Bean
@Profile("test")
public DataSource dataSource() {	// 프로필이 test인 경우에만 데이터 소스를 직접 등록
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");				// h2 드라이버 지정
    dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");		// h2 DB를 mem(메모리)로 실행
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}
*/

 

(2) ~test/resources/application.properties

  • test 하위의 application.properties에 설정했던 datasource 관련 설정들을 모두 주석처리 하거나 제거
#spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
#spring.datasource.username=sa

2) 실행

  • 이렇게 아무 설정안하고 테스트를 실행해보면 테스트가 정상 수행됨
  • 로그를 보면  conn0: url=jdbc:h2:mem:7653c1b6-84bf-41f1-a66d-9b96a1a152b8 user=SA 이렇게 H2 데이터베이스를 메모리를 띄우고 임의의 넘버링이 붙어있는 것을 볼 수 있음(임의의 이름은 여러 데이터소스가 사용될 때 같은 데이터베이스를 사용하면서 발생하는 충돌을 방지하기 위해 스프링부트가 임의의 이름을 부여하는 것)
  • 만약 데이터베이스 이름을 스프링 부트가 기본으로 제공하는 jdbc:h2:mem:testdb로 고정하려면 application.properties에 아래의 설정을 추가하면 되지만 충돌 방지를 위해 사용하지 않는 것을 권장
spring.datasource.generate-unique-name=false

 

3) 정리

  • 빙빙 돌아왔지만 정리해보면 테스트는 @Transactional을 붙혀주면 테스트도 트랜잭션을 적용해서 반복할 수 있음
  • 스프링부트로 했다면 아무런 DB설정 없이 알아서 임베디드 모드 DB를 동작해주므로 그냥 테스트를 돌려도 됨