개념
이미 아는 것부터 정리한다
모듈 3(레슨 10)에서 순환 복잡도 V(G) = E - N + 2 로 독립 경로의 개수를 구하고, 제어 흐름 그래프에서 기저 경로 집합을 뽑는 절차를 익혔다. 이 레슨은 그 절차를 다시 설명하지 않는다. 대신 그 절차를 실제 코드에 적용했을 때 반드시 부딪히는 세 가지 문제부터 시작한다.
- 뽑아낸 기저 경로 중 실행이 불가능한 경로 가 섞여 있다
- 루프가 있으면 경로의 수가 사실상 무한해진다
- 모든 경로를 다 지나도 변수가 잘못 쓰인 결함 은 잡히지 않는다
앞의 둘은 경로 테스트를 실무에서 쓸 수 있게 만드는 문제이고, 셋째가 데이터 흐름 테스트가 필요한 이유다.
실현 불가능 경로(infeasible path)
제어 흐름 그래프는 조건들 사이의 관계를 모른다. 그래서 그래프상으로는 존재하지만 어떤 입력으로도 탈 수 없는 경로가 나온다.
int classify(int score) {
String grade = "";
if (score >= 90) { grade = "A"; } // 조건 1
if (score < 60) { grade = "F"; } // 조건 2
if (grade.equals("A") && score < 60) { // 조건 3 — 절대 참이 될 수 없다
log.warn("모순 상태");
}
return grade.length();
}V(G)는 4를 넘지만, 조건 1이 참이면서 조건 2도 참인 경로는 존재하지 않는다. 이런 경로를 붙들고 "테스트 케이스를 못 만들겠다"고 시간을 쓰는 것이 초보자가 가장 많이 하는 낭비다.
판정 절차는 이렇다.
| 단계 | 할 일 | 결과 |
|---|---|---|
| 1 | 경로를 따라가며 각 분기의 조건을 수식으로 모은다 | score >= 90 ∧ score < 60 |
| 2 | 모은 조건이 동시에 참이 되는 입력이 있는지 본다 | 없다 |
| 3 | 없으면 실현 불가능으로 표시하고 근거를 기록한다 | 경로 P3: infeasible |
| 4 | 실현 불가능 경로가 많으면 코드 냄새로 보고한다 | 조건 3은 죽은 코드 후보 |
4단계가 QA의 진짜 산출물이다. 실현 불가능 경로가 많다는 것은 대개 도달할 수 없는 방어 코드 이거나 조건이 중복 이라는 뜻이고, 둘 다 리뷰에서 지적할 거리다. 100% 경로 커버리지를 달성할 수 없다는 사실 자체는 문제가 아니다 — 안전 규격에서도 구조 커버리지 미달성 부분은 "왜 도달 불가능한지"를 정당화 문서로 대신한다(모듈 35).
루프를 유한하게 자르기
루프가 하나만 있어도 반복 횟수만큼 경로가 늘어난다. 실무에서는 경계 값으로 자른다.
| 루프 유형 | 테스트할 반복 횟수 |
|---|---|
| 단순 루프(최대 n회) | 0회(진입 안 함), 1회, 2회, 임의의 m회(1 < m < n), n-1회, n회, n+1회(가능하면) |
| 중첩 루프 | 안쪽 루프부터: 바깥은 최소로 고정하고 안쪽을 위 기준으로 → 바깥으로 확장 |
| 연결 루프 | 서로 독립이면 각각 따로, 의존하면 중첩 루프처럼 |
| 비구조적 루프 | 테스트 설계 대상이 아니라 리팩터링 대상 으로 보고한다 |
0회와 1회가 가장 많은 결함을 낸다. 빈 리스트로 진입했을 때 초기화되지 않은 누적 변수가 그대로 반환되는 결함, 첫 반복에서만 다른 처리를 하는 코드의 경계 결함이 여기서 나온다.
데이터 흐름 테스트 — 제어가 아니라 값을 따라간다
경로 테스트는 "어디를 지나갔는가"를 본다. 데이터 흐름 테스트는 "어떤 값이 어디서 만들어져 어디서 쓰였는가" 를 본다. 변수 하나에 대해 코드의 각 지점은 셋 중 하나다.
| 표기 | 이름 | 뜻 |
|---|---|---|
d | 정의(definition) | 변수에 값이 대입된다. 선언·대입·입력·참조 전달 |
u | 사용(use) | 변수의 값을 읽는다 |
k | 소멸(kill) | 변수가 유효 범위를 벗어나거나 무효화된다 |
사용은 다시 둘로 나뉜다.
- c-use(계산 사용) — 계산식·출력·인자 전달처럼 값이 계산에 쓰이는 경우.
total = price * qty의price - p-use(술어 사용) — 조건식에 쓰여 제어 흐름을 가르는 경우.
if (qty > 0)의qty
p-use를 따로 세는 이유는, 조건에 쓰인 변수는 양쪽 분기 모두로 이어지는 두 개의 DU 쌍을 만들기 때문 이다.
DU 쌍 뽑기 — 실제 코드로
1 def settle(order, coupon, points):
2 discount = 0 # d(discount)
3 if coupon is not None: # p-use(coupon)
4 discount = coupon.amount # d(discount)
5 total = order.amount - discount # c-use(discount), c-use(order)
6 if points > 0: # p-use(points)
7 total = total - points # c-use(total), d(total)
8 if total < 0: # p-use(total)
9 total = 0 # d(total)
10 return total # c-use(total)변수 discount 의 정의-사용 쌍을 표로 만든다. 정의 지점에서 사용 지점까지 가는 동안 그 변수가 다시 정의되지 않는 경로 만 유효한 DU 경로다(이것을 definition-clear
path라고 부른다).
| 변수 | 정의 | 사용 | 사용 종류 | 정의-소멸 없는 경로 | 이 쌍을 태우는 입력 |
|---|---|---|---|---|---|
| discount | 2 | 5 | c-use | 2→3→5 (3이 거짓) | coupon=None |
| discount | 4 | 5 | c-use | 4→5 | coupon 있음 |
| total | 5 | 6 | p-use | 5→6 | 항상 |
| total | 5 | 7 | c-use | 5→6→7 | points>0 |
| total | 5 | 8 | p-use | 5→6→8 (6이 거짓) | points=0 |
| total | 7 | 8 | p-use | 7→8 | points>0 |
| total | 7 | 10 | c-use | 7→8→10 (8이 거짓) | points>0, 결과 ≥ 0 |
| total | 9 | 10 | c-use | 9→10 | 결과가 음수가 되는 입력 |
| total | 5 | 10 | c-use | 5→6→8→10 (둘 다 거짓) | points=0, 금액 ≥ 0 |
2 → 5 쌍이 핵심이다. 쿠폰이 없을 때 discount = 0 으로 초기화한 값이 실제로 쓰이는지를 확인하는 케이스이고, 분기 커버리지만 채우면 이 쌍을 안 타고도 100%가 나올 수 있다.
4 → 5 쌍만 타는 테스트로 3번 줄의 참·거짓을 모두 채울 수 있기 때문이다.
데이터 흐름 커버리지 기준의 계층
| 기준 | 요구 사항 | 강도 |
|---|---|---|
| all-defs | 각 정의에 대해 적어도 하나의 사용에 도달하는 경로를 최소 1회 | 가장 약함 |
| all-c-uses | 각 정의에서 모든 계산 사용으로 가는 경로를 1회 이상 | |
| all-p-uses | 각 정의에서 모든 술어 사용으로 가는 경로를 1회 이상(참·거짓 양쪽) | |
| all-uses | 각 (정의, 사용) 쌍을 최소 1회 | 실무 상한선 |
| all-du-paths | 각 쌍에 대해 정의-소멸 없는 모든 경로 를 1회씩 | 경로 폭발. 안전 규격 일부에서만 |
포함 관계를 알아 두면 협상이 쉬워진다. all-uses는 분기 커버리지를 포함하지 않는다. 예외 분기가 어떤 변수도 쓰지 않으면 all-uses를 100% 채워도 그 분기는 안 타진다. 그래서 실무에서는 분기 커버리지 + all-uses 를 같이 목표로 잡는다.
데이터 흐름 이상(anomaly) — 도구가 잡아 주는 것들
DU 쌍을 손으로 뽑는 것은 비싸다. 다행히 정의-사용 순서가 이상한 패턴 은 정적 분석기가 잡는다. 연속하는 두 동작의 조합으로 분류한다.
| 패턴 | 뜻 | 위험도 | 대표 경고 |
|---|---|---|---|
ur | 정의 전에 사용 | 버그 | use of uninitialized variable, Java의 "variable might not have been initialized" |
dd | 정의하고 다시 정의(그 사이 사용 없음) | 의심 | dead store, value never read |
dk | 정의하고 사용 없이 소멸 | 의심 | unused variable, assigned but never used |
du | 정의 후 사용 | 정상 | — |
ku | 소멸 후 사용 | 버그 | use-after-free, use-after-move, 닫힌 커넥션 사용 |
kk | 소멸 후 소멸 | 버그 | double free, double close |
/* ku 와 kk — C에서 데이터 흐름 이상이 곧 크래시가 되는 예 */
char *buf = malloc(64);
strcpy(buf, "hello");
free(buf); /* k */
printf("%s\n", buf); /* u → ku: use-after-free */
free(buf); /* k → kk: double free */// dd — 대입이 즉시 덮어써진다. 대개 잘못된 분기 조건이나 병합 실수의 흔적이다
String status = "PENDING";
status = resolveStatus(order); // 앞 줄의 값은 한 번도 읽히지 않았다dd 와 dk 는 그 자체로는 동작을 깨뜨리지 않는다. 하지만 원래 쓰려던 변수를 잘못 썼거나, 있어야 할 분기가 빠졌다는 신호 인 경우가 많다. 리뷰에서 "이 대입은 왜 필요한가"를 물어야
하는 자리다.
JVM·JS·Python처럼 GC가 있는 환경에서는 ku·kk 가 메모리 문제로 나타나지 않지만, 자원 객체 에서 똑같은 모양으로 나타난다. 닫힌 Connection·FileStream을
다시 쓰거나, 두 번 닫는 코드가 그것이다. 레슨 4에서 이 계열을 동적 분석으로 잡는다.
언제 여기까지 하는가
DU 쌍을 표로 뽑는 작업은 비용이 크다. 전 코드에 적용하는 것이 아니라 대상을 고른다.
- 상태 변수가 여러 분기에서 갱신되는 함수(위 예의
total같은) - 계산 결과가 돈·재고·권한처럼 틀리면 되돌리기 어려운 값
- 순환 복잡도가 높은데 분기 커버리지는 높게 나오는 함수 — 분기는 다 탔는데 결함이 계속 나오는 구간
- 안전 규격에서 요구되는 단위(ISO 26262·IEC 61508의 기법 표에 데이터 흐름 분석이 포함된다)
실무에서 왜 필요한가
분기 커버리지 100%인 코드에서 계속 결함이 나오는 상황이 이 기법이 필요한 순간이다. "커버리지가 100%인데 왜 버그가 나오죠"라는 질문에 "커버리지는 실행 여부만 세니까요"라고 답하는 것과, "이 함수의 DU 쌍 9개 중 3개가 테스트되지 않습니다. 2번 줄에서 초기화한 discount가 실제로 쓰이는 경로를 타는 케이스가 없어요" 라고 답하는 것은 회의에서 전혀 다른 무게를 가진다.
실현 불가능 경로 판정은 또 다른 쓸모가 있다. 경로를 따라가다 "이 조건은 어떤 입력으로도 참이 될 수 없다"를 발견하면 그것은 테스트로 잡을 수 없는 결함을 설계 단계에서 잡은 것 이다. 죽은 코드, 중복 조건, 잘못된 부정 조건이 이 방식으로 드러난다.