티켓은 통과했지만 사용자는 떠났다
지난 분기에 배포된 세 개의 기능이 있었다. 자동화 테스트를 통과했고, 시니어 엔지니어가 머지했으며, a11y 체크도 클리어했다. 그런데 6주 후, 서비스 활성화율이 6%p 빠져 있었다. 범인은 전통적인 의미의 버그가 아니었다. dev.to에 공개된 한 프로덕션 UX 부검 사례는 이 불편한 진실을 정면으로 드러낸다. AI가 생성한 코드는 기술적으로 올바르지만, 경험적으로는 망가져 있는 경우가 많다. 모델은 티켓을 해결한다. 그런데 티켓에는 사용자의 감정이 적혀 있지 않다.
세 개의 기능, 세 가지 방식으로 신뢰를 잃다
첫 번째 사례: 마찰 없는 확인 모달. "설정에 계정 삭제 버튼과 확인 모달을 추가하라"는 티켓이었다. AI는 완벽한 다이얼로그 컴포넌트를 만들었다. Cancel 버튼과 Delete 버튼, 두 개. 3주 만에 14명이 계정을 실수로 삭제했다. 문제는 GitHub이나 Stripe가 당연히 넣는 '사용자명 직접 입력' 마찰 단계가 없었다는 것이다. 모델에게 "확인 모달"은 정확히 두 개의 버튼이다. 그 이상의 감정적 맥락, 즉 "이 액션은 되돌릴 수 없고 사용자는 패닉 상태에서 실수를 저지를 수 있다"는 정보는 프롬프트에 없었다.
두 번째 사례: 신뢰를 갉아먹는 로딩 스피너. 결제 처리 중 스피너를 추가하라는 요청에 AI는 {isProcessing ? <Spinner /> : <Button>Pay $49</Button>} 을 뽑아냈다. 교과서적인 정답이다. 그런데 사용자는 버튼이 사라지고 아무 맥락 없이 원이 돌아가는 1.2초를 견뎌야 했다. A/B 테스트 결과, "Contacting your bank..." → "Confirming payment..." → "Almost done..." 순서로 단계별 텍스트를 붙였을 때 결제 완료율이 4.1% 올랐다. 이 레이블들은 백엔드의 실제 상태와 무관하다. 단지 페이싱된 텍스트다. 모델은 스피너가 "로딩 상태의 정석 해법"이라는 것은 알지만, 스피너가 사용자에게는 "UX 절벽"이 될 수 있다는 것은 모른다.
세 번째 사례: 사용자를 탓하는 에러 메시지. AI가 생성하는 에러 핸들링의 전형적인 패턴은 toast.error("Invalid input. Please check your details and try again.") 이다. 모든 단어가 실패의 책임을 사용자에게 밀어낸다. 실제 서버 로그를 보면 문제는 300ms 안에 버튼을 두 번 클릭해서 발생한 idempotency key 충돌이었다. 카드에는 아무 문제가 없었고, 첫 번째 클릭으로 주문은 이미 완료됐다. 사용자는 "두 번 확인했는데 내 카드 문제가 아니다"며 탭을 닫았다. 에러 메시지 하나를 "Something on our end didn't work. Your card wasn't charged."로 바꾸자 환불 요청이 절반으로 줄었다.
AI가 놓치는 레이어: 감정 맥락과 책임 소재
세 사례를 관통하는 패턴은 하나다. 모델은 '충분한' 코드를 쓰지, '인간적인' 코드를 쓰지 않는다. 스펙은 좋은 제품이 어떤 느낌인지를 손실 압축한 것이다. 모델은 그 압축에서 복원되지 않는 부분—사용자의 감정 상태, 실수 가능성, 실패 시 심리적 귀인—을 채우지 못한다.
해당 팀은 프롬프트에 세 가지 절을 추가했다. 감정적 위험 조항: "이 플로우는 삭제 액션입니다. 실수 클릭으로 실제 데이터가 사라질 수 있습니다. 그에 걸맞은 마찰을 설계하세요." 지연 정직 조항: "400ms 이상의 비동기 호출에는 단순 스피너 대신 3단계 레이블 진행 상태를 보여주세요." 책임 회피 조항: "에러 메시지에 'Invalid', 'Wrong', 'You'를 쓰지 마세요. 무엇이 일어나지 않았는지(청구 없음, 저장 없음)와 다음 단계를 명시하세요." 이 세 줄만으로 리뷰 후에 발견하던 UX 리그레션의 약 70%를 사전에 잡았다.
신뢰는 '전송 중' 상태로 증명되지 않는다
dev.to의 또 다른 아티클은 이 문제를 폼 제출 신뢰성이라는 각도에서 보완한다. 버튼을 비활성화하는 것은 "개선"이지 "전달 증명"이 아니다. 사용자가 긴 버그 리포트를 작성하고 Send를 눌렀을 때, 버튼이 흐려지는 것만으로는 아무것도 확인되지 않는다. 서버가 받았는가? 새로고침하면 어떻게 되나? 다시 누르면 티켓이 두 개 생기나?
React 19의 useActionState와 PostgreSQL을 조합한 패턴은 이 질문들에 프로토콜 수준으로 답한다. 핵심은 idle → transport-error → received로 이어지는 명시적 상태 분기다. transport-error는 단순한 "실패"가 아니라 "전달 여부를 확인할 수 없으니 같은 요청으로 재시도 가능하다"는 신호다. idempotency key를 sessionStorage에 보존해두면 브라우저가 새로고침되어도 서버가 이미 커밋한 요청을 다시 확인할 수 있다. "전송 중" 상태와 "수신 완료" 상태를 UI가 구별하는 것—이것이 disabled 버튼이 절대 줄 수 없는 신뢰의 실체다.
린터가 잡지 못하는 것들을 체크리스트로
두 사례를 합치면 AI 생성 PR에 적용할 수 있는 UX 검증 프레임이 된다. 자동화 도구가 잡지 못하는 레이어다.
- 모든 파괴적 액션에는 결과에 비례하는 마찰 단계가 있는가?
- 400ms 이상의 비동기 작업에 레이블이 붙은 진행 상태가 있는가? (스피너만으로는 부족)
- 에러 메시지는 무엇이 일어나지 않았는지를 명시하고 다음 단계를 안내하는가?
- UI 상태는 '전송 중'과 '수신 완료'를 구별하는가? (disabled 버튼 ≠ 전달 증명)
이 항목들은 linter로 강제할 수 없다. 50시간의 사용자 세션 녹화를 본 사람만이 직관적으로 체크할 수 있는 것들이다. 체크리스트는 그 직관을 팀 전체가 쓸 수 있는 형태로 만든 것이다.
프롬프트는 스펙이 아니라 감정 문서여야 한다
AI는 점점 더 빠르게, 점점 더 정확하게 티켓을 해결하는 방향으로 발전하고 있다. 그런데 UX 품질의 위기는 정확성의 문제가 아니다. AI가 모르는 것은 사용자가 오후 11시에 배터리 40%짜리 폰으로 결제를 시도하면서 느끼는 긴장감이다. 그 맥락은 티켓에 없고, 린터에도 없고, 테스트에도 없다.
이 간극을 메우는 책임은 여전히 사람에게 있다. 다만 방식이 달라졌다. 코드를 직접 짜는 대신, 감정 맥락을 프롬프트에 녹이는 설계자가 되는 것이다. "확인 모달"이라고 쓰지 말고, "사용자가 실수로 누를 경우 되돌릴 수 없는 데이터 손실이 발생하는 확인 모달"이라고 써야 한다. AI가 코드를 더 잘 짤수록, 우리가 전달해야 할 것은 스펙이 아니라 그 스펙 뒤에 숨은 사용자 경험의 온도다.