결함 주입으로 방어선 24곳을 망가뜨려 봤습니다 — 방어가 두 겹이면 테스트는 한 겹만 잽니다
테스트는 전부 초록이었습니다. 그런데 방어선 하나를 통째로 지워도 여전히 초록이었습니다. 두 겹의 방어가 서로를 가리고 있었습니다. 이틀 동안 방어 지점 24곳에 결함을 주입해, 테스트가 정말 그 방어를 재고 있는지 확인한 기록입니다.
작은 NestJS 서버를 만들었습니다. 여러 AI 코딩 세션이 요청과 작업 상태를 주고받고, 사람은 GitHub 계정으로 로그인해 이력을 읽는 서버입니다. 데이터베이스는 MySQL 8.4 이고, 코드와 테스트는 AI 코딩 에이전트와 함께 썼습니다. 다른 계정 사칭, 참여하지 않은 요청 열람, 비밀값이 섞인 메시지, 허용 목록 밖 로그인, 같은 메시지를 두 세션이 동시에 가져가기까지 막을 것이 많았고, 단위 테스트와 실제 MySQL 에 붙어 도는 e2e 테스트는 모두 초록이었습니다.
그런데 초록은 무엇이 막았는지를 말해 주지 않습니다. 요청이 막혔다는 뜻일 뿐, 제가 막는다고 믿은 그 코드가 막았다는 뜻은 아닙니다. 테스트가 방어를 재고 있다면 방어가 무너졌을 때 빨개져야 하니, 방어 코드를 하나씩 일부러 망가뜨려 보기로 했습니다.
뮤테이션 테스트를 손으로, 방어선만 골라서
코드를 일부러 조금 바꾼 뒤 기존 테스트가 그 변화를 알아채는지 보는 방법을 뮤테이션 테스트(mutation testing)라고 부릅니다. 돌이켜 보면 제가 한 일은 이것을 Stryker 같은 전용 도구 없이, 방어선만 골라 손으로 한 셈이었습니다. 이 글에서는 결함 주입이라고 부르겠습니다.
러너는 (이름, 파일, 바꿀 원문, 바꿀 내용) 목록을 하나씩 적용하고, e2e 와 단위 테스트를 JSON 리포터로 돌려 실패한 테스트 이름을 모은 뒤 git 으로 되돌립니다. 목록의 한 줄은 이를테면 «재전달 한도 무시»로, delivery_attempts < ? 를 delivery_attempts < ? + 100 으로 바꿉니다.
for name, path, old, new in MUTATIONS:
p = pathlib.Path(path)
src = p.read_text()
assert src.count(old) == 1, f"{name}: 원문이 {src.count(old)}곳" # 헛도는 주입을 막는다
p.write_text(src.replace(old, new))
try:
... # e2e(실제 MySQL)와 단위 테스트를 JSON 리포터로 실행
failed = failed_test_names("/tmp/mut-e2e.json", "/tmp/mut-unit.json")
finally:
subprocess.run(["git", "checkout", "--", path], check=True) # 그래서 주입 전에 커밋한다
print(f"[{'잡음' if failed else '놓침'}] {name}")
규칙은 두 가지입니다. 첫째, 바꿀 원문이 정확히 한 곳에만 나오는지 단언합니다. 원문이 없으면 아무것도 바뀌지 않은 채 초록이 나오고, 여러 곳이면 파이썬의 replace 가 전부 바꿔 상관없는 테스트까지 빨개질 수 있습니다. 헛돈 주입은 방어가 아니라 주입 실수를 잽니다.
둘째, 주입 전에 커밋합니다. git checkout -- 파일 은 그 파일을 인덱스의 내용으로 덮어써, 스테이징하지 않은 변경까지 지우기 때문입니다.
첫 라운드는 서버의 핵심 방어선 8곳이었습니다. 출력은 이랬습니다.
[잡음] 참여자 범위(API 상세) (e2e rc=1, unit rc=0)
[잡음] 사칭 차단(strict 제거) (e2e rc=1, unit rc=1)
[잡음] 비밀값 거부 끔 (e2e rc=1, unit rc=0)
[잡음] 웹 허용 목록 무시 (e2e rc=1, unit rc=0)
[잡음] 웹 상세 참여자 범위 제거 (e2e rc=1, unit rc=0)
[놓침] 세션-허용목록 조인 제거 (e2e rc=0, unit rc=0)
[잡음] SKIP LOCKED 제거 (e2e rc=1, unit rc=0)
[잡음] 재전달 한도 무시 (e2e rc=1, unit rc=0)
일곱 곳은 e2e 가 빨개졌습니다. «세션-허용목록 조인 제거» 한 곳만 e2e 도 단위 테스트도 실패 없이 끝났습니다. 허용 목록에서 빠진 계정을 막는 방어를 지웠는데도, 테스트는 아무것도 느끼지 못했습니다. 뮤테이션 테스트에서는 테스트가 실패해 변형을 알아채는 것을 변형을 «죽인다(kill)»고 하는데, 이 변형은 죽지 않았습니다.
두 겹의 방어가 서로를 가렸습니다
로그인 세션은 두 겹으로 막혀 있었습니다. 세션을 조회할 때마다 허용 목록과 조인하는 것이 첫 번째 겹이고, 허용 목록을 동기화할 때 빠지는 계정의 세션을 먼저 지우는 것이 두 번째 겹입니다.
-- 첫 번째 겹: 세션을 조회할 때마다 허용 목록과 조인한다
SELECT s.login, a.role
FROM web_session s
JOIN web_allowlist a ON a.login = s.login
WHERE s.id_hash = ? AND s.expires_at > CURRENT_TIMESTAMP(3);
-- 두 번째 겹: 동기화할 때 빠진 계정의 세션을 먼저 지운다
DELETE FROM web_session WHERE login NOT IN (?);
DELETE FROM web_allowlist WHERE login NOT IN (?);
제가 넣은 결함은 첫 번째 겹의 JOIN 을 빼고 역할을 상수로 돌려주는 것이었습니다. 그런데 기존 테스트는 동기화 코드로 계정을 뺐고, 동기화가 세션을 먼저 지우니 같은 쿠키로 다시 요청할 즈음엔 세션 행 자체가 없었습니다. 조인이 있든 없든 로그인은 막혔습니다. 부끄럽지만 테스트가 잰 것은 «어느 겹이든 하나는 막는다»였지 «조인이 막는다»가 아니었습니다.
DB 를 직접 고칠 때처럼 동기화를 거치지 않고 허용 목록 행만 사라지면, 남은 세션을 막는 것은 조인뿐입니다. 그 겹이 사라져도 아무도 모를 뻔했습니다.
곰곰이 생각해 보니 이것은 설계가 의도대로 동작한 결과이기도 했습니다. 심층 방어(defense in depth)는 한 겹이 놓친 공격을 다른 겹이 잡도록 방어를 겹쳐 두는 설계입니다. 한 겹이 사라져도 결과가 달라지지 않는 것이 목적이니, 결과만 보는 테스트에는 그 부재가 보이지 않습니다. 방어가 여러 겹이면 테스트는 가장 먼저 막는 겹만 잽니다.
한 겹만 남는 상황을 만들어 잽니다 — 전제 단언
새 테스트는 동기화를 우회해 허용 목록 행만 지운 뒤, 같은 쿠키로 요청하면 로그인 화면으로 돌려보내지는지(302) 봅니다. 기존 테스트에서 store.syncAllowlist(['alice']) 로 bob 을 빼던 한 줄이 직접 삭제와 전제 단언으로 바뀌었습니다.
it('허용 목록 행만 지워져도 세션 조회에서 막힌다', async () => {
const bob = await webLogin('bob');
await request(server).get('/').set('Cookie', bob.cookie).expect(200);
await sql("DELETE FROM web_allowlist WHERE login = 'bob'"); // 동기화 우회
const [row] = await sql("SELECT COUNT(*) AS n FROM web_session WHERE login = 'bob'");
expect(Number(row.n)).toBeGreaterThan(0); // 전제: 세션 행은 아직 남아 있다
await request(server).get('/').set('Cookie', bob.cookie).expect(302);
});
핵심은 가운데의 expect 한 줄, 전제 단언입니다. 기존 테스트가 조용히 놓친 조건을 이번에는 소리 내어 확인합니다. 이 줄이 없으면 언젠가 세션까지 함께 지우는 장치가 생겼을 때, 이 테스트도 초록인 채로 다른 겹을 재게 됩니다. 이 줄이 있으면 그때 빨개집니다.
테스트를 더한 뒤 같은 자리에 다시 결함을 넣었습니다. JOIN 앞에 LEFT 한 단어만 붙이는, 통째로 빼는 것보다 약한 변형이었고, 이번에는 잡혔습니다.
이틀 동안 24곳에 결함 주입, 첫 시도에 23곳
같은 방식으로 이틀 동안 다섯 라운드를 돌았습니다. «잡음»은 결함을 넣은 상태에서 테스트가 하나 이상 실패한 경우입니다.
| 라운드 | 대상 | 넣은 결함 | 결과 |
|---|---|---|---|
| 1 | 서버 핵심 방어선 | 8 | 7 잡음 · 1 놓침 (테스트 추가 후 재주입 잡음) |
| 2 | 웹훅 수신 | 6 | 6 잡음 |
| 3 | 요청 «반려» 상태 | 4 | 4 잡음 |
| 4 | 세션을 깨우는 로컬 연결기 | 5 | 5 잡음 |
| 5 | 연결기 설정 검사 | 1 | 1 잡음 |
| 합계 | 24 | 첫 시도 23 잡음 · 1 놓침 |
놓친 한 곳은 테스트를 더한 뒤 다시 넣은 결함이 잡혔고, 주입 실행은 모두 25번이었습니다. 2라운드는 GitHub 웹훅 이벤트를 쌓는 이벤트 원장의 서명 검증, 가시성 제한, 배달 중복 제약 같은 것이었습니다. 3라운드의 «반려»는 받은 요청을 사유와 함께 보낸 쪽으로 돌려보내는 상태입니다.
4·5라운드는 순서가 달랐습니다. 4라운드의 대상은 서버의 메시지를 로컬 AI 세션에 넘기고 깨우는 연결기였습니다. 라운드 전에 코드를 읽다가, «세션에 넘기지 못하면 서버에 확인을 보내지 않는다»는 규칙을 재는 테스트가 없다는 것을 먼저 알아챘습니다. 확인하지 않아야 서버가 다시 보냅니다. 그 부분을 함수로 떼어 내 테스트 4건을 더한 뒤에 결함 5개를 넣었고, 모두 잡혔습니다.
5라운드에서는 «토큰이 없으면 실패한다»는 테스트가 제 홈 디렉터리의 기본 토큰 파일에 기대고 있었습니다. 파일이 있느냐에 따라 결과가 갈릴 수 있는, 코드보다 제 컴퓨터를 잴 수도 있는 테스트였습니다. 없는 경로를 명시하게 고친 뒤 결함을 넣었더니 잡혔습니다.
1라운드는 주입이 구멍을 찾았고, 4·5라운드는 구멍을 먼저 찾아 고친 뒤 주입으로 확인했습니다. 읽어서 보이는 구멍이 있고, 넣어 봐야 보이는 구멍이 있다는 생각이 들었습니다.
잡은 쪽은 거의 e2e 테스트였습니다
서버 쪽인 1~3라운드의 첫 시도 18건을 러너 출력의 rc 로 셌습니다. 명령별 rc 가 1 이면 그쪽 테스트가 잡은 것으로 봤습니다. 실제 MySQL 8.4 로 도는 e2e 가 17건, 놓친 1건을 뺀 전부를 잡았습니다. 단위 테스트는 사칭 차단과 반려 사유 요구 2건만 잡았고, 그 2건도 e2e 가 함께 잡았습니다. 단위 테스트만 잡은 결함은 없었습니다.
제 판단으로는 방어선 대부분이 경계에 살기 때문입니다. 권한을 가르는 SQL 조건절, 잠금, 유일 제약, 요청 본문 상한, 쿠키 같은 것들입니다. SKIP LOCKED 만 해도 다른 트랜잭션이 잠근 행을 기다리지 않고 결과에서 빼는 MySQL 의 잠금 읽기 옵션이라, 실제 데이터베이스 안에서만 일어나는 일입니다. mock 은 제가 상상한 계약을 재현할 뿐이라는 것을 R2 멀티파트 업로드를 붙이며 적은 적이 있는데, 이번 숫자도 같은 쪽을 가리켰습니다.
다만 이 숫자를 테스트 종류의 우열로 읽는 것은 조심스럽습니다. 제가 고른 것은 처음부터 막는 코드였고, 막는 코드는 대개 경계에 있습니다. 17 대 2 는 단위 테스트의 성적이라기보다 제 방어선이 어디에 몰려 있는지를 보여 주는 숫자에 가깝습니다.
결함 주입 러너에도 맹점이 있었습니다
러너를 다시 읽어 보면, 이 도구에도 맹점이 있습니다. 실제 러너는 JSON 리포트를 못 읽거나 실행된 테스트가 0건이면 그 사실을 문자열로 failed 에 넣었고, 그러면 [잡음]이 찍힙니다. [잡음] 표시만 믿으면 아무 테스트도 돌지 않았는데 잡았다가 가능한 구조였습니다.
초록이 무엇이 막았는지 말해 주지 않듯, [잡음]도 무엇이 잡았는지 말해 주지 않았습니다. 그래서 그 아래 찍힌 것이 실제 테스트 이름인지까지 봅니다. 이번에 [잡음]이 찍힌 24번은 모두 실제 테스트 이름이었습니다. 이 구조에서 [놓침]은 리포트를 읽었고 테스트가 돌았다는 뜻이니, 맹점은 [잡음] 쪽에만 있습니다. 하필 테스트를 믿음직하게 보이게 하는 쪽입니다.
같은 원리를 배포에도 썼습니다. 새 상태 값을 배포하기 전에 지금 서버가 그 값을 모른다며 거부하는 것을 먼저 보고, 배포 후 같은 요청이 성공하는지 봤습니다. 웹훅 경로도 배포 전 404 가 배포 후 서명 거부로 바뀌는 것을 확인했습니다. 확인이 실패할 수 있는 상태를 먼저 봐야 성공이 의미를 갖습니다.
남는 것 — 테스트의 눈을 재는 일
돌이켜 보면 이틀 동안 배운 것은 테스트를 더 쓰는 법이 아니라, 초록이 무엇을 말해 주지 않는지였습니다. 방어가 여러 겹이면 테스트는 가장 먼저 막는 겹만 재고, 뒤의 겹은 사라져도 소리가 나지 않습니다. 그래서 겹마다 그 겹만 남은 상황을 따로 만들고, 그 상황이 정말 만들어졌는지를 전제 단언으로 확인해야 합니다.
이 점은 AI 코딩 에이전트가 쓴 테스트든 제가 쓴 테스트든 같습니다. 놓친 테스트는 이름도 기대값도 틀린 데가 없었고, 틀린 것은 테스트가 만든 상황이었습니다. 결함을 넣은 24곳이 제가 알아본 방어선뿐이라는 한계도 남겨 둡니다. 그래도 결함 주입은 테스트를 늘리는 일이라기보다 테스트의 눈을 재는 일이었다는 생각이 듭니다.
참고 문서
관련 글
주석이 거짓말을 하고 있었습니다 — 이스케이프가 실제로 막는 범위
경로를 감싸는 함수가 콜론을 이스케이프하고 있었습니다. 그래서 윈도우 경로를 막아준다고 주석에 적혀 있었는데, 재보니 콜론만 여전히 실패했습니다. 이스케이프하는 것과 지켜주는 것은 다른 말이었습니다.
테스트를 통과시켰지만 근거가 틀렸습니다 — 리뷰가 잡아낸 두 건
경로를 잘못 단정하던 테스트를 고쳤는데, 리뷰가 제 수정의 근거를 무너뜨렸습니다. 검색 결과의 첫 히트를 근거로 삼았고, 그것은 제가 찾던 것과 이름만 같은 다른 값이었습니다.
Compiled successfully를 찍고 실패했습니다 — CI가 안 보고 있던 패키지
타입체크가 깨진 채 main에 머물러 있었는데 아무도 몰랐습니다. 그 패키지는 CI에 빌드 잡도 e2e 잡도 없었기 때문입니다. 같은 종류의 구멍이 이 레포에서 세 번째였습니다.