본문 바로가기
기술 경험

시간 도둑 테스트 코드, 효율화를 위한 발버둥

by Alex_beom 2024. 7. 26.

시작하기 앞서..

현재 저희 팀이 관리하고 있는 백엔드 프로젝트에서는 아쉽게도 테스트 코드가 작성되어 있는 것이 없습니다.
하지만 저희 회사 개발 조직에서 권고하고 있는 최소 테스트 커버리지는 50% 이상인데요.
그에 맞게 저희 팀도 테스트 코드에 대한 필요성을 느꼈습니다.

따라서 저는 팀 내 주요 프로젝트에 테스트 코드를 효율적으로 관리하기 위한 전략을 세우고, 예시 코드를 통한 가이드를 작성하고, 테스트 환경을 구축해 나갔습니다.
이 글에서는 테스트 코드 전략과 간단한 예시 코드를 소개해보면서 관련 지식들을 정리해보려고 합니다.

 

 

3가지 테스트 코드 전략

백엔드 API에서 많이 사용되는 아키텍처 중 하나는 레이어드 아키텍처입니다.
저희 프로젝트들도 기본적으로 레이어드 기반의 4개의 계층(4-tier Architecture)으로 구축되어 있는데요.

 

여기서 저는 다음과 같은 고민을 하였습니다.

  • 어떻게 하면 하나의 테스트 코드가 최대한 많은 영역(Layer)을 커버할 수 있을까?
  • 어떻게 하면 지금 작성한 테스트 코드가 추후에 진행하는 리팩터링에도 깨지지 않고 안전하게 방어막 역할을 해줄 수 있을까?

 

며칠 간의 토론과 고민 끝에 다음과 같은 결론을 내렸습니다.

  1. 통합 테스트는 E2E(End-to-End) 방식으로 진행하여 외부 장벽 쌓기
  2. 내부 로직에서 외부 환경과 통신하는 의존성이 있을 경우, Mock을 사용하여 실제 요청 우회하기
  3. E2E 방식의 통합 테스트에서 커버하지 못하는 비즈니스 로직 등의 검증은 단위 테스트로 커버하기

 

1. 통합 테스트는 E2E(End-to-End) 방식으로 진행하여 외부 장벽 쌓기

E2E(End-to-End) 방식의 통합 테스트는 사용자의 관점에서 소프트웨어의 전체적인 흐름을 검증하는 테스트입니다.

물론 기능에 필요한 모든 하위 시스템(DB, 캐시, 메시지큐 등)을 실제 연동하여 테스트를 진행합니다.

조금 더 정리하자면 테스트 환경을 운영 환경과 동일하게 구축해 놓고, 테스트 대상인 API를 직접 HTTP 통신을 통해 요청/응답이 원하는 대로 잘 흘러가는지 검증하는 테스트입니다.

 

제가 E2E(End-to-End) 방식의 통합 테스트를 채택한 이유는 아래와 같습니다.

  • 실무에서는 테스트 코드를 작성하는 시간이 별도로 주어지기 어렵습니다.
  • 따라서 최대한 효율적으로 하나의 테스트 코드가 최대한 많은 영역(Layer)을 커버하는 것이 좋습니다.
  • 프러덕션 환경과 동일한 상태에서 진행하는 E2E 방식의 통합 테스트는 모든 영역(하위 시스템)을 검증할 수 있습니다.
  • 즉 Presentation, Application, Infrastructure 영역의 검증은 E2E 테스트 코드 하나로 모두 커버될 수 있습니다.

 

따라서 E2E 방식의 통합 테스트가 가장 효율적이라고 판단하고, 아래와 같은 그림을 그렸습니다.

 

참고로 프러덕션 환경과 동일한 상태를 만들어야 한다고 해서, 꼭 동일한 Database를 사용할 필요는 없습니다.

하지만 저는 회사 운영 환경에서 사용되고 있는 Oracle DB를 그대로 테스트 환경에도 구성했는데요.

굳이 동일한 DB로 사용한 이유는, 현재 많은 레거시 코드에서 SQL 표준문법(ANSI SQL) 이외에도 Oracle DB에서만 제공하는 문법과 기능들을 많이 사용하고 있기 때문입니다.

(테스트 환경 관련 내용은 0에서 1로 가기 위한 테스트 환경 구축에서 자세히 소개합니다.)

 

결론적으로 저는 위 사진과 같이 테스트 환경에서 Oracle DB와 Spring 컨테이너에 모든 Bean들을 띄운 상태로 E2E 테스트를 진행할 수 있도록 구성하였습니다.

그리고 E2E 테스트 코드는 유저 시나리오 기반의 실제 HTTP 요청을 진행하도록 작성하였습니다.

 

이 경우, 얻을 수 있는 장점은 다음과 같습니다.

  • 시나리오 기반의 E2E 테스트 코드는 곧 명세서의 역할도 수행할 수 있습니다.
  • E2E 테스트는 실제 유저처럼 완전 외부에서 HTTP 요청을 진행하기에, 서비스 관점에서는 가장 외부에서의 테스트 보호막(외부 장벽)이 형성됩니다.
  • 내부 Layer 영역(Presentation, Application 등)에서의 통합 테스트 코드를 작성하지 않아도 E2E 테스트로 커버됩니다.

 

예시 코드는 다음과 같습니다.
(예시 코드와 회사 코드는 약간의 차이는 있지만, 거의 동일합니다.)

 

2. 내부 로직에서 외부 환경과 통신하는 의존성이 있을 경우, Mock을 사용하여 실제 요청 우회하기

특정 API 기능에서는 요구사항에 따라 외부 환경을 요청해야 하는 경우가 있습니다.

하지만 외부 환경의 경우, 아래의 사례처럼 요청 횟수나 비용 등의 제한이 있는 경우가 많은데요.

  • 하루 최대 요청 횟수가 300회 제한인 맞춤법 검사 조회
  • 요청할 때마다 비용이 지불되는 주식 관련 정보 조회
  • 실제 결제가 진행되는 PG사 요청

 

따라서 테스트 코드를 실행할 때마다 실제 외부 환경을 요청을 우회할 필요가 있습니다.

이때 사용할 수 있는 기법이 Mock과 Fake인데요.

 

Mock 방식을 이용할 땐 mockito에서 제공해 주는 기능(@Mock)이나 Spring이 제공해 주는 기능(@MockBean)을 사용하고, Fake 방식을 이용할 때는 외부 환경을 요청하는 객체를 Interface로 도출한 후에 테스용으로 기능을 "간단"하게 구현한 가짜 객체(Fake Object)를 만들고 이를 주입(DI)하여 사용하면 됩니다.

 

외부 환경과 통신하는 의존성이 있는 기능을 테스트하는 경우, 아래와 같은 그림으로 진행됩니다.

 

위와 같이 Service 객체(Application Layer)에서 외부 환경을 호출하는 객체와 DB 등의 내부 환경을 호출하는 객체가 있다고 가정해 봅니다.

이때 중요한 부분은 외부 환경을 호출하는 객체는 Mock이나 Fake 객체를 사용하여 실제 요청을 우회하지만, 내부 환경을 호출하는 객체는 실제 객체를 사용하는 것이 좋습니다.

 

여기서 DB, 캐시 저장소 등 내부 환경들도 애플리케이션 관점에선 외부 시스템이니까 동일하게 실제 객체를 안 쓰는 게 좋지 않나? 라는 의문이 들 수도 있습니다.

하지만 위의 1번 전략에서 말했듯이 저희 통합 테스트의 기본 전제는 테스트 환경을 운영 환경과 동일하게 구축해 놓고, 테스트 대상인 API를 직접 HTTP 통신을 통해 요청/응답이 원하는 대로 잘 흘러가는지 검증하는 테스트라는 걸 다시 한번 상기하면 좋을 것 같습니다.

 

내용을 정리해 보겠습니다.

  1. 내부 환경(DB, 캐시 저장소 등)은 개발자가 직접 컨트롤이 가능한 영역이므로 이 부분은 실제 객체를 사용합니다.
  2. 완전 외부 환경은 개발자가 직접 컨트롤할 수 없는 영역이기 때문에 이 부분은 Mock과 Fake를 통해 우회를 진행합니다.

 

이 경우, 얻을 수 있는 장점은 다음과 같습니다.

  • 테스트 독립성 : 테스트 실행 시 외부 환경의 의존성을 단절(slice)시켜서, 순수하게 우리의 영역에 있는 우리의 로직만을 테스트할 수 있습니다. 이는 외부 환경 문제에 따른 테스트 실패를 방지할 수 있습니다.
  • 속도 향상 : 외부 환경 호출은 네트워크 속도, 외부 서버의 상태 및 응답속도(Latency) 등의 이슈로 우리의 테스트 속도에 영향이 미치는 문제를 방지할 수 있습니다.
  • 비용 절감 : 실제 외부 환경 요청을 하지 않기 때문에, API 호출에 따른 비용을 절감할 수 있습니다. (유료 호출 API인 경우)

 

예시 코드는 설명으로 대체하겠습니다.

  • Mock 방식을 사용할 경우
    • 외부 환경 의존 객체는 @MockBean이나 @Mock을 선언하여 사용합니다.
    • 나머지 내부 환경 의존 객체는 @Autowired를 통해 실제 객체를 주입받습니다.
    • 이후 Service 객체의 생성자를 통해 각각의 의존성을 주입시킵니다.
  • Fake 방식을 사용할 경우
    • 현재 외부 환경에 의존하는 객체에 대한 interface가 없다면, interface를 도출시킵니다.
      (보통 외부 환경에 의존성이 있는 객체인 경우, interface를 사용하는 것이 추후 유지보수에도 좋습니다.)
    • 외부 환경 의존 객체를 대체할 Fake 객체를 생성하고, 간단한 응답 로직을 구현합니다.
    • 나머지 내부 환경 의존 객체는 @Autowired를 통해 실제 객체를 주입받습니다.
    • 이후 Service 객체의 생성자를 통해 각각의 의존성을 주입시킵니다.

 

3. E2E 방식의 통합 테스트에서 커버하지 못하는 비즈니스 로직 등의 검증은 단위 테스트로 커버하기

보통 API 구현할 내부적으로 수량 /, 특정 계산, 특정 조건에 따른 데이터 변경 비즈니스 성격을 가진 로직들이 존재합니다. 이를 비즈니스 로직(혹은 도메인 로직)이라고 부르곤 하는데요.

 

위의 사례(1번, 2번)에서 소개한 특정 데이터를 넣었을 때(Input), 특정 결과가 나와야 하는(Output) Input/Output 성격의 통합 테스트는 내부 비즈니스 로직이 잘 돌아가는지에 대한 세세한 검증을 하기가 어렵습니다.

따라서 이 구멍을 메꾸기 위한 좋은 수단이 단위 테스트입니다.

 

단위 테스트를 쉽게 작성하려면 프러덕션 코드를 어떻게 작성하는 것이 좋을까요?

저희 프로젝트 아키텍처는 비즈니스 로직들을 전부 Domain Layer에 구현하여 응집시키는 도메인 모델 패턴으로 구성되어 있습니다.

즉 모든 비즈니스 로직은 순수 자바 객체로만 이루어진 POJO 방식으로 구현되어 있는데요.

이렇게 순수 자바 객체로만 이루어진 비즈니스 로직은 단위 테스트를 작성하기에 매우 용이합니다.

 

예시 코드는 다음과 같습니다.
(예시 코드와 다르게 회사의 단위 테스트 코드는 계층 구조(@Nested)로 작성하지 않습니다.)

 

모든 메서드를 하나도 빠짐없이 단위 테스트로 작성하는 것도 좋지만, 항상 일정에 쫓기는 필자는 보통 주요 비즈니스 로직에 대한 단위 테스트 코드만이라도 꼭 작성하는 편입니다.

TDD로 테스트 코드를 먼저 작성하든, 프러덕션 코드를 작성하고 이후에 테스트 코드를 작성하든 그것은 중요하지 않다고 생각합니다.

단지 우리가 구현한 비즈니스 로직이 운영 환경에 배포되기 전까지만 주요 비즈니스 로직에 대한 테스트 코드가 작성되어 있으면 된다고 생각합니다.

 

테스트 방탄복을 입고, 리팩터링 전쟁으로 글에서 정리했듯이, 주요 비즈니스 로직 외에는 배포 이후 발생하는 버그에 대한 재발 방지 테스트 코드를 쌓아 나가는 것이 실무에서 효율적으로 테스트 커버리지를 높여나가는 방법이라고 생각합니다.

 

이렇게 단위 테스트로 통합 테스트의 구멍을 메꿔준다면, 아래 그림처럼 모든 영역이 테스트 코드로부터 안전하게 보호되는 형태가 이루어집니다.

 

 

테스트 코드 전략의 문제점

지금까지 소개한 3가지의 전략들로 테스트 코드를 관리해 나갈 때, 문제가 될만한 포인트들이 몇 가지 있습니다.

그중 대표적인 사례들을 3가지 정도 소개해보겠습니다.

  1. E2E 방식의 통합 테스트를 진행할 경우, 테스트 실행 속도가 저하될 수 있습니다.
  2. E2E 방식의 통합 테스트를 조회성 API로 진행할 경우, 테스트용 사전 데이터를 생성해 주는 작업이 별도로 필요합니다.
  3. 통합 테스트의 멱등성을 보장하기 위한 "테스트 데이터 생성 & 테스트 진행 & 테스트 데이터 삭제" 프로세스는 테스트 실행 속도를 저하시킬 수 있습니다.

 

1. 테스트 실행 속도 저하 이슈

E2E 방식의 통합 테스트를 진행할 경우, Spring은 테스트를 실행하기 전에 ApplicationContext를 초기화하는 작업을 진행합니다.

즉 애플리케이션 실행에 필요한 모든 Bean(객체)들을 스프링 컨테이너에 올리고, 필요에 따라 생성&관리&주입 등을 진행합니다.

 

이는 가볍지 않은 작업이므로, 테스트 실행 속도를 저하시키는 주요 요인 중 한 가지입니다.

하지만 이 무거운 ApplicationContext 초기화 작업이 테스트 실행 동안 여러 번이 진행된다면, 테스트 실행 속도는 계속해서 저하될 텐데요.

저희가 할 수 있는 최선은 ApplicationContext 초기화 작업 횟수를 최대한 줄이고, 캐싱하여 재사용하는 방법입니다.

 

그렇다면 ApplicationContext는 어떨 때 새로 초기화 작업을 진행할까요?

스프링이 내부적으로 특정 조건을 충족하면 ApplicationContext를 캐싱하여 재사용하는 기능을 제공합니다.

공식 문서 링크에 자세하게 작성되어 있지만, 간단하게 얘기하면 테스트 코드에서 @DirtiesContext, @MockBean 등을 사용하거나, 동일한 설정의 컨텍스트가 아닐 때 ApplicationContext를 재사용하지 않고 매번 초기화를 진행합니다.

따라서 이런 부분들을 고려하여 ApplicationContext를 최대한 효율적으로 재사용한다면, 테스트 실행 속도를 유의미한 수치로 단축시킬 수 있습니다.

 

 

2. 테스트용 사전 데이터 생성을 위한 추가 작업 이슈

조회성 API 기능을 통합 테스트로 검증할 경우, 테스트 실행 전 조회할 데이터가 미리 DB에 생성되어 있어야 합니다.

이런 경우 보통 @BeforeEach@BeforeAll 어노테이션을 활용하여 사전 데이터를 생성하는데요.

 

매번 각 테스트마다 @BeforeEach 메서드에 일일이 객체 인스턴스를 만들어 데이터를 생성해 주는 사전 작업은 매우 번거롭습니다.

따라서 필자는 Fixture 객체를 별도로 만들어서, 테스트 데이터를 생성하기 위한 인스턴스를 편하게 재사용할 수 있는 방법을 스쿼드 인원들에게 가이드하였습니다.

 

예시 코드는 다음과 같습니다.
(예시 코드와 회사 코드는 약간의 차이는 있지만, 거의 동일합니다.)

 

 

3. 테스트 데이터 생성, 테스트 진행, 테스트 데이터 삭제 프로세스에 따른 속도 저하

통합 테스트를 진행할 땐 사전에 테스트용 데이터를 DB에 생성해줘야 하는 경우가 있습니다.

단, 다른 테스트에 영향을 미치지 않고 테스트 간 멱등성을 보장하려면, 생성한 테스트용 데이터는 테스트 실행 직후에 바로 삭제해주어야 합니다.

하지만 매 테스트마다 "사전 데이터 생성 -> 테스트 실행 -> 사전 데이터 삭제" 프로세스를 반복한다면 당연히 그만큼 실행 속도가 느려질 텐데요.

 

필자는 2가지 방법으로 이를 최대한 보완하였습니다.

첫 번째 방법은 각 테스트가 끝나면 @AfterEach 어노테이션을 활용하여, DB의 TRUNCATE 명령어를 사용하는 것입니다.

TRUNCATE 명령어는 내부적으로 DELETE 명령어에 비해 처리 속도가 훨씬 더 빠르기 때문입니다.

 

두 번째 방법은 조회성(Query) API와 변경성(Command) API를 분리하여 테스트를 진행하는 것입니다.

먼저 조회성 API의 필요한 사전 데이터들을 최초 한번 DB에 밀어 넣습니다.

그 후 모든 조회성 API 통합 테스트를 진행하고, 모든 조회성 테스트가 완료되기 전까지 데이터 삭제 작업은 별도로 진행하지 않습니다.

모든 조회성 테스트가 완료되면, 변경성 API 통합 테스트를 기존 프로세스(사전 데이터 생성 -> 테스트실행 -> 사전 데이터 삭제)대로 진행합니다.

이 방법은 조회성 API의 테스트가 동작하는 동안은 "사전 데이터 생성 & 삭제" 작업을 하지 않기 때문에, 그만큼 테스트 실행 시간을 단축시키는 효과를 볼 수 있습니다.

 

 

마무리

오늘은 필자가 팀 내에 전파한 3가지의 테스트 전략과, 그에 따른 3가지의 고려할 점에 대해서 이야기해 보았습니다.

  • 3가지 테스트 전략
    1. 통합 테스트는 E2E(End-to-End) 방식으로 진행하여 외부 장벽 쌓기
    2. 내부 로직에서 외부 환경과 통신하는 의존성이 있을 경우, Mock을 사용하여 실제 요청 우회하기
    3. E2E 통합 테스트에서 커버하지 못하는 비즈니스 로직 등의 검증은 단위 테스트로 커버하기
  • 3가지 고려할 점
    1. 테스트 실행 속도 저하 이슈 -> ApplicationContext를 최대한 효율적으로 재사용
    2. 테스트용 사전 데이터 생성을 위한 추가 작업 이슈 -> Test Fixture 객체 활용
    3. 테스트 진행, 테스트 데이터 생성 & 삭제에 따른 속도 저하 -> TRUNCATE 사용 및 API 성격에 따른 테스트 분리

 

이 글을 읽는 분들도 좋은 테스트 코드 전략들을 통해, 조금 더 안정성 있고 품질 좋은 소프트웨어를 만들어 나가시길 바랍니다.

바로 다음 글에서는 이 테스트 전략들을 잘 적용하기 위한 테스트 환경 구축 여정에 대해 소개하려고 합니다.

 

감사합니다 🙏

'기술 경험' 카테고리의 다른 글

0에서 1로 가기 위한 테스트 환경 구축  (0) 2024.08.18