당신의 AI 생성 코드가 '괜찮을' 때 사용자가 실제로 느끼는 것: 출시된 3가지 기능에 대한 UX 사후 분석
요약
AI가 생성한 코드가 기술적으로는 정확하더라도 사용자 경험(UX) 측면에서는 치명적인 결함을 가질 수 있음을 분석합니다. 실제 사례를 통해 감정적 이해관계와 지연 시간의 정직함 등을 프롬프트에 포함해야 함을 강조합니다.
핵심 포인트
- AI 코드는 기술적 요구사항은 충족하나 사용자 경험(UX)은 놓칠 수 있음
- 프롬프트에 감정적 이해관계(Emotional stakes)를 포함하는 것이 중요함
- 지연 시간의 정직함(Latency honesty)과 비난 회피(Blame avoidance) 원칙 적용 필요
- 린터(Linter)가 잡아낼 수 없는 UX 회귀 체크리스트 활용 권장
- AI 생성 코드는 종종 기술적으로는 정확하지만 경험적으로는 망가져 있습니다 — 티켓(Ticket)의 문제는 해결하지만, 티켓은 사용자의 느낌을 거의 설명하지 않습니다.
- 세 가지 실제 사례: 마찰(Friction)이 없는 확인 모달 (14건의 실수로 인한 계정 삭제), 아무런 안내 없는 결제 스피너 (완료율 -4.1%), 사용자를 탓하는 에러 메시지 (환불 요청 2배 증가).
- 세 가지 프롬프트 구절 — 감정적 이해관계 (Emotional stakes), 지연 시간의 정직함 (Latency honesty), 비난 회피 (Blame avoidance) — 가 리뷰 전에 이러한 문제의 약 70%를 잡아냈습니다.
- AI가 작성한 PR(Pull Request)을 위한 4가지 UX 회귀(Regression) 체크리스트, 이 중 그 어떤 것도 린터(Linter)는 잡아낼 수 없습니다.
지난 분기에 출시된 세 가지 기능은 모든 자동화된 검사를 통과했고, 시니어 엔지니어들에 의해 머지(Merge)되었으며, 우리가 문제를 발견하기 전까지 조용히 활성화율(Activation rate)을 6%포인트나 떨어뜨렸습니다.
세 기능 모두 AI가 생성했습니다. 전통적인 의미에서의 버그는 하나도 없었습니다. 실제로 무엇이 잘못되었는지 설명하겠습니다.
불편한 진실: 대부분의 AI 생성 코드는 기술적으로는 정확하지만 경험적으로는 망가져 있습니다. 모델은 사용자가 배터리가 40% 남은 휴대폰으로 밤 11시에 결제를 시도할 때 어떤 기분인지 알지 못합니다. 모델은 티켓을 해결하는 코드를 작성합니다. 하지만 티켓은 사용자의 느낌을 거의 설명하지 않습니다.
기능 1: 확인을 해주지 않는 확인 모달
티켓: "설정 내에 확인 모달이 포함된 계정 삭제 버튼을 추가할 것."
출시된 내용:
<Dialog>
<DialogTitle>Delete account?</DialogTitle>
<DialogDescription>
...
기술적으로 완벽했습니다. 접근성(a11y) 검사를 통과했고, 디자인 시스템과 일치했습니다. 이후 3주 동안 접수된 고객 지원 티켓: 14명의 사용자가 실수로 계정을 삭제했습니다.
누락된 것: '확인을 위해 사용자 이름을 직접 입력하게 하는' 마찰(Friction) 단계였습니다. GitHub은 그렇게 합니다. Stripe도 그렇게 합니다. 인터넷상의 모든 중대한 삭제 흐름은 그렇게 합니다. 모델은 티켓이 요구한 대로 작성했고, 티켓은 마찰을 요구하지 않았습니다. PM(Product Manager)이 "확인 모달"이라는 단어에 그것이 포함되어 있다고 가정했기 때문입니다. 하지만 포함되어 있지 않습니다. 모델에게 확인 모달이란 정확히 두 개의 버튼일 뿐입니다.
이후 진행된 사용자 인터뷰:
"취소(Cancel) 버튼이 파괴적인 동작이니까 빨간색일 거라고 생각했어요."
"그냥 엄지손가락이... 눌러버렸어요. 모달창을 읽었다는 기억조차 안 나요."
해결책은 단 8줄이었습니다. 하지만 교훈은 더 컸습니다: 모델에게는 기능적 요구사항 (functional requirements)뿐만 아니라, 프롬프트에 정서적 이해관계 (emotional stakes)가 포함되어야 합니다.
기능 2: 신뢰의 800ms를 앗아간 로딩 상태 (loading state)
티켓: "결제가 처리되는 동안 로딩 스피너 (loading spinner)를 추가하세요."
출시된 코드 (의역):
{isProcessing ? <Spinner /> : <Button onClick={pay}>Pay $49</Button>}
기술적으로는 정확합니다. 하지만 사용자가 본 것은 다음과 같습니다:
- "Pay $49" 클릭
- 버튼이 사라지고 스피너가 나타남
- 맥락(context) 없는 회전하는 원을 약 1.2초 동안 바라봄
- 성공 화면
우리는 이를 다음 방식과 A/B 테스트했습니다:
{isProcessing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner />
...
단계별 라벨 (staged labels)은 백엔드에서 일어나고 있는 일을 전혀 반영하지 않습니다. 그저 속도에 맞춰 보여주는 텍스트일 뿐입니다. 결과: 결제 단계의 완료율 (completion rate)이 4.1% 상승했습니다. 정보를 제공받고 있다고 느낀 사용자는 이탈하지 않았지만, 아무 맥락 없는 스피너를 지켜본 사용자는 이탈했습니다.
모델은 스피너가 UX의 절벽 (UX cliff)이라는 사실을 몰랐습니다. 모델은 스피너가 로딩 상태 (loading state)에 대한 전형적인 해결책이라는 것만 알고 있었습니다. 이 둘은 전혀 다른 것입니다.
기능 3: 사용자를 탓하는 에러 메시지
대부분의 AI 생성 에러 핸들링 (error handling)은 다음과 같은 형태를 띱니다:
try {
await createOrder(input);
} catch (e) {
...
해당 메시지의 모든 단어는 실패의 원인을 사용자에게 떠넘깁니다. "잘못된 입력입니다." "상세 정보를 확인하세요." "다시 시도하세요."
세션 녹화 영상 중 한 사용자의 반응:
"확인했어요. 두 번이나요. 제 카드가 문제가 아니라고요. 관둡시다." (탭을 닫음)
서버 로그에 기록된 실제 에러: 사용자가 300ms 이내에 버튼을 두 번 클릭했기 때문에 멱등성 키 (idempotency key)가 충돌했습니다. 잘못된 것은 아무것도 없었습니다. 주문은 첫 번째 클릭에서 이미 처리되었습니다.
재작성:
try {
await createOrder(input);
} catch (e) {
...
두 가지가 바뀌었습니다. 모델이 간과했던 특정 에러 케이스를 처리했고, 일반적인 폴백(fallback) 방식이 사용자에게 책임을 돌리는 대신 사용자에게 카드가 안전하다는 것을 알려주며 책임을 우리에게 돌리도록 수정했습니다. 해당 흐름에서의 환불 요청이 절반으로 줄었습니다.
모델이 느낄 수 없는 것 — 그리고 프롬프트에서 이를 보완하는 방법
세 가지 사례 모두에서 나타난 패턴은 다음과 같습니다. 모델은 '인간적인 (humane)' 코드가 아닌, '충분한 (sufficient)' 코드를 작성했습니다. 모델은 명세(spec) 완수에 최적화되어 있으며, 명세란 좋은 제품이 주는 느낌을 손실 압축(lossy compression)한 것에 불과합니다.
우리가 프롬프트에 추가한 내용:
- 정서적 이해관계 조항 (Emotional-stakes clause): "이 흐름은 삭제 작업입니다. 실제 사용자가 실수로 클릭하면 실제 데이터를 잃게 됩니다. 그에 맞춰 마찰(friction)을 설계하세요."
- 지연 시간 정직성 조항 (Latency-honesty clause): "400ms를 초과하는 모든 비동기 호출(async call)은 단순한 스피너(spinner)가 아닌 라벨이 붙은 진행 표시기(progress indicator)를 보여줘야 합니다. 단일 '로딩 중...'보다는 3단계의 단계별 라벨을 선호하세요."
- 비난 회피 조항 (Blame-avoidance clause): "에러 메시지에 '유효하지 않음(Invalid)', '틀림(Wrong)', 또는 '당신(You)'이라는 단어를 사용해서는 안 됩니다. 무엇이 일어나지 않을 것인지(결제, 저장, 전송 등)와 사용자가 다음에 할 수 있는 일을 명시해야 합니다."
시스템 프롬프트에 추가된 이 세 줄의 문구는 사후 검토 과정에서 발견되던 UX 퇴보(regression)의 약 70%를 잡아냈습니다.
AI가 작성한 PR을 위한 UX 퇴보 방지 기준
이제 우리는 머지(merge) 전, 사용자에게 노출되는 모든 AI PR(Pull Request)에 대해 4가지 항목의 체크리스트를 실행합니다:
- 모든 파괴적인 작업(destructive action)은 그 결과에 비례하는 마찰(friction) 단계를 갖는다.
- 400ms를 초과하는 모든 비동기 작업(async op)은 단순한 스피너가 아닌 라벨이 붙은 진행 상태를 갖는다.
- 모든 에러 메시지는 무엇이 일어나지 않을지를 명시하고 다음 단계를 제안한다.
- 모든 빈 상태(empty state)는 단순히 "항목이 아직 없습니다"가 아닌, 첫 실행 팁을 제공한다.
이 중 그 어떤 것도 린터(linter)가 잡아낼 수 있는 것이 아닙니다. 이는 50시간 동안 사용자 세션 녹화 영상을 시청한 사람만이 잡아낼 수 있는 것들입니다. 모델이 해당 녹화 영상들을 시청하기 전까지는 그 사람이 루프(loop) 안에 남아 있어야 하지만, 이 체크리스트 덕분에 그들의 작업 속도는 4배 더 빨라졌습니다.
만약 여러분이 새로운 SaaS를 구축하고 있으며, 이러한 것들을 고통스러운 방식으로 다시 배우고 싶지 않다면, 위에서 언급한 대부분의 패턴들 — 유형이 지정된 확인 모달 (typed-confirm modals), 속도가 조절된 로딩 상태 (paced loading states), 인간의 실수를 고려한 문구 (human error copy) — 은 성숙한 템플릿이 기본적으로 내장하고 있는 종류의 것들입니다. Applighter 스타터(starters) 제품군에 이러한 기능들이 포함되어 있는 이유는, 바로 이러한 종류의 실제 서비스 사후 분석 (production autopsy) 결과로부터 추출되었기 때문입니다.
네 가지 체크리스트 항목 중 여러분의 마지막 AI 작성 PR (Pull Request)은 어떤 항목에서 실패했을까요? 저의 경우는 아무런 장식 없는 스피너 (naked spinner)였습니다 — 그것도 두 번이나 말이죠.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기