17장. AI 기반 테스트 — 믿을 수 있는 코드

이 장이 끝나면: 온티켓의 핵심 로직(잔여석 계산·예매 검증)에 자동화된 테스트가 붙는다. 테스트를 일부러 실패시켜 '진짜 감시하고 있음'을 증명하고, 형식적으로만 통과하는 빈 껍데기 테스트를 가려내는 안목을 기른다.


17.1 개념 — AI가 코드를 짤수록 테스트가 중요해진다

역설처럼 들리지만

AI가 코드를 잘 짜니 검사가 덜 필요할 것 같지만, 사실은 반대다. AI가 빠르게, 많이 짤수록 사람이 일일이 다 읽고 확인하기가 불가능해진다. 어제 만든 결제 코드가 오늘 리팩토링 한 번에 조용히 망가져도, 수천 줄 앞에서 사람 눈으로는 못 잡는다.

왜 이것이 역설이 아닌지, 조금 더 뜯어 보자. 사람이 직접 코드를 한 줄씩 칠 때는 속도가 느린 대신, 그 느림이 일종의 안전장치였다. 손으로 짜는 동안 자연스럽게 "이 함수가 저 함수를 부르는데 괜찮나"를 머릿속으로 검토하게 되고, 하루에 바꿀 수 있는 양도 제한돼 있어서 사람이 전체를 따라갈 수 있었다. 그런데 AI에게 맡기면 이 '느림이라는 안전장치'가 사라진다. 오전에 지시 몇 줄로 파일 열 개가 바뀌고, 오후에 리팩토링 지시 한 줄로 그 열 개가 또 바뀐다. 생산 속도는 열 배가 됐는데 사람이 눈으로 검토하는 속도는 그대로다. 속도가 빨라진 만큼 사람 눈의 사각지대도 함께 커진다. 이 벌어진 틈을 메우는 것이 자동화된 테스트다.

'리팩토링(refactoring)'이라는 말을 한 번 짚어 두자. 겉으로 드러나는 동작은 그대로 두고, 코드의 내부 구조만 더 깔끔하게 다시 짜는 작업을 말한다. 방을 쓰는 용도는 그대로 두고 가구 배치만 바꾸는 것과 같다. 문제는, 가구를 옮기다 보면 전선을 잘못 건드려 어딘가의 불이 꺼질 수 있다는 점이다. 리팩토링은 '동작은 그대로'가 목표지만 실제로는 그 과정에서 동작이 조용히 깨지기 쉽고, 그래서 리팩토링이야말로 테스트가 지켜 주어야 하는 대표적인 순간이다.

그래서 필요한 것이 자동화된 테스트 — 코드가 약속대로 도는지 사람 대신 기계가, 매번, 순식간에 검사해 주는 장치다. QA 서브에이전트가 손으로 돌렸던 검사 시나리오를 떠올려 보라. 그것을 코드로 박아 두면, 앞으로 코드가 바뀔 때마다 자동으로 다시 돌아간다. QA의 일회성 검사가 영구적 안전망이 되는 것이다.

이 '일회성 검사를 코드로 박는다'가 이 장의 첫 번째 핵심 발상이다. 사람이 손으로 하는 검사는 아무리 꼼꼼해도 그 순간 한 번뿐이다. 오늘 QA가 "2장 예매하면 잔여 -2가 맞다"를 열 번 확인해도, 내일 코드가 바뀌면 그 확인은 무효가 된다. 다시 사람이 열 번 확인해야 한다. 반면 그 검사를 테스트 코드로 한 번 적어 두면, 내일도 모레도 코드가 바뀔 때마다 기계가 알아서 그 열 번을 대신 돌린다. 검사가 '그때 한 번 한 일'에서 '앞으로 계속 자동으로 도는 자산'으로 바뀐다.

[짚고 가기] 이 장의 감을 한마디로 잡으면 "빨리 짤수록 빨리 망가진다"이다. 흔히 '테스트'를 개발을 다 하고 나서 붙이는 숙제나 격식으로 여기기 쉽다. 하지만 그 인식은 뒤집는 게 맞다 — AI 시대에 테스트는 격식이 아니라 생산 속도를 감당하기 위한 필수 인프라다. AI가 하루에 열 배를 짜는데 사람 눈은 그대로라면, 그 열 배를 누가 지켜보겠는가. 이렇게 자문해 보면 테스트가 왜 필요한지 실감이 난다.

테스트는 '문화'가 아니라 '구조'다

흔히 "테스트를 잘 짜는 문화를 만들자"고 말한다. 이 과정은 문화에 기대지 않는다. 테스트를 구조로 만든다 — 검사 자체는 AI가 짜고(빠르다), 사람은 그 검사가 진짜인지 검증하며(빈 껍데기 판별), 커밋 때마다 자동 실행되게 강제한다(다음 장의 Hook). '열심히 하자'가 아니라 '안 하면 막힌다'로 만드는 것이 이 과정의 방식이다.

왜 문화가 아니라 구조여야 하는지, 이 차이가 이 장의 두 번째 핵심이다. '문화'에 기댄다는 것은 사람의 의지와 성실함에 기댄다는 뜻이다. "우리 팀은 테스트를 꼭 씁시다"라는 다짐은, 바쁘면 제일 먼저 버려진다. 마감이 급하면 "이번만 테스트 없이 배포하자"가 되고, 그게 한 번 통하면 두 번째부터는 다짐 자체가 유명무실해진다. 문화는 사람이 지치면 무너진다.

반면 '구조'에 기댄다는 것은 사람의 의지와 무관하게 작동하는 장치를 만든다는 뜻이다. 다음 장에서 배울 훅(Hook)이 그 장치다. 커밋(코드 저장)을 하려고 할 때 훅이 테스트를 자동으로 돌리고, 테스트가 빨간불이면 커밋 자체가 막힌다. 사람이 성실하든 지쳤든, 테스트를 통과하지 않으면 코드가 앞으로 나아가지 못한다. '하고 싶은 사람이 한다'가 아니라 '안 하면 막힌다'가 된다. 아래 표로 대비하면 차이가 또렷하다.

문화에 기대는 방식 구조로 만드는 방식(이 과정)
무엇에 의존 사람의 의지·성실함 자동으로 도는 장치(훅)
바쁠 때 제일 먼저 버려진다 바빠도 그대로 작동한다
강제력 "합시다"(권고) "안 하면 커밋이 막힘"(강제)
지속성 사람이 지치면 무너짐 사람과 무관하게 유지됨

이 표에서 오른쪽 열을 만드는 것이 이 장(테스트 작성)과 다음 장(자동 실행 강제)의 목표다. 지금 이 장에서는 그 구조의 앞부분 — AI가 검사를 짜게 하고, 사람이 그 검사가 진짜인지 판별하는 부분 — 을 완성한다.

무엇을 테스트하나 — 입출력이 분명한 곳부터

모든 것을 테스트할 수는 없고, 그럴 필요도 없다. 우선순위는 명확하다 — 틀리면 사고 나고, 입력과 출력이 분명한 곳. 온티켓이라면 잔여석 계산과 예매 검증이 1순위다. 돈과 재고가 걸렸고, "2장 예매하면 잔여 -2"처럼 정답이 또렷하기 때문이다. 반대로 화면의 미묘한 배치 같은 것은 자동 테스트가 어렵고(그건 디자인 리뷰어의 눈이 맡는다), 우선순위도 낮다.

이 우선순위를 두 개의 축으로 생각하면 판단이 쉬워진다. 하나는 틀렸을 때의 피해(사고가 나는가, 그냥 보기 싫은가)이고, 다른 하나는 정답의 또렷함 (정답이 딱 떨어지는가, 사람마다 다르게 느끼는가)이다. 이 두 축으로 온티켓의 기능을 나눠 보면 무엇부터 테스트할지가 보인다.

대상 틀렸을 때 피해 정답이 또렷한가 자동 테스트 우선순위
잔여석 계산 큼(초과 판매·환불 사고) 또렷함(숫자로 딱 떨어짐) 1순위
예매 검증(수량·등급) 큼(잘못된 주문 성립) 또렷함(된다/안 된다) 1순위
결제 금액 합산 큼(돈이 틀림) 또렷함(금액이 딱 떨어짐) 1순위
목록 정렬 순서 중간 대체로 또렷함 여력 되면
버튼 색·여백 작음(보기 문제) 사람마다 다름 자동 테스트 대상 아님

표의 위쪽 — 피해가 크고 정답이 또렷한 곳 — 이 자동 테스트가 가장 빛나는 자리다. "총 100석, 이미 99석 팔림, 여기 2장 주문이 오면 거부돼야 한다"는 컴퓨터가 판정할 수 있는 명제다. 반면 "이 버튼이 예쁜가"는 컴퓨터가 판정할 수 없다. 그래서 앞쪽은 자동 테스트에게, 뒤쪽은 사람의 눈(디자인 리뷰어)에게 맡긴다. 온티켓에서 골목 라이브가 예전에 겪은 초과 판매 사고를 떠올려 보라 — 그 사고는 버튼 색이 아니라 잔여석 계산이 틀려서 났다. 테스트의 창끝을 어디에 겨눠야 하는지가 여기서 나온다.

'핵심 로직(core logic)'이라는 말을 짚어 두자. 서비스의 본질적인 규칙이 들어 있는 코드를 가리킨다. 온티켓의 본질은 "표를 정확히 파는 것"이므로, 잔여석과 예매 검증이 핵심 로직이다. 화면을 꾸미는 코드나 목록을 예쁘게 보여주는 코드는 중요하지만 '핵심'은 아니다. 테스트는 이 핵심부터, 바깥으로 넓혀 가는 순서로 붙인다.

[짚고 가기] "그럼 화면은 테스트 안 하나요?"라는 의문이 들 수 있다. 화면도 테스트할 수 있지만(E2E), 그건 느리고 잘 깨져서 최소로만 둔다(다음 절의 피라미드로 이어진다). 그리고 "버튼이 예쁜가"처럼 정답이 사람마다 다른 것은 애초에 자동 테스트의 몫이 아니라 사람(디자인 리뷰어)의 몫이다. 이 선을 그어 두면 "무엇이든 다 테스트해야 한다"는 부담에서 벗어날 수 있다.


17.2 용어 정리

이 절의 용어들은 앞으로 테스트를 다룰 때 계속 쓰인다. 완벽히 외울 필요는 없고, "이런 게 있구나" 정도로 눈에 익혀 두면 실습하며 손에 붙는다. 특히 뒤쪽의 빈 껍데기 테스트커버리지의 함정은 이 장의 핵심이니 천천히 읽자.

단위 테스트 / 통합 테스트 / E2E

테스트는 '얼마나 큰 덩어리를 검사하는가'로 나뉜다.

종류 검사 대상 온티켓 예 속도
단위(Unit) 함수 하나 잔여석 계산 함수에 (총100, 판매98, 2장) → 통과여야 매우 빠름
통합(Integration) 여러 부품의 연결 POST /orders 가 DB까지 거쳐 주문을 만드는가 보통
E2E(End-to-End) 사용자 전 여정 브라우저에서 목록→예매→결제→QR 전체 느림

세 종류를 자동차 검사에 비유하면 감이 온다. 단위 테스트는 부품 하나를 공장에서 따로 시험하는 것이다 — 브레이크 패드 하나를 눌러 보고 "제대로 멈추는 힘이 나오나"만 본다. 빠르고, 어디가 문제인지 정확히 짚어 준다. 통합 테스트는 부품 몇 개를 조립해서 시험하는 것이다 — 브레이크 패드와 페달과 유압선을 연결해 "페달을 밟으면 패드가 움직이나"를 본다. E2E는 완성차를 실제 도로에서 몰아 보는 것이다 — 시동부터 주행, 정차까지 사람이 겪는 전 과정을 그대로 돌린다. 가장 현실에 가깝지만, 가장 느리고, 무엇이 문제인지 짚기도 어렵다(멈추긴 하는데, 패드 때문인지 페달 때문인지 도로 하나로는 모른다).

여기서 초심자가 자주 헷갈리는 지점 하나. "그럼 E2E가 제일 좋은 것 아닌가? 실제 사용자랑 똑같이 하는 거잖아"라고 생각하기 쉽다. 맞다, 가장 현실적이다. 하지만 가장 현실적인 것이 가장 실용적인 것은 아니다 — 이 딜레마가 바로 다음의 테스트 피라미드로 이어진다.

테스트 피라미드

이 셋을 아래가 넓은 삼각형으로 쌓는 것이 정석이다.

        /\        E2E     ← 적게 (느리고 깨지기 쉬움, 핵심 여정 1~2개만)
       /  \       통합    ← 적당히
      /____\      단위    ← 많이 (빠르고 튼튼함, 기반)

왜 이 모양인가 — 단위 테스트는 빠르고 안정적이라 수백 개를 돌려도 몇 초다. E2E는 느리고, 화면이 조금만 바뀌어도 깨져서 유지비가 크다. 그래서 빠른 것을 바닥에 많이 깔고, 느린 것은 꼭대기에 최소로 둔다. AI에게 "테스트 잔뜩 만들어줘"라고 하면 E2E를 잔뜩 만들어 피라미드를 뒤집어 놓기도 한다 — "단위 위주로" 를 지시에 넣는 이유다.

'피라미드'라는 이름은 그냥 삼각형이라서가 아니라, 아래가 넓고 위가 좁아야 안정적으로 선다는 뜻을 담고 있다. 피라미드를 거꾸로 세우면(위가 넓고 아래가 좁으면) 무게중심이 위로 쏠려 쓰러진다. 테스트도 마찬가지다. 느리고 잘 깨지는 E2E를 잔뜩 쌓고 빠른 단위 테스트가 적으면, 테스트 한 번 돌리는 데 몇 분이 걸리고, 화면을 조금 고칠 때마다 우수수 깨진다. 그러면 사람들은 결국 테스트를 꺼 버린다. 너무 느리고 자꾸 깨지니까. 안전망을 아예 걷어 버리는 최악으로 간다. 그래서 뒤집힌 피라미드는 '안티패턴(하지 말아야 할 전형)'으로 불린다.

각 층의 유지비와 효용을 표로 정리하면 왜 이 비율이 나오는지 보인다.

속도 잘 깨지는가 문제 위치를 짚는가 몇 개나
단위 매우 빠름(수백 개 몇 초) 튼튼함 정확히 짚음 많이(수십~수백)
통합 보통 중간 어느 연결인지 대략 적당히(핵심 흐름)
E2E 느림(하나에 수 초~수십 초) 잘 깨짐(화면 바뀌면) 짚기 어려움 최소(1~2개 여정)

이 표를 보면 "왜 단위를 바닥에 많이 까는가"가 분명해진다. 단위는 빠르고 튼튼하고 문제를 정확히 짚는다 — 삼박자가 다 좋다. E2E는 그 반대라서, 정말 중요한 전체 여정 한둘(예: "목록에서 예매해서 QR까지 받는다")만 지키게 두는 것이다. AI는 그냥 두면 눈에 보이는 결과가 화려한 E2E를 선호하는 경향이 있어서, "단위 위주로, E2E는 만들지 마" 라는 한 줄을 지시에 꼭 넣는다. 이 한 줄이 피라미드가 뒤집히는 것을 막는다.

[짚고 가기] 피라미드가 뒤집히면 왜 나쁜지는 이렇게 정리하면 또렷하다 — 테스트가 너무 느리고 자꾸 깨지면 사람들이 결국 꺼 버리고, 그러면 안전망이 통째로 사라진다. 실무에서 실제로 자주 벌어지는 일이다. 직접 AI에게 "테스트 만들어줘"만 시켜 보면 E2E 범벅이 되어 돌아오기 쉬운데, 여기에 "단위 위주로"를 넣었을 때와 비교해 보면 지시 한 줄의 힘이 체감된다.

assert — 테스트의 심장

assert(단언) 는 "이 값은 반드시 이래야 한다"고 못 박는 한 줄이다. 테스트의 본체가 바로 이 assert다.

const remaining = calcRemaining(100, 98)   // 총 100, 판매 98
assert(remaining === 2)                     // 반드시 2여야 한다 ← 이 줄이 심장

assert가 없는 테스트는 심장이 없는 테스트다 — 코드를 실행만 하고 결과를 확인하지 않으니, 무엇이 나오든 통과한다. 이것이 다음 개념으로 이어진다.

'assert'라는 영어 단어는 법정에서 증인이 "나는 이것이 사실이라고 단언한다"고 말할 때의 그 단언이다. 강한 확신을 담아 "이건 반드시 이렇다"고 못 박는 것이다. 테스트에서 assert 한 줄은 그런 단언이다 — "잔여석은 반드시 2여야 한다"고 못 박고, 실제 값이 그렇지 않으면 테스트가 즉시 비명을 지른다(실패, 빨간불).

assert가 왜 '심장'인지 구조로 보면 이렇다. 테스트 한 개는 보통 세 박자로 되어 있다. 흔히 AAA라고 부른다.

단계 이름 하는 일 잔여석 예
1 준비(Arrange) 검사에 필요한 값을 차린다 총 100, 판매 98을 준비
2 실행(Act) 검사 대상 함수를 부른다 calcRemaining(100, 98) 호출
3 단언(Assert) 결과가 기대와 같은지 못 박는다 remaining === 2 인가 확인

앞의 두 박자(준비·실행)만 있고 세 번째(단언)가 없으면, 함수를 불러 놓고 결과를 아무도 안 보는 셈이다. 요리를 해서 접시에 담아 놓고 맛은 안 보는 것과 같다. 무엇이 나오든 "통과"로 처리된다. 그래서 assert가 빠진 테스트가 곧 다음에 나올 '빈 껍데기 테스트'의 대표 증상이다. 테스트를 볼 때 가장 먼저 찾을 것은 assert가 있는가, 그 assert가 무엇과 무엇을 비교하는가이다.

빈 껍데기 테스트 (가짜 통과)

항상 통과하도록 만들어진, 아무것도 검사하지 않는 테스트. 초록 불(통과)이 켜져 있어서 안심하지만, 실은 아무것도 지키지 않는다 — 가장 위험한 종류다. 차라리 없는 것이 낫다(없으면 최소한 '검사 안 됨'을 안다). 전형적인 모습:

AI에게 테스트를 시키면 이런 것이 섞여 나올 수 있다. 그래서 사람이 테스트를 검토하는 단계가 반드시 있어야 한다. "테스트가 통과한다"가 아니라 "이 테스트가 무엇을 지키는가"를 물어야 한다.

왜 이것이 '가장 위험한 종류'인지, 한 번 더 강조할 값어치가 있다. 테스트가 아예 없으면, 사람은 "이 부분은 검사가 안 됐으니 조심하자"고 안다. 위험을 아는 것은 그 자체로 안전장치다. 그런데 빈 껍데기 테스트는 초록불을 켜서 "검사됐다"는 거짓 신호를 준다. 사람은 안심하고 넘어가고, 실제로는 아무도 안 지키는 코드가 배포된다. 없는 것보다 나쁜 이유가 여기 있다 — 위험을 위험이 아닌 것처럼 위장하기 때문이다. 소방 점검표에 "이상 없음" 도장이 찍혀 있는데 실제로는 아무도 점검을 안 한 것과 같다. 도장 때문에 아무도 다시 안 본다.

네 가지 전형을 온티켓 코드로 구체화하면 이렇다. 이 모습을 눈에 익혀 두면 실제 테스트 파일을 열었을 때 바로 알아챌 수 있다.

// (1) assert가 없다 — 함수를 부르기만 하고 결과를 안 본다
test('잔여석 계산', () => {
  calcRemaining(100, 98)          // 실행만 함. 2가 나오든 500이 나오든 통과
})

// (2) 항상 참인 것을 검사한다 — 무엇을 넣어도 통과
test('예매 검증', () => {
  const ok = true
  assert(ok === true)             // 함수는 부르지도 않았다. 늘 통과
})

// (3) 경계·실패를 안 본다 — 정상만 있고, '잔여 0'·'초과 주문'이 없다
test('잔여석', () => {
  assert(calcRemaining(100, 98) === 2)   // 정상 하나뿐. 딱 매진 지점은 안 봄
})

// (4) 에러를 삼킨다 — 함수가 터져도 통과 처리
test('예매', () => {
  try {
    reserve({ qty: -1 })          // 음수 주문. 원래 거부(에러)돼야 함
  } catch (e) {
    // 아무것도 안 함 — 에러를 잡아서 조용히 버린다. 그래서 통과
  }
})

이 넷 중 특히 (4) '에러를 삼킨다'는 초심자가 알아채기 어렵다. try/catch는 원래 에러가 났을 때 프로그램이 죽지 않게 받아 주는 장치인데, 받아만 놓고 아무 검사도 안 하면 "에러가 나야 정상인 상황"에서 에러가 나도 통과가 된다. 음수 주문은 거부(에러)돼야 맞는데, 그 에러를 조용히 삼켜 버리니 "거부가 잘 됐는지"를 아무도 확인하지 않는 것이다. 제대로 된 테스트라면 "에러가 나야 한다"는 것 자체를 assert로 못 박는다(예: "음수 주문은 반드시 거부된다").

커버리지 — 숫자에 속지 않기

커버리지는 코드의 몇 %가 테스트로 실행됐는지를 나타내는 지표다. 유용하지만 함정이 있다 — 빈 껍데기 테스트도 커버리지는 올린다(실행은 하니까). 커버리지 90%가 품질 90%를 뜻하지 않는다. 숫자보다 "핵심 로직의 정답·경계·실패가 검사되는가"가 먼저다.

'커버리지(coverage)'는 '덮은 정도', 즉 테스트가 코드를 얼마나 덮었는가를 뜻한다. 여기서 함정의 정체가 바로 이 '덮었다'의 의미에 있다. 커버리지가 재는 것은 그 코드 줄이 테스트 도중에 실행됐는가뿐이다 — 그 결과를 검사했는가는 재지 않는다. 앞 절의 빈 껍데기 (1)번을 떠올려 보라. calcRemaining(100, 98)을 부르기만 하고 assert가 없어도, 그 함수 코드는 '실행'됐으니 커버리지는 올라간다. 검사는 0인데 숫자는 100%가 될 수 있다.

비유하면 이렇다. 도서관의 모든 책을 한 번씩 펼쳐 보기만 하고 덮으면, "모든 책을 봤다"고 100%를 채울 수 있다. 하지만 그중 한 권도 읽지는 않았다. 커버리지는 '펼쳐 본 비율'이고, 우리가 정말 원하는 것은 '읽고 이해한 비율'이다. 둘은 다르다.

커버리지가 재는 것 우리가 정말 원하는 것
질문 이 코드 줄이 실행됐나? 이 코드의 결과가 검사됐나?
빈 껍데기는? 올려 준다(실행은 하니까) 못 지킨다(assert가 없으니)
100%의 의미 모든 줄이 한 번은 돌았다 (보장 안 됨)

그렇다고 커버리지가 쓸모없다는 말은 아니다. 낮은 커버리지는 확실한 경고다 — 커버리지 20%는 "코드의 80%는 테스트가 한 번도 안 건드렸다"는 진짜 위험 신호다. 반대로 높은 커버리지는 안심의 근거가 못 된다. 90%여도 그 90%가 전부 빈 껍데기일 수 있다. 그래서 커버리지는 '너무 낮은 수치를 경계하는 지표'로 쓰고(너무 낮으면 문제), '위를 자랑하는 지표'로는 쓰지 않는다(높다고 좋은 게 아님). 숫자를 목표로 삼는 순간 사람들은 숫자만 채우는 가짜 테스트를 짜게 된다 — 이것이 커버리지를 목표(KPI)로 걸면 안 되는 이유다.

[짚고 가기] "커버리지 100% 하면 되는 거 아니에요?"는 누구나 한 번쯤 품는 의문이다. 앞의 빈 껍데기 (1)번(assert 없이 함수만 호출)을 떠올려 보면 풀린다 — 그것도 커버리지는 오른다. 결론은 이렇다. 커버리지는 '너무 낮으면 경고등'으로만 보고, 품질의 증거로는 삼지 않는다. 품질의 진짜 증거는 뒤에서 배울 '일부러 망가뜨리기'다.


17.3 좋은 테스트의 조건 — 정상·경계·실패

믿을 수 있는 테스트는 세 종류의 케이스를 모두 가진다. 잔여석 계산으로 예를 들면:

케이스 검사 내용
정상 평범한 입력에 올바른 답 총100·판매98·2장 주문 → 성공, 잔여 0
경계 아슬아슬한 지점 총100·판매99·1장 → 성공(마지막 1장) / 총100·판매99·2장 → 실패
실패 잘못된 입력을 제대로 거부 0장·5장·음수 → 거부 / 없는 등급 → 거부

경계와 실패가 없는 테스트는 절반짜리다. 버그는 정상 케이스가 아니라 경계와 실패에 살기 때문이다(QA에게 엣지 케이스를 찾게 했을 때와 같은 이야기다).

세 종류를 각각 왜 봐야 하는지 짚어 보자.

경계와 실패에 버그가 사는 이유는 단순하다. 개발자도 AI도, 코드를 짤 때 머릿속에 '평범한 경우'를 그리며 짠다. "고객이 2장 사면…"을 생각하며 짜지, "고객이 마이너스 3장을 사면…"이나 "딱 매진된 순간에 1장을 더 사면…"까지 매번 떠올리진 않는다. 그래서 정상 경로는 대개 맞고, 사고는 늘 구석(경계·실패)에서 난다. 테스트의 진짜 값어치는 이 구석을 대신 기억해 주는 데 있다.

이 '정상·경계·실패' 틀은 코드 밖에서도 검토의 뼈대가 된다 — 행사 리허설이라면 정상(예정 인원), 경계(정원 꽉 참), 실패(초과 도착 시 대응). 검사를 설계한다는 것은 언제나 이 세 방향을 보는 일이다.

이 틀이 개발 밖에서 얼마나 넓게 쓰이는지, 온티켓 세계관 안팎의 사례로 넓혀 보자. '정상·경계·실패'는 사실 개발 용어가 아니라 일이 어긋날 지점을 미리 상상하는 보편적 사고법이다.

상황 정상 경계 실패
온티켓 공연 리허설 예정 인원이 순서대로 입장 좌석이 딱 정원까지 참 정원 초과 도착·예매 없는 사람 등장
결혼식 하객 응대 초대 인원이 예상대로 옴 식장 좌석이 꽉 참 예상보다 훨씬 많이·적게 옴
식당 예약 운영 예약 고객이 시간에 옴 마지막 남은 테이블 예약 노쇼·예약 없이 단체 방문
발표·강연 준비 슬라이드가 순서대로 넘어감 시간이 딱 맞게 끝남 빔프로젝터 고장·질문이 안 끊김
여행 짐 싸기 예정 일정대로 입을 옷 가방 용량 꽉 참 비 예보·항공 지연

이 표의 어느 줄을 봐도, 정상만 준비한 사람은 경계와 실패에서 무너진다. 리허설을 정상 인원으로만 해 본 행사는, 실제로 정원이 꽉 차거나 예매 없는 고객이 몰려올 때 우왕좌왕한다. 좋은 준비는 "잘 풀릴 때"가 아니라 "아슬아슬할 때"와 "틀어질 때"를 미리 그려 본 준비다. 테스트를 잘 설계하는 눈은 곧 일을 잘 준비하는 눈이고, 이 감각은 코드를 짜지 않는 사람에게도 그대로 쓸모가 있다.

[짚고 가기] 개발이 낯설다면 코드 예제보다 이 '리허설/결혼식/식당' 비유가 훨씬 잘 와닿는다. 행사를 준비한다면 정원이 꽉 찬 순간(경계)과 초과 도착(실패)을 반드시 리허설해 보지 않는가. 테스트가 바로 그 리허설을 코드로 하는 것이다. 이렇게 연결해 보면, 테스트가 낯선 개발 격식이 아니라 이미 아는 사고법의 코드 버전임을 알 수 있다.


17.4 프롬프트 작성법 — 테스트를 시키고, 의심하고, 증명한다

이 절의 프롬프트는 세 박자다 — 시킨다(작성) → 의심한다(빈 껍데기 판별) → 증명한다(일부러 망가뜨리기). 이 세 박자가 이 장 전체의 뼈대이므로, 각 프롬프트가 왜 그렇게 생겼는지까지 함께 본다.

완료 기준부터 준다

테스트는 완료 기준의 번역이다. 기준을 먼저 주고 그것을 테스트로 바꾸게 한다.

프롬프트
/backend 잔여석 계산과 예매 검증에 단위 테스트를 붙여줘.
완료 기준(이걸 테스트로):
- 정상: 총100·판매98에 2장 주문 → 성공, 잔여 0
- 경계: 총100·판매99에 1장 → 성공 / 2장 → 실패(잔여 부족)
- 실패: 수량 0·5·음수 → 거부 / 없는 티켓 등급 → 거부
단위 테스트 위주로. E2E는 만들지 마. 테스트 실행 명령도 알려줘.

이 프롬프트가 좋은 이유를 한 줄씩 뜯어 보자. 첫째, 완료 기준을 정상·경계· 실패로 나눠서 줬다. 그냥 "테스트 붙여줘"라고 하면 AI는 정상 케이스만 몇 개 짜고 끝낼 수 있다. 세 종류를 명시하니 경계와 실패까지 강제된다. 둘째, 구체적인 숫자(총100·판매99에 1장은 성공, 2장은 실패)를 줬다. 이러면 AI가 임의로 만든 값이 아니라 내가 지킬 규칙 그대로가 테스트가 된다. 셋째, "단위 테스트 위주로, E2E는 만들지 마" 로 피라미드가 뒤집히는 것을 막았다. 넷째, "실행 명령도 알려줘" 로, 만든 테스트를 내가 직접 돌려 볼 수 있게 했다.

'약한 지시 → 강한 지시'로 대비하면 차이가 분명하다.

약한 지시 강한 지시 무엇을 바꿨나
테스트 좀 만들어줘 잔여석·예매 검증에 단위 테스트를 붙여줘. 실행 명령도 대상과 종류를 특정
잔여석 테스트 해줘 정상·경계·실패로 나눠서: 총100·판매99에 1장 성공/2장 실패… 케이스 종류와 숫자를 명시
테스트 많이 만들어줘 단위 위주로, E2E는 만들지 마 피라미드가 뒤집히지 않게
예매 검증 테스트 음수·0·초과 수량, 없는 등급을 각각 거부하는지 실패 케이스를 구체적으로

'단위 테스트 러너'라는 말이 처음 나오니 짚어 두자. 테스트 코드를 모아서 자동으로 돌리고, 통과/실패를 초록·빨강으로 보고해 주는 프로그램을 테스트 러너(test runner)라고 한다. 우리가 pnpm test라고 치면, 이 러너가 프로젝트의 테스트를 전부 찾아 한꺼번에 실행하고 결과를 표로 보여준다. 어떤 러너를 쓸지는 AI에게 맡겨도 되지만, "가볍고 빠른 걸로"라고 한마디 덧붙이면 무거운 도구를 피할 수 있다.

빈 껍데기 판별을 시킨다 — AI로 AI를 검사

만든 AI에게 "이 테스트 빈 껍데기 아니야?"를 스스로 묻게 하는 것도 방법이지만, 더 확실한 것은 검증 역할(qa)에게 교차 검사시키는 것이다.

프롬프트
qa 서브에이전트에게: 방금 만든 테스트들을 검토해줘.
각 테스트가 '무엇을 검사하는지' 한 줄로 요약하고, 다음을 찾아라 —
assert 없는 것, 항상 통과하는 것, 경계·실패 케이스가 빠진 것.
발견하면 위치만 보고해. 고치는 건 내가 /backend 에 시킨다.

만든 AI가 아니라 다른 역할(qa)에게 맡기는가. 이것이 이 프롬프트의 핵심 설계다. 테스트를 짠 AI에게 "네 테스트 빈 껍데기 아니야?"라고 물으면, 자기가 만든 것을 자기가 검사하는 셈이라 무르게 보기 쉽다. 사람도 자기 답안을 자기가 채점하면 실수를 잘 못 찾는 것과 같다. 그래서 짠 쪽과 검사하는 쪽을 분리한다. qa라는 별도 역할에게 맡기면, 그 역할은 "통과시키는 것"이 아니라 "빈 껍데기를 찾아내는 것"이 임무라서 훨씬 매섭게 본다. 회사에서 개발자가 짠 코드를 QA가 따로 검증하는 것과 정확히 같은 구조다.

이 프롬프트에는 한 가지 중요한 울타리가 더 있다 — "고치는 건 내가 /backend에 시킨다". qa에게 검사만 시키고, 고치는 것은 시키지 않는다. 검사하는 역할이 직접 코드를 고치기 시작하면 "고치기 쉽게 검사 기준을 무르게" 만들 유혹이 생긴다. 찾는 사람과 고치는 사람을 나누는 것 — 이 분리가 검사의 매서움을 지킨다.

교차 검사 지시를 더 날카롭게 만드는 표현들을 덧붙인다.

밋밋한 지시 날카로운 지시
테스트 검토해줘 각 테스트가 뭘 검사하는지 한 줄로 요약하고, assert 없는 것부터 찾아
문제 있으면 알려줘 함수를 안 고쳐도 통과할 것 같은 테스트를 골라내 — 그게 빈 껍데기다
빠진 거 있어? 경계(딱 매진)와 실패(음수·0·없는 등급)가 빠진 곳을 표로 정리해
괜찮아? 각 테스트에 대해 '이걸 지우면 어떤 버그를 놓치나'를 한 줄씩 답해

특히 마지막 줄 — "이 테스트를 지우면 어떤 버그를 놓치나" — 는 강력한 질문이다. 답을 못 하는 테스트는 아무것도 안 지키는 테스트, 즉 빈 껍데기다. 이 질문 하나로 빈 껍데기를 거의 다 걸러낼 수 있다.

진짜 감시하는지 증명한다 — 일부러 망가뜨리기

이것이 이 장의 백미다. 테스트가 통과한다는 사실만으로는 아무것도 증명되지 않는다 — 빈 껍데기도 통과하니까. 테스트가 진짜 감시 중임을 증명하는 유일한 방법은, 감시 대상을 일부러 고장 내서 테스트가 비명을 지르는지 보는 것이다.

프롬프트
잔여석 계산 함수를 일부러 틀리게 바꿔줘 — 빼기를 더하기로.
그 상태로 테스트를 돌려서, 관련 테스트가 실패(빨간불)하는지 보여줘.
확인했으면 원래대로 되돌리고 다시 통과시켜줘.

빨간불이 뜨면 그 테스트는 진짜다. 만약 함수를 망가뜨렸는데도 초록불이면 — 그 테스트가 바로 빈 껍데기다. 잡아낸 것이다.

이 기법의 논리를 한 번 더 또렷하게 새기자. 초록불(통과)은 두 가지 경우에 뜬다 — (가) 코드가 맞고 테스트도 진짜라서 뜨거나, (나) 테스트가 빈 껍데기라서 뜨거나. 겉보기엔 둘 다 똑같은 초록불이라 구분이 안 된다. 그런데 코드를 일부러 틀리게 만들면 이 둘이 갈라진다. (가)라면 빨간불이 떠야 하고, (나)라면 여전히 초록불이다. 일부러 망가뜨리기는 이 두 초록불을 갈라내는 리트머스 시험지다. 빨간불이 뜨면 "이 테스트는 진짜 감시하고 있었다"가 증명되고, 초록불 그대로면 "이 테스트는 아무것도 안 보고 있었다"가 들통난다.

이런 '일부러 결함을 심어 테스트를 시험하는' 기법에는 정식 이름이 있다 — 뮤테이션 테스트(mutation testing), 우리말로 '돌연변이 테스트'다. 코드에 작은 돌연변이(mutation)를 심어서 — 빼기를 더하기로, >>=로, truefalse로 — 테스트가 그 변이를 잡아내는지 본다. 잡아내면(빨간불) 그 테스트는 살아 있고, 못 잡으면(초록불) 그 테스트는 죽어 있다. 우리가 손으로 하는 '함수 일부러 망가뜨리기'는 이 뮤테이션 테스트를 가장 간단하게 체험하는 방법이다.

망가뜨리는 방식을 몇 가지로 바꿔 가며 시험하면 테스트의 촘촘함을 더 잘 잰다.

어떻게 망가뜨리나 무엇을 확인하나
빼기를 더하기로 (total - soldtotal + sold) 잔여석 계산의 방향을 테스트가 지키나
부등호를 바꾸기 (>=>) 딱 매진되는 경계를 테스트가 잡나
검증을 아예 빼기 (음수 거부 코드를 삭제) 실패 케이스 테스트가 살아 있나
상수를 바꾸기 (수량 한도 5 → 50) 한도 경계 테스트가 있나

핵심은 마지막 지시 — "확인했으면 원래대로 되돌리고 다시 통과시켜줘" — 를 반드시 붙이는 것이다. 일부러 망가뜨린 코드를 되돌리지 않으면, 증명하려다 진짜 버그를 심는 꼴이 된다. 망가뜨리기는 반드시 되돌리기와 한 쌍으로 움직인다. 빨간불을 눈으로 본 순간 목적은 달성됐으니, 곧바로 원상복구하고 초록불로 돌아오는 것까지가 한 번의 완결된 시험이다.

[짚고 가기] '일부러 망가뜨리기'는 이 장에서 가장 강렬한 장면이다. 직접 눈으로 보면 확실하다 — 함수의 빼기를 더하기로 바꾸는 순간 초록불이 빨간불로 바뀌는데, 이 장면이 백 마디 설명보다 낫다. 여기에 일부러 빈 껍데기 테스트(assert 없는 것)를 하나 만들어 두고 같은 실험을 해 보면, 그것만 초록불로 남는다. 바로 이 순간이 빈 껍데기가 들통나는 순간이다. 한 가지, 되돌리기를 빼먹지 않도록 확인하는 것도 잊지 말자.


17.5 실습 — 안전망을 짜고, 당겨서 시험한다

앞 절의 세 박자(시킨다·의심한다·증명한다)를 온티켓 코드에 실제로 적용한다. 아래 따라하기를 순서대로 밟으면, 마지막에는 "이 테스트는 진짜 감시하고 있다"를 눈으로 확인한 안전망이 남는다.

따라하기 1 — 테스트 도구 준비

프롬프트
api/ 에 단위 테스트 환경을 세팅해줘. 가벼운 테스트 러너로,
pnpm test 로 실행되게. 예시 테스트 하나로 실행이 되는지까지 확인해줘.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

pnpm test가 초록불로 도는 것을 확인한다 — 아직 예시뿐이지만, 안전망을 걸 틀이 생겼다.

이 단계에서 하는 일은 '검사 도구를 설치하고 콘센트에 꽂아 불이 들어오는지'까지다. 아직 지킬 코드는 없지만, 예시 테스트 하나가 초록불로 도는 것을 봐야 한다 — 도구 자체가 제대로 동작하는지를 먼저 확인하는 것이다. pnpm은 자바스크립트 세계에서 부품(패키지)을 설치하고 명령을 실행해 주는 도구의 한 종류로, pnpm test는 "test라고 등록된 명령을 실행해라"라는 뜻이다. 여기서 초록불이 안 뜨면, 다음 단계로 넘어가기 전에 도구 설정부터 바로잡아야 한다.

따라하기 2 — 핵심 로직에 테스트 붙이기

아래 테스트 작성 지시를 실행하고, pnpm test를 돌린다. 정상·경계·실패 케이스가 모두 통과하는지 본다. 실행 시간도 눈여겨보자 — 아마 1초 안쪽일 것이다. 이 속도가 단위 테스트를 '매번 돌릴 수 있게' 만드는 힘이다.

프롬프트
/backend 잔여석 계산과 예매 검증에 단위 테스트를 붙여줘.
완료 기준(이걸 테스트로):
- 정상: 총100·판매98에 2장 주문 → 성공, 잔여 0
- 경계: 총100·판매99에 1장 → 성공 / 2장 → 실패(잔여 부족)
- 실패: 수량 0·5·음수 → 거부 / 없는 티켓 등급 → 거부
단위 테스트 위주로. E2E는 만들지 마. 테스트 실행 명령도 알려줘.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

실행 시간을 굳이 눈여겨보라는 데는 이유가 있다. 단위 테스트가 '몇 초'가 아니라 '몇 분'이 걸린다면, 사람들은 결국 바쁠 때 안 돌리게 된다. 빠르니까 매번 돌릴 수 있고, 매번 도니까 안전망이 된다. 이 속도는 우연이 아니라 앞에서 '단위 위주로'를 고집한 결과다 — 피라미드의 바닥을 넓게 깐 보상을 여기서 받는 것이다. 결과 표에서 초록불이 몇 개, 각각 무엇을 검사하는지도 슬쩍 세어 두자. 다음 단계에서 이걸 사람이 직접 읽을 것이다.

따라하기 3 — 사람이 테스트를 읽는다

통과했다고 넘어가지 않는다. 테스트 파일을 직접 열어 한 개만 골라 "이게 뭘 검사하지?"를 스스로 답해 본다. assert가 있는가, 무엇과 무엇을 비교하는가. 이어서 아래 지시로 qa에게 교차 검사를 맡겨 빈 껍데기를 찾게 한다. 발견되면 /backend에 고치게 하고 다시 검사한다.

프롬프트
qa 서브에이전트에게: 방금 만든 테스트들을 검토해줘.
각 테스트가 '무엇을 검사하는지' 한 줄로 요약하고, 다음을 찾아라 —
assert 없는 것, 항상 통과하는 것, 경계·실패 케이스가 빠진 것.
발견하면 위치만 보고해. 고치는 건 내가 /backend 에 시킨다.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

'사람이 직접 읽는다'가 왜 따로 한 단계일까. 여기가 바로 이 장 첫머리에서 말한 '문화가 아니라 구조'의 사람 몫 — 빈 껍데기 판별 — 이 실제로 일어나는 지점이기 때문이다. 초록불만 믿고 넘어가면, 그 초록불이 빈 껍데기의 가짜 초록불인지 알 길이 없다. 그래서 한 개라도 사람이 눈으로 열어, 다음 세 가지를 확인한다.

  1. assert가 있는가 — 없으면 그 자리에서 빈 껍데기다(용어 정리에서 본 '테스트의 심장' 이야기).
  2. 무엇과 무엇을 비교하는가remaining === 2처럼 진짜 값과 기대값을 비교하는가, 아니면 1 === 1 같은 늘 참인 것을 비교하는가.
  3. 이 테스트를 지우면 어떤 버그를 놓치나 — 답이 안 나오면 아무것도 안 지키는 테스트다.

이 세 질문에 스스로 답해 본 뒤, 같은 일을 규모 있게 하도록 qa 서브에이전트에게 교차 검사를 맡긴다. 사람이 한 개를 손으로 확인하는 것은 '감을 잡기 위해서'이고, 전체를 훑는 것은 qa에게 위임한다 — 사람의 눈은 표본으로, AI의 검사는 전수로 쓰는 분업이다. qa가 빈 껍데기를 찾으면, 고치는 것은 qa가 아니라 /backend에게 시킨다(위 '프롬프트 작성법'에서 본 '찾는 사람과 고치는 사람 분리').

따라하기 4 — 일부러 망가뜨리기 (증명)

아래 '망가뜨리기' 지시를 실행한다. 함수가 틀린 동안 빨간불이 뜨는 것을 눈으로 확인하고, 되돌린 뒤 초록불로 돌아오는 것까지 본다.

프롬프트
잔여석 계산 함수를 일부러 틀리게 바꿔줘 — 빼기를 더하기로.
그 상태로 테스트를 돌려서, 관련 테스트가 실패(빨간불)하는지 보여줘.
확인했으면 원래대로 되돌리고 다시 통과시켜줘.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

이 장면을 기억하자 — 안전망은 '있다'가 아니라 '작동한다'를 봐야 안전망이다. 소방 훈련을 안 해 본 소화기처럼, 당겨 보지 않은 테스트는 믿을 수 없다. (디자인 리뷰어의 권한 시험, 보안 훅의 레드팀과 같은 정신이다 — 이 과정은 계속해서 '만들었다'가 아니라 '작동한다'를 요구한다.)

한 걸음 더 나아가, 이 실습을 '두 번' 하면 배움이 완성된다. 첫 번째는 진짜 테스트로 — 잔여석 함수의 빼기를 더하기로 바꾸고, 관련 테스트가 빨간불로 바뀌는 것을 본다. 두 번째는, 시간이 되면 일부러 만든 빈 껍데기 테스트로 — assert 없는 테스트만 하나 남기고 같은 함수를 망가뜨려 본다. 이번엔 초록불이 그대로다. 두 실험을 나란히 놓으면 "빨간불로 바뀌는 것이 진짜, 초록불로 남는 것이 빈 껍데기"라는 이 장의 결론이 몸에 새겨진다.

되돌리기를 잊지 말자. 망가뜨린 코드는 빨간불을 확인한 즉시 원래대로 되돌리고, 초록불로 돌아오는 것까지 봐야 한 번의 시험이 끝난다. 증명하겠다고 진짜 버그를 남겨 두면 본말이 뒤집힌다.

따라하기 5 — 경계 하나 더 추가

배운 눈으로 케이스를 하나 보탠다. 온티켓에서 놓치기 쉬운 경계 — "이미 판매수량이 총수량과 같은(완전 매진) 등급에 1장 주문". 이것이 실패로 거부되는지 테스트가 있는가? 없으면 추가를 지시한다. 테스트는 이렇게 경계를 하나씩 발견하며 자란다.

이 '완전 매진에 1장 더'는 온티켓이 실제로 조심해야 할 경계다. 골목 라이브가 겪은 초과 판매 사고가 바로 이 지점 — 남은 게 0인데 주문이 하나 더 들어오는 순간 — 에서 났다. 잔여가 1일 때는 잘 막다가도, 잔여가 0인 딱 그 순간에 >>=를 헷갈리면 마지막 한 장이 새어 나간다. 그래서 이 경계는 테스트로 반드시 못 박아 둘 값어치가 있다.

경계를 추가로 지시할 때의 프롬프트 예시다.

프롬프트
/backend 잔여석 테스트에 경계 케이스를 하나 추가해줘.
- 완전 매진(총100·판매100)인 등급에 1장 주문 → 반드시 거부(잔여 부족)
추가한 뒤 pnpm test 로 통과 확인하고, 이 케이스가 없을 때 어떤 사고가
날 수 있는지 한 줄로 설명해줘.

위 프롬프트를 그대로 복사해 입력하면 된다. 다른 내용으로 바꿔서 해 보고 싶다면 자유롭게 바꿔도 좋다.

여기서 얻는 더 큰 교훈은, 테스트는 한 번 짜고 끝나는 게 아니라 자란다는 것이다. 버그를 하나 만날 때마다, 새 경계를 하나 깨달을 때마다 테스트가 한 줄씩 는다. 그리고 한번 심어 둔 경계 테스트는 앞으로 영원히 그 자리를 지킨다 — 이 장 첫머리에서 말한 '검사를 자산으로 박아 둔다'가 이렇게 쌓인다. 이렇게 붙여 둔 테스트 묶음은 다음 장에서 커밋 때마다 자동으로 도는 안전망의 재료가 된다.

완료 기준 체크리스트

이 다섯 줄은 그냥 확인 목록이 아니라 이 장의 요약이기도 하다. 위에서부터 '빠른 안전망이 있다 → 정상·경계·실패를 다 본다 → 사람이 읽을 수 있다 → 빈 껍데기가 없다 → 진짜 감시함이 증명됐다'로 이어진다. 특히 마지막 줄이 가장 중요하다 — 앞의 네 줄이 다 맞아도, 망가뜨렸을 때 빨간불을 못 봤다면 그 안전망은 아직 '작동함'이 증명되지 않은 것이다.

[짚고 가기] 실습 시간이 빠듯하면 '사람이 테스트를 읽는다' 단계를 건너뛰고 싶어지지만, 여기가 이 장의 사람 몫이라 오히려 지켜야 한다. 시간이 없다면 '테스트 한 개만' 열어 세 질문(assert 있나·뭘 비교하나·지우면 뭘 놓치나)에 답하는 것으로 줄여도 좋다 — 시간을 아끼면서 핵심은 지킬 수 있다. 반대로 '일부러 망가뜨리기'는 절대 생략하지 않는 게 좋다 — 이 장의 결론이 거기서 나온다.


17.6 정리

오명운 · macro@prag-ai.com