/click-path-audit — 동작 흐름 감사
정적 코드 읽기가 놓치는 버그를 찾습니다: 상태 상호작용 부작용, 순차 호출 간 경합 조건, 그리고 서로를 조용히 취소하는 핸들러.
이 스킬이 해결하는 문제
전통적인 디버깅은 확인합니다:
- 함수가 존재하는가? (와이어링 누락)
- 충돌하는가? (런타임 오류)
- 올바른 타입을 반환하는가? (데이터 흐름)
하지만 확인하지 않습니다:
- 최종 UI 상태가 버튼 라벨이 약속하는 것과 일치하는가?
- 함수 B가 함수 A가 방금 한 일을 조용히 취소하는가?
- 공유 상태(Zustand/Redux/컨텍스트)에 의도된 동작을 취소하는 부작용이 있는가?
실제 예시: "새 이메일" 버튼이 setComposeMode(true)를 호출한 다음 selectThread(null)을 호출했습니다. 둘 다 개별적으로는 잘 작동했습니다. 하지만 selectThread에 composeMode: false를 리셋하는 부작용이 있었습니다. 버튼은 아무 일도 하지 않았습니다. 체계적 디버깅으로 54개의 버그를 찾았지만, 이 하나는 놓쳤습니다.
작동 방식
대상 영역의 모든 인터랙티브 터치포인트에 대해:
1. 핸들러(onClick, onSubmit, onChange 등)를 식별합니다.
2. 핸들러의 모든 함수 호출을 **순서대로** 추적합니다.
3. 각 함수 호출에 대해:
a. 어떤 상태를 **읽나요**?
b. 어떤 상태를 **쓰나요**?
c. 공유 상태에 **부작용**이 있나요?
d. 부작용으로 상태를 리셋/클리어하나요?
4. 확인: 나중 호출이 이전 호출의 상태 변경을 **취소하나요**?
5. 확인: **최종 상태**가 버튼 라벨에서 사용자가 기대하는 것인가요?
6. 확인: 경합 조건(잘못된 순서로 해결되는 비동기 호출)이 있나요?
실행 단계
1단계: 상태 저장소 매핑
터치포인트를 감사하기 전에, 모든 상태 저장소 액션의 부작용 맵을 구축합니다:
각 Zustand 저장소 / React 컨텍스트에 대해:
각 액션/세터에 대해:
- 어떤 필드를 설정하나요?
- 다른 필드를 **리셋**하나요? (부작용)
- 문서화: actionName → {sets: [...], resets: [...]}
이것이 중요한 참조입니다. "새 이메일" 버그는 selectThread가 composeMode를 리셋한다는 사실을 몰랐으면 보이지 않았을 것입니다.
출력 형식:
STORE: emailStore
setComposeMode(bool) → sets: {composeMode}
selectThread(thread|null) → sets: {selectedThread, selectedThreadId, messages, drafts, selectedDraft, summary} RESETS: {composeMode: false, composeData: null, redraftOpen: false}
setDraftGenerating(bool) → sets: {draftGenerating}
...
위험한 리셋 (자신이 소유하지 않은 상태를 지우는 액션):
selectThread → composeMode 리셋 (setComposeMode 소유)
reset → 모든 것 리셋
2단계: 각 터치포인트 감사
대상 영역의 각 버튼/토글/폼 제출에 대해:
TOUCHPOINT: [버튼 라벨] in [Component:line]
HANDLER: onClick → {
call 1: functionA() → sets {X: true}
call 2: functionB() → sets {Y: null} RESETS {X: false} ← 충돌
}
EXPECTED: 사용자가 [버튼 라벨이 약속하는 설명]을 봄
ACTUAL: functionB가 리셋했으므로 X는 false
VERDICT: BUG — [설명]
다음 각 버그 패턴을 확인합니다:
패턴 1: 순차적 취소
handler() {
setState_A(true) // X = true 설정
setState_B(null) // 부작용: X = false 리셋
}
// 결과: X는 false. 첫 번째 호출은 무의미했습니다.
패턴 2: 비동기 경합
handler() {
fetchA().then(() => setState({ loading: false }))
fetchB().then(() => setState({ loading: true }))
}
// 결과: 최종 loading 상태는 어느 것이 먼저 해결되느냐에 달려 있습니다.
패턴 3: 오래된 클로저
const [count, setCount] = useState(0)
const handler = useCallback(() => {
setCount(count + 1) // 오래된 count 캡처
setCount(count + 1) // 동일한 오래된 count — 2가 아닌 1만 증가
}, [count])
패턴 4: 상태 전환 누락
// 버튼 라벨이 "저장"이지만 핸들러는 유효성 검사만 할 뿐 실제 저장은 하지 않음
// 버튼 라벨이 "삭제"이지만 핸들러는 플래그만 설정하고 API는 호출하지 않음
// 버튼 라벨이 "보내기"이지만 API 엔드포인트가 제거/손상됨
패턴 5: 조건부 사망 경로
handler() {
if (someState) { // someState는 이 시점에서 항상 false
doTheActualThing() // 절대 도달하지 않음
}
}
패턴 6: useEffect 간섭
// 버튼이 stateX = true로 설정
// useEffect가 stateX를 감시하고 false로 리셋
// 사용자는 아무 일도 일어나지 않음을 봄
3단계: 보고
발견된 각 버그에 대해:
CLICK-PATH-NNN: [심각도: CRITICAL/HIGH/MEDIUM/LOW]
터치포인트: [버튼 라벨] in [file:line]
패턴: [순차적 취소 / 비동기 경합 / 오래된 클로저 / 전환 누락 / 사망 경로 / useEffect 간섭]
핸들러: [함수 이름 또는 인라인]
추적:
1. [호출] → sets {필드: 값}
2. [호출] → RESETS {필드: 값} ← 충돌
예상 결과: [사용자가 기대하는 것]
실제 결과: [실제로 일어나는 것]
수정: [구체적인 수정]
범위 제어
이 감사는 비용이 많이 듭니다. 적절히 범위를 지정하세요:
- 전체 앱 감사: 출시 시 또는 주요 리팩터링 후에 사용합니다. 페이지별로 병렬 에이전트를 실행합니다.
- 단일 페이지 감사: 새 페이지를 구축한 후 또는 사용자가 버튼 오작동을 보고한 후에 사용합니다.
- 저장소 중심 감사: Zustand 저장소를 수정한 후에 사용합니다 — 변경된 액션의 모든 소비자를 감사합니다.
전체 앱에 권장되는 에이전트 분할:
Agent 1: 모든 상태 저장소 매핑 (1단계) — 다른 모든 에이전트의 공유 컨텍스트
Agent 2: 대시보드 (작업, 메모, 일지, 아이디어)
Agent 3: 채팅 (DanteChatColumn, JustChatPage)
Agent 4: 이메일 (ThreadList, DraftArea, EmailsPage)
Agent 5: 프로젝트 (ProjectsPage, ProjectOverviewTab, NewProjectWizard)
Agent 6: CRM (모든 하위 탭)
Agent 7: 프로필, 설정, 금고, 알림
Agent 8: 관리 제품군 (모든 페이지)
Agent 1이 먼저 완료되어야 합니다. 그 출력은 다른 모든 에이전트의 입력입니다.
사용 시점
- 체계적 디버깅에서 "버그 없음"을 찾았지만 사용자가 UI 오작동을 보고하는 경우
- Zustand 저장소 액션을 수정한 후 (모든 호출자 확인)
- 공유 상태를 건드리는 모든 리팩터링 후
- 출시 전, 중요한 사용자 흐름에 대해
- 버튼이 "아무 일도 안 할 때" — 이를 위한 도구입니다.
사용하지 말아야 할 시점
- API 수준 버그(잘못된 응답 형태, 누락된 엔드포인트) — 체계적 디버깅 사용
- 스타일링/레이아웃 문제 — 시각적 검사
- 성능 문제 — 프로파일링 도구
다른 스킬과의 통합
/superpowers:systematic-debugging후에 실행 (다른 54가지 버그 유형을 찾음)/superpowers:verification-before-completion전에 실행 (수정이 작동하는지 확인)/superpowers:test-driven-development에 입력 제공 — 여기서 발견된 모든 버그는 테스트를 받아야 함
예시: 이 스킬의 영감이 된 버그
ThreadList.tsx "새 이메일" 버튼:
onClick={() => {
useEmailStore.getState().setComposeMode(true) // ✓ composeMode = true 설정
useEmailStore.getState().selectThread(null) // ✗ composeMode = false 리셋
}}
저장소 정의:
selectThread: (thread) => set({
selectedThread: thread,
selectedThreadId: thread?.id ?? null,
messages: [],
drafts: [],
selectedDraft: null,
summary: null,
composeMode: false, // ← 이 조용한 리셋이 버튼을 망쳤습니다
composeData: null,
redraftOpen: false,
})
체계적 디버깅이 놓친 이유:
- 버튼에 onClick 핸들러가 있음 (데드 아님)
- 두 함수 모두 존재함 (와이어링 누락 없음)
- 함수 중 어느 것도 충돌하지 않음 (런타임 오류 없음)
- 데이터 타입이 올바름 (타입 불일치 없음)
클릭 경로 감사가 포착하는 이유:
- 1단계:
selectThread가composeMode를 리셋함을 매핑 - 2단계: 핸들러 추적 — 첫 번째 호출이 true 설정, 두 번째 호출이 false 리셋
- 판정: 순차적 취소 — 최종 상태가 버튼 의도와 모순됨