테스트는 왜 버그가 없다는 걸 증명하지 못할까

CI 파이프라인이 초록색으로 빛나고, 저장소 첫 화면에는 커버리지 100% 배지가 붙어 있다. 리뷰도 통과했고 테스트도 전부 통과했다. 이 정도면 안심하고 배포해도 될 것 같은 분위기다.
그리고 배포 20분 뒤, 운영 채널에 메시지가 하나 올라온다. "결제 금액이 0원으로 찍히는 주문이 있는데요?" 그 순간 누군가 조용히 말한다. "테스트는 다 통과했는데..."
이 장면에서 우리가 놀라는 이유는, 테스트를 통과했다는 사실을 버그가 없다는 증거로 받아들였기 때문이다. 그런데 이 착각을 이미 반세기도 더 전에 정확하게 짚은 사람이 있다. 에츠허르 데이크스트라(Edsger W. Dijkstra)는 1969년 NATO 소프트웨어 공학 회의에서 테스트는 버그가 있음을 보여줄 수는 있어도 없음을 보여줄 수는 없다는 취지의 말을 남겼고, 같은 시기의 글 『구조적 프로그래밍에 대한 노트』에 이 문장을 다시 적었다.
프로그램 테스트는 버그가 있다는 걸 보여주는 데는 쓸 수 있지만, 버그가 없다는 걸 보여주는 데는 결코 쓸 수 없다.
그래서 이번 포스팅에서는 테스트가 왜 버그 없음을 증명할 수 없는지를 과학철학과 확률의 눈으로 한번 뜯어보려고 한다. 이 한계가 논리의 문제인지 규모의 문제인지, 커버리지 숫자는 무엇을 재고 무엇을 놓치는지, 그리고 증명할 수 없다면 테스트는 대체 무엇을 해주는지를 차례대로 이야기해보자.
검은 백조
먼저 이 문제가 테스트만의 문제가 아니라는 이야기부터 해보려고 한다. 과학자들도 똑같은 문제를 수백 년 동안 붙잡고 있었기 때문이다.
오랫동안 유럽 사람들에게 백조는 당연히 하얀 새였다. 수천 년 동안 본 백조가 전부 하얬으니, "모든 백조는 희다"는 문장은 거의 상식에 가까웠다. 그런데 1697년, 서호주에 도착한 네덜란드 탐험대가 검은 백조를 기록한다. 수천 년 동안 쌓인 하얀 백조의 관찰이 단 한 마리의 검은 백조 앞에서 무너진 것이다.
철학자 칼 포퍼(Karl Popper)는 1934년 『과학적 발견의 논리』에서 바로 이 비대칭을 과학의 핵심으로 삼았다. 하얀 백조를 아무리 많이 관찰해도 "모든 백조는 희다"를 증명할 수는 없지만, 검은 백조 한 마리만 있으면 그 문장이 틀렸다는 것은 확실히 보일 수 있다. 그래서 포퍼는 좋은 과학 이론이란 증명된 이론이 아니라, 틀렸다는 걸 보일 수 있는 방법이 있는데도 아직 틀리지 않은 이론이라고 보았다. 이것이 흔히 반증주의라고 부르는 생각이다.
이걸 테스트로 옮기면 꽤 정확하게 들어맞는다. "이 함수에는 버그가 없다"는 주장은 "모든 백조는 희다"와 같은 모양의 문장이다. 통과한 테스트 하나하나는 관찰된 하얀 백조이고, 아무리 많이 쌓여도 주장을 증명하지는 못한다. 반면 실패한 테스트 하나는 검은 백조라서, 버그가 있다는 사실을 확실하게 보여준다.
이 이야기는 나중에 나심 탈레브(Nassim Nicholas Taleb)의 『블랙 스완』(2007)으로 다시 유명해졌다. 탈레브는 드물게 일어나지만 일어나면 충격이 크고, 일어난 뒤에야 다들 그럴 줄 알았다고 설명하는 사건을 블랙 스완이라고 불렀다. 필자는 운영 환경의 큰 장애가 대부분 이 정의에 꼭 들어맞는다고 생각한다. 장애 회고 문서를 쓰다 보면, 그 원인이 사후에는 너무 당연해 보여서 왜 미리 테스트하지 않았는지 스스로도 이해가 안 될 때가 많기 때문이다. 하지만 그 당연함은 검은 백조를 본 다음에야 생기는 당연함이다.
즉, 데이크스트라의 문장은 테스트를 잘못 짜서 생기는 한계가 아니다. "모든 경우에 대해 참"이라는 형태의 주장을 몇 개의 사례로 확인하려는 모든 시도가 부딪히는, 논리 자체의 한계다.
입력 공간은 생각보다 넓다
그렇다면 이런 생각이 들 수 있다. 모든 경우를 전부 테스트해버리면 되지 않을까? 사례가 유한하다면 전부 확인하는 것도 증명이니까.
맞는 말이다. 문제는 그 "전부"가 얼마나 큰지다. 정수 두 개를 받아서 더하는 아주 단순한 함수를 떠올려보자.
function add(a: number, b: number) {
return a + b;
}인자가 32비트 정수라고만 해도, 하나의 인자가 가질 수 있는 값은 가지다. 인자가 두 개이니 가능한 입력의 조합은 이렇게 된다.
여기서 는 입력 공간의 크기, 즉 이 함수에 넣을 수 있는 모든 입력 쌍의 개수다. 이 숫자가 얼마나 큰지 감을 잡으려면 직접 계산해보는 게 가장 빠르다.
// 1초에 10억 개의 테스트를 돌린다고 가정
const inputs = 2 ** 64;
const testsPerSecond = 1e9;
const secondsPerYear = 60 * 60 * 24 * 365.25;
inputs / testsPerSecond / secondsPerYear; // 약 584.5 년1초에 10억 번 테스트를 돌리는 아주 빠른 컴퓨터로도 584년이 걸린다. 덧셈 하나에 말이다. 수식은 지수 몇 개를 곱한 게 전부인데, 결과는 조선 왕조가 한 번 시작해서 끝나고도 남을 만큼의 시간이다.
실제 코드는 이보다 훨씬 넓은 공간에서 돈다. 함수의 인자뿐 아니라 현재 시각, 네트워크 상태, 동시에 실행되는 다른 요청, 데이터베이스에 이미 들어 있는 값까지 전부 입력의 일부이기 때문이다. 조건 분기도 마찬가지다. 켜고 끌 수 있는 기능 플래그가 20개만 있어도, 가능한 조합은 , 약 100만 가지가 된다.
우리가 작성한 테스트 수백 개는 이 거대한 공간에 찍힌 점 몇 개에 불과하다. 그러니 테스트를 전부 통과했다는 건, 우리가 찍어본 몇 개의 점에서는 문제가 없었다는 뜻일 뿐이다. 나머지 공간 어딘가에 검은 백조가 숨어 있을 가능성은 언제나 남는다.
그래서 테스트 설계에서는 오래전부터 입력 공간을 비슷하게 동작할 것 같은 덩어리로 나누고, 덩어리마다 대표값을 하나씩 고르는 동등 분할이라는 방법을 써왔다. 양수, 음수, 0 처럼 덩어리를 나누고 각 덩어리의 경계 근처를 집중적으로 찌르는 식이다. 다만 이 방법도 "같은 덩어리 안의 값들은 똑같이 동작할 것"이라는 가정 위에 서 있다는 점은 기억해두자. 그 가정이 틀린 곳이 바로 버그가 사는 곳이다.
커버리지 100%의 함정
이쯤에서 커버리지 이야기를 하지 않을 수 없다. 커버리지 100%라면 적어도 모든 코드는 확인한 것 아닌가 하는 생각이 들기 때문이다.
그런데 커버리지가 재는 건 "테스트가 실행하는 동안 이 줄이 한 번이라도 실행되었는가"다. 이 줄이 올바르게 동작하는지를 재는 게 아니다. 아래 예시를 보자.
function averagePrice(prices: number[]) {
const total = prices.reduce((sum, p) => sum + p, 0);
return total / prices.length;
}
// 모든 줄이 실행되니 라인 커버리지는 100%
test("평균 가격", () => {
expect(averagePrice([1000, 3000])).toBe(2000);
});이 테스트는 함수의 모든 줄을 실행하니 커버리지는 100%가 나온다. 하지만 빈 배열을 넣으면 0 / 0 이 되어 NaN 이 돌아온다. 장바구니가 비어 있는 사용자에게 평균 가격으로 NaN원 을 보여주는 버그가, 커버리지 100% 배지 아래에서 조용히 살아 있는 셈이다.
더 극단적인 예도 있다. 함수를 호출하기만 하고 아무것도 검증하지 않는 테스트도 커버리지는 똑같이 올려준다. 커버리지는 테스트가 코드의 어디를 지나갔는지는 알려주지만, 지나가면서 무엇을 확인했는지는 알려주지 않는다. 마치 박물관의 모든 전시실을 걸어서 지나갔다고 해서 작품을 전부 감상했다고 말할 수 없는 것과 같다.
커버리지가 이런 숫자라는 걸 알면서도 우리는 종종 커버리지를 목표로 삼는다. 경제학자 찰스 굿하트(Charles Goodhart)의 이름이 붙은 굿하트의 법칙은 인류학자 메릴린 스트래선(Marilyn Strathern)의 표현으로 더 널리 알려져 있는데, 측정값이 목표가 되는 순간 좋은 측정값이 아니게 된다는 것이다. 커버리지 80%가 배포 조건이 되면, 사람들은 버그를 잡는 테스트보다 줄을 지나가는 테스트를 먼저 쓰게 된다. 필자는 커버리지를 목표가 아니라 경고등으로 쓰는 편이 낫다고 생각한다. 커버리지가 낮다는 건 확인하지 않은 곳이 있다는 확실한 신호지만, 높다는 건 그 이상 아무것도 보장하지 않는다.
테스트를 테스트하는 법
그렇다면 테스트가 정말로 무언가를 확인하고 있는지는 어떻게 알 수 있을까. 1978년 리처드 데밀로(Richard DeMillo), 리처드 립턴(Richard Lipton), 프레드 세이워드(Fred Sayward)는 이 질문에 꽤 영리한 답을 내놓았다. 일부러 코드에 작은 버그를 심어보고, 테스트가 그 버그를 잡아내는지 확인하자는 것이다. 이 방법을 뮤테이션 테스트라고 부른다.
예를 들어 total / prices.length 를 total * prices.length 로 바꾼 돌연변이를 만들고 테스트를 돌려본다. 테스트가 실패하면 돌연변이를 잡아낸 것이고, 그대로 통과하면 그 테스트는 이런 종류의 버그를 볼 수 없는 테스트라는 뜻이다. 이렇게 만든 돌연변이 중 테스트가 잡아낸 비율을 뮤테이션 점수라고 한다.
은 만들어낸 돌연변이의 수, 는 그중 테스트가 실패해서 잡아낸 돌연변이의 수다. 커버리지가 "어디를 지나갔는가"를 재는 숫자라면, 뮤테이션 점수는 "지나가면서 무엇을 확인했는가"에 조금 더 가까운 숫자다. 자바스크립트와 타입스크립트에서는 Stryker 같은 도구로 이 과정을 자동으로 돌려볼 수 있다. (처음 돌려보면 꽤 많은 돌연변이가 살아남아서, 마음이 조금 아플 수 있다)
증명이라는 다른 길
테스트로 증명할 수 없다면, 아예 증명을 하면 되지 않을까? 실제로 컴퓨터 과학에는 프로그램이 올바르다는 걸 수학적으로 증명하는 형식 검증이라는 분야가 있다. 그런데 여기에도 근본적인 한계가 있다.
1936년 앨런 튜링(Alan Turing)은 임의의 프로그램이 언젠가 멈추는지를 판단하는 일반적인 알고리즘은 존재하지 않는다는 것을 보였다. 흔히 정지 문제라고 부르는 결과다. 이후 헨리 라이스(Henry Rice)는 이걸 더 일반화해서, 프로그램이 "어떤 의미 있는 성질을 갖는지"를 모든 프로그램에 대해 판단하는 일반적인 방법은 없다는 라이스의 정리를 증명했다. "이 프로그램에는 버그가 없다"도 바로 그런 의미 있는 성질 중 하나다.
물론 이게 증명이 불가능하다는 뜻은 아니다. 모든 프로그램에 통하는 기계적인 방법이 없다는 뜻이고, 특정 프로그램을 사람이 공들여 증명하는 건 가능하다. 실제로 2009년에는 seL4 라는 운영체제 마이크로커널의 C 구현이 명세대로 동작한다는 형식 증명이 발표되기도 했다. 다만 이런 증명에는 엄청난 인력과 시간이 들기 때문에, 대부분의 서비스 코드에 그대로 적용하기는 어렵다.
우리가 매일 쓰는 도구 중에서 가장 가벼운 증명은 사실 타입 검사다. 타입스크립트가 string 이 들어갈 자리에 number 를 넣지 못하게 막는 건, 테스트처럼 몇 개의 사례를 확인하는 게 아니라 그런 실수가 어떤 입력에서도 일어나지 않는다는 걸 컴파일 시점에 보장하는 일이다. 범위는 좁지만, 그 좁은 범위 안에서는 하얀 백조를 세는 대신 정말로 증명을 하고 있는 셈이다.
그래서 테스트는 무엇을 해주는가
여기까지 읽고 나면 이런 반론이 나올 수 있다.
증명도 못 하는 테스트를 왜 그렇게 열심히 써야 하나?
맞다. 사실 테스트는 증명을 해주지 않는다. 하지만 테스트가 아무것도 해주지 않는 건 아니다. 테스트는 버그가 없다는 확신을 조금씩 올려준다. 그리고 이 "조금씩"을 가장 잘 설명해주는 도구가 베이즈 정리다.
는 "버그가 없다"는 가설이고, 는 "테스트를 통과했다"는 관찰이다. 는 테스트를 돌리기 전의 믿음, 는 테스트를 통과한 뒤의 믿음이다. 핵심은 분모에 숨어 있는 , 그중에서도 버그가 있는데도 테스트를 통과할 확률 다.
버그가 있으면 절반의 확률로 실패하는 테스트가 있다고 해보자. 처음 믿음을 반반이라고 두면, 이 테스트를 하나 통과할 때마다 버그가 없다는 쪽의 승산이 두 배씩 늘어난다. 세 개를 통과하면 믿음은 약 0.89, 열 개를 통과하면 약 0.999가 된다. 하지만 버그가 있어도 무조건 통과하는 테스트라면, 즉 이라면 아무리 많이 통과해도 믿음은 처음의 0.5에서 한 걸음도 움직이지 않는다.
// 테스트를 하나 통과할 때마다 버그가 없다는 믿음을 갱신
function update(prior: number, passIfBug: number) {
const passIfClean = 1;
return (passIfClean * prior) / (passIfClean * prior + passIfBug * (1 - prior));
}
let belief = 0.5;
for (let i = 0; i < 3; i++) belief = update(belief, 0.5);
belief; // 약 0.89
let useless = 0.5;
for (let i = 0; i < 100; i++) useless = update(useless, 1);
useless; // 0.5, 100 개를 통과해도 그대로물론 이 계산은 꽤 많은 것을 단순하게 가정했다. 실제로는 "버그가 있으면 절반의 확률로 실패한다" 같은 값을 정확히 알 방법이 없다. 더 큰 함정은 테스트들이 서로 독립이라는 가정이다. 같은 경로를 같은 방식으로 확인하는 테스트 열 개는, 독립된 증거 열 개가 아니라 사실상 같은 증거를 열 번 본 것에 가깝다. 같은 동네의 하얀 백조를 열 번 세어봐야, 다른 대륙의 백조에 대해서는 아무것도 알 수 없는 것처럼 말이다. 그래서 테스트를 늘릴 때는 개수보다 서로 다른 곳을 찌르고 있는지를 먼저 따져보는 편이 낫다.
앞에서 본 아무것도 검증하지 않는 테스트가 바로 이 평평한 선이다. 그 테스트는 초록불을 켜주고 커버리지를 올려주지만, 우리의 믿음에는 아무것도 보태지 않는다. 반대로 경계값을 찌르는 테스트, 실패할 가능성이 높은 테스트일수록 통과했을 때 올려주는 믿음이 크다. 포퍼의 말로 바꾸면, 반증될 위험이 큰 테스트일수록 통과했을 때 더 많은 것을 말해주는 셈이다.
백조를 고르는 법
그렇다면 실제로 테스트를 쓸 때는 무엇을 하면 좋을까. 지금까지의 이야기를 정리하면 결국 하나의 질문으로 모인다. 이 테스트는 버그가 있을 때 실패할 수 있는가.
첫 번째 습관은 경계를 찌르는 것이다. 빈 배열, 0, 음수, 최댓값, 아주 긴 문자열, 이모지가 섞인 이름처럼 버그가 숨기 좋은 곳에 먼저 점을 찍어보자. 입력 공간 전체를 다 확인할 수 없다면, 검은 백조가 살 가능성이 높은 동네부터 찾아가는 게 합리적이다.
두 번째 습관은 사례 대신 성질을 테스트하는 것이다. 2000년 코엔 클라센(Koen Claessen)과 존 휴스(John Hughes)가 하스켈용으로 발표한 QuickCheck 는 "어떤 입력이 들어와도 이 성질은 지켜져야 한다"고 적어두면 도구가 무작위 입력을 수백 개씩 만들어서 반례를 찾아주는 방식을 보여줬다. 이런 방식을 속성 기반 테스트라고 부르고, 자바스크립트에서는 fast-check 같은 라이브러리로 쓸 수 있다.
import fc from "fast-check";
// 어떤 가격 배열이 들어와도 평균은 NaN 이 아니어야 한다는 성질
test("평균 가격은 항상 숫자", () => {
fc.assert(
fc.property(fc.array(fc.nat()), (prices) => {
expect(Number.isNaN(averagePrice(prices))).toBe(false);
}),
);
});이 테스트를 돌리면 도구는 금방 빈 배열이라는 반례를 찾아낸다. 우리가 미처 떠올리지 못한 검은 백조를, 도구가 대신 찾아와 준 것이다. 게다가 이런 도구들은 반례를 찾으면 그 반례를 가장 단순한 형태로 줄여서 보여주기 때문에, 디버깅도 한결 수월해진다.
세 번째 습관은 버그를 고칠 때 먼저 그 버그를 재현하는 실패 테스트부터 쓰는 것이다. 운영 환경에서 만난 검은 백조를 테스트로 박제해두면, 그 백조는 더 이상 미지의 존재가 아니라 확인된 사례가 된다. 같은 버그가 나중에 다른 모습으로 돌아와도, 적어도 같은 자리에서는 테스트가 먼저 울린다. 필자는 이 습관이 테스트 개수를 늘리는 어떤 규칙보다 실제 버그를 줄이는 데 효과가 크다고 생각한다. 이미 한 번 나타난 곳이야말로 검은 백조가 다시 나타날 가능성이 가장 높은 동네이기 때문이다.
네 번째 습관은 운영 환경을 관찰의 연장으로 보는 것이다. 테스트가 아무리 촘촘해도 입력 공간의 대부분은 실제 사용자만 지나간다. 오류율과 이상한 값을 지켜보는 모니터링은, 테스트가 찍지 못한 공간에서 검은 백조가 나타났을 때 가장 먼저 알려주는 관측소에 가깝다.
마치며
처음 장면으로 돌아가 보자. 커버리지 100% 배지와 초록색 파이프라인 아래에서 결제 금액 0원 버그가 나온 건, 누군가 테스트를 대충 짜서가 아니었다. 우리가 확인한 하얀 백조가 아무리 많아도, 검은 백조가 없다는 증명은 되지 않기 때문이다.
그래서 필자는 "테스트가 버그 없음을 증명할 수 있을까"라는 질문이 사실 "우리 테스트는 버그가 있을 때 실패할 수 있는 테스트일까"라는 질문에 더 가깝다고 생각한다. 테스트의 가치는 통과한 개수가 아니라, 통과하지 못할 수도 있었는데 통과했다는 데서 나오기 때문이다.
다음에 커버리지 숫자를 올려야 할 일이 생긴다면, 숫자를 올려줄 테스트 대신 실패할 가능성이 높은 테스트부터 하나 써보자. 초록불의 개수는 그대로여도, 그 초록불이 말해주는 것은 훨씬 많아질 것이다.
이상으로 테스트는 왜 버그가 없다는 걸 증명하지 못할까 포스팅을 마친다.