-
[Software Engineering] Fundamentals of TestingCS/Software Engineering 2026. 5. 26. 17:27
"코드가 동작한다"는 것과 "코드가 올바르다"는 것은 다르다. 소프트웨어 테스팅은 단순히 실행해보고 결과를 확인하는 행위가 아니라, 제품의 품질에 대한 정보를 체계적으로 수집하는 조사 활동이다. 이번 글에서는 테스팅이 왜 필요한지, 왜 어려운지, 그리고 어떤 기법들이 있는지를 정리한다.
Testing과 V&V
소프트웨어 개발 생명주기(SDLC)에서 테스팅은 완성된 제품이 제대로 됐는지 확인하는 단계다. 이 과정을 Software Validation & Verification, 줄여서 V&V라고 한다.
- Validation: 우리가 올바른 제품을 만들고 있는가? (요구사항에 부합하는가)
- Verification: 우리가 제품을 올바르게 만들고 있는가? (명세대로 구현됐는가)
소프트웨어 테스팅의 공식 정의는 이렇다.
Software testing: an investigation conducted to provide stakeholders with information about the quality of the product or service under test.
테스팅은 품질에 대한 정보를 이해관계자에게 제공하기 위한 체계적인 조사다. 동작하는지 정도의 확인이 아니라는 뜻이다.
소프트웨어 품질이란 무엇인가
품질은 한 가지 기준으로 정의되지 않는다. 크게 세 가지 차원에서 생각할 수 있다.
Dependability (신뢰성)
"믿고 쓸 수 있는가?" 가 핵심 질문이다. 네 가지 하위 속성이 있다.
- Correctness (정확성): 공식 명세에 비춰 올바른가. 증명이 필요하므로 비자명한 시스템에서는 달성하기 어렵다. VS Code의 Undo 기능을 생각해보자. "최근 동작을 취소한다"는 게 정확히 무엇인지 형식적으로 정의하는 것 자체가 쉽지 않다.
- Reliability (신뢰성): 가끔 맞는 것으로는 부족하다. 특정 기간 동안 지속적으로 올바르게 동작할 확률이 높아야 한다. 모든 시나리오를 예측할 수 없으므로 통계적으로 논증한다.
- Safety (안전성): 내부에 fault가 있더라도 인명이나 재산 손실로 이어지는 위험이 없어야 한다.
- Robustness (견고성): 주변 환경이 바뀌거나 나빠지더라도 합리적인 수준에서 계속 동작해야 한다. 비정상 입력, 엣지 케이스, 부분적 시스템 장애를 처리할 수 있어야 한다.
Performance (성능)
"얼마나 효율적인가?" 기능적 정확성 외에도 실행 시간(지연), 네트워크 처리량(I/O), 리소스 사용량(CPU·메모리·에너지), 동시접속 수 등을 만족해야 한다. 성능은 실행 환경에 민감하게 반응하기 때문에 철저하게 테스트하기 가장 어려운 영역이다.
Q. 기능은 1명의 사용자에게 완벽하게 동작하지만 1,000명이 몰리면 느려진다면, 이것은 버그인가?
Usability (사용성)"쓰기 편한가?" 사용자가 소프트웨어를 쉽게 사용할 수 있는가의 문제다. 실험실 환경에서 테스트하기 어렵고, 포커스 그룹, 베타 테스트, A/B 테스트처럼 실제 사용자를 포함한 방식으로 평가한다.
Q. 기능적으로 완벽하고 빠른데 쓰기 힘든 프로그램이 있을 수 있는가?
CLI 도구 ffmpeg가 좋은 예시다. 강력하지만 명령어 하나가 이렇다.
ffmpeg -i input.mp4 -vf "scale=1280:720,format=yuv420p" -c:v libx264 -preset slow -crf 22 output.mp4
Fairness / Ethics (공정성)"공정하게 작동하는가?" AI/ML 시스템에서 특히 중요해진 분야다. Joy Buolamwini와 Timnit Gebru의 2018년 연구에 따르면 상업용 얼굴인식 시스템이 밝은 피부의 남성에게는 최대 99%의 정확도를 보였지만, 어두운 피부의 여성에게는 65%까지 떨어졌다. IBM의 경우 그 격차가 34.4%p에 달했다.
전통적인 테스팅과 다른 점은 입력 그룹의 분포와 대표성, 민감한 특성에 대한 모델 동작, 모델 내부 로직까지 고려해야 한다는 것이다.
Q. AI 시스템이 전체 정확도는 90%인데 특정 집단에서는 60%라면 허용 가능한가?
Fault, Error, Failure 구분
테스팅에서 버그로 뭉뚱그려 부르는 것들을 정확히 구분하면 다음과 같다.
Fault → (유발) → Error → (발현) → Failure 또는 Success- Fault (결함): 소스 코드에 존재하는 정적 이상. 실행 경로에 따라 아무 문제를 일으키지 않을 수도 있다.
- Error (오류): Fault가 실행됐을 때 발생하는 런타임 상태 이상. 잘못된 변수 값, 널 포인터 등.
- Failure (장애): Error가 프로그램 외부로 드러난 결과. 사용자가 인식할 수 있는 잘못된 동작.
참고로 고전 SE / 형식 방법론에서는 "Error가 Fault를 유발한다"고 보는 경우도 있다. Error를 개발자의 인간적 실수, Fault를 그 실수가 코드에 반영된 결과로 정의하는 방식이다. SW 테스팅 문맥에서는 Fault → Error → Failure 순서로 이해하는 것이 일반적이다.
코드로 이해하는 세 가지 개념
void rotateLeft(int* rgInt, int size) { int i; for (i = 0; i < size; i++) { rgInt[i] = rgInt[i+1]; // Fault: 범위 초과 + wraparound 없음 } }이 함수는 정수 배열을 왼쪽으로 한 칸 회전하는 코드다. Fault는 분명히 존재하지만, 테스트 입력에 따라 결과가 달라진다.
Test Input #1 — rgInt[], size=0
- 루프가 실행되지 않으므로 Error도, Failure도 없다. Fault가 있어도 실행되지 않으면 아무 일도 일어나지 않는다.
Test Input #2 — rgInt[0, 1] 0, size=2
- 배열은 인덱스 1까지 존재하지만, 루프 마지막에 rgInt[1] = rgInt[2]를 실행하면서 배열 범위를 벗어난 메모리를 읽는 Error가 발생한다. 그런데 그 메모리 주소에 마침 0이 들어있었기 때문에 결과가 [1, 0]으로 우연히 올바르게 나온다. 이것을 coincidental correctness(우연한 정확성) 라고 부른다.
Test Input #3 — rgInt[0, 1, 66], size=2
- Failure가 관측된다. 결과가 잘못 나온다.
이 예시에서 알 수 있는 두 가지 교훈이 있다.
- 모든 테스트를 통과했다고 해서 프로그램이 올바른 것이 아니다.
- Fault가 있는 코드를 실행하더라도 통과할 수 있다.
Fault는 해석에 따라 달라진다
int abs(int x) { if (x < 0) return x; // 잘못된 코드 else return x; }이 코드의 Failure는 명확하다. 하지만 Fault가 무엇인지는 어떻게 수정하느냐에 따라 달라진다.
- Fix 1: return -x로 수정 → Fault는 "부호 반전 로직 누락"이었다.
- Fix 2: 음수 입력에 0을 반환하도록 수정 → Fault는 "음수 입력 유효성 검사 누락"이었다.
A failure is observable; a fault is interpreted: 장애는 관측하면 알 수 있지만, 결함은 해석해야 한다.
테스팅 주요 용어
- Test Input: 프로그램을 실행할 때 사용하는 입력값의 집합.
- Test Oracle: 테스트 실행 결과가 올바른지 판단하는 메커니즘. 정의하기 가장 어렵고 노동집약적인 부분이다. 명시적 오라클(기대 결과를 직접 명시)과 암묵적 오라클(시스템 크래시, 무한 루프, Division by zero 등)이 있는데, 암묵적 오라클만으로는 발견할 수 있는 결함이 극히 제한된다.
- Test Case: Test Input + Test Oracle의 조합.
- Test Suite: 여러 Test Case의 모음.
- Test Effectiveness: 테스트가 결함을 얼마나 잘 찾아내는가. 테스트 자체의 품질 지표.
- Testing vs. Debugging: 테스팅은 결함을 발견하는 것, 디버깅은 결함을 제거하는 것. 둘은 다른 활동이다.
왜 테스팅은 어려운가
정확성 속성은 결정 불가능(Undecidable)하다
프로그램과 속성을 넣으면 Pass/Fail을 자동으로 판단해주는 완벽한 결정 절차는 이론적으로 존재하지 않는다. 거의 모든 올바름 속성에 정지 문제(Halting Problem)가 내재해 있기 때문이다. 따라서 완전히 자동화된 검증은 불가능하다.
완전 탐색(Exhaustive Testing)은 현실적으로 불가능하다
간단한 예를 보자. triangle(int a, int b, int c)라는 함수가 있다. 32비트 정수 세 개를 받는다. 가능한 입력 조합의 수는 약 2⁹⁶ ≈ 7.92 × 10²⁸이다. 알려진 우주 전체의 별 개수(약 10²⁴개)보다도 많다. 프로그래밍 입문 수업 과제 수준의 함수조차 완전 탐색은 불가능하다.
테스팅으로 버그 부재를 증명할 수 없다
Edsger W. Dijkstra(1970)가 남긴 말이 있다.
"Program testing can be used to show the presence of bugs, but never to show their absence!"
테스팅은 샘플링이다. 가능한 모든 입력을 다 테스트하지 않는 이상, 버그가 없다는 것은 증명할 수 없다. 테스팅의 어려움은 결국 어떻게 효과적인 샘플링을 설계하느냐에 있다.
좋은 테스트는 도메인 지식과 창의성이 필요하다
2008년 12월 31일, Microsoft Zune 30GB가 전 세계에서 동시에 먹통이 됐다. 원인은 내부 클럭 드라이버가 윤년의 마지막 날(366번째 날)을 처리하지 못하는 버그였다. 이런 버그는 일반적인 테스트로는 잡기 어렵다. "12월 31일이 윤년인 해"라는 케이스를 떠올리려면 달력에 대한 도메인 지식이 있어야 한다.
테스팅에는 만능 레시피가 없다. 자동화와 인간의 창의적 사고, 둘을 결합해야 한다.
테스팅 기법 분류
테스팅 기법은 내부 구현에 얼마나 접근하느냐에 따라 나뉜다.
- Black-box Testing: 코드를 보지 않고 명세만으로 테스트를 설계한다. 기능 테스팅, 행동 테스팅이라고도 한다.
- White-box Testing: 코드의 내부 구조를 보고 테스트를 설계한다. 구조적 테스팅이라고도 한다.
- Gray-box Testing: 두 가지를 혼합한 방식이다.
Black-box 테스팅 기법들
Random Testing
입력을 무작위로 선택해서 테스트한다. 구현이 단순하고 실제 결함을 찾아내기도 한다. 단점은 의미 있는 결과를 얻기까지 매우 오래 걸릴 수 있고, 전혀 쓸모없는 입력만 반복할 가능성이 있다는 것이다. Black-box이기도 하고 White-box이기도 하다.
Equivalence Class Partitioning (ECP, 동치 분할)
입력 공간을 동일하게 취급할 수 있는 그룹(동치 클래스)으로 나누고, 각 클래스에서 대표 입력 하나씩만 선택해 테스트한다. 같은 클래스 내의 입력은 동일하게 처리된다고 가정한다.
예시: 사용자 나이를 받는 보험 시스템, 20세와 70세를 기준으로 그룹이 나뉜다고 하면
- E1 = {0 ≤ i ≤ 20}, E2 = {20 < i ≤ 70}, E3 = {70 < i ≤ 120}
- U1 = {i < 0}, U2 = {i > 120}
최종 테스트 입력: {-10, 10, 30, 80, 200}
수억 개의 입력 공간이 5개로 줄어든다.
Boundary Value Analysis (BVA, 경계값 분석)
ECP와 함께 사용한다. 경계에서 결함이 가장 많이 발생한다는 경험적 사실에 기반해, 동치 클래스의 경계 근처 값들을 집중 테스트한다.
같은 보험 예시에 BVA를 적용하면 테스트 입력은 {-1, 0, 1, 19, 20, 21, 69, 70, 71, 119, 120, 121}이 된다.
Off-by-one 버그를 잡아내는 데 특히 효과적이다.
if (age < 20) { ... } // Fault: < 대신 <= 이어야 한다 if (age <= 20) { ... } // 올바른 코드age = 20인 입력을 테스트해야 이 버그를 잡을 수 있다.
Combinatorial Testing (조합 테스팅)
여러 입력 파라미터가 있을 때 모든 조합을 테스트하는 것은 불가능하다. Pairwise Testing(k=2)은 임의의 두 파라미터 조합이 최소 한 번씩 테스트되도록 최적화해서 구성한다. 항공사, 출발 도시, 출발일, 귀국일처럼 여러 변수가 조합되는 시스템에서 유용하다.
White-box 테스팅 기법들
Structural Testing (구조적 테스팅)
소스 코드의 구조적 단위(라인, 브랜치, 경로 등)를 기준으로 테스트 충분성을 측정한다. 커버리지(Coverage)가 대표적인 지표다.
"필요 조건이지 충분 조건이 아니다"라는 점이 핵심이다. 100% 코드 커버리지가 버그 없음을 보장하지 않는다.
Mutation Testing
코드에 인위적으로 결함을 주입하고, 기존 테스트 스위트가 그 결함을 탐지하는지 확인한다. 테스트 자체의 품질을 평가하는 데 사용한다. 잠재력은 크지만 뮤턴트 수가 매우 많아 실행 비용이 높다는 단점이 있다.
Regression Testing (회귀 테스팅)
최근 수정이 기존 기능을 망가뜨리지 않았는지 확인하는 테스트다. Black-box와 White-box 모두 될 수 있다. 개발 주기가 짧아질수록(애자일, CI/CD) 중요도가 높아지고, 기업이 막대한 자원을 투입하는 분야다.
정리
- Fault는 코드에 존재하는 정적 이상, Error는 그것이 실행됐을 때의 런타임 상태, Failure는 외부로 드러난 결과다.
- Fault가 항상 Failure로 이어지지는 않는다. Coincidental correctness가 존재한다.
- 테스팅으로 버그의 부재는 증명할 수 없다. 효과적인 샘플링 전략을 설계하는 것이 핵심이다.
- 품질은 신뢰성, 성능, 사용성, 공정성 등 다차원적이다.
- 어떤 기법도 만능이 아니다. 각 기법의 장단점을 이해하고 상황에 맞게 조합해서 사용하는 것이 실력이다.
출처: 경북대학교 손정주 교수님, "소프트웨어공학" 강의 자료
'CS > Software Engineering' 카테고리의 다른 글
[Software Engineering] 소프트웨어 개발 생명주기(SDLC)와 프로세스 모델 (3) 2026.04.14 [Software Engineering] Use Case (1) 2026.04.12 [Software Engineering] Requirement Engineering (2) 2026.04.12 [Software Engineering] 버전 관리 시스템(VCS): Git 핵심 (1) 2026.04.11 [Software Engineering] Design Pattern: Behavioral Patterns (1) 2026.04.10