Search

10장 데이터베이스 테스트

작성자
진행일
2022/12/29
통합 테스트의 마지막 퍼즐은 프로세스 외부 관리 의존성이다. 가장 일반적인 예시인 데이터베이스를 테스트 하는 방법을 알아보자.

10. 1 데이터베이스 테스트를 위한 전제 조건

8장에서 살펴봤듯이, 통합 테스트에서는 관리 의존성이 그대로 있어야 한다. 테스트를 작성하기 전에 통합 테스트가 가능하게끔 준비 단계를 수행해야 하는데, 다음과 같은 전제조건을 먼저 살펴보자.

데이터베이스를 형상 관리 시스템에 유지

DB를 테스트하는 방법의 첫 단계는 DB 스키마를 일반 코드로 취급하는 것이다. 모든 DB 스키마 업데이트를 형상 관리 시스템(ex: Git)에 두면 원천 정보를 하나로 할 수 있고, 일반 코드 변경과 함께 DB 변경을 추적할 수 있다.

참조 데이터도 데이터베이스 스키마다.

참조 데이터 : 애플리케이션이 제대로 작동하도록 미리 채워야 하는 데이터
DB 스키마라면 자주 거론되는 대상은 테이블, 뷰, 인덱스, 저장 프로시저 그리고 나머지 모든 것이다. 스키마는 SQL 스크립트 형태로 표현된다. 하지만 참조 데이터는 DB 스키마에 속하지만, DB 스키마로 거의 여기지 않는다.
이전 장의 CRM 시스템에서, 사용자 타입은 Customer 또는 Employee 일 수 있었다. 모든 사용자 타입이 포함된 테이블을 만들고 User에서 해당 테이블로 외래 키 제약을 두고 싶다고 가정해보자. 이런 조건을 통해 애플리케이션이 사용자에게 존재하지 않는 타입을 할당하지 않게끔 보증할 수 있다. 이 시나리오에서 UserType 테이블의 내용이 참조 데이터에 해당한다.
애플리케이션이 데이터를 수정할 수 있으면 일반 데이터고, 그렇지 않으면 참조 데이터다.
참조 데이터는 애플리케이션의 필수 사항이므로, 다른 DB 스키마와 함께 SQL INSERT 문 형태로 형상 관리 시스템에 저장해야 한다.

모든 개발자를 위한 별도의 데이터베이스 인스턴스

실제 DB로 테스트하는 것은 어렵다. 다른 개발자들과 DB를 공유해야 한다면 더 어렵다. 서로 다른 개발자가 실행한 테스트가 간섭되기도 하고, 하위 호환성이 없는 변경으로 다른 개발자의 작업을 막을 수도 있다. 테스트 실행 속도를 극대화하려면 개발자마다 별도로 DB 인스턴스를 사용하라.

상태 기반 데이터베이스 배포와 마이그레이션 기반 데이터베이스 배포

DB 배포에는 상태 기반과 마이그레이션 기반이라는 두 가지 방식이 있다. 마이그레이션 기반 방식은 초기 구현과 유지보수가 어렵지만 장기적으로 상태 기반보다 훨씬 효과적이다.
상태 기반 배포 방식은 개발 내내 유지보수하는 모델 DB가 있다. 배포 중에 비교 도구가 스크립트를 생성해서 운영 DB를 모델 DB와 비교해서 최신 상태로 유지한다. 차이점은 상태 기반 방식을 사용하면 물리적인 모델 DB는 원천 데이터가 아니라는 것이다.
반면 마이그레이션 기반은 DB를 어떤 버전에서 다른 버전으로 전환하는 명시적인 마이그레이션을 의미한다. 이 방식은 운영 DB와 개발 DB를 자동으로 동기화하기 위한 도구를 쓸 수 없고, 업그레이드 스크립트를 직접 작성해야 한다.
// 마이그레이션 1 CREATE TABLE dbo.Customer(...) -> // 마이그레이션 2 ALTER TABLE dbo.Customer(...) -> // 마이그레이션 3 CREATE TABLE dbo.User(...)
Kotlin
복사
상태 기반 방식은 상태를 형상 관리에 저장함으로써 상태를 명시하고 비교 도구가 마이그레이션을 암묵적으로 제어할 수 있게 한다.
마이그레이션 기반 방식은 마이그레이션을 명시적으로 하지만 상태를 암묵적으로 둔다. 데이터베이스 상태를 직접 볼 수 없으며 마이그레이션으로 조합해야 한다.
데이터베이스 상태
마이그레이션 메커니즘
상태 기반 방식
명시적
암묵적
마이그레이션 기반 방식
암묵적
명시적
데이터베이스 상태가 명확하면 충돌을 처리하기가 수월한 반면, 명시적 마이그레이션은 데이터 모션 문제를 해결하는데 도움이 된다.
데이터 모션 : 새로운 데이터베이스 스키마를 준수하도록 기존 데이터 형태를 변경하는 과정이다.
대부분 프로젝트에서는 데이터 모션이 충돌보다 훨씬 더 중요하다. 아직 애플리케이션을 운영 환경에 릴리스하지 않는 경우가 아니라면 쉽게 폐기할 수 없는 데이터가 반드시 있을 것이다.

10. 2 데이터베이스 트랜잭션 관리

데이터베이스 트랜잭션 관리는 제품 코드와 테스트 코드 모두에 중요한 주제다.

제품 코드에서 데이터베이스 트랜잭션 관리하기

예시의 CRM 프로젝트는 Database 클래스를 사용하여 User 및 Company와 작동한다. Database는 각 메서드 호출에서 별도의 SQL 연결을 생성한다.
읽기 전용 연산 중에는 여러 트랜잭션을 열어도 괜찮지만 비즈니스 연산에 데이터 변경이 포함된다면, 이 연산에 포함된 모든 업데이트는 원자적이어야 한다.
잠재적 모순을 피하려면 결정 유형을 두 가지로 나눠야 한다.
업데이트 할 데이터
업데이트 유지 또는 롤백 여부
컨트롤러가 이런 결정을 동시에 내릴 수 없으므로 이렇게 분리하는 것이 중요하며 비즈니스 연산의 모든 단계가 성공했을 때 업데이트를 수행할 수 있는지 여부만 안다. Database 클래스를 리포지터리와 트랜잭션으로 나눠서 이러한 책임을 구분할 수 있다.
리포지터리는 DB의 데이터에 대한 접근과 수정을 가능하게 하는 클래스다.
트랜잭션은 업데이트를 완전히 커밋하거나 롤백하는 클래스다.
리포지터리와 트랜잭션은 책임이 서로 다를 뿐만 아니라 수명도 다르다. 트랜잭션은 전체 비즈니스 연산 동안 존재하며 연산이 끝나면 폐기된다. 반면 리포지터리는 수명이 짧다. DB 호출이 완료되는 즉시 리포지토리를 폐기할 수 있다.
트랜잭션 도입 전
step2_1 : 코드 참조
트랜잭션 도입 후
Transaction 내부는 중요하지 않지만, TransactionScope를 배후에서 사용하고 있다. Transaction 에서 중요한 점은 다음 두 가지 메서드가 있다는 것이다.
commit()은 트랜잭션을 성공으로 표시한다. 비즈니스 연산이 성공하고 모든 데이터 수정을 저장할 준비가 된 경우에만 호출된다.
dispose()는 트랜잭션을 종료한다. 비즈니스 연산이 끝날 때 항상 호출된다. 이전에 commit()이 호출된 경우 dispose()는 모든 데이터 업데이트를 저장하고, 그렇지 않으면 롤백한다.
리포지터리와 트랜잭션을 도입하면 잠재적인 데이터 모순을 피할 수 있지만 더 좋은 방법이 있다. Transaction 클래스를 작업 단위로 업그레이드 하는 것이다.
일반 트랜잭션과 비교하여 작업 단위가 갖는 가장 큰 장점은 업데이트 지연이다. 트랜잭션과 달리 작업 단위는 비즈니스 연산 종료 시점에 모든 업데이트를 실행하므로 데이터베이스 트랜잭션의 기간을 단축하고 데이터 혼잡을 줄인다.
대부분의 ORM 라이브러리가 작업 단위 패턴을 구현한다. UserController에 엔티티 프레임워크를 적용하면 다음 예제와 같아진다.
step2_2 : 코드 참조
이제, DB와 도메인 모델 간의 매핑을 포함하는 커스텀 클래스인 CrmContext를 사용한다. 결과적으로,
이전 버전에서 두 리포지터리가 Transaction 위에서 작동한 것처럼 CrmContext 위에서 작동한다.
컨트롤러는 context.saveChanges를 통해 DB에 변경 사항을 커밋한다.
이제 UserFactoryCompanyFactory는 엔티티 프레임워크를 사용함에 따라 필요가 없어졌다.

통합 테스트에서 데이터베이스 트랜잭션 관리하기

통합 테스트에서 DB 트랜잭션을 관리하는 경우 테스트 구절 간에 DB 트랜잭션이나 작업 단위를 재사용하지 말라.
step2_2 : 코드 참고
이 테스트는 준비, 실행, 검증이라는 세 구절 모두에 동일한 CrmContext 인스턴스를 사용한다. 이렇게 작업 단위를 재사용하는 것은 컨트롤러가 운영 환경에서 하는 것과 다른 환경을 만들기 때문에 문제가 된다.
동작 모순에 빠지지 않으려면 통합 테스트를 가능한 한 운영 환경에서와 비슷하게 해야 한다.
통합 테스트에서 적어도 세 개의 트랜잭션 또는 작업 단위를 사용하라.(준비, 실행, 검증)

10.3 테스트 데이터 생명 주기

공유 데이터베이스를 사용하면 통합 테스트를 서로 분리할 수 없는 문제가 생긴다. 이 문제를 해결하려면,
통합 테스트를 순차적으로 실행하라.
테스트 실행 간에 남은 데이터를 제거하라.

병렬 테스트 실행과 순차적 데이터 실행

통합 테스트를 병렬로 실행하려면 상당한 노력이 필요하다. 모든 테스트 데이터가 고유한지 확인해야 DB 제약 조건을 위반하지 않고, 테스트가 다른 테스트 후에 입력 데이터를 잘못 수집하는 일이 없다.
대부분의 단위 테스트 프레임워크는 별도의 테스트 모음을 정의하고 일부에 대해 병렬 처리를 비활성화 할 수 있다.
데이터베이스는 개발자당 하나의 인스턴스만 갖는 것이 더 실용적이다.

테스트 실행 간 데이터 정리

테스트 실행 간에 남은 데이터를 정리하는 방법은 네 가지가 있다.
각 테스트 전에 DB 백업 복원하기 : 데이터 정리 문제를 해결할 수 있지만 다른 세 가지 방법보다 느리다.
테스트 종료 시점에 데이터 정리하기 : 빠르지만 건너뛰기 쉽다. 테스트 도중 서버가 중단되거나 디버거에서 테스트를 종료하면 입력 데이터가 DB에 남아있게 된다.
DB 트랜잭션에 각 테스트를 래핑하고 커밋하지 않기 : 이 경우 테스트와 SUT에서 변경한 모든 내용이 자동으로 롤백된다. 이 방법은 작업 단위를 재사용할 때와 같은 문제가 발생하는데, 추가 트랜잭션으로 인해 운영 환경과 다른 설정이 생성되는 것이다.
테스트 시작 시점에 데이터 정리하기 : 이 방법이 가장 좋다. 빠르게 작동하고 일관성이 없는 동작을 일으키지 않으며, 정리 단계를 실수로 건너뛰지 않는다.
별도의 종료 단계는 필요 없다. 준비 구절에 준비하라.
모든 통합 테스트의 베이스 클래스를 두고, 삭제 스크립트를 작성하라. 이렇게 베이스 클래스를 사용하면 다음 예제와 같이 테스트가 시작될 때마다 스크립트가 자동으로 실행되도록 할 수 있다.
step3_2 : 코드 참고

인메모리 데이터베이스 피하기

통합 테스트를 서로 분리하는 또 다른 방법으로 DB를 SQLite와 같은 인메모리 데이터베이스로 교체할 수도 있다. 인메모리 데이터베이스는 다음과 같은 장점이 있다.
테스트 데이터를 제거할 필요가 없음
작업 속도 향상
테스트가 실행될 때마다 인스턴스화 가능
이런 장점에도 불구하고, 인메모리 DB는 일반 DB와 기능적으로 일관성이 없기 때문에 사용하지 않는 것이 좋다. 이는 또 다시 운영 환경과 테스트 환경이 일치하지 않는 문제이며, 일반 DB와 인메모리 DB의 차이로 인해 테스트에서 거짓 양성 또는 거짓 음성이 발생하기 쉽다.

10.4 테스트 구절에서 코드 재사용하기

통합 테스트가 너무 빨리 커지면 유지 보수 지표가 나빠질 수 있다. 통합 테스트는 가능한 한 짧게 하되 서로 결합하거나 가독성에 영향을 주지 않는 것이 중요하다.
통합 테스트를 짧게 하기에 가장 좋은 방법은 비즈니스와 관련이 없는 기술적인 부분을 비공개 메서드나 헬퍼 클래스로 추출하는 것이다. 더구나 그 부분은 재사용할 수 있다.

준비 구절에서 코드 재사용하기

다음 예제는 각 구절에 대해 별도의 DB 컨텍스트를 두고 나서의 통합 테스트다.
step4 : 테스트 코드 참고

실행 구절에서 코드 재사용하기

실행 구절도 줄일 수 있다. 어떤 컨트롤러 기능을 호출해야 하는지에 대한 정보가 있는 대리자를 받는 메서드를 도입할 수 있다.
step4 : 테스트 코드 참고

검증 구절에서 코드 재사용하기

마지막으로 검증 구절도 줄일 수 있다. 가장 쉬운 방법은 다음 예제와 같이 createUsercreateCompany와 유사한 헬퍼 메서드를 두는 것이다.
step4 : 테스트 코드 참고
여기서 한 발짝 더 나아가, 9장에서 본 BusSpy와 같이 데이터 검증을 위한 플루언트 인터페이스를 만들 수 있다.
step4 : 테스트 코드 참고

테스트가 데이터베이스 트랜잭션을 너무 많이 생성하는가?

이전에 설명한 대로 통합 테스트를 간결하게 하면 더 읽기 쉽고 유지 보수가 용이하다. 그러나 한 가지 단점이 있는데, 이전에는 데이터베이스 트랜잭션을 세 개만 사용한 데 반해 이 테스트는 총 다섯 개의 데이터베이스 트랜잭션을 사용한다는 것이다.
DB 트랜잭션 수가 증가하는 것이 문제가 되는가? 이런 경우에는 유지 보수성을 위해 성능을 양보함으로써 절충하는 것이 좋다. 그리고 성능 저하가 그다지 크지 않을 것이다. 특히 DB가 개발자 머신에 있는 경우 그렇다.

10.5 데이터베이스 테스트에 대한 일반적인 질문

이 장의 마지막 절에 데이터베이스 테스트에 관한 일반적인 질문에 답하고, 8장과 9장에서 다룬 몇 가지 중요 사항을 간단히 되짚어 본다.

읽기 테스트를 해야 하는가?

대부분의 애플리케이션에는 읽기와 쓰기 작업이 있다. 쓰기와 읽기를 모두 테스트해야 할까?
쓰기는 철저히 테스트하는 것이 매우 중요하다. 위험성이 높기 때문이다.
하지만 읽기는 이에 해당하지 않는다. 읽기 작업의 버그에는 보통 해로운 문제가 없다. 가장 복잡하거나 중요한 읽기 작업만 테스트하고, 나머지는 무시하라.
또한 읽기에는 도메인 모델이 필요하지 않다. 읽기에는 추상화 계층이 거의 없기 때문에 단위 테스트가 아무 소용이 없다. 또한 읽기 오류의 비용이 쓰기의 경우보다 낮기 때문에 통합 테스트도 크게 필요하지 않다.

리포지토리 테스트를 해야 하는가?

리포지터리는 DB 위에 유용한 추상화를 제공한다. 리포지터리를 다른 통합 테스트와는 독립적으로 테스트해야 하는가? 리포지터리가 도메인 객체를 어떻게 DB에 매핑하는지를 테스트하는 것이 유익할지 모른다. 결국 이 기능에는 실수가 상당히 있을 여지가 있다. 그러나 이런 테스트는 유지비가 높고 회귀 방지가 떨어져서 테스트 스위트에 손실이 된다.
리포지터리는 7장의 코드 타입 다이어그램에서 컨트롤러 사분면(협력자 수가 많고 복잡도 및 도메인 유의성이 낮음)에 포함되며, 복잡도가 거의 없고 프로세스 외부 의존성인 DB와 통신한다. 프로세스 외부 의존성이 있으면 테스트 유지비가 증가한다.
리포지터리를 테스트하기에 가장 좋은 방법은 리포지터리가 갖고 있는 약간의 복잡도를 별도의 알고리즘으로 추출하고 해당 알고리즘 전용 테스트를 작성하는 것이다. 바로 이전 장에서 설명한 UserFactoryCompanyFactory가 여기에 해당한다.
안타깝게도 ORM을 사용할 때 데이터 매핑과 데이터베이스 상호작용 간의 분리는 불가능하다. 적어도 리팩터링 내성이 저하되지 않고서는 DB 호출 없이 ORM 매핑을 테스트할 수 없다.
따라서 다음 지침을 준수하라. 리포지터리는 직접 테스트하지 말고, 포괄적인 통합 테스트 스위트의 일부로 취급하라.