내가 만든 서비스에 신규 유저로 가입해봤다 — UX 셀프 리뷰로 P0 4건 고친 이야기
만든 사람의 눈에는 안 보이는 것들
사이드 프로젝트로 해외 취업 도구 **매치다(MatchDa)**를 만들고 있다. 한국어로 경력을 입력하면 AI가 영어 이력서로 정리해주고, 관심 회사의 채용 페이지를 등록하면 공고를 자동 수집해서 매칭 점수를 매겨주는 서비스다.
기능이 어느 정도 갖춰져서 슬슬 커뮤니티에 홍보를 해볼까 하다가, 문득 이런 생각이 들었다.
"나는 이 서비스를 매일 쓰는 사람인데, 처음 온 사람한테도 이게 자연스러울까?"
만든 사람은 절대 신규 유저가 될 수 없다. 어디를 눌러야 하는지, 어떤 순서로 써야 하는지 이미 다 알고 있기 때문이다. 그래서 홍보 전에 "처음부터 가입하고 → 이력서 넣고 → 공고 찾고 → 서류 만드는" 전체 여정을 신규 유저 시선으로 다시 밟아봤다.
결과는? 홍보를 시작했다면 큰일 날 뻔한 P0급 문제가 4건 나왔다. 이 글은 그 4건을 찾고 고친 기록이다.
리뷰 방법: 코드와 라이브 사이트를 같이 본다
셀프 UX 리뷰를 할 때 브라우저만 보면 "왜 이렇게 동작하는지"를 놓치고, 코드만 보면 "실제로 어떻게 보이는지"를 놓친다. 그래서 두 가지를 병행했다.
- 라이브 사이트를 신규 유저처럼 탐색 — 랜딩 → 가입 → 온보딩 → 핵심 기능 순서로, 각 단계에서 "처음 온 사람이 다음에 뭘 해야 할지 알 수 있는가?"를 기준으로 확인
- 해당 화면의 코드를 바로 확인 — 문제를 발견하면 그 화면을 렌더링하는 컴포넌트와 서버 액션을 열어서 원인과 수정 범위를 파악
이렇게 하면 "불편하다"에서 끝나지 않고 바로 "어느 파일의 몇 번째 줄을 고치면 된다"까지 이어진다.
P0-1. 랜딩에 목업 시절의 가짜 통계가 그대로 살아있었다
가장 아찔했던 발견. 랜딩 페이지에 이런 통계 밴드가 떠 있었다.
- "12,000+ 활동 중인 사용자"
- "45개국+ 해외 채용 공고"
- "81% 평균 직무 매칭률"
디자인 목업을 만들 때 넣었던 placeholder 수치가 실서비스 랜딩에 그대로 노출되고 있었던 거다. 심지어 히어로에는 가짜 유저 아바타 4개와 "이미 12,000명의 한국 전문가가 함께합니다"라는 소셜 프루프까지 있었다.
이게 왜 P0인가?
- 커뮤니티에 홍보했을 때 누군가 "12,000명이라는데 후기가 왜 하나도 없죠?"라고 묻는 순간 신뢰가 통째로 무너진다
- 표시광고법 관점에서 허위·과장 광고 리스크도 있다
수정: 수치 대신 검증 가능한 사실로
가짜 숫자를 그럴듯한 진짜 숫자로 바꾸는 게 아니라, 숫자 약속 자체를 버리고 기능 기반 문구로 교체했다.
// src/lib/matchda/i18n.ts — before
stats: [
{ value: '45개국+', label: '해외 채용 공고' },
{ value: '12,000+', label: '활동 중인 사용자' },
{ value: '81%', label: '평균 직무 매칭률' },
{ value: '3분', label: '평균 이력서 번역 시간' },
],
// after — 전부 지금 당장 사실인 것들
stats: [
{ value: 'AI 번역', label: '한국어 이력서 → 전문가 수준 영어' },
{ value: '4종 ATS', label: 'Greenhouse · Lever 등 채용페이지 자동 인식' },
{ value: '공고별', label: '맞춤 이력서 · 커버레터 자동 생성' },
{ value: '₩0', label: '무료로 시작 · 카드 등록 불필요' },
],
가짜 아바타 + 소셜 프루프 블록은 컴포넌트에서 통째로 삭제했다. 유저가 진짜로 쌓이면 그때 진짜 숫자로 되살리면 된다.
교훈: 디자인 목업에서 출발한 프로젝트라면, 실서비스 전환 시 목업 데이터가 어디까지 살아있는지 전수 조사가 필요하다. i18n 딕셔너리처럼 "데이터"로 분리해둔 문구는 특히 놓치기 쉽다.
P0-2. 검색바가 약속하는 것과 제품이 주는 것이 달랐다
랜딩 히어로에는 근사한 검색바가 있었다. "전체 국가" 셀렉터에, 인기 검색어로 "베를린 · 백엔드", "싱가포르 PM" 같은 칩까지. 딱 보면 전 세계 공고 DB를 검색하는 플랫폼처럼 보인다.
그런데 실제 제품은 "내가 등록한 회사의 채용 페이지에서 수집한 공고"를 보여주는 도구다. 신규 유저가 검색바에 "베를린 백엔드"를 치고 가입하면? 등록한 소스가 0개라서 검색 결과는 반드시 빈 화면이다.
이런 기대-현실 갭은 이탈로 직결된다. 유저는 "검색이 안 되네, 별로다"라고 느끼지, "아, 먼저 회사를 등록해야 하는구나"라고 친절하게 해석해주지 않는다.
수정: 약속을 현실 크기로 줄이기
이상적으로는 공유 공고 풀을 비로그인에게도 열어서 검색이 진짜 동작하게 만드는 게 맞지만(백로그에 등록), 일단 홍보 전 응급조치로 카피가 하는 약속을 실제 서비스 범위에 맞게 줄였다.
| 항목 | Before | After |
|---|---|---|
| 국가 셀렉터 | 전체 국가 | 호주 · 뉴질랜드 |
| 인기 검색 칩 | 베를린 · 백엔드, 싱가포르 PM | 시드니 · 백엔드, 멜번 · 풀스택, 오클랜드 · 데이터 |
| 서브헤드 | "전 세계 채용 공고를 검색하세요" | "관심 회사의 채용 공고를 자동 수집하고 직무·기술로 검색하세요" |
포지셔닝도 "45개국 글로벌"에서 "호주·뉴질랜드 IT"로 좁혔다. 작게 시작해서 잘 되면 넓히는 게, 넓게 약속하고 못 지키는 것보다 낫다.
P0-3. 가입한 유저가 "빈 화면"에 떨어지고 있었다
가입/로그인 흐름을 직접 밟아보니 이랬다.
- 가입 → 이메일 인증 → 로그인
- 무조건
/discover(잡 탐색)로 이동 - 그런데 이력서(프로필)가 없으니 매칭 점수가 전부 무의미
- 안내라고는 상단의 얇은 배너 하나
이 서비스의 "아하 모먼트"는 한국어로 입력했더니 영어 이력서가 나오는 순간이다. 그 경험을 가입 3분 안에 시켜야 하는데, 유저를 빈 잡 탐색 화면에 떨궈놓고 알아서 찾아오길 기대하고 있었다.
수정: 로그인 직후 목적지를 서버에서 분기
온보딩 완료 여부에 따라 목적지를 정하는 서버 액션을 하나 만들었다.
// src/app/auth-actions.ts
export async function postLoginPath(): Promise<string> {
const email = await getAuthUserEmail()
if (!email) return '/login'
const profile = await getOrCreateProfile(email)
return profile?.onboarding_completed ? '/discover' : '/onboarding'
}
이걸 두 군데에 연결했다.
① 이메일 로그인 성공 시 (클라이언트 컴포넌트에서 서버 액션 호출):
// src/app/login/LoginForm.tsx
const { postLoginPath } = await import('@/app/auth-actions')
router.push(await postLoginPath())
② OAuth·가입 인증 콜백 — 여기서 한 가지 주의할 점. 콜백 라우트는 비밀번호 재설정 같은 next 파라미터 지정 진입도 처리하므로, next가 없을 때만 온보딩 분기를 태워야 한다.
// src/app/auth/callback/route.ts
if (!next) {
const email = await getAuthUserEmail()
if (email) {
const profile = await getOrCreateProfile(email)
if (profile && !profile.onboarding_completed) target = '/onboarding'
}
}
강제 진입에는 항상 탈출구가 필요하다. 온보딩 헤더에 "나중에 하기 →" 링크도 함께 달았다.
P0-4. 이력서 파일이 있는 사람도 8단계 채팅을 다 쳐야 했다
온보딩은 채팅 형식으로 이름 → 연락처 → 학력 → 경력 → 스킬 → 희망 포지션… 8단계를 묻는다. 한국어로 편하게 답하면 되니 나쁘지 않은 설계인데, 문제는 해외 취업 준비자 대부분은 이미 이력서 파일을 갖고 있다는 점이다. 그 사람들한테 학력·경력을 처음부터 다시 타이핑하라는 건 고문이다.
더 억울한 건, 이력서 PDF/DOCX를 업로드하면 AI가 파싱해서 구조화하는 서버 액션(analyzeResumeFile)이 이미 프로필 페이지에 존재했다는 것. 온보딩에서 노출을 안 하고 있었을 뿐이다.
수정: 기존 액션을 재사용하는 업로드 카드
새 기능을 만들 필요 없이, 온보딩 채팅 위에 업로드 카드 컴포넌트 하나만 추가했다.
// src/app/onboarding/ResumeUploadOption.tsx (핵심만)
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setBusy(true)
const fd = new FormData()
fd.append('resume', file)
const res = await analyzeResumeFile(fd) // 기존 서버 액션 재사용
if (res.error) { setError(res.error); return }
// 파싱 결과를 확인·수정할 수 있게 이력서 스튜디오로
router.push('/profile')
}
analyzeResumeFile이 내부에서 onboarding_completed: true까지 처리해주므로, 업로드 한 번이면 온보딩이 통째로 끝난다. 8단계 채팅이 클릭 한 번으로 줄었다.
교훈: UX 문제라고 해서 항상 새 코드가 필요한 건 아니다. 이미 있는 기능이 필요한 자리에 노출이 안 된 것뿐인 경우가 의외로 많다.
검증과 커밋
수정 후에는 타입 체크와 프로덕션 빌드, 그리고 로컬 서버에서 실제 렌더 결과를 확인했다.
npx tsc --noEmit # 타입 체크
npx next build # 프로덕션 빌드
npx next start -p 3457 # 로컬에서 띄우고
curl -s localhost:3457/ | grep -c "12,000" # → 0 (가짜 통계 사라짐 확인)
로그인이 필요한 흐름(리다이렉트 분기, 실제 파일 업로드)은 빌드 검증만으로는 부족해서, 배포 전에 직접 가입 → 업로드를 한 번 타보는 걸 원칙으로 잡았다.
정리 — 홍보 전 셀프 UX 리뷰 체크리스트
이번에 겪은 걸 일반화하면 이렇다. 사이드 프로젝트를 밖에 알리기 전에 최소한 이건 확인하자.
- 목업 데이터 전수 조사 — 디자인 시절의 가짜 통계·후기·아바타가 실서비스에 남아있지 않은가? (i18n 딕셔너리, mock-data 파일 특히 주의)
- 랜딩의 약속 vs 제품의 현실 — 첫 화면이 약속하는 경험을 신규 유저가 가입 직후 실제로 받을 수 있는가? 빈 화면이 나오는 경로는 없는가?
- 가입 직후 동선 — 유저가 서비스의 "아하 모먼트"에 도달하기까지 몇 번의 클릭이 필요한가? 시스템이 데려다주는가, 유저가 찾아야 하는가?
- 이미 있는 기능의 노출 위치 — 마찰을 줄여줄 기능이 정작 필요한 순간에 숨어있지 않은가?
만든 사람의 눈은 이미 오염되어 있다. 홍보 버튼을 누르기 전에, 하루만 시간을 내서 자기 서비스의 신규 유저가 되어보자. 나는 그 하루 덕분에 신뢰를 무너뜨릴 뻔한 문제 4개를 손님 오기 전에 치웠다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.