Introduction to Testing and Verification

‍이세현·어제

Testing and Verification are HARD

현재 소프트웨어는 매우 복잡하며, 오류 발생 시 사회·경제적 손실이 막대하다. 다음 세 가지 실제 사례는 소프트웨어 오류가 얼마나 심각한 결과를 초래하는지 보여준다.

  • Toyota 급가속 사고 (2010s) — 89명 사망, 12억 달러 벌금
  • Boeing 737 MAX 사고 (2018–2019) — 346명 사망, 기체 전 세계 운항 금지
  • CrowdStrike 장애 (2024) — 전 세계 IT 인프라 마비, 약 100억 달러 피해 추정

소프트웨어 검증이 어려운 이유

테스트 비용이 매우 크다.
Microsoft 사례에서 언급된 내용에 따르면,

  • 개발자 수만큼의 테스터가 필요
  • 테스트를 위한 코드 (test harness)가 실제 프로그램보다 3배 이상 긴 경우도 있다.

즉, 품질 확보는 개발보다 더 많은 자원을 요구할 수 있다.

e.g) 삼각형 판단 프로그램

  • 문제 정의
    • 정수 세 개를 입력받아 삼각형의 유형을 scalene, Isosceles, equilateral 중 한 가지로 판단한다.

이 문제는 단순해 보이지만 고려해야 할 사항이 많다.

  1. 입력 유효성 검사 필요
    • 조건 1: 입력된 세 정수는 0보다 커야 한다.
    • 조건 2: 한 변의 길이는 나머지 두 변의 길이의 합 보다 작아야 한다.
    • Overflow 고려 필요
    • 개발자는 명시되지 않은 조건을 놓치기 쉽다. (Cause of many hard-to-find bugs)
  2. 실행 경로가 많다.
    • 문서에 따르면 총 11개의 실행 경로가 존재한다.
    • 직관적으로 보이는 테스트 몇 개만으로는 충분하지 않다.

테스트가 더 어려워지는 이유: 프로그램의 변화

소프트웨어는 지속적으로 변하므로 테스트 또한 지속적으로 수행되어야 한다.

  • 입력 타입을 int에서 float으로 바꾸면 오류 처리가 필요하다.
  • 직각삼각형 판별 기능이 추가되면 기존 테스트 재검증이 필요하다.

코드 변경 시 기존 기능이 깨지지 않는지 변경의 안전성을 보장하기 위해서 반복적으로 검사하는 regression testing이 중요하다.

기존 기능이 깨지지 않았는지 테스트하는 활동인 regression testing은 서로 다른 코드 두 개의 결과를 비교해서 이미 발견된 버그가 재발하지 않도록 방지하고 품질을 유지할 수 있다.

Concurrency programming에서 검증의 어려움

예를 들어 두 개의 스레드 각각 세 개의 연산을 수행한다면 가능한 스케줄은 (3+3)!/(3!×3!)=20(3+3)!/(3!\times3!)=20이지만 결과는 11 가지로 수렴한다. 이때 어떠한 곳에서 문제가 발생하는지 찾는 것은 매우 어렵다.

Concurrency로 인한 bug는 재현성이 낮고, 타이밍에 따라 드러나므로 테스트로 발견하기 가장 어려운 유형 중 하나이다.

Testing and Verification Have Many Facets

소프트웨어 분석에서 반드시 고려해야 하는 근본 질문

  1. 명세 (specification)은 올바른가?
    • 사용자의 요구를 잘 반영했는지 확인 필요 (validation)
  2. 시스템은 명세를 충족하는가?
    • 구현이 명세대로 동작하는지 확인 (verification)
  3. 시스템은 사용자 니즈를 충족하는가?
    • 사용자가 진정으로 원하는 기능을 제공하는가
  4. 시스템이 잘못 동작하지 않음을 어떻게 보장할 것인가?
    • 오류가 드러나는 조건은 보통 특정 경계 상황, 드문 조합, 비정상 입력 등에서 발생한다. 이런 영역을 체계적으로 탐색하는 것이 테스트의 핵심 난점이다.

Verification vs Validation

Verification은 명세/설계/코드가 올바른지 확인하는 것을 목적으로 한다. Validation은 사용자 요구를 충족하는 것을 목적으로 한다.

예를 들어 계산기 앱이 사칙연산을 정확히 수행하는지 확인하는 것은 verification, 계산기의 기능이 실제 사용자 니즈를 충족하는지 확인하는 것은 validation이다.

올바르게 만들었는가 vs 올바른 것을 만들었는가

단일 기법으로는 충분하지 않은 이유

소프트웨어 시스템은 구조, 규모, 사용 목적이 다양한다. 게임은 사용성, 반응성이 중요하고 항공 소프트웨어는 안전성과 신뢰성이 요구된다. 따라서 어떤 분석 기법을 사용할지는 시스템의 종류, 규모, 품질 목표, 자원 등에 따라 달라진다.

Definition: Software Analysis

소프트웨어 분석은 소프트웨어 산출물 (artifact)을 체계적으로 분석하여 그 속성을 파악하는 것을 뜻한다. 여기서 artifact의 범위는 매우 넓다.

  • 코드, 모듈, 시스템, 실행 trace, 테스트 케이스, 설계 문서, 요구사항 문서 등

소프트웨어 분석 기술

  • Dynamic: 실행 (run-time)을 기반으로 분석한다.
    • Testing: 테스트 데이터로 프로그램을 직접 실행하여 결과를 검사한다.
    • Analysis: 실행 중 수집한 데이터를 기반으로 커버리지 분석, 메모리 누수 탐지 등 검사한다.
  • Static: 코드를 실행하지 않고 분석한다.
    • Inspection: 사람이 직접 코드를 검토하며 fault를 조기에 발견한다.
    • Analysis: 도구를 이용하여 제어 흐름, 데이터 흐름, 타입 정보 등을 자동으로 분석한다.

다양한 분석/테스트 기법이 존재하지만, 어떤 기술도 단독으로는 충분하지 않다. 또한 시스템 특성, 품질 목적, 보안/안전 요구사항, 예산 등에 따라 기술 조합이 달라진다. 따라서 test strategy를 설계하는 것이 소프트웨어 품질 보증의 핵심이다.

Classic Testing

(전통적 소프트웨어 테스팅 즉, 기능적 정확성을 중심으로 한 테스트의 목표와 한계)

What is Testing?

Testing은 통제된 환경에서 테스트 데이터를 사용하여 실제 코드를 실행하는 활동을 칭한다. 즉, 테스트는 컴파일만 하거나 코드를 눈으로 보는 것이 아니라 프로그램을 실행한 결과를 관찰하는 활동이라는 점이 핵심이다.

Testing의 주요 목적은 다음과 같다.

  • Verification: 프로그램이 요구사항을 정확히 만족하는지 확인한다.
  • Defect Testing: 프로그램을 실행하면서 오류를 찾는다.

또한 testing은 결함을 발견하고 품질을 평가하고, 명세와 문서를 명확히하는 것도 목적으로 한다.

하지만 아무리 많은 테스트를 수행해도 실행되지 않는 경로에 숨어 있는 버그는 여전히 존재할 수 없다. 따라서 테스트는 버그 "없음"을 절대 보장할 수 없다.

Bug, Defect, Error, Failure

용어정의예시
Error사람이 만든 실수time=0인 경우를 고려하지 않음
Fault (Defect, Bug)코드에 존재하는 문제divide-by-zero 예외 처리가 없음
FailureFault가 실행되어 실제로 잘못된 결과 발생time=0 실행 시 예외 발생

Error가 fault로 fault가 failure로 이어질 수 있지만 fault가 실행되지 않으면 failure는 발생하지 않는다.

Debugging is Not Testing

  • Debugging: 실패가 발생한 후 원인을 분석하고 수정하는 과정이다.
  • Testing: 실패를 찾아내기 위해 체계적으로 검색하는 과정이다. 여기에서 코드 수정은 포함하지 않으므로 debugging을 완전히 대체할 수 없다.

Testing Levels

Level설명
Unit Testing개별 모듈 (함수/클래스)을 독립적으로 테스트
Integration Testing모듈 간 상호작용 테스트
System Testing전체 시스템이 하나의 제품으로서 정상 동작하는지 화인

Test-Driven Development (TDD)

테스트를 먼저 작성하는 개발 방법을 Test-Driven Development라고 한다. 이때 실패하는 테스트 없이는 코드를 작성하면 안 된다. 이러한 agile 기법의 기대 효과는 다음과 같다.

  • 인터페이스 우선 설계 유도
  • 불필요한 코드 감소
  • 결함 감소 및 코드 품질 향상
  • 테스트 커버리지 향상
  • 생산성 증가 사례 존재

Continuous Integration

코드가 커밋될 때마다 자동으로 빌드하고 테스트를 실행해서 결과를 기록하면 빠른 피드백을 통해 회귀 발생을 빠르게 감지할 수 있고 협업 개발 시 품질 유지에 도움이 된다.

Oracle Problem

프로그램이 정답을 출력했는지 자동으로 판단하기 어렵다는 근본적인 문제가 있다.
실제 시스템에서 Input generator로 이상적 정답인 golden oracle을 만들기는 어렵다. 결론적으로 테스트에서 오라클 비용은 매우 크며, 자동화의 가장 큰 장애 요소 중 하나이다.

Testing Beyond Functional Correctness

기능적 정확성만으로 실제 소프트웨어 품질을 충분히 설명할 수 없다. 테스트는 기능과는 다른 영역 (사용성, 보안, 성능)으르 포함해야 하지만, 정답이 명확하지 않고 자동화가 쉽지 않다.

Testing Usability

사용성 테스트가 어려운 이유는 아래와 같다.

  • Specification: 사용성은 명확한 규격으로 정의하기 어렵다.
  • Test harness / Environment: 다양한 사용자·브라우저·기기 환경에서 동작해야 하기 때문에 통일된 테스트 환경을 정의하기 어렵다.
  • Nondeterminism: 사용자의 행동은 예측할 수 없고, 실험마다 달라진다.
  • Unit testing / Automation / Coverage: 사용성을 코드 단위로 분해하거나 자동화하기는 거의 불가능하다. 또한 coverage라는 개념 자체가 적용되기 어렵다.

즉 사용성은 기능처럼 정답이 있는 테스트가 아니며, 사람의 행동과 경험이 관여하기 때문에 자동화가 매우 어렵다.

또한 GUI/Web 사용성을 테스팅하는 것 또한 상당히 어려운 문제이다.

  • Capture and Replay 방식
    • 마우스 클릭이나 키보드 입력 같은 시스템 이벤트를 녹화 후 반복 실행하면서 테스트 하게 된다.
    • 이러한 방식은 간단한 테스트에는 유용하지만 UI 변경에는 매우 취약하고 타이밍이나 환경 차이를 반영하기 어렵다.
  • GUI 테스트에 과도하게 의존하게 되면 유지비가 커질 수 있어서 모델과 GUI를 분리하여 테스트 부하를 줄여야 한다.

Manual Testing

수동 테스트에서는 다음과 같은 고려사항이 있다.

  • 실제 시스템에서 테스트해야 하는가?
  • 별도 환경이 필요한가?
  • 사용성 결과는 자동 assert가 불가능하다.
  • 비용이 매우 크다.
  • 재현성이 낮다.

즉, 사용성 테스트는 잦동화가 어렵고 비용이 크며, 정량 평가가 어렵다는 특징을 가진다.

A/B Testing

Usability 평가에서 가장 실용적인 접근 중 하나로 A/B Testing이 있다. A와 B 두 변형을 그룹에 나누고 클릭 수 같이 실제 사용자 행동을 지표로 삼아 비교하는 방법이다. 이는 광고, GUI 배치, UX 디자인 결정에 자주 사용된다.

A/B Testing은 표본을 기반으로 하는 통계적인 비교이기 때문에 명확한 정답이 없는 사용성 평가에서 실질적인 도구가 된다.

Testing Security & Robustness

보안 테스트는 기능 테스트보다 훨씬 복잡하며, 다음의 근본적 문제들이 있다.

  • Specification: "보안적으로 안전해야 한다"는 모호한 요구. 명세가 어렵다.
  • Test harness / Environment: 실제 공격자를 완전히 모사하기 어렵다.
  • Nondeterminism: 공격 시나리오를 예측할 수 없다.
  • Automation / Coverage: 악의적 입력은 무한하며, 이를 전부 테스트할 수는 없다.

Random Testing

프로그램의 입력 도메인에서 무작위 입력을 생성해서 테스팅하는 방식이다. 단순하지만 의외로 강력하며, 대규모 시스템 테스트에 활용된다.

예를 들어 23,000개의 무작위 입력에서 실패가 발생하지 않았다면 이 프로그램의 실패율은 최소 90%의 신뢰도로 1/10,000 이하로 추정할 수 있다. 즉, random testing은 기능 정확성보다 신뢰성을 확률적을 ㅗ보증할 수 있다는 장점이 있다.

Fuzz Testing (Fuzzing)

잘못된 입력이나 예상하지 못한 입력, 랜덤 변형 입력을 지속적으로 주입하여 보안 취약점이나 심각한 오류를 찾아내는 기법이다. 서비스가 중단되는 원인을 찾거나 강한 오류를 발생시키는 문제를 발견할 수 있다.

Fuzzing으로 발견할 수 있는 버그 유형은

  • 포인터/배열 접근 오류
  • 반환 값 미확인
  • Boundary 오류
  • 부호 오류
  • Race conditions
    등이 있다.

Fuzzing은 다음과 같은 흐름으로 진행된다.

  1. Seed input pool 유지
  2. 입력을 bit flip, byte flip 등의 방식으로 변형
  3. 새 경로를 커버하는 입력을 pool에 유지

(AFL, libFuzzer 같은 현대적 fuzzer가 이 방식을 사용한다.)

Fuzzing은 단순 무작위가 아니라 새로운 코드 경로를 여는 입력을 학습하며 진화한다.

Testing Performance

성능 테스트는 기능이 정확히 돌아가는지보다, 시스템이 얼마나 빠르고 안정적으로 동작하는지를 검증하는 영역이다.

Unit & Regression Performance Testing

핵심 컴포넌트들의 실행 시간을 측정해서 이전 버전과 비교한다. 성능 저하가 regression으로 발생했는지 감지할 수 있다. 또한 bottleneck을 빠르게 찾아낼 수 있다.

Profilling

CPU 사용량, 메모리 사용량을 파악하고 어떤 함수가 전체 실행 시간을 잡아먹는지 식별할 수 있는 기법이다. 이를 통해 어디를 최적화해야 하는지 알 수 있다.

Performance Testing during Design

코드가 완성되기 전에 모델링과 시뮬레이션을 통해 성능을 예측할 수 있다.
예를 들어 queuing 모델에서 서버 수, 요청 빈도, 처리 속도 등을 기반으로 병목을 예측할 수 있다. 이처럼 성능 테스트는 구현 이후뿐 아니라 설계 단계에서도 가능하다.

Stress Testing

정상 범위를 넘어서는 극단적인 부하를 주면서 강건성을 측정하는 기법이다.
소프트웨어의 오류 처리 능력을 파악하고, 복구 가능성을 예상할 수 있어서 결국 서비스의 지속성을 보장할 수 있는지 판단할 수 있다.

Soak Testing

메모리 누수 같은 문제들은 짧은 테스트에서는 드러나지 않을 수 있다. Soak testing은 오랜 시간 동안 일정한 부하를 주어 시스템을 테스트하는 기법이다.
메모리 누수를 포함하여 리소스가 고갈되는 문제처럼 오랜 시간 동안 실행했을 때 발생하는 문제를 탐지할 수 있다.

Chaos Engineering (Infrastructure-level Fuzz Testing)

넷플릭스에서는 무작위로 서버 인스턴스를 종료시키거나 네트워크 지연, 장애를 강제로 유발하여 시스템이 얼마나 탄력적인지 평가한다. 이 방법은 전통적 소프트웨어 테스트가 아니라, 대규모 분산 시스템의 안정성과 회복력을 검증하는 기법이다.

Limits of Testing

  • 실행되지 않은 코드의 오류는 발견할 수 없다.
  • 정답을 자동으로 판정하지 어려운 oracle 문제
  • Nondeterminism
  • 실행 시간이 오래 걸릴 수 있다.
  • 많은 비용이 필요하다.
  • Verification 중심이며, validation은 충분히 보장하지 못한다.
profile
Hi, there 👋

0개의 댓글