13장. 검증팀① — 코드리뷰어와 QA

이 장이 끝나면: 코드를 등급으로 평가하되 고치지는 못하는 코드리뷰어와, PRD의 완료 기준을 실행 가능한 검사로 바꿔 직접 돌려 보는 QA — 두 검증 역할이 서브에이전트로 합류한다. 그리고 티켓 서비스의 고전적 난제, "마지막 1장을 두 명이 동시에 사면?" 을 우리 손으로 시험한다.


13.1 개념 — 검증을 코드의 세계로

같은 구조, 다른 대상

디자인에서 우리는 만드는 사람(디자이너)과 검토하는 사람(리뷰어)을 분리했고, 리뷰 왕복이 품질을 만드는 것을 경험했다. 코드도 정확히 같은 구조가 필요하다. 백엔드·프론트엔드 역할이 만든 코드를, 만들지 않은 눈이 본다.

왜 굳이 '만들지 않은 눈'이어야 하는가. 사람이든 AI든, 자기가 만든 것에는 맹점이 생기기 때문이다. 내가 어떤 의도로 코드를 짰는지 스스로 알고 있으면, 그 코드를 볼 때도 '의도대로 읽어 버린다.' "여기서는 잔여가 당연히 1 이상일 테니 검사 안 해도 되겠지" 하고 짠 사람은, 리뷰할 때도 같은 가정을 무의식적으로 반복한다. 그 가정이 틀렸다는 걸 발견하려면, 그 가정을 공유하지 않은 제3의 눈이 필요하다. 이것이 소프트웨어 공학이 수십 년간 코드 리뷰와 QA를 별도 역할로 둔 이유다 — 실력의 문제가 아니라 관점의 문제다.

코드 검증의 눈은 둘이다. 보는 것이 서로 다르다.

코드리뷰어 QA
보는 대상 코드 그 자체 동작하는 결과
묻는 질문 이 코드는 잘 쓰였는가 이 기능은 약속대로 도는가
잡는 것 경계 위반, 위험한 패턴, 명세 불일치, 중복 완료 기준 미달, 엣지 케이스에서의 오작동
산출물 등급 리포트 (Blocker/Major/Minor) 검사 시나리오와 판정표 (통과/실패)

이 둘은 겹치지 않고 서로를 메운다. 비유하면 이렇다. 새로 지은 건물을 검사할 때, 한 사람은 설계 도면과 실제 시공을 대조한다 — 철근이 도면대로 들어갔는지, 배선이 규정을 어기지 않았는지. 눈에 보이지 않는 벽 속까지 도면으로 따진다. 이것이 코드리뷰어다. 다른 사람은 실제로 수도꼭지를 틀고 스위치를 눌러 본다 — 물이 나오는지, 불이 켜지는지, 비상구 문이 진짜 열리는지. 도면이 아니라 동작을 본다. 이것이 QA다. 도면 검사만으로는 "수도꼭지가 실제로 새는지"를 알 수 없고, 동작 검사만으로는 "벽 속 배선이 규정 위반인지"를 알 수 없다. 그래서 두 눈이 다 필요하다.

한 가지 흔한 오해를 미리 풀자. "AI가 짠 코드인데 AI한테 또 검사를 시키면, 자기가 자기 걸 보는 것 아니냐"는 질문이 반드시 나온다. 답은 — 역할이 다르면 관점이 다르다는 것이다. 백엔드 역할에게는 "만들라"는 임무와 만드는 도구(Edit·Write)를 줬고, 코드리뷰어에게는 "지적하라"는 임무와 읽는 도구만 줬다. 같은 Claude 두뇌라도 어떤 임무·어떤 도구를 쥐여 주느냐에 따라 완전히 다르게 행동한다. 게다가 코드리뷰어는 백엔드가 '왜 그렇게 짰는지'라는 속사정을 공유하지 않은 채, 오직 눈앞의 코드와 명세 문서만 보고 판단한다. 그래서 '자기가 자기 걸 보는' 것이 아니라, 역할로 분리된 남의 눈이 되는 것이다.

코드리뷰어 — 등급만 매기고, 고치지 않는다

코드리뷰어의 규율은 디자인 리뷰어와 같다 — 지적만 한다, 등급과 근거를 붙인다, 고치지 않는다(도구가 없다). 실무의 코드 리뷰 문화도 같은 원리다. 회사에서 동료의 코드를 리뷰할 때 리뷰어가 남의 브랜치에 직접 손대지 않는다. 지적하고, 수정은 작성자가 한다. 책임의 소재가 흐려지지 않게 하기 위해서다.

'책임의 소재'가 왜 그렇게 중요한지 조금 더 풀어 보자. 리뷰어가 지적만 하고 작성자가 고치면, 그 코드는 여전히 작성자의 코드다. 나중에 문제가 생겨도 "누가 이걸 이렇게 짰지"의 답이 분명하고, 작성자는 자기 코드를 끝까지 이해하고 있으므로 다음 수정도 매끄럽다. 반대로 리뷰어가 지적하면서 직접 고쳐 버리면, 그 코드는 두 사람이 섞여 작성한 코드가 된다. 작성자는 자기가 안 짠 부분을 떠안게 되고, 리뷰어는 지적자이면서 동시에 작성자가 되어 버려 '검증'이라는 자기 본분을 스스로 무너뜨린다. 그래서 좋은 리뷰 문화일수록 이 선을 엄격히 지킨다. 우리는 이 규율을 사람의 자제력이 아니라 도구 구성으로 강제한다 — 코드리뷰어에게는 애초에 파일을 고치는 도구(Edit·Write)를 주지 않는다. 고치고 싶어도 고칠 수단이 없다. 규율을 성격이 아니라 구조로 박아 두는 것, 이것이 이 과정 전체를 관통하는 발상이다.

또 하나, 리뷰어가 매기는 '등급'이 왜 필요한지도 짚어 두자. 리뷰 결과가 "여기 이거 이상하고, 저기 저것도 좀 그렇고, 이 이름도 별로고…"처럼 중요도 구분 없이 나열되면, 사람은 무엇부터 손대야 할지 알 수 없다. 그래서 리뷰어는 발견한 문제마다 무게를 매긴다.

등급 온티켓 예
Blocker 이대로 나가면 안 되는 것. 반드시 고쳐야 함 결제 금액을 클라이언트가 보낸 값 그대로 신뢰함, 비밀키가 코드에 박혀 있음
Major 큰 문제지만 서비스가 당장 죽지는 않는 것. 곧 고쳐야 함 잔여 검사와 차감이 원자적이지 않음, 명세와 응답 코드가 어긋남
Minor 고치면 좋은 것. 미뤄도 되는 것 함수 이름이 모호함, 같은 로직이 두 군데 중복됨

이렇게 무게가 매겨져 있으면 사람은 Blocker → Major → Minor 순으로 결정할 수 있다. 등급이 없는 리뷰는 "다 중요하다"고 말하는 것과 같고, 그건 "아무것도 중요하지 않다"와 같다.

QA — 완료 기준이 실행되지 않으면 의견일 뿐이다

QA의 핵심 문장은 이것이다. "검사할 수 없는 기준은 기준이 아니라 희망이다."

PRD에 "매진되면 못 산다"라고 적혀 있다. 이것이 지켜지는지 어떻게 아는가? "코드를 봤는데 맞는 것 같다"는 의견이다. QA는 이것을 실행 가능한 검사로 바꾼다 — "잔여 0인 등급에 주문을 보낸다 → 409가 와야 한다 → 실제로 보내 봤더니 409가 왔다/안 왔다." 실행과 판정. 이것이 의견과 검증의 차이다.

이 원리를 조금 더 뜯어 보자. "매진되면 못 산다"는 문장은 사람이 읽기에는 완벽해 보인다. 하지만 이 문장 하나로는 아무것도 검사할 수 없다. '매진'이 정확히 어떤 상태인지(잔여가 0인지, 0 이하인지), '못 산다'가 화면에서 어떻게 나타나는지(주문 버튼이 사라지는지, 눌러도 거부되는지, 서버가 무슨 신호를 보내는지)가 하나도 정해져 있지 않기 때문이다. QA가 하는 일은 이 흐릿한 문장을 한 치의 해석 여지도 없는 절차로 조각내는 것이다.

흐릿한 기준(희망) QA가 바꾼 검사(사실)
매진되면 못 산다 잔여 0인 등급에 수량 1 주문을 POST → 응답 코드가 409(충돌)여야 하고, DB의 판매수량이 늘지 않아야 한다
결제가 잘 된다 정상 금액으로 결제 요청 → 200 + 주문 상태 'paid'. 금액을 1원으로 조작해 요청 → 거부(4xx)
없는 티켓은 못 산다 존재하지 않는 이벤트 ID로 주문 → 404여야 한다

오른쪽 칸은 전부 누가 실행해도 같은 답이 나오는 문장이다. 여기서 '409' 같은 숫자가 낯설 텐데, 이건 웹 서버가 응답에 붙이는 상태 코드(status code) 라는 것이다. 200번대는 성공, 400번대는 "요청한 쪽 잘못"(404=그런 것 없음, 409=지금 상태와 충돌해서 못 함), 500번대는 "서버 쪽 잘못"을 뜻하는 약속된 숫자다. QA가 "409가 와야 한다"고 적을 수 있는 이유는, 이 숫자가 사람마다 다르게 읽히지 않는 기계가 판정할 수 있는 사실이기 때문이다. "잘 막힌다"는 사람마다 다르지만, "409가 온다"는 오지 않으면 실패다. 이 차이가 곧 희망과 검증의 차이다.

이 사고법은 소프트웨어 밖에서도 강력하다. 행사를 준비한다면 — "동선이 원활할 것"은 희망이고, "리허설에서 200명 입장에 15분 미만"은 검사다. 매장을 연다면 — "청결 유지"는 희망이고, "오픈 체크리스트 12항목 통과"는 검사다. QA 역할을 만든다는 것은 이 전환을 팀의 습관으로 만드는 일이다.

이 실무 사례를 조금 더 넓혀 보면, QA의 사고법이 얼마나 보편적인지 보인다.

분야 흔한 희망 QA식 검사
공연·행사 "관객 입장이 매끄러울 것" 리허설에서 QR 인식 실패율 1% 미만, 200명 입장 15분 이내
매장 오픈 "매장을 깨끗하게 유지" 오픈 전 12개 항목 체크리스트 전부 서명, 항목별 담당자 명시
항공 정비 "이륙 전 이상 없음" 이륙 전 점검표(checklist) 한 줄씩 확인·복창
식당 위생 "재료를 신선하게" 냉장고 온도 5도 이하, 개봉 재료 라벨에 개봉일 기재, 매일 기록
온티켓 예매 "매진되면 못 산다" 잔여 0 등급에 주문 시 409 + 판매수량 불변

여기서 공통점을 발견할 수 있다. 왼쪽은 전부 좋은 마음이고, 오른쪽은 전부 틀리면 즉시 드러나는 절차다. 항공기 이륙 전 점검표가 유명한 이유가 여기 있다 — 아무리 숙련된 조종사라도 '느낌으로 괜찮다'에 의존하지 않고, 한 줄씩 소리 내어 확인한다. 왜냐하면 '괜찮은 것 같다'는 판단은 피곤하거나 서두를 때 가장 먼저 무너지는데, 체크리스트는 무너지지 않기 때문이다. QA를 팀에 둔다는 것은 우리 서비스에도 이 '이륙 전 점검표'를 만들어 두는 일이다.

[짚고 가기] 이 장의 개념을 한마디로 붙잡으면 "코드리뷰어는 도면을 보고, QA는 수도꼭지를 튼다" 이다. '리뷰'와 'QA'를 뭉뚱그려 "둘 다 검사하는 거 아냐?"라고 생각하기 쉬운데, 도면 vs 수도꼭지, 또는 "코드를 읽는 눈 vs 동작을 눌러 보는 손"으로 대비시켜 보면 즉시 갈린다. 그리고 "검사할 수 없는 기준은 희망이다"는 문장은 이 장 전체를 관통하는 후렴구이니, 실습하는 내내 되돌아오면 좋다. 개발이 처음이라면 '행사 리허설·매장 오픈 체크리스트' 사례가 특히 잘 와닿을 텐데, 이미 자기가 아는 세계이기 때문이다.


13.2 용어 정리

이 절의 용어들은 이번 장에서 처음 나오지만, 사실 이 과정의 실무에서 가장 중요한 개념들을 담고 있다. 특히 '경쟁 조건'은 실제 서비스에서 큰돈이 걸린 사고로 이어지는 유형이니, 완벽히 외우지는 못해도 "이런 게 있고, 왜 무서운지"는 감으로 남겨 두자.

엣지 케이스 (edge case)

정상 흐름의 가장자리에서 생기는 극단 상황. 버그의 서식지다. 온티켓 주문이라면 —

정상 케이스는 개발자도 확인한다. 엣지는 QA가 없으면 아무도 안 가 본다.

'엣지(edge)'는 말 그대로 가장자리·모서리라는 뜻이다. 왜 하필 가장자리에 버그가 몰리는가? 개발자는 코드를 짤 때 머릿속으로 '보통의 경우'를 그린다 — 고객이 티켓 2장을 산다, 재고는 넉넉하다, 이벤트는 당연히 존재한다. 이 '보통'을 기준으로 코드가 짜이기 때문에, 보통에서 벗어난 극단은 검사에서 자주 빠진다. "수량은 1 이상 4 이하"라는 규칙이 있으면, 개발자는 2나 3으로 테스트하고 넘어가기 쉽다. 하지만 사고는 0을 넣었을 때, 5를 넣었을 때, 마이너스를 넣었을 때, 소수점을 넣었을 때 터진다. 가장자리는 정의상 '드문 경우'라서 눈에 덜 띄지만, 세상의 실제 사용자는 반드시 그 드문 경우를 밟는다.

경계값을 고를 때 QA가 습관적으로 쓰는 요령이 있다. 경계 바로 앞, 경계, 경계 바로 뒤를 함께 본다. "수량 1~4"가 규칙이라면 0(앞)·1(경계)·4(경계)·5(뒤)를 전부 넣어 본다. 잔여 재고라면 2·1·0·-1을 본다. 정상값(2, 3) 하나만 통과한 것으로는 아무것도 보증하지 못한다는 것이 QA의 기본 감각이다.

경쟁 조건 (race condition)

둘 이상의 요청이 같은 자원을 동시에 건드릴 때 순서에 따라 결과가 달라지는 문제. 티켓 판매의 고전적 사고 유형이다.

잔여 1장. 고객 A와 B가 같은 순간 주문 버튼을 누른다. 서버가 순진하게 "잔여 확인(1장 있네) → 주문 저장"을 각각 처리하면 — A도 확인 시점엔 1장, B도 확인 시점엔 1장. 둘 다 성공하고, 판매는 2장, 잔여는 -1. 골목 라이브의 초과 판매가 정확히 이런 종류의 사고였을 가능성이 크다.

마지막 1장을 두 고객이 동시 주문할 때 잔여가 −1이 되는 경쟁 조건 타임라인

이름부터 풀어 보자. '경쟁 조건(race condition)'의 'race'는 경주다. 두 요청이 같은 자원(마지막 1장)을 향해 달리기를 하는데, 누가 먼저 결승선을 끊느냐에 따라 결과가 달라지는 상황을 가리킨다. 정상이라면 A가 사면 B는 못 사야 한다. 그런데 둘이 너무 가까이 붙어 달리면, 서버가 "A 확인 → B 확인 → A 저장 → B 저장" 순서로 처리해 버릴 수 있다. 이 순서에서는 A도 B도 '확인'하는 그 찰나엔 잔여가 아직 1장이다. 둘 다 "있네!" 하고 통과해 버린다.

왜 이게 무서운지 손으로 짚어 보면 이렇다. 시간을 아주 잘게 쪼개서 보자.

시각   고객 A의 요청           고객 B의 요청
----   -------------------    -------------------
t1     잔여 확인 → "1장 있음"
t2                            잔여 확인 → "1장 있음"   ← 아직 아무도 안 샀다
t3     주문 저장 (판매 1)
t4                            주문 저장 (판매 2)        ← 이미 팔린 걸 또 판다
결과   판매 2장, 잔여 -1  (있지도 않은 티켓을 하나 더 팔았다)

핵심은 t1의 확인과 t3의 저장 사이에 틈이 있다는 것이다. 그 틈에 B가 끼어 들어 같은 1장을 확인해 버린다. 사람이 창구에서 표를 팔 때는 이런 일이 안 생긴다 — 한 사람이 표를 집어 드는 동안 다른 사람이 같은 표를 동시에 집을 수 없으니까. 하지만 서버는 초당 수천 건의 요청을 거의 동시에 처리하기 때문에, 이 '집어 드는 찰나'가 실제로 겹칠 수 있다. 평소엔 멀쩡하다가, 인기 공연 티켓처럼 수요가 순간적으로 몰릴 때만 터진다. 그래서 테스트에서는 잘 안 잡히고, 하필 가장 중요한 순간(오픈런)에 사고가 난다.

해법의 방향만 알아 두자 — 확인과 차감을 한 번의 원자적 동작으로 묶거나 (쪼개지지 않는 동작), DB 제약(판매수량 ≤ 총수량)이 마지막에 막게 한다. 구현은 AI의 일이지만, "동시에 오면?"이라는 질문을 던지는 것은 사람과 QA의 일이다.

여기 나온 '원자적(atomic)'이라는 말을 풀어 두자. 원자(atom)는 그리스어로 '더 이상 쪼갤 수 없는 것'이라는 뜻이다. 원자적 동작이란 중간에 끼어들 틈 없이, 확인과 차감이 하나로 붙어 통째로 일어나는 동작을 말한다. 위 표에서 문제는 t1(확인)과 t3(저장)이 따로 떨어져 그 사이에 B가 끼어든 것이었다. 이 둘을 "잔여가 1 이상이면 그 즉시 하나 줄여라"라는 쪼갤 수 없는 한 덩어리로 만들면, A가 그 덩어리를 실행하는 동안 B는 절대 끼어들지 못한다. B는 A가 끝난 뒤에야 잔여를 보게 되고, 그때는 이미 0이라 거부된다. 창구 직원이 "표를 확인함과 동시에 손에서 뺀다"를 한 동작으로 하는 것과 같다.

또 하나의 방어선인 'DB 제약(constraint)'도 짚자. 이건 데이터베이스에 "판매수량은 총수량을 절대 넘을 수 없다"는 규칙을 아예 못 박아 두는 것이다. 설령 코드가 실수로 초과 주문을 저장하려 해도, DB가 마지막 문지기로서 "규칙 위반"이라며 거부한다. 우리는 DBA 역할과 함께 데이터베이스를 세울 때 이미 이 제약(CHECK)을 걸어 두었다. 코드의 원자적 처리가 1차 방어선이라면, DB 제약은 그 뒤를 받치는 2차 방어선이다. 좋은 설계는 보통 이 둘을 겹쳐 둔다 — 하나가 뚫려도 다른 하나가 막게.

이 대목이 온티켓 세계관과 직접 이어진다. 오대표의 지난 공연 '골목 라이브'의 초과 판매 사고 — 있지도 않은 좌석이 팔려서 현장에서 환불 대란이 났던 그 일 — 이 바로 이 경쟁 조건이었을 공산이 크다. 그래서 이번 온티켓에서는 출시 전에 우리 손으로 이 상황을 재현해 보고, 막히는지 확인하려는 것이다. 이 장 실습의 '마지막 1장의 동시 구매' 따라하기가 그 장면이다.

[짚고 가기] 경쟁 조건은 이 과정에서 "오, 이건 진짜 실무구나" 하고 눈이 커지는 대목이다. 위의 t1~t4 시간표를 종이에 그려 가며 손으로 짚어 보면 훨씬 잘 박힌다. 옆 사람과 고객 A·B가 되어 "동시에 마지막 1장을 향해 손을 뻗는" 상황을 30초만 흉내 내 봐도 개념이 몸에 남는다. 여기서 헷갈리기 쉬운데, 코드 구현부터 파고들 필요는 없다 — '원자적으로 묶는다', 'DB CHECK 제약' 같은 해법은 방향만 알아 두고, "이 질문을 던지는 것이 사람의 일" 이라는 데 방점이 있다. 구현 세부는 AI가 하고, 우리는 "동시에 오면?"을 묻는 사람이 된다.

회귀 (regression)

전에 되던 것이 새 변경 때문에 다시 깨지는 현상. "고쳤더니 딴 데가 터졌다." 검증을 일회성이 아니라 반복 가능하게 만들어야 하는 이유다 — 같은 검사를 변경 때마다 다시 돌려야 회귀를 잡는다. (반복 실행의 자동화는 테스트 코드의 영역이고, 이 장의 QA 검사 시나리오가 그대로 그 재료가 된다.)

'회귀(regression)'는 뒤로 돌아감·퇴행이라는 뜻이다. 앞으로 나아가려고 (새 기능·수정) 손을 댔는데, 멀쩡하던 옛 기능이 뒤로 미끄러져 다시 고장 나는 현상을 가리킨다. 소프트웨어에서 이 일이 흔한 이유는, 코드의 각 부분이 보이지 않는 실로 얽혀 있기 때문이다. 예매 화면의 잔여 표시 방식을 바꿨을 뿐인데, 같은 데이터를 쓰던 마이티켓 조회가 깨지는 식이다. 손댄 곳은 A인데 터진 곳은 B라서, 사람이 눈으로 잡아내기가 특히 어렵다.

회귀를 막는 방법은 원리적으로 하나뿐이다 — 전에 통과했던 검사를 변경할 때마다 처음부터 다시 돌려 보는 것. 한 번 통과했다고 그 검사를 버리지 않고, 회귀를 감시하는 파수꾼으로 계속 세워 둔다. 그래서 QA가 만든 검사 시나리오를 문서로 저장해 자산으로 남기는 일(이 장 실습의 마지막 따라하기)이 중요하다. 일회성으로 한 번 통과시키고 버리면, 다음 변경에서 그게 깨져도 아무도 모른다.

단위 테스트와 통합 테스트 — 맛보기

QA가 만드는 '검사'는 자연스럽게 테스트(test) 라는 개념으로 이어진다. 지금 당장 테스트 코드를 짜지는 않지만, 앞으로 자주 나올 두 단어의 감만 잡아 두자. 검사의 '크기'가 다르다.

단위 테스트 (unit test) 통합 테스트 (integration test)
무엇을 본다 작은 조각 하나가 홀로 맞게 도는가 여러 조각을 이어 붙였을 때 전체 흐름이 맞게 도는가
온티켓 예 "잔여를 계산하는 함수에 (총 10, 판매 7)을 넣으면 3이 나온다" "주문 요청을 보내면 → 결제가 처리되고 → 재고가 줄고 → QR이 발급된다"를 통째로
비유 자동차의 브레이크 하나만 떼어 눌러 본다 차를 직접 몰고 나가 실제로 서는지 본다
성격 빠르고, 실패하면 어디가 문제인지 콕 집힌다 느리지만, 사용자가 겪는 진짜 흐름에 가깝다

'단위(unit)'는 더 나눌 수 없는 최소 단위, 보통 함수 하나를 가리킨다. 그 함수만 똑 떼어, 넣는 값과 나와야 할 값을 정해 두고 확인한다. 부품 하나의 품질 검사다. '통합(integration)'은 그 부품들을 한데 모아 붙였을 때(integrate) 전체가 맞물려 도는지를 본다. 브레이크·엔진·핸들이 각각 멀쩡해도, 차로 조립하면 안 서는 수가 있기 때문이다.

이 장에서 QA가 curl로 "주문을 보내 409가 오는지" 확인하는 것은 성격상 통합 테스트에 가깝다 — 서버·DB·응답이 이어진 실제 흐름을 밖에서 눌러 보기 때문이다. 지금은 이 검사를 사람이 지시해 한 번씩 돌리지만, 이 시나리오를 그대로 코드로 옮기면 '자동으로 매번 도는 테스트'가 된다. 그래서 오늘 QA가 만든 판정표가 나중에 자동화 테스트의 설계도가 된다고 말하는 것이다.

QA에게 Bash는 주고 Edit는 안 주는 이유

이 장에서 도구 구성의 한 대목이 특히 헷갈린다 — "QA한테 왜 Bash(명령 실행)는 주면서 Edit(파일 수정)는 안 주지?" 이 질문의 답 안에 QA라는 역할의 본질이 들어 있다.

정리하면 QA의 도구 조합은 "실행은 하되, 고치지는 못한다" 를 물리적으로 강제한다. Bash는 실행을 위해 주고, Edit·Write는 수정을 막기 위해 뺀다. 앞 장들에서 반복된 "도구 조합이 곧 역할"이라는 원칙이, QA에서 가장 선명하게 드러나는 지점이다. 코드리뷰어와 QA를 나란히 놓으면 이렇다.

역할 읽기(Read/Grep/Glob) 실행(Bash) 수정(Edit/Write) 그래서 할 수 있는 일
코드리뷰어 있음 없음 없음 코드를 읽고 등급을 매긴다. 실행도 수정도 못 한다
QA 있음 있음 없음 검사를 실행해 판정한다. 수정은 못 한다
백엔드(만드는 역할) 있음 있음 있음 만들고 고친다

[짚고 가기] "그럼 QA가 실패를 찾으면 누가 고치냐"는 의문이 여기서 자연스럽게 든다. 답은 찾은 사람(QA)이 아니라, 만든 사람(백엔드)이 고친다 이다. 이게 뒤에서 볼 '결함 처리 동선'으로 이어진다. 위 도구 표에서 "수정(Edit) 칸이 채워진 역할은 백엔드뿐"이라는 것을 짚어 보면, 왜 이렇게 나눴는지가 한눈에 들어온다. 검증자에게 수정 권한을 주지 않는 건 불신이 아니라 역할 분리의 규율이라는 점을 기억해 두면 좋다.


13.3 두 역할의 정의

코드리뷰어 서브에이전트

---
name: code-reviewer
description: 코드 변경을 검토할 때 사용한다. 코드 리뷰, 커밋 전 점검
  요청이 오면 이 에이전트에 위임한다.
tools: Read, Grep, Glob
---

너는 (주)승승장구 온티켓의 코드 리뷰어다. 산출물은 수정이 아니라
등급이 매겨진 지적이다.

## 판단 기준 (검사 순서대로)
1. 경계 위반 — web/에 판정 로직이 있는가, api/ 밖에서 DB를 만지는가,
   백엔드가 DBA의 스키마를 임의로 고쳤는가 (docs/ERD.md 와 대조)
2. 명세 불일치 — docs/API-Spec.md 와 코드가 어긋나는가
3. 위험 패턴 — 비밀값 하드코딩, 입력 미검증, 오류 무시(빈 catch)
4. 그다음에야 중복·이름·구조

## 리포트 형식
| 등급(Blocker/Major/Minor) | 파일:위치 | 문제 | 근거 |
종합 판정: 통과 / 조건부 통과 / 반려

## 금지
- 코드를 고치지 않는다. 수정 코드 조각을 제안하지도 않는다
- 취향 지적("나라면 이렇게")을 하지 않는다 — 근거 있는 것만

이 정의문을 한 줄씩 뜯어 보면, 앞 장들에서 다진 서브에이전트 작성 원칙이 그대로 관철된다.

검사 순서에 주목하자. 이름이 예쁜지보다 경계와 명세가 먼저다. 리뷰어가 사소한 것부터 잡기 시작하면 중요한 것이 묻힌다 — 사람 리뷰 문화에서도 동일한 함정이 있다.

이 함정은 실무에서 이름이 붙어 있을 정도로 흔하다. 리뷰어가 코드를 열면 가장 먼저 눈에 띄는 건 사소한 것이다 — 들여쓰기가 안 맞는다, 변수 이름이 짧다, 빈 줄이 하나 더 있다. 이런 건 발견하기 쉬우니 지적도 쉽다. 그래서 리뷰가 이런 사소한 지적으로 가득 차 버리면, 정작 "결제 금액을 클라이언트가 보낸 값 그대로 믿고 있다" 같은 진짜 Blocker가 그 잡음에 묻혀 버린다. 그래서 좋은 리뷰어는 의식적으로 순서를 뒤집는다 — 경계·명세·위험을 먼저 다 훑고, 남는 시간에만 이름·스타일을 본다. 우리의 역할 정의문이 판단 기준을 굳이 번호로 매겨 둔 이유가 이것이다.

QA 서브에이전트

---
name: qa
description: 기능이 완료 기준대로 동작하는지 검사할 때 사용한다.
  QA, 검수, 완료 기준 검증 요청이 오면 이 에이전트에 위임한다.
tools: Read, Grep, Glob, Bash
---

너는 (주)승승장구 온티켓의 QA 엔지니어다. 너의 판정은 의견이 아니라
실행 결과다.

## 프로세스
1. docs/PRD.md 의 완료 기준에서 검사 시나리오를 만든다
   (정상 / 경계 / 실패 케이스를 반드시 포함)
2. 각 시나리오를 실제로 실행한다 (curl 등) — 실행하지 않은 판정 금지
3. 판정표로 보고한다: 시나리오 / 기대 / 실제 / 통과 여부

## 금지
- 코드를 수정하지 않는다 (파일 편집 도구가 없다)
- "아마 될 것이다"류의 실행 없는 판정 금지
- 실패를 발견해도 원인 추정까지만 — 수정 방향 결정은 사람의 몫

QA에게는 Bash가 있다 — 검사를 실행해야 하기 때문이다. 그러나 편집 도구 (Edit·Write)는 없다. 실행은 하되 고치지는 못한다. 도구 조합이 곧 역할이라는 원칙이 여기서도 관철된다.

이 정의문에서 특히 두 대목이 QA를 QA로 만든다.

'curl'이라는 도구가 처음 나오니 짚어 두자. curl은 터미널에서 서버에 요청을 보내는 명령 도구다. 브라우저로 사이트에 접속하는 것과 본질은 같지만, 화면 없이 글자로 요청을 보내고 글자로 응답을 받는다. 그래서 "잔여 0인 등급에 주문을 보낸다"를 사람이 브라우저로 일일이 클릭하는 대신, curl 한 줄로 정확히 재현하고 응답 코드(200/409/404 등)를 그대로 받아 볼 수 있다. QA에게 Bash를 준 것은 바로 이 curl 같은 명령을 실행하기 위해서다.

마지막 금지 조항 "실패를 발견해도 원인 추정까지만 — 수정 방향 결정은 사람의 몫" 도 중요하다. QA가 "409가 안 오고 200이 왔습니다. 아마 잔여 확인과 차감이 따로 처리되어 그런 것으로 보입니다"까지 말하는 건 좋다. 하지만 "그러니 원자적 처리로 바꾸겠습니다"까지 가면 선을 넘는다. 무엇을 고칠지, 지금 고칠지 미룰지 정하는 것은 사람의 결정이다. 이게 바로 뒤 '프롬프트 작성법'에서 볼 결함 처리 동선으로 이어진다.

[짚고 가기] 두 역할을 만들 때 가장 자주 틀리는 곳이 tools 줄이다. code-reviewer에 실수로 Bash나 Edit가 붙거나, qa에서 Bash가 빠지는 일이 흔하다. "만들었으면 tools 줄부터 확인" 을 습관으로 삼으면 좋다. code-reviewer는 정확히 세 개(Read, Grep, Glob), qa는 네 개(+ Bash)다. 이 도구 구성이 어긋나면 뒤 실습 전체가 어긋난다 — 리뷰어가 코드를 고쳐 버리거나, QA가 실행을 못 해 "될 것 같다"만 반복하게 된다.


13.4 프롬프트 작성법 — 검증을 시키는 요령

검증을 시키는 프롬프트에는 공통된 요령이 하나 있다 — "무엇을, 어디서, 무엇을 기준으로" 검사할지를 좁혀 준다. 검증은 넓게 시키면 초점을 잃고, 좁게 시키면 날카로워진다. 아래 사례들을 '약한 지시 → 강한 지시'의 대비로 먼저 보자.

약한 지시 강한 지시 무엇을 바꿨나
코드 다 봐줘 api/의 주문 생성과 입장 확인 코드를 리뷰해줘. 특히 경계 위반과 명세 불일치를 우선으로 대상 범위 + 우선 관점을 지정
QA 좀 해줘 docs/PRD.md의 예매 완료 기준으로 검사 시나리오를 만들어 실행해줘. 정상·경계·실패를 반드시 포함하고 판정표로 보고 기준 문서 + 케이스 종류 + 산출물 형식을 지정
동시에 사면 되는지 봐줘 잔여 1장인 등급을 찾아 같은 순간 주문 2건을 동시 전송. 기대: 정확히 한 건 성공·잔여 0. DB 최종 잔여·판매수량까지 확인 기대 결과 + 확인 대상을 못 박음

핵심은 오른쪽 칸들이 전부 결과를 판정할 수 있게 쓰였다는 점이다. "봐줘"는 무엇을 보면 끝인지 알 수 없지만, "409가 와야 한다", "한 건만 성공해야 한다"는 오면 통과, 안 오면 실패로 즉시 갈린다. 검증 지시 자체가 이미 QA의 사고법 ("검사할 수 있게 쓴다")을 닮아야 한다.

리뷰 요청 — 범위를 지정한다

"코드 다 봐줘"는 초점 없는 리뷰를 부른다. 대상을 지정한다.

프롬프트
code-reviewer 서브에이전트에게: api/ 의 주문 생성과 입장 확인 코드를
리뷰시켜줘. 특히 경계 위반과 명세 불일치를 우선으로.

범위를 좁히는 이유는 두 가지다. 첫째, 리뷰어가 프로젝트 전체를 훑으면 지적이 수십 개로 퍼져 정작 지금 커밋하려는 코드의 문제가 묻힌다. 둘째, "특히 경계 위반과 명세 불일치를 우선으로"처럼 관점을 지정하면, 리뷰어가 이번에 무엇을 가장 신경 써야 하는지 안다. 결제를 갓 만졌다면 "위험 패턴(금액 검증·비밀값) 우선", 명세를 새로 반영했다면 "명세 불일치 우선"으로 관점을 바꿔 줄 수 있다.

리뷰 범위를 좁히는 표현들을 몇 가지 더 익혀 두자.

프롬프트
방금 /backend가 수정한 주문 생성 코드만 다시 리뷰시켜줘.
지난번 지적한 Blocker 두 개가 해소됐는지 우선 확인.
프롬프트
api/의 결제 처리 코드를 리뷰시켜줘. 위험 패턴(금액을 클라이언트 값 그대로
신뢰, 비밀키 하드코딩, 빈 catch)을 최우선으로.

QA 검사 요청 — 기준 문서를 지목한다

프롬프트
qa 서브에이전트에게: docs/PRD.md 의 예매 관련 완료 기준으로 검사
시나리오를 만들어 실행시켜줘. 정상·경계(마지막 1장)·실패(초과, 금액 조작,
없는 ID)를 반드시 포함하고, 판정표로 보고받아줘.
api 서버는 localhost:8787 에 떠 있다.

이 지시가 좋은 이유를 뜯어 보자. 기준 문서를 지목(docs/PRD.md)했고, 케이스 종류를 못 박았고(정상·경계·실패), 경계와 실패의 구체 예시까지 줬으며(마지막 1장 / 초과·금액 조작·없는 ID), 산출물 형식(판정표)과 실행 환경(localhost:8787)까지 알려 줬다. QA가 헤맬 여지가 거의 없다.

특히 "정상·경계·실패를 반드시 포함"이 중요하다. 이 한 줄을 빼면 QA가 정상 케이스만 확인하고 통과시켜 버리기 쉽다 — 정상 흐름은 개발자도 이미 확인한, 가장 안 터지는 곳인데 말이다. 오늘의 수확은 대부분 경계와 실패 케이스에서 나온다. 그래서 이 세 종류를 명시적으로 요구한다. 참고로 여기서 'localhost'는 '내 컴퓨터 자신'을 가리키는 주소이고, '8787'은 그중에서도 api 서버가 귀를 열어 둔 문(포트) 번호다. 즉 "지금 내 PC에서 도는 api 서버로 요청을 보내라"는 뜻이다.

동시 구매 시험 — 질문을 검사로 바꾼다

프롬프트
qa 서브에이전트에게: 경쟁 조건 검사를 시켜줘.
잔여가 1장인 티켓 등급을 찾아서(없으면 나에게 보고), 같은 순간에 주문 요청
두 개를 동시에 보내라. 기대: 정확히 한 건만 성공하고 잔여는 0이 된다.
실제 결과와 DB의 최종 잔여·판매 수량까지 확인해 보고하라.

이 프롬프트가 이 장의 백미다. "동시에 오면?"이라는 막연한 걱정을, QA가 실제로 돌릴 수 있는 검사 절차로 바꿔 놓았다. 한 줄씩 무슨 역할인지 보자.

같은 검사를 조금 더 세게 몰아붙이는 변형도 익혀 두면 좋다.

프롬프트
qa 서브에이전트에게: 동시 주문을 2건이 아니라 10건으로 늘려서 같은 순간에
쏴라. 잔여 1장에 10명이 동시에 달려든 상황이다. 기대: 성공은 정확히 1건,
나머지 9건은 거부(409). DB 최종 판매수량이 정확히 1인지 확인해 보고.

동시 요청 수를 늘릴수록 경쟁 조건이 더 잘 드러난다 — 두 건으로는 우연히 안 겹칠 수도 있지만, 열 건이 몰리면 틈이 벌어질 확률이 커진다.

발견된 결함의 처리 동선

검증 역할이 결함을 찾으면 동선은 언제나 같다.

발견(리뷰어·QA) → 보고(등급·판정표) → 결정(사람: 고칠 것·미룰 것)
→ 수정(만든 역할: /backend, /frontend) → 재검(같은 검사를 다시)

검증자가 직접 고치는 순간 이 동선이 무너진다는 것 — 이제는 익숙할 것이다.

이 다섯 단계는 순서가 고정이고, 각 단계의 주체가 다르다는 점이 핵심이다. 표로 정리하면 누가 무엇을 하는지 선명해진다.

단계 주체 하는 일 하지 않는 일
발견 리뷰어 / QA 문제를 찾는다 고치지 않는다
보고 리뷰어 / QA 등급·판정표로 남긴다 수정 방향을 정하지 않는다
결정 사람 무엇을 고치고 무엇을 미룰지 정한다 직접 코드를 짜지 않는다
수정 백엔드 / 프론트엔드 실제로 고친다 스스로 통과 판정을 내리지 않는다
재검 리뷰어 / QA 같은 검사를 다시 돌린다 새 문제만 보고, 봐주지 않는다

이 동선이 왜 이렇게까지 엄격해야 하는지, 실무의 비유로 한 번 더 짚자. 병원에서 진단하는 의사와 수술하는 의사, 그리고 수술 후 경과를 확인하는 검사는 역할이 나뉘어 있다. 진단한 사람이 곧바로 수술까지 하고 자기 손으로 "성공"이라 판정하면, 실수를 걸러 낼 눈이 사라진다. 우리 동선도 같은 이유로 발견·수정·재검의 손을 분리한다. 특히 마지막 '재검'을 빼먹지 않는 것이 중요하다 — 고쳤다고 끝이 아니라, 처음 실패했던 그 검사를 다시 돌려 진짜로 통과하는지 확인해야 비로소 닫힌다. 이게 회귀를 잡는 방식이기도 하다.

[짚고 가기] 실습을 하다 보면 무심코 QA/리뷰어에게 "그럼 네가 고쳐놔"라고 시키게 되기 쉽다. 이때가 동선을 몸에 새길 좋은 순간이다. "고쳐놔"를 실제로 시켜 보면 — 도구가 없어 못 고치는 것을 눈으로 확인하게 된다 — "그래서 수정은 만든 역할(/backend)에게 시키는 것"이 자연스럽게 이해된다. 규율이 말이 아니라 도구로 강제되고 있다는 것을 직접 체험하는 장면이다.


13.5 실습 — 코드에 검증의 눈을 붙인다

따라하기 1 — 코드리뷰어 생성과 첫 리뷰

먼저 위 '두 역할의 정의'에서 본 정의대로 code-reviewer를 만들게 한다.

프롬프트
.claude/agents/code-reviewer.md 로 코드 리뷰어 서브에이전트를 만들어줘.
- description: 코드 변경을 검토할 때 사용. 코드 리뷰, 커밋 전 점검 요청이
  오면 이 에이전트에 위임
- tools 는 Read, Grep, Glob 만. Edit·Write·Bash 는 절대 넣지 마
- 역할: (주)승승장구 온티켓의 코드 리뷰어. 산출물은 수정이 아니라 등급이
  매겨진 지적
- 판단 기준(이 순서대로 검사):
  1. 경계 위반 — web/에 판정 로직이 있는가, api/ 밖에서 DB를 만지는가,
     백엔드가 DBA의 스키마를 임의로 고쳤는가(docs/ERD.md 와 대조)
  2. 명세 불일치 — docs/API-Spec.md 와 코드가 어긋나는가
  3. 위험 패턴 — 비밀값 하드코딩, 입력 미검증, 오류 무시(빈 catch)
  4. 그다음에야 중복·이름·구조
- 리포트 형식: 등급(Blocker/Major/Minor)·파일:위치·문제·근거 표 +
  종합 판정(통과/조건부 통과/반려)
- 금지: 코드 수정 금지, 수정 코드 조각 제안도 금지, 근거 없는 취향 지적 금지

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

생성된 .claude/agents/code-reviewer.md직접 열어 tools: 줄이 정확히 Read, Grep, Glob 세 개인지 확인한다. 그다음 아래 프롬프트로 api/ 리뷰를 맡긴다.

프롬프트
code-reviewer 서브에이전트에게: api/ 의 주문 생성과 입장 확인 코드를
리뷰시켜줘. 특히 경계 위반과 명세 불일치를 우선으로.

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

리포트에서 볼 것 —

권한 시험도 짧게 한 번 — "네가 고쳐놔" → 못 고치는 것 확인.

이 '권한 시험'을 건너뛰지 말자. 겉으로는 사소해 보이지만, 이 한 번의 확인이 "규율이 성격이 아니라 도구로 강제된다"는 이 과정의 핵심 발상을 몸에 새긴다. 리뷰어에게 "방금 지적한 거 네가 직접 고쳐"라고 시키면, 리뷰어는 고칠 도구 (Edit·Write)가 없어서 고치지 못하고, 대신 "저는 수정 도구가 없어 고칠 수 없습니다"라고 답한다. 이게 정상이다. 만약 리뷰어가 어떻게든 코드를 고쳐 낸다면, 그건 tools 줄에 Edit나 Write가 잘못 들어간 것이니 역할 정의를 다시 확인해야 한다.

리포트 예시를 하나 그려 보면, 사람이 무엇을 받게 되는지 감이 온다.

| 등급     | 파일:위치              | 문제                          | 근거 |
|----------|------------------------|-------------------------------|------|
| Blocker  | api/orders.ts:42       | 결제 금액을 요청 body 값 그대로 | 클라이언트가 금액을 조작해 보낼 수 있음. 금액은 서버가 DB의 가격으로 재계산해야 함 |
| Major    | api/orders.ts:31       | 잔여 확인과 차감이 분리됨       | 두 요청이 동시에 오면 경쟁 조건. 원자적 처리 필요 |
| Minor    | api/orders.ts:58       | 함수명 doIt 이 모호           | 무슨 일을 하는지 이름만으로 안 드러남 |

종합 판정: 반려 (Blocker 1건, Major 1건)

사람은 이 표에서 등급 칸만 훑어 Blocker부터 처리할지 정한다. 이것이 등급을 매기게 한 보람이다. 표 대신 "여기저기 좀 이상해요" 같은 줄글이 왔다면, 무엇부터 손댈지 사람이 다시 읽어 내야 했을 것이다.

따라하기 2 — 리뷰 지적 처리

Blocker·Major를 사람이 골라 /backend에게 수정시키고, 같은 범위를 다시 리뷰시킨다. 두 번째 리포트에서 해당 지적이 사라졌는지 확인한다.

여기서 '사람이 골라'가 핵심이다. 리뷰어가 지적을 10개 냈다고 10개를 다 지금 고칠 필요는 없다. Blocker는 지금, Major는 곧, Minor는 나중에 — 무엇을 언제 고칠지 정하는 것이 사람의 결정이다. 예컨대 위 예시라면 "Blocker(금액 재계산)와 Major(원자적 처리)는 지금 /backend에게 수정시키고, Minor(이름)는 다음에"처럼 가른다. 이 선별이 앞서 본 결함 처리 동선의 '결정' 단계다.

수정 지시 프롬프트는 이렇게 쓴다.

프롬프트
/backend: code-reviewer가 지적한 아래 두 건을 수정해줘.
- Blocker api/orders.ts:42 — 결제 금액을 서버가 DB 가격으로 재계산하도록
- Major api/orders.ts:31 — 잔여 확인과 차감을 원자적 한 동작으로

다 고치면 어디를 어떻게 바꿨는지 한 줄씩 알려줘.

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

수정이 끝나면 같은 범위를 다시 리뷰시킨다. "다시"가 중요하다. 새로 리뷰를 시키되 범위를 그대로 두어야, 지난 지적이 진짜로 사라졌는지 대조할 수 있다.

프롬프트
code-reviewer: 방금 /backend가 고친 api/orders.ts를 다시 리뷰해줘.
지난번 Blocker(금액)·Major(원자성)가 해소됐는지 우선 확인하고,
수정 과정에서 새로 생긴 문제가 있는지도 봐줘.

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

두 번째 리포트에서 지난 지적이 사라졌으면 그 왕복이 닫힌 것이다. "수정 과정에서 새로 생긴 문제"까지 보라고 한 것은, 고치다 다른 데를 깨뜨리는 회귀를 같은 리뷰에서 함께 잡기 위해서다.

따라하기 3 — QA 생성과 완료 기준 검사

먼저 위 '두 역할의 정의'에서 본 정의대로 qa 서브에이전트를 만들게 한다.

프롬프트
.claude/agents/qa.md 로 QA 서브에이전트를 만들어줘.
- description: 기능이 완료 기준대로 동작하는지 검사할 때 사용. QA, 검수,
  완료 기준 검증 요청이 오면 이 에이전트에 위임
- tools 는 Read, Grep, Glob, Bash 만. Edit·Write 는 절대 넣지 마
- 역할: (주)승승장구 온티켓의 QA 엔지니어. 판정은 의견이 아니라 실행 결과
- 프로세스:
  1. docs/PRD.md 의 완료 기준에서 검사 시나리오를 만든다
     (정상 / 경계 / 실패 케이스를 반드시 포함)
  2. 각 시나리오를 실제로 실행한다(curl 등) — 실행하지 않은 판정 금지
  3. 판정표로 보고한다: 시나리오 / 기대 / 실제 / 통과 여부
- 금지: 코드 수정 금지, "아마 될 것이다"류의 실행 없는 판정 금지,
  실패를 발견해도 원인 추정까지만(수정 방향 결정은 사람의 몫)

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

생성된 .claude/agents/qa.md 를 열어 tools: 줄에 Bash가 있고 Edit·Write가 없는지 확인한다. 그다음 아래 프롬프트로 완료 기준 검사를 실행한다.

프롬프트
qa 서브에이전트에게: docs/PRD.md 의 예매 관련 완료 기준으로 검사
시나리오를 만들어 실행시켜줘. 정상·경계(마지막 1장)·실패(초과, 금액 조작,
없는 ID)를 반드시 포함하고, 판정표로 보고받아줘.
api 서버는 localhost:8787 에 떠 있다.

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

판정표를 받으면 사람이 훑는다.

"실행 흔적"을 확인하는 법을 조금 더 구체적으로 짚자. QA가 진짜로 curl을 돌렸다면, 판정표에 실제로 보낸 요청과 받은 응답이 남는다 — 어떤 주소에 무슨 값을 보냈고, 응답 코드가 몇이었고, 응답 본문이 무엇이었는지. 반대로 QA가 슬쩍 코드만 읽고 "될 것 같다"로 통과시켰다면, 판정표의 '실제' 칸이 비어 있거나 "코드상 처리되고 있음" 같은 추측성 문장으로 채워진다. 이게 보이면 역할 위반이니, "실제로 실행해서 응답을 붙여 다시 보고하라"고 되돌린다.

QA 판정표는 대략 이런 모습이다.

| 시나리오              | 기대        | 실제               | 통과 |
|-----------------------|-------------|--------------------|------|
| 정상: 잔여 5, 2장 주문 | 200, 재고 3 | 200, 재고 3        | O    |
| 경계: 마지막 1장 주문  | 200, 재고 0 | 200, 재고 0        | O    |
| 실패: 잔여 0에 주문    | 409         | 409                | O    |
| 실패: 금액 1원 조작    | 4xx 거부    | 200 결제됨(!)      | X    |
| 실패: 없는 이벤트 ID   | 404         | 404                | O    |

여기서 '금액 1원 조작'이 X로 뜬 것이 오늘의 수확이다. 통과만 잔뜩 있는 판정표는 사실 조금 의심스럽다 — 경계·실패까지 전부 통과했다면, QA가 정말 가장자리를 밟아 봤는지 되짚어 볼 만하다. 실패 항목 하나가 나왔다는 건, QA가 제 역할을 했다는 뜻이다.

[짚고 가기] 판정표에 O가 가득하면 "오, 잘 됐네" 하고 안심하기 쉽다. 그런데 여기서 관점을 한 번 뒤집어 볼 만하다 — "실패가 하나도 없으면 오히려 검사가 허술한 것 아닌지 의심하라." 특히 정상 케이스만 통과하고 경계·실패가 아예 시나리오에 없다면, QA가 쉬운 길로 샌 것이다. "실패를 찾으러 가는 게 QA의 일"이라는 태도를 이 대목에서 짚어 두면 좋다.

따라하기 4 — 마지막 1장의 동시 구매

아래 프롬프트로 경쟁 조건 검사를 실행한다.

프롬프트
qa 서브에이전트에게: 경쟁 조건 검사를 시켜줘.
잔여가 1장인 티켓 등급을 찾아서(없으면 나에게 보고), 같은 순간에 주문 요청
두 개를 동시에 보내라. 기대: 정확히 한 건만 성공하고 잔여는 0이 된다.
실제 결과와 DB의 최종 잔여·판매 수량까지 확인해 보고하라.

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

결과는 둘 중 하나이고, 어느 쪽이든 배운다.

이 검사의 가치를 기억하자 — "동시에 오면?"이라는 한 줄 질문이, 실제 서비스에서 피해가 가장 큰 사고 유형 하나를 출시 전에 걸러냈다.

두 결과를 조금 더 풀어 두자. 어느 쪽이 나와도 손해가 아니라는 것이 이 실습의 묘미다.

"한 건만 성공"이 나왔다면, 방어가 이미 되고 있다는 뜻이지만 거기서 멈추면 안 된다. 무엇이 막았는지를 아는 게 중요하다. 왜냐하면 방어선이 어디에 있는지 모르면, 나중에 코드를 고치다 그 방어선을 실수로 걷어 내도 아무도 모르기 때문이다. QA에게 이렇게 물어 확인시킨다.

프롬프트
qa: 방금 동시 주문에서 한 건만 성공했는데, 무엇이 두 번째를 막았는지 확인해줘.
코드의 원자적 처리가 막은 건지, DB 제약(판매수량 ≤ 총수량)이 막은 건지,
근거와 함께 보고. (고치지는 말고 확인만)

"둘 다 성공"이 나왔다면 — 걱정할 일이 아니라 오늘 가장 값진 순간이다. 있지도 않은 티켓이 하나 더 팔렸고(판매 2, 잔여 -1), 이것이 바로 골목 라이브의 초과 판매 사고를 우리 손으로, 고객이 아니라 우리 앞에서 재현해 낸 것이다. 실제 서비스였다면 오픈런 때 터져 환불 대란과 신뢰 추락으로 이어졌을 사고를, 출시 전에 잡았다. 이제 동선을 탄다 — QA의 판정표를 근거로 삼아 /backend에게 수정을 맡긴다.

프롬프트
/backend: QA가 동시 주문 검사에서 초과 판매를 재현했다(첨부: 판정표).
잔여 확인과 차감을 원자적 한 동작으로 묶어 마지막 1장에 동시 요청이 와도
한 건만 성공하도록 수정해줘. 2차 방어선인 DB의 CHECK 제약(판매수량 ≤ 총수량)이
실제로 걸려 있는지도 확인해서 알려줘. 걸려 있지 않다면 스키마를 직접 고치지
말고 나에게 보고해 — 스키마는 /dba 의 몫이다. 어떻게 막았는지 설명도 붙여줘.

수정이 끝나면 반드시 같은 검사를 다시 돌린다. 이번엔 한 건만 성공하고 잔여가 0이 되어야 한다. 이 재검까지 마쳐야 왕복이 닫힌다. 고쳤다는 말만 믿고 넘어가지 않고, 처음 실패했던 그 검사로 직접 확인하는 것 — 이게 QA를 두는 이유 전부다.

[짚고 가기] 이 실습은 이 장의, 어쩌면 과정 전체의 하이라이트다. 가능하면 "둘 다 성공(초과 판매)"이 나오는 상태를 한 번 만들어 보고, 그 자리에서 고쳐 "한 건만 성공"으로 바뀌는 것까지 직접 확인하면 훨씬 오래 남는다. 만약 내 코드가 이미 막고 있어 처음부터 한 건만 성공한다면, 실망할 필요 없이 "무엇이 막았는지" 를 파헤치는 쪽으로 방향을 틀면 된다 — 방어선의 위치를 아는 것도 똑같이 값진 수확이다. 세계관을 떠올려 보면 의미가 더 커진다: "오대표의 골목 라이브에서 났던 그 사고를, 지금 우리가 고객이 아니라 우리 앞에서 재현하고 막았다"는 것, 이 한마디가 실습에 무게를 준다.

따라하기 5 — 검사 시나리오를 자산으로

프롬프트
qa가 만든 검사 시나리오와 판정표를 docs/qa-시나리오.md 로 저장해줘.
다음 변경 때 같은 검사를 다시 돌릴 수 있게 실행 명령까지 포함해서.

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

일회성 검사를 반복 가능한 자산으로 바꿔 두는 것이다. 회귀를 잡는 유일한 방법은 같은 검사를 다시 돌리는 것이고, 이 문서가 곧 자동화 테스트의 설계도가 된다.

여기서 "실행 명령까지 포함"이 결정적이다. 판정표만 남기면 "예전에 이런 걸 검사했었지"라는 기록에 그친다. 하지만 각 시나리오를 재현하는 curl 명령까지 함께 적어 두면, 다음에 코드를 고쳤을 때 그 명령들을 그대로 다시 돌려 회귀를 잡을 수 있다. 저장할 문서에는 최소한 이 세 가지가 들어가야 한다.

한 가지 주의를 덧붙이자. QA는 이 문서를 직접 저장할 수 없다 — Edit·Write 도구가 없기 때문이다. 그래서 이 저장 작업은 파일을 쓸 수 있는 역할(예: /backend)이나 사람이 맡는다. QA는 시나리오와 판정표라는 내용을 만들고, 그것을 파일로 남기는 은 따로인 것이다. 이 작은 불편함조차 "QA는 검증만 한다"는 규율의 결과라는 점을 알아 두면 좋다. 이렇게 남긴 docs/qa-시나리오.md는 앞으로 코드를 고칠 때마다 다시 꺼내 돌리는 온티켓의 이륙 전 점검표가 된다.

완료 기준 체크리스트


13.6 정리

오명운 · macro@prag-ai.com