Skip to main content

레거시 코드의 효과적인 단위 테스트 적용 방법

1. 레거시 코드란 무엇인가

레거시 코드(Legacy Code)라는 용어는 산업계에서 다양한 의미로 사용됩니다. 일반적으로는 “오래된 코드” 또는 “이전 시스템에서 물려받은 코드”를 의미하지만, 소프트웨어 테스트 관점에서는 보다 구체적인 정의가 필요합니다.

Michael Feathers는 그의 저서 “Working Effectively with Legacy Code”(2004)에서 레거시 코드를 “테스트가 없는 코드(Code without tests)”라고 정의하였습니다. 이 정의에 따르면, 코드의 작성 시점이나 기술의 신구(新舊)와 관계없이 테스트가 수반되지 않은 코드는 모두 레거시 코드에 해당합니다.

다만 이는 테스트 관점에서의 정의이며, 현업에서는 “이해하기 어려운 코드”, “변경이 두려운 코드”, “원 개발자가 없는 코드” 등 보다 넓은 의미로 사용되기도 합니다. 이 글에서는 Feathers의 정의를 중심으로, 테스트가 부재하거나 불충분한 코드에 테스트를 추가하는 방법을 다룹니다.

테스트가 없는 코드가 문제인 이유는 변경에 대한 안전망이 없기 때문입니다. 코드를 수정할 때 그 변경이 기존 동작에 영향을 미치는지 확인할 수단이 없으므로, 변경 자체가 위험 요소가 됩니다.

2. 자동차 소프트웨어에서 레거시 코드의 문제

2.1 규격 요구사항과의 충돌

ISO 26262(기능안전) Part 6에서는 소프트웨어 단위 검증(Software Unit Verification)을 요구하며, ASIL 등급에 따라 코드 커버리지 수준을 규정하고 있습니다. Automotive SPICE에서도 SWE.4 프로세스를 통해 단위 검증 수행과 그에 따른 작업산출물(Work Product)을 요구합니다.

테스트 없이 개발된 레거시 코드는 이러한 규격 요구사항을 충족하지 못하므로, 인증이나 심사를 앞두고 테스트를 소급 적용해야 하는 상황이 발생합니다.

2.2 테스트 추가의 어려움

레거시 코드에 테스트를 추가하는 것이 어려운 이유는 다음과 같습니다.

 

자동차 소프트웨어의 경우 AUTOSAR 환경에서의 RTE 의존성, MCU 특화 코드, 실시간 제약 조건 등이 추가적인 어려움으로 작용합니다.

3. 현실적 접근 원칙

3.1 위험 기반 접근(Risk-Based Approach)

모든 레거시 코드에 동일한 수준의 테스트를 적용하는 것은 현실적이지 않습니다. ISO 26262에서도 위험 기반 접근을 권장하며, 이는 테스트 우선순위 결정에도 적용될 수 있습니다.

높은 우선순위:

  • ASIL이 배정된 안전 관련 기능
  • 변경 빈도가 높은 코드
  • 결함 이력이 있는 코드
  • 순환 복잡도가 높은 코드

낮은 우선순위:

  • 단순 초기화/설정 코드
  • 변경 예정이 없는 안정된 코드
  • 교체가 계획된 코드

3.2 점진적 테스트 추가

“변경 시점에 테스트 추가”라는 원칙을 적용합니다. 기존 코드 전체에 테스트를 소급 적용하는 것보다, 수정이 필요한 시점에 해당 부분의 테스트를 추가하는 것이 효율적입니다. 이 접근법은 다음과 같은 이점이 있습니다.

실제 변경이 발생하는 코드에 테스트가 집중됨

투입 리소스 대비 효과가 높음

시간이 지남에 따라 테스트 커버리지가 자연스럽게 확장됨

4. 단계별 수행 방법

4.1 1단계: 현황 분석

정적 분석을 통해 코드의 현재 상태를 파악합니다.

분석 항목:

  • 순환 복잡도(Cyclomatic Complexity) 분포
  • MISRA 규칙 위반 현황
  • 함수별 라인 수
  • 전역 변수 사용 현황
  • 외부 의존성

순환 복잡도의 경우, McCabe의 원 논문(1976)에서는 10 이하를 권장하였습니다. 자동차 소프트웨어 현업에서는 15~20을 기준으로 삼는 사례가 있으나, 이는 조직 및 프로젝트의 코딩 가이드라인에 따라 다릅니다. 설정된 기준을 초과하는 함수는 테스트 난이도가 높으므로 분할을 고려해야 합니다.

정적 분석은 코드의 구조적 문제와 코딩 규칙 위반을 식별하는 데 유용하지만, 런타임 동작을 검증하지는 못합니다. 따라서 정적 분석과 동적 테스트는 상호 보완적으로 적용되어야 하며, 정적 분석이 동적 테스트를 대체할 수는 없습니다.

4.2 2단계: 테스트 가능 영역 식별

코드를 테스트 용이성에 따라 분류합니다.

즉시 테스트 가능한 코드:

  • 외부 의존성이 없는 순수 계산 로직은 별도의 준비 없이 테스트할 수 있습니다.

/* 순수 함수의 예 */
uint16_t CalculateCRC(const uint8_t* data, uint16_t length);
float32_t ConvertRawToPhysical(uint16_t rawValue, float32_t scale, float32_t offset);

Stub/Mock이 필요한 코드:

하드웨어나 외부 모듈에 의존하는 코드는 해당 의존성을 대체해야 테스트가 가능합니다.

  • Stub: 테스트 대상이 호출하는 의존성을 고정된 응답을 반환하도록 대체한 것
  • Mock: Stub의 기능에 더해, 호출 여부나 호출 횟수 등 행위(Behavior)를 검증할 수 있도록 한 것

/* 하드웨어 의존 코드의 예 */
uint16_t ReadADCValue(uint8_t channel)
{
    return ADC_REG->RESULT[channel];
}

위와 같은 함수는 테스트 시 ADC 레지스터를 Stub으로 대체하여 원하는 값을 주입할 수 있도록 해야 합니다.

4.3 3단계: 특성화 테스트(Characterization Test) 작성

특성화 테스트란 레거시 코드의 현재 동작을 기록하는 테스트입니다. 이 테스트의 목적은 코드가 “올바르게” 동작하는지 검증하는 것이 아니라, 현재 동작을 문서화하여 향후 변경 시 동작 변화를 감지하는 것입니다.

특성화 테스트 작성 절차:

  1. 함수를 다양한 입력값으로 실행
  2. 실제 출력값을 기록
  3. 해당 출력값을 기대값으로 설정

void Test_CalculateSpeed_Characterization(void)
{
    /* 현재 동작을 기록한 테스트 */
    ASSERT_EQUAL(CalculateSpeed(100, 10), 10);
    ASSERT_EQUAL(CalculateSpeed(0, 10), 0);
    ASSERT_EQUAL(CalculateSpeed(50, 5), 10);
}

주의사항: 특성화 테스트 작성 시 정의되지 않은 동작(Undefined Behavior)을 유발할 수 있는 입력(예: 0으로 나누기, NULL 포인터 역참조 등)은 피해야 합니다. 이러한 입력에 대해서는 먼저 방어 로직이 있는지 확인하고, 없다면 방어 로직 추가를 선행하거나 해당 케이스를 테스트 범위에서 명시적으로 제외해야 합니다.

4.4 4단계: Seam 확보 및 커버리지 확장

Seam이란 코드를 변경하지 않고 동작을 바꿀 수 있는 지점을 의미합니다. Michael Feathers(2004)가 제시한 이 개념은 레거시 코드 테스트의 핵심 기법입니다. 각 Seam 유형의 특성을 이해하고 코드 상황에 맞는 방식을 선택합니다.

Link Seam: 링크 시점에 다른 구현으로 교체

  • 적용: 테스트 빌드에서 실제 드라이버 대신 Stub 라이브러리를 링크
  • 예: 프로덕션에서는 o, 테스트에서는 eeprom_stub.o 링크

Preprocessor Seam: 전처리기 지시문 활용

  • 적용: 매크로로 함수 호출을 대체
  • 예: #ifdef UNIT_TEST 블록 내에서 #define ReadEEPROM(addr) ReadEEPROM_Stub(addr)

Function Pointer Seam: 함수 포인터를 통한 의존성 주입

  • 적용: 런타임에 의존성을 교체할 수 있도록 설계
  • 예: typedef uint16_t (*ADC_ReadFunc)(uint8_t); ProcessData(ADC_ReadFunc reader);

일반적으로 Link Seam은 빌드 시스템 수정이 필요하고, Preprocessor Seam은 코드 가독성에 영향을 줄 수 있으며, Function Pointer Seam은 가장 유연하지만 기존 인터페이스 수정이 필요합니다. 기존 코드 변경이 어려운 경우 Link Seam이나 Preprocessor Seam을 먼저 고려합니다.

4.5 5단계: 리팩토링과 테스트의 선순환

특성화 테스트가 확보된 코드는 안전하게 리팩토링할 수 있습니다.

리팩토링 전:

void ProcessMessage(Message_t* msg)
{
    /* 200줄의 코드: 검증, 파싱, 처리, 응답 모두 포함 */
}

리팩토링 후:

bool ValidateMessage(const Message_t* msg);
ParsedData_t ParseMessage(const Message_t* msg);
Result_t HandleParsedData(const ParsedData_t* data);

void ProcessMessage(Message_t* msg)
{
    if (!ValidateMessage(msg)) return;
    ParsedData_t parsed = ParseMessage(msg);
    Result_t result = HandleParsedData(&parsed);
    SendResponse(CreateResponse(result));
}

분리된 함수는 각각 독립적으로 테스트할 수 있으므로 테스트 용이성이 향상됩니다.

5. 규격 관점에서의 고려사항

5.1 ISO 26262 Part 6 요구사항

ISO 26262-6에서는 ASIL 등급에 따른 구조적 커버리지 메트릭을 권고하고 있습니다. 

구조적 커버리지 메트릭의 의미:

  • Statement Coverage (구문 커버리지): 각 실행문이 최소 1회 실행되었는지 측정
  • Branch Coverage (분기 커버리지): 각 분기(if-else, switch 등)의 모든 경로가 실행되었는지 측정
  • MC/DC (Modified Condition/Decision Coverage): 각 조건이 결정(Decision)의 결과에 독립적으로 영향을 미치는지 측정. 복합 조건문에서 더 엄격한 검증을 요구함

레거시 코드에 테스트를 추가할 때에도 해당 코드에 배정된 ASIL 등급에 따른 커버리지 요구사항을 충족해야 합니다. 특히 ASIL D가 배정된 코드는 MC/DC 커버리지가 강력히 권고되므로 우선적으로 테스트를 확보해야 합니다.

5.2 Automotive SPICE SWE.4 요구사항

Automotive SPICE의 SWE.4(Software Unit Verification) 프로세스에서 요구하는 주요 사항은 다음과 같습니다.

  • 단위 검증 전략 및 계획 수립
  • 테스트 케이스와 소프트웨어 상세 설계 간 양방향 추적성
  • 테스트 결과 기록 및 요약
  • 회귀 테스트 전략

레거시 코드에 테스트를 추가하는 경우에도 이러한 작업산출물을 함께 생성해야 심사에서 적합성을 인정받을 수 있습니다.

5.3 AUTOSAR 환경에서의 테스트

 

AUTOSAR 환경에서는 RTE(Runtime Environment)가 SWC 간 통신을 담당하므로, RTE 인터페이스를 Stub으로 대체하면 각 SWC를 독립적으로 테스트할 수 있습니다.

 

6. 실무 적용 시 고려사항

6.1 점진적 목표 설정

단기간에 높은 커버리지를 달성하려는 접근보다 측정 가능한 중간 목표를 설정하는 것이 효과적입니다.\

  • 1단계: 안전 관련 핵심 함수 테스트 확보, 해당 영역 Branch 70%
  • 2단계: 변경 예정 모듈 테스트 추가, 전체 Statement 50%
  • 3단계: 신규 개발 코드 TDD 적용, 레거시 영역 지속 확장

6.2 테스트 미수행 관리

테스트가 확보되지 않은 코드는 기술 부채로 기록하여 관리합니다. 코드 내 주석보다는 이슈 트래킹 시스템(Jira, Redmine 등)이나 테스트 관리 도구를 활용하는 것이 추적과 우선순위 관리에 효과적입니다.

기록 항목 예시:

  • 대상 함수/모듈명
  • 테스트 미작성 사유
  • 배정된 ASIL 등급
  • 예상 소요 공수

6.3 테스트 어려운 패턴 대응

 

7. 맺음말

레거시 코드에 단위 테스트를 추가하는 작업은 “제대로 만들고 있는가(Verification)”를 사후에 확인하는 과정입니다. 이상적으로는 개발 초기부터 테스트가 함께 작성되어야 하지만, 현실에서는 테스트 없이 개발된 코드를 인수하거나 기존 코드에 규격 요구사항을 적용해야 하는 상황이 빈번합니다.

이러한 상황에서 핵심은 위험 기반 우선순위 설정, 특성화 테스트를 통한 현재 동작 기록, Seam 확보를 통한 테스트 가능성 향상, 그리고 점진적 확장입니다.

완전한 커버리지 달성보다 중요한 것은 지속 가능한 테스트 체계를 구축하는 것입니다. 오늘 하나의 함수에 테스트를 추가하는 것이 내일의 코드 품질 향상으로 이어집니다.