본문 바로가기
멘토링/NEXTSTEP ATDD

테스트 방탄복을 입고, 리팩터링 전쟁으로

by Alex_beom 2024. 7. 22.
이 글은 필자의 이전 블로그인 https://velog.io/@beomdrive/NEXTSTEP-ATDD-4에서 핵심 내용을 중심으로 요약한 후에, 내용을 추가 보완하였습니다.

 

멀고 험난한 리팩터링, 안전하게라도 하자

리팩터링에 대한 개인적인 고찰을 주저리주저리 작성하기 전에, 요약본을 먼저 작성해보려고 한다.

아래에 대한 긴 장문의 이야기는 해당 내용들이다.

  • 외부 보호 장벽 역할의 테스트를 둘러쌓아 놓자
  • 외부 보호막을 기반으로 점진적이고 안전하게 리팩터링 해나가자
  • 외부 보호막만으로는 부족하므로, 각각의 통합/단위 테스트도 보강해 나가자
  • 실무에서는 시간/리소스의 싸움일 테니, 주요 비즈니스 로직만이라도 우선적으로 작성해 나가자
    • 나머지 테스트들은 버그가 발생했을 때 재발 방지 테스트를 하나씩 쌓아나가면서 테스트 코드를 효율적으로 관리해 나가자
    • 시간 날 때마다 틈틈이 외부 장벽을 만들어 놓자

서론

요즘 개발 커뮤니티에서는 소위 금지어(?)로 불리는 몇 가지 키워드가 있다.

  • "클린 코드", "DDD", "MSA", "헥사고날" 등

나도 저런 키워드들 중 제대로 습득한 개념들은 하나도 없지만, 공통적으로 느껴지는 것이 있다.

저 개념들의 공통적인 목적은 결국 많은 개발자들이 하나의 프로젝트로 협업할 때, 더욱 생산성을 높여 빠르게 개발하며 유지보수 하기 쉽도록 도와주는 요소들이지 않나 싶다.

 

 

클린 하지 않은 코드는 작성한 사람만 한 번에 알아볼 수 있고, 타인은 당장 그 기능을 수정하거나 확장해 나가야 할 때 코드 분석부터 시작해야 한다.

하지만 가독성이 좋은 코드라면 더욱 빠르게 기존 코드에 요구 사항을 반영할 수 있을 것이다.

(이를 위해 실무에서는 별도의 사내 컨벤션을 구축해 나가면서 개발자들의 코드 스타일을 일관성 있게 유지하려고 하기도 한다)

 

DDD나 MSA 역시 각 도메인별로 모듈과 DB를 분리하여, 독립적으로 개발함으로써 각각의 요구사항들을 외부 의존성의 제약 없이 빠르게 쳐내면서 발전시킨다. 물론 그에 따라 트랜잭션 관리 등 고려해줘야 할 머리 아픈 내용들이 많겠지만, 이 글에서는 잠시 미뤄두자.

 

헥사고날도 마찬가지로 여러 Presentation 환경에서 유스 케이스는 재사용이 가능하도록 하고, 다양한 외부 환경에 맞게 어댑터를 자유롭게 갈아 끼워 사용하기 좋은 아키텍처이다. 즉 유스케이스와 어댑터 간의 의존성을 분리하여 서로 간의 인터페이스만 정의해 준다면 내부 로직들은 따로따로 각개전투(개발)가 가능하다.

이 말은 곧 각 클라이언트의 관심사가 유스 케이스에 흘러들어 가지 않도록 의존성을 끊어내고, Presentation 영역이 어댑터 역할을 하여 유스 케이스를 재사용하며 중복 기능을 줄이는 효과를 가져갈 수 있다.

 

결론적으로 회사에서 가장 중요한 요소 중 하나가 생산성이기 때문에 개발 속도를 높이고, 유지 보수를 쉽게 하기 위한 여러 가지 방법들을 사용하는 것이 아닐까 싶다. 특히 서비스 스타트업 같은 경우는 빠르게 변화에 대응하는 것이 곧 생존과 직결될 테니..

 

 

본론

본론으로 들어가자면 어떤 회사에서 프로젝트를 처음 시작한다고 했을 때, 정말 뛰어난 개발자들이 킥오프 시점부터 참여해서 정말 좋은 아키텍처와 설계로 시작하면 더할 나위 없이 좋겠지만, 그럴 확률은 매우 낮을 것이다.

그렇다면 기존 코드를 계속해서 리팩터링 해나가야 된다는 것인데, 별다른 안전장치 없이 곧바로 리팩터링에 들어간다면 어떻게 될까?

아마도 이곳저곳에서 사이드 이펙트가 많이 날 것이고, 내가 잘 모르는 블랙박스 영역에서 보통 문제가 발생하더라. (경험담)

그렇다면 안전장치를 어떻게 해놓는 것이 좋을까?

 

백엔드 기준으로 생각해 보겠다.

Spring MVC로 구성된 백엔드 API 애플리케이션은 보통 프론트와 분리되어 있는 상태일 것이다.

즉 클라이언트와 서버 간의 약속(인터페이스)을 기반으로 각자 각개전투로 개발해 나가는 것이다. 여기에서 약속(인터페이스)을 우리는 보통 HTTP 프로토콜이라고 부른다

 

그럼 백엔드 기준에서 어떠한 값들로 요청이 들어올 때 약속된 결과값만 문제없이 내려온다면, 우리는 사이드 이펙트로부터 한결 편해지지 않을까? 물론 약속된 결과값이 반환된다고 하더라도, 내부적인 동작이 의도한 대로 잘 돌아가는지는 별도로 확인해야겠지만 일단 그건 그 이후 문제다.

 

모든 요청에 대한 응답 값이 테스트 코드로부터 보호받을 수 있다면, 즉 외부 보호 장벽이 둘러싸여 있다면 우리는 좀 더 마음 편하게 내부를 지지고 볶으면서 리팩터링을 진행할 수 있을 것이다.

외부 보호 장벽 역할을 인수 테스트가 해줄 수 있다.

단, 여기서 말하는 인수 테스트는 특정 범위와 구현 방법이 딱 정해져 있다기보다는, 테스트 의도에 따라 조금씩 달라질 수 있다. (아 다르고 어 다르듯이)

필자가 주로 사용하는 인수 테스트는 블랙박스의 성격을 가진 테스트로 내부 구현이 어떻게 돌아가는지에 대한 관심사는 없고, 단지 가장 끝 단(End-Point)에서 요구 사항에 정확하게 동작하는지 확인하는 테스트이다.

그 요구 사항은 프론트엔드와의 약속(인터페이스)이 될 수도 있고, PM의 기획서 기반이 될 수도 있고, 업체의 실제 요구 사항이 기반이 될 수도 있다.

우리가 만드는 소프트웨어는 요구 사항대로 완벽히 동작한다면, 일단은 1차적으로 성공일 테니 말이다.

 

결론: 리팩터링 전쟁터에 나가기 전에, 우선 테스트 코드 방탄복을 꼭 입자

 

 

테스트하기 쉬운 코드로 개발하기

해당 주제 관련해서 참고하기 좋은 영상 첨부드립니다
https://www.youtube.com/watch?v=Cz_a2gQp63c&ab_channel=OKKY

 

테스트하기 어려운 코드

  • 같은 입력에 항상 같은 결과를 반환하지 않는 코드
  • 테스트하는 메서드를 벗어나서 외부 상태를 변경하는 코드

 

어떻게 테스트하기 쉬운 코드로 만들까?

  • 테스트하기 쉬운 코드와 어려운 코드를 분리하자
  • 테스트가 어려운 영역을 메서드 외부로 빼고 결과값을 메서드 인자로 받도록 변경
  • 바깥으로 뺀 테스트하기 어려운 영역은 통합 테스트로 커버되도록 진행

 

인수 테스트 리팩터링

  • 응답 코드만 검증하는 Input/Output 테스트만 하기보다는 실제 데이터가 잘 저장되었는지에 대한 검증도 같이 하면 좋음
    • 단, 이 경우 테스트가 무거워지고 실행 속도가 느려질 수 있음
      • Application Context를 재사용, DB Truncate 방식 등 여러 가지 기법을 통해 속도를 최적화해야 함
    • 또한 서로 다른 테스트끼리의 중복된 검증이 일어나기도 함
      • ex) 데이터 생성(POST) 테스트 <-> 데이터 조회(GET) 테스트 (조회 테스트를 하려면 생성도 같이 발생해야 함)
      • 중복되는 테스트 검증을 하나로 퉁쳐버려서 테스트 개수를 줄이는 방식도 효율적으로 테스트를 관리할 수 있는 방법임
        • 수강 신청 요청 + 수강 신청되었는지 조회 = 강의 수강 신청 기능 테스트
  • 내부 비즈니스 로직 등 인수 테스트가 커버할 수 없는 영역들은 단위 테스트를 통해 커버리지를 높이는 것을 추천

 

레거시 리팩터링

  • 프러덕션 코드의 리팩터링은 최대한 인수 테스트로 외부 보호막을 둘러놓은 상태에서 진행하자
  • 특정 메서드를 리팩터링 할 때 작업 순서
    1. 리팩터링을 진행할 코드의 테스트 코드를 새로 작성 (테스트 코드 중복 발생은 감수)
    2. 기존 코드를 건드리지 않고 리팩터링 코드를 새로 작성 (프러덕션 코드 중복 발생은 감수)
      • 테스트 코드로 커버되는 범위 내에서만 리팩터링 코드 작성
    3. 리팩터링 코드 작성이 완료되고 문제가 없다면, 기존 레거시 코드 + 기존 테스트 코드 날려버리기
  • 위의 작업 순서대로 진행한다면 리팩터링 코드가 완성될 때까지 기존 코드는 그대로 살아 있으므로, 사이드 이펙트가 일어나지 않고 작업 도중 다른 일을 하다 나중에 작업을 마무리하기에도 용이함

 

테스트 환경

@ExtendWith

  • 단위 테스트 간에 공통적으로 사용할 기능을 주입하기 위해 사용

 

@ExtendWith(MockitoExtension.class)

  • Mockito 기능 사용 가능 (ex. @Mock)
  • 만약 환경을 구성해주지 않으면 어노테이션 주입은 사용 불가하지만 mock()은 Extension 없이도 사용 가능
    • LineRepository lineRepository = mock(LineRepository.class)

 

@ExtendWith(SpringExtension.class)

  • Spring의 컨테이너 사용 가능
  • 컨테이너를 사용하니 @MockBean도 주입해서 사용 가능

 

@SpringBootTest

  • Spring의 컨테이너 사용 가능 + 컨테이너에 프러덕션 Bean들을 모두 채워줌
    • @SpringBootApplication을 통해 실행하는 서버의 환경과 동일한 설정
  • 모든 Bean을 올리기 때문에 테스트 시간이 오래 걸림

 

@WebMvcTest

  • Presentation 계층만 테스트할 때 사용 (Controller, Interceptor, Resolver 등)

 

@DataJpaTest

  • Spring Data JPA 사용 시 Repository 관련 Bean들만 컨텍스트에 올려줘서 테스트에 사용할 수 있도록 해줌
  • 다른 DB도 사용 가능 (ex. @DataJdbcTest@DataRedisTest)

 

궁금 포인트

  • Q: 통합 테스트 진행 시 특정 빈들만 필요할 때는 @SpringBootTest보다 @ExtendWith(SpringExtension.class)로 선언하고 필요한 빈들만 따로 주입해서 사용하는 방식이 많이 쓰이는가?
  • A: @ExtendWith(SpringExtension.class)를 사용하면 훨씬 속도가 빠르긴 하겠지만, 실제로 서비스 테스트를 진행한다고 했을 때 일반적으로 5~6개의 Bean 들에 의존을 하기 때문에 @SpringBootTest를 사용하는 것이 좀 더 편할 수 있음
  • 만약 의존 객체가 4개 이하 정도로 적다면 충분히 @ExtendWith를 사용할만한 가치가 있을 것이다 → 비용 절감(속도 증가)

 

API 문서 자동화

Swagger

  • UI를 지원하여 API를 호출하면서 테스트할 수 있는 기능을 지원
  • RestDocs에 비해 기능이 많지만, 프러덕션 코드가 더럽혀진다는 단점
    • Controller를 인터페이스로 활용하면 그나마 Swagger에 대한 관심사를 Controller 클래스와 분리할 수 있음

 

RestDocs

 

Spring Rest Docs 프로세스

Test 실행 → 문서 조각(Snippets) 발생 → 템플릿에 맞게 스니펫들이 끼워 맞춰 짐 → 문서로 전환