본문으로 건너뛰기

초록불인 테스트가 아무것도 지키지 않고 있었습니다

2026년 8월 27일
10 views
MathCanvas
주니어 개발
회고
테스트

들어가며

테스트 코드는 오랫동안 남의 일이었습니다. 기능을 만드는 게 먼저였고 테스트는 여유가 생기면 하는 일이라고 생각했어요. 실제로 여유는 안 생겼습니다.

지금은 spec 파일 238개를 굴립니다. 태도가 바뀐 건 신념 때문이 아니라 계산 때문이었어요. 그리고 그 계산이 한 번 더 뒤집혔는데, 이 글은 두 번째 뒤집힘에 대한 이야기입니다.

고치는 값보다 찾는 값이 비싸질 때

제가 맡은 건 수학 수업용 웹 캔버스입니다. 캔버스 하나에 교구·필기·3D·조작 기록이 겹쳐 있어요. 한 곳을 건드리면 다른 곳이 조용히 깨집니다. 소리를 안 내는 게 문제입니다.

서버가 준 슬라이드 데이터를 한 번 더 벗기던 코드를 지운 적이 있습니다. 캔버스 쪽은 정상이 됐는데, 대시보드와 슬라이드 목록과 리포트가 같이 죽었어요. 그 함수를 쓰는 경로가 두 갈래였고 한쪽은 이미 벗겨진 데이터를, 다른 쪽은 안 벗겨진 원본을 넘기고 있었습니다.

빈 캔버스를 저장에서 걸러내는 가드를 넣었을 땐 PNG 내려받기가 조용히 아무 일도 안 하게 됐습니다. 에러도 안내 문구도 없었고 버튼만 안 먹었어요.

고치는 데 든 시간보다 "무엇이 깨졌는지 찾는" 데 든 시간이 매번 더 컸습니다. 그때 계산이 뒤집혔습니다.

테스트는 품질을 위해 쓰는 게 아니라, 회귀를 찾아다니는 시간을 안 쓰려고 씁니다.

통과만 하는 테스트

그래서 고칠 때마다 회귀 테스트를 같이 붙이기 시작했습니다. 붙였으니 이제 안전하다고 생각했어요.

빈 캔버스 판정에서 걸렸습니다. 캔버스가 비었으면 자동으로 지우는데, 판정 근거 중 하나가 잠금 목록이었어요.

const lockIds = ref<string[][]>([[]])   // ← 스토어 초기값. 바깥 배열 길이는 1
if (lockIds.length > 0) return false    // ← 항상 참. 아무도 잠근 적 없어도

빈 캔버스인데 "잠금 데이터 있음"으로 판정됩니다. 그리고 이걸 검사하는 테스트는 통과했어요. 케이스를 둘만 넣었기 때문입니다. 데이터가 있는 경우와, 배열이 완전히 빈 경우.

스토어가 실제로 갖는 초기값을 케이스로 안 넣었습니다. 통과했지만 실제 상황을 한 번도 안 지나갔어요.

커버리지도 초록이었습니다. 그 줄들은 전부 실행됐거든요. 실행됐다는 것과 검사됐다는 것은 다른 말인데, 그때는 같은 말인 줄 알았습니다.

지금은 이렇게 잠겨 있습니다.

it('lockIds 기본값 [[]] (안에 빈 배열만)은 빈으로 판정', () => { ... })
it('lockIds [[], []] (여러 빈 inner)도 빈으로 판정', () => { ... })
it('lockIds [[], ["id1"]] (안에 실제 ID 1개)는 보존', () => { ... })

초록불 아래의 사각

더 크게 걸린 건 학습 리포트의 카드 미리보기였습니다. 학생이 푼 마지막 화면이 카드에 안 보이는 문제였어요.

원인을 세우고, 고치고, 테스트를 둘 붙였습니다. 소스 형태를 잠그는 것 하나와 실제 데이터를 넣어 돌려보는 것 하나. 둘 다 통과했고 전체 테스트도 통과해서 머지했습니다.

다른 페이지들이 깨졌습니다. 테스트에 넣은 예시 데이터가 캔버스 타입 하나뿐이었고 슬라이드 종류마다 데이터 모양이 달라서 나머지는 그 코드를 지나가지도 않았어요.

되돌렸습니다. 그리고 그 테스트 둘도 같이 지웠어요. 가설이 틀렸는데 그 가설을 지키는 테스트를 남겨두면, 다음에 보는 사람이 "이건 검증됐다"고 읽습니다.

여기서 알게 된 건 통과의 의미가 제가 생각한 것과 달랐다는 점입니다.

통과는 "이 예시 데이터에서 통과"였지 "이 동작을 지킨다"가 아니었습니다.

이미 돌아가는 서비스에 TDD를 얹으려면

정석은 압니다. 실패하는 테스트를 먼저 쓰고 그게 통과하도록 구현하는 것. 빨강을 본 다음에 초록으로 가면 그 테스트가 진짜 그 동작을 잡는다는 게 증명됩니다.

문제는 순서를 지킬 수가 없었다는 겁니다. 대상은 몇 년째 돌아가는 서비스고 제가 하는 일의 대부분은 이미 있는 코드가 잘못 도는 걸 고치는 일이에요. 신규 기능엔 앞에 테스트를 둘 수 있지만 회귀 방어는 성격상 사후입니다. 고칠 곳을 먼저 알아야 무엇을 잠글지 정할 수 있으니까요.

전면 도입은 비용이 컸고, 안 하면 앞의 두 문제가 그대로 남았습니다.

테스트를 테스트하기

순서를 바꾸는 대신 단계를 하나 더 붙였습니다.

원인 계측 → 고침 → 회귀 테스트 → 고친 것만 되돌려 테스트가 빨개지는지 확인 → 되살림

마지막에서 두 번째가 핵심입니다. TDD가 빨강 단계에서 공짜로 얻는 보증을, 순서를 뒤집는 대신 사후에 확인해서 얻습니다. 순서는 다르지만 얻는 게 같아요.

최근 회차에 붙인 테스트 셋을 전부 이렇게 확인했습니다. 진입 화면 프레이밍 7케이스, 답안 대기 5케이스, 문제은행 로드 실패 4케이스. 되돌리면 빨개지고 되살리면 초록이 됩니다.

한 번은 빨개지지 않았습니다. 정규식이 파일 안의 첫 번째 할당을 잡고 있었는데 제가 고친 건 두 번째였어요. 통과하고 있었고, 지키는 건 없었습니다. 전부 검사하도록 고쳤습니다.

이 방식에는 이름이 있습니다. 변이 테스트예요. 일주일 전 AI 감사 건에서 43종을 한꺼번에 돌려본 적이 있는데, 그때는 감사가 끝난 코드를 검사하는 일회성 작업이었습니다. 지금은 고칠 때마다 붙는 기본 단계가 됐다는 게 달라진 점이에요.

도구는 소스를 자동으로 수백 가지로 비틉니다. 부등호를 뒤집고, 반환값을 죽이고, 문자열을 비우고요. 그리고 테스트가 몇 개나 잡는지 셉니다. 살아남은 변형체가 곧 구멍입니다.

제가 매번 하는 건 그걸 손으로, 변형체 하나만 두고 하는 버전입니다. 그 하나가 원래 그 버그예요.

변이 테스트는 코드를 위한 테스트가 아니라 테스트를 위한 테스트입니다.

배운 것

초록은 정보가 아니다

테스트가 통과하는 이유는 넷입니다. 코드가 맞거나, 테스트가 그 코드를 안 지나가거나, 단언이 항상 참이거나, 검사 대상과 같은 출처를 보고 있거나. 쓰는 것만으로는 어느 쪽인지 모릅니다. 되돌려서 빨개지는 걸 봐야 나머지 셋이 배제돼요.

저희 팀 테스트 원칙 세 번째가 "통과 = 진짜 보장"인데, 원칙만 적어두고 강제할 절차가 없었습니다. 변이 확인이 그 절차입니다.

제가 틀렸던 것

카드 미리보기 건에서 "회귀 테스트 2개를 같이 붙였다"고 보고했습니다. 사실 그 문장이 뜻한 건 "예시 데이터 하나에서 통과했다"였는데, 저도 읽는 사람도 "안전하다"로 읽었어요.

지금은 테스트를 붙였다고 쓸 때 무엇을 지나가고 무엇을 안 지나가는지 같이 적습니다.

순서가 아니라 보증

TDD의 값은 테스트를 먼저 쓴다는 순서 자체가 아니라 빨강을 봤다는 사실에 있습니다. 순서를 지킬 수 없는 코드베이스에서도 보증은 따로 얻을 수 있어요. 다만 TDD가 덤으로 주는 설계 압력은 못 얻습니다. 이미 있는 코드의 인터페이스는 테스트가 바꿔주지 않으니까요.

남은 것

자동 도구는 아직 안 깔았습니다. 변형체마다 테스트를 다시 돌리는 구조라 테스트 파일 238개짜리 저장소에서는 CI에 못 넣어요. 손으로 하는 단일 변형체가 지금까진 충분했습니다.

넣는다면 기준은 넷이 동시에 맞을 때입니다. 순수 함수이고, 분기가 촘촘하고, 커버리지가 이미 높고, 틀렸을 때 되돌릴 수 없는 곳.

마지막 조건이 실질적으로 제일 중요합니다. 렌더가 한 프레임 틀린 건 다시 그리면 되지만, 학생 조작 기록의 압축 경계가 틀려서 저장된 답안이 안 풀리면 그 기록은 돌아오지 않아요. 그쪽이 첫 후보입니다.

점수를 쫓지 않는 것도 남은 숙제입니다. 변이 점수를 올리는 제일 쉬운 방법은 의미 없는 단언을 늘리는 것이고, 그러면 점수는 오르고 테스트는 나빠집니다. 산출물은 점수가 아니라 살아남은 변형체 목록이어야 해요.

그리고 변이 확인도 전부를 잡지는 못합니다. 기간 종료일 계산을 날짜 더하기에서 시간 더하기로 바꿔도 테스트가 전부 통과한 적이 있어요. 한국은 서머타임이 없어서 두 방식의 결과가 같기 때문입니다. 변형체를 만들어도 실행 환경에서 차이가 안 나면 잡히지 않습니다.

되돌려서 빨개지는 걸 봤다는 건 그 환경에서 잡힌다는 뜻이지, 모든 환경에서 잡힌다는 뜻이 아닙니다.

어떻게 확인했나

  • 새로 붙인 테스트마다 고친 것만 되돌려 실패하는지 확인하고 되살렸습니다 (7·5·4 케이스)
  • 정규식이 엉뚱한 줄을 잡던 테스트 하나를 이 과정에서 찾아 고쳤습니다
  • 전체 테스트 재실행으로 다른 영역 회귀가 없는지 확인했습니다
  • 자동 변이 도구는 돌리지 않았습니다. 위 기준에 맞는 대상을 아직 안 골랐어요