레거시 코드에 테스트를 도입하는 방법
    2023-04-07 10:00
    프로젝트

    제가 인턴으로 팀에 합류하던 당시, 개발팀은 빈번하게 변화하는 요구사항에 고통받고 있었습니다.

    테스트 커버리지가 단순히 높은 것이 좋은 테스트 코드라고 생각하지는 않습니다만, 비즈니스 로직 대비 테스트 코드의 비율 자체가 빈약했기 때문에 기능 추가나 요구사항 변경 시 마다 사이드이펙트에 대한 우려가 개발팀에 존재했습니다.

    그리고 이러한 사이드이펙트에 대한 확인을 오롯이 QA 팀에서 담당하고 있었기 때문에 큰 부담을 안고 있던 상황이었습니다.

    그 때문에 레거시 코드에 테스트를 점진적으로 도입하고자 하였고 참고할만한 사례나 전략을 알아보던 도중에 좋은 해외 포스트가 있어서 이를 번역하여 공유하고자 합니다.

    아래부터는 다음 포스트를 번역한 해석본입니다. How to Test Legacy Code: Best Practices to Follow | ModLogix


    마이클 C.페더스 (“레거시 코드 활용 전략”의 저자) - “코드가 아무리 객체 지향적으로 작성되어있거나 잘 캡슐화 되어있든 간에 테스트가 없는 코드는 나쁜 코드이다. 테스트를 통해 코드의 동작을 빠르고 검증할 수 있게 변경할 수 있으며 테스트가 없으면 코드가 좋아지는지 나빠지는지 알 수 없다.”

    이 문서에서는 레거시 코드에 접근하는 방법을 이해하는 데 도움이 되는 내용을 포함하고 있다. 레거시 코드 베이스의 자동 테스트에 대한 Best Practice를 공유하고 레거시 코드 테스트에서 직면할 수 있는 문제들을 공유하고자 한다.

    레거시 코드 테스트가 일반적인 테스트와 다른 이유

    • 레거시 코드는 매우 낮은 테스트 커버리지를 갖고 있다.
    • 강한 의존성 결합: 레거시 코드에는 수많은 클래스, 메서드 등이 복잡하게 얽혀있기 때문에 의존성을 격리하는 것이 불가능에 가깝다.
    • 코드 모듈화가 잘 되어있지 않거나 없다. 따라서 부분적으로 Mocking을 할 수 없다.
    • 코드의 부피가 매우 크다. 따라서 레거시 코드에 단위 테스트를 작성하는 것은 좋지 않은 방법이다.
    • 테스트를 추가하기 위해서 프로덕션 코드를 변경해야 할 수 있다. 이렇게 하면 코드를 수정하는 과정에서 버그가 발생할 가능성이 커진다.

    위와 같은 문제들에도 불구하고 레거시 어플리케이션을 테스트하는 것은 가능하다.

    레거시 코드를 테스트하기 위한 7가지 Best Practice

    1. 개발자가 시작부터 포기해야할 관행

    • 테스트 커버리지 100%를 원하는 것:
      • 일반 코드를 테스트하는 것이 아니므로 100% 테스트 커버리지에 집중할 수 없으며 이에 도달하는 것은 거의 불가능하다. 양호하거나 중간 정도의 커버리지가 아예 테스트 커버리지가 없는 것 보다 낫다.
    • 레거시 코드 단위 테스트:
      • 레거시 코드 베이스는 일반적으로 방대하고 복잡하다. 특정 클래스의 특정 메서더의 특정 루프를 열심히 테스트하는 대신 통합 및 기능 테스트와 같은 “큰” 테스트에 집중하는 것이 좋다.
    • 빈번한 컨텍스트 전환:
      • 평소처럼 모델 코드를 변경하지 않고 모든 테스트를 통과하도록 한다.

    Best Practices

    2. 빌드 및 기존 테스트 작동

    시스템 빌드 및 기존 테스트를 작동시키기 위해서는 아래 질문을 통해 코드 베이스의 현재 상태를 먼저 평가해야 한다.

    • 이미 존재하는 테스트는 무엇인가
    • 기존 테스트들이 다루는 영역은 어디이며 커버리지를 얼마나 제공하는가
    • 기존 테스트들은 언제 실행되는가

    기존 테스트가 있는 경우 먼저 모든 테스트가 실행되고 작동하는지 먼저 확인을 해야 한다. 그런 다음 getter나 to-string과 같이 작은 메서드든 상관없이 첫 번째 테스트를 작성한다. 이는 빌드 시스템을 설정하기 위해서이다. 그러나 테스트가 변경되는 이유를 이해할 때까지 실패한 테스트를 제거하지 않도록 해야 한다.

    다음은 JUnit을 사용하여 코드의 기능을 테스트하는 의료 관리 시스템의 메인 메서드에 대한 샘플 테스트이다.

    쉬운 메서드나 클래스에 대한 테스트를 먼저 작성하라. 간단할수록 좋다. 목표는 한 번에 한 단계씩 커버리지를 높여 변화를 시작하는 것이다.

    3. 크게 하라

    레거시 코드를 테스트할 때는 더 큰 그림을 보고 더 넓은 관점에서 테스트를 보는 것이 좋다. 더 큰 규모의 레거시 코드에 대한 테스트를 작성해야 한다. 작은 단위 테스트를 피하라: 클래스 → 메서드 → 루프 → if 문… 대규모 프로세스를 확인하기 위한 GUI 테스트 또는 통합 테스트와 같은 특정 작업에 대한 광범위한 테스트를 작성해야 한다.

    더 중요한 것은 테스트 케이스의 우선순위를 지정하는 것이다. 일반적으로 테스트 케이스 우선순위 지정은 먼저 실행하는 것이 가장 중요한 테스트를 결정하는 프로세스이다. 일반적으로 테스트의 효율성 또는 유효성을 개선하기 위해 수행된다. 목표는 가능한 한 빨리 결함 또는 장애 조건을 감지할 확률을 최대화하는 것이다.

    가장 먼저 해야 할 일은 우선순위를 정하기 위한 가장 중요한 메트릭을 식별하는 것이다.

    다음은 그중 일부 목록이다.

    • 테스트할 기능이 얼마나 중요한가? 기능이 핵심이고 작동하지 않는 경우 더 높은 우선순위를 부여한다.
    • 테스트 케이스 실행 시간(적을수록 좋음).
    • 테스트 케이스 코드 범위(많을수록 좋음).
    • 통과한 테스트 케이스 수(많을수록 좋음).
    • 기능 또는 버그와 관련된 리스크. 리스크는 새로운 기능을 제공하거나 버그를 신속하게 수정하는 능력에 영향을 미칠 수 있다. 비즈니스에 미치는 영향에 따라 작업의 우선순위를 지정한다.

    Prioritization Matrix

    4. CI/CD 파이프라인 내에서 테스트 구현

    레거시 코드를 테스트하는 가장 좋은 방법은 테스트를 자동화하고 CI 환경에서 실행하는 것이다. 방법은 다음과 같다.

    • 원하는 언어나 도구를 사용하여 레거시 어플리케이션의 각 클래스에 대한 테스트 세트를 작성한다.
      • JUnit 또는 다른 단위 테스트 프레임워크를 사용하는 경우 이 단계는 쉬울 것이다. 그러나 더 이국적인 것을 사용한다면, 해당 테스트를 작성하는 것이 일반 JUnit 테스트를 작성하는 것보다 조금 더 어려울 수 있다.
    • 다음 단계로 이동하기 전에 모든 테스트가 통과하는지 확인해야 한다.
      • 하나 이상의 테스트가 실패한다면 프로세스의 추가 단계를 진행하기 전에 코드의 일부 측면을 수정해야 함을 의미한다.

    5. 스모크 테스트 세트 준비

    레거시 코드의 어느 부분을 테스트하는 것이 가치 있을지 알아낼 수 있는 방법이 바로 스모크 테스트이다.

    스모크 테스트는 기본적으로 특정 상태에서의 어플리케이션 스냅샷과 같다. 테스트가 필요한 것과 필요하지 않은 것을 확인할 수 있다. 모든 가능한 시나리오를 테스트하지는 않으며, 함께 작동하는 방법에 대한 전반적인 아이디어를 얻기에 충분하다.

    좋은 스모크 테스트는 모든 레거시 코드 베이스에 필수다. 레거시 프로젝트 작업을 시작할 때 이러한 스모크 테스트 세트를 만드는 것이 좋으며 이를 사용하여 변경 사항이 시스템에 예상치 못한 영향을 주는 것을 방지할 수 있다.

    다음은 레거시 코드 베이스에 대한 스모크 테스트 세트를 준비하기 위한 단계이다.

    • 어플리케이션 기능의 주요 영역을 식별한다.

    예를 들어 어플리케이션이 의료 관리 시스템인 경우 환자 등록, 청구, 의사 예약 등과 같은 여러 기능 영역이 존재한다. 각 영역에 필요한 테스트 유형이 다를 수 있으므로(ex. 통합 테스트) 각 영역을 별도로 식별해야 한다.

    • 위에 식별된 각 영역의 모든 공용 메서드에 대한 단위 테스트를 작성한다.

    (이 작업은 다른 작업을 수행하기 전에 수행되어야 한다.)

    • 모든 외부 의존성에 대한 통합 테스트를 만든다.

    (ex. 외부 서비스, 데이터베이스 등)에 대한 통합 테스트를 만든다.

    스모크 테스트는 장기간 사용하기 위한 것이 아니다. 더 광범위한 테스트를 작성하거나 기능 자체에 대해 작업하기 전에 문제가 어디에서 잘못되었는지에 대한 아이디어를 제공하기 위한 것이다.

    6. 회귀 테스트 계획

    레거시 코드의 주요 문제는 회귀 테스트 모음이 없을 수 있다는 것이다. 회귀 테스트는 코드 베이스의 변경 사항이 코드 손상이나 예기치 않은 동작 변경으로 이어지지 않도록 하기 때문에 필수적이다.

    회귀 테스트에는 두 가지 유형이 있다.

    기능적 회귀 테스트 - 가장 일반적인 유형의 회귀 테스트이며 블랙박스 또는 행동 테스트라고도 한다. 기능 회귀 테스트는 일련의 기능 요구 사항에 대해 어플리케이션 또는 웹 사이트를 테스트하는 데 중점을 둔다. 기능적 회귀 테스트는 일반적으로 자동화되지만 필요한 경우 수동으로 수행할 수 있다.

    기능적 요구 사항이 많은 경우 모든 요구 사항에 대한 테스트를 작성하는 것은 실용적이지 않다. 자동화할 항목을 선택하면 작업의 우선 순위를 지정하고 시스템의 가장 중요한 부분을 먼저 처리하는 데 도움이 된다.

    인수 회귀 테스트 - 인수 테스트는 사용자와 이해 관계자가 소프트웨어 사용 경험이 기대치를 충족하고 예상대로 작동하는지 평가하기 위해 수행된다.

    회귀 테스트는 수동으로도 수행할 수 있지만 자동화된 도구가 엑셀 스프레드시트와 같은 수동 테스트 방법보다 시간이 지남에 따라 더 빠르고 안정적이며 유지 관리하기 편리하기 때문에 일반적으로 사용된다.

    7. 코드 커버리지 측정

    코드 커버리지 측정을 통해서 아직 수행되지 않은 작업이나 놓친 작업에 대한 명확한 아이디어를 제공하는 커버리지 보고서를 생성한다.

    8. 기술 문서에 투자

    레거시 코드보다 개발자가 싫어하는 것이 바로 불완전하거나 누락된 문서이다. 팀이 시스템에 적용된 모든 변경 사항을 항상 문서화해야 하는 이유는 다음과 같다.

    기술 문서는 시스템의 아키텍처를 설명하는 외부 문서이거나 테스트 코드에 포함될 수 있다. 결국 목표는 미래의 개발자가 기존 코드를 이해하고 작업하기 위한 학습 곡선을 줄이는 데 도움을 주는 것이다.

    이러한 문서는 개발자가 어플리케이션의 작동 방식과 시스템이 데이터베이스 또는 서버와 같은 다른 구성 요소와 어떤 방식으로 상호작용하는지 이해하는 데 도움을 준다.

    대부분의 회사는 다음과 같은 목적으로 기술 문서를 작성한다.

    • 재작성이 필요하거나 기존 기능이 수정된 경우.
    • 새로운 기능을 추가하고 기존 기능을 개선하면서;
    • 코드 베이스를 리팩토링하고 싶을 때

    더 중요한 것은 커버리지 범위에 차이가 있는 경우, 테스트 된 항목과 아직 테스트 되지 않은 항목을 추적하는 데 도움을 준다는 것이다. 이뿐만 아니라 문서는 유지 관리 및 새로운 기능 개발에 드는 시간과 노력을 또한 줄여줄 수 있다.

    레거시 코드를 테스트하는 방법: 흔히 발생하는 문제들을 방지하기 위한 방법

    레거시 코드 베이스 테스트의 주요 목표 중 하나는 코드를 정리하고 회귀(실수로 인한 장애)를 방지하는 것이다.

    레거시 코드를 테스트하면서 마주하는 가장 흔한 문제들과 해결책들을 알아보도록 하겠다.

    Challeges to Test Lagacy Code

    강한 의존성 결합

    테스트를 고려하지 않고 작성된 수많은 클래스에 의해서 강한 의존성 결합이 발생할 수 있다. 이러한 의존성을 분리하는 건 불가능에 가깝다.

    해결책:

    SEAM을 사용하여 두 클래스 간의 강한 의존성 결합을 우회할 수 있다.

    “SEAM은 프로그램에서 수정하지 않고도 동작을 변경할 수 있는 곳이다.” - 마이클 C.페더스(“레거시 코드 활용 전략”의 저자)

    저자에 따르면 SEAM 모델에는 Preprocessing Seam, Link Seam 및 Object Seam과 같은 다양한 유형이 있습니다. 따라서 소스 코드와 언어에 따라 선호하는 변형을 선택할 수 있다.

    (참고 - Working Effectively with Legacy Code) 004. The Seam Model)

    코드 베이스의 부분들을 이해하기 힘든 문제

    레거시는 다른 사람에 의해 작성되거나 오래전에 작성되기 때문에 코드 베이스의 요소를 이해하는 것은 어려운 일이다.

    해결책:

    만약 레거시에 단위테스트를 어떻게 작성해야 할지 고민한다면 이는 좋지 않은 선택이며 피해야 할 선택지이다. 그보다는 위에서 언급했듯이 광범위한 테스트에 중점을 두어야 한다. 코드의 주요 섹션이 무엇을 의미하는지 이해하면 자동화된 테스트를 작성하기 더 쉬워진다.

    특성화 테스트:

    특성화 테스트 기술은 위의 문제에 대한 또 다른 솔루션이다. 테스트 중인 특정 코드 섹션에서 작동하는 방식은 다음과 같다.

    • 코드가 올바른 출력을 제공한다고 가정한다.
    • 테스트를 작성하고 임의의 값을 예상한다. 예를 들어 ‘100’이라면, 테스트 실패 방법에서 어떤 값이 반환되는지 확인한다. 그런 다음 예상 값을 갖도록 테스트를 변경한다.

    테스트하고자 하는 모든 메서드에 대해 이 작업을 수행할 수 있다.

    테스트 또는 디버그를 수행해야 하는가?

    테스트를 작성하고 실행하는게 먼저 인지, 아니면 가급적이면 코드를 디버그해야 할지에 대한 문제에 직면하게 된다.

    해결책:

    코드 문제 해결을 통해 버그를 찾은 다음, 아래와 같은 경우 버그를 수정한다.

    • 간단하고 명백하며 지역적인 경우.
    • 버그가 나타나는 코드를 이해하는 경우.
    • 수정 사항을 이해하는 경우

    그렇지 않으면 테스트 묶음을 확장하는데 시간을 할애하는 편이 좋다.

    무엇을 테스트해야 하는가?

    테스트할 항목이 많기 때문에 어디서부터 시작해야 하는지, 무엇을 우선시해야 하는지, 무엇을 무시해야 하는지 혼란스러울 수 있다.

    해결책: 엣지 케이스가 아닌 어플리케이션의 주요 경로에 집중해야한다. 명백한 입력/테스트 조건으로 시작한다. 테스트 묶음이 확장됨에 따라(특히 버그 보고서를 받은 경우) 엣지 케이스를 살펴보기 시작한다. 어플리케이션의 기본 경로에 대한 테스트는 여전히 필요하다.

    마무리

    레거시 코드를 테스트하기 위해서는 규모 산정이 중요하다고 생각합니다.

    사실 거대한 Non-testable 소프트웨어에 대해 테스트를 도입할 땐, bottom-up approach ( 단위 > 통합 ) 이 Functional unit이나 모듈 간의 테스트 불균형을 초래하기 쉽기 때문에 지양하는 것이 좋습니다.

    그래서 top-down approach를 통해 e2e 테스트와 같은 통합테스트 우선 도입을 고려해볼 수 있으나 그 규모를 크지 않게 잡고 Success/Fail case만 고려하며 진행하고 그 안에서 빠르게 Outer scope -> Inner Scope ( 서버라면 filter - interceptor - controller - service - repository, etc...)로 전파해나가는 걸 목표로 테스터블한 BackBone 을 잡아나가는 것이 비용적으로 더 효율적이지 않을까 싶습니다. 이후에 실제 레거시가 동작 중에 발생하는 에러 케이스들에 대한 엣지 케이스들을 추가해나가면서 완성도를 높이는 방법을 고려해보는 것이 좋다고 생각합니다.