데스크톱에선 멀쩡했는데요 — Playwright로 모바일 UX 셀프 점검하고 고친 6가지
"모바일에서 보면 어때?"라는 질문에 답이 막혔다
지난 글에서 사이드 프로젝트 매치다(MatchDa)를 신규 유저 관점으로 리뷰하고 P0 4건을 고친 이야기를 썼다. 그 다음 자연스럽게 나온 질문이 이거였다.
"그런데 이거, 모바일에서 보면 어때?"
뜨끔했다. 개발은 늘 데스크톱 브라우저에서 했고, 모바일 확인이라곤 가끔 데브툴 반응형 모드를 흘깃 본 게 전부였다. 한국 커뮤니티에서 유입되는 트래픽은 카카오톡·커뮤니티 앱에서 링크를 타고 오는 모바일이 대부분일 텐데 말이다.
그래서 이번엔 모바일을 제대로 점검했다. 이 글은 그 점검 방법(Playwright 모바일 뷰포트)과, 거기서 나온 문제들을 고친 기록이다. 데스크톱 우선으로 개발해온 프로젝트라면 아마 비슷한 문제들이 숨어 있을 거다.
Step 1. Playwright로 모바일 스크린샷 자동화
폰을 들고 페이지를 일일이 돌아다니는 것보다, 스크립트로 주요 페이지를 한 번에 찍는 게 빠르고 재현 가능하다. 프로젝트에 이미 스크래핑용으로 playwright-core가 있어서 그대로 활용했다.
// mobile-shots.mjs — 프로덕션 빌드를 로컬에 띄우고 실행
import { createRequire } from 'module'
const require = createRequire('/path/to/project/package.json')
const { chromium } = require('playwright-core')
const browser = await chromium.launch({ channel: 'chrome' }) // 시스템 크롬 사용
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 }, // iPhone 14 급
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
})
const page = await ctx.newPage()
for (const [name, path] of [['landing', '/'], ['login', '/login'], ['pricing', '/pricing']]) {
await page.goto('http://localhost:3457' + path, { waitUntil: 'networkidle' })
// 핵심: 가로 오버플로(모바일 깨짐의 주범)를 수치로 측정
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
)
console.log(`${name}: horizontal overflow = ${overflow}px`)
await page.screenshot({ path: `${name}-top.png` })
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight * 0.45))
await page.screenshot({ path: `${name}-mid.png` })
}
await browser.close()
두 가지 포인트:
- 가로 오버플로를 숫자로 측정한다.
scrollWidth - clientWidth가 0이 아니면 어딘가 삐져나온 요소가 있다는 뜻이다. 스크린샷만 보면 놓치기 쉬운데, 이 한 줄이면 확정적으로 잡힌다. playwright풀 패키지가 없어도 된다.playwright-core+channel: 'chrome'이면 브라우저 바이너리를 따로 내려받지 않고 설치된 크롬을 쓴다.
결과: 공개 페이지들은 오버플로 0px, 레이아웃도 깨짐 없음. 다행이었다. 하지만 로그인 후 "작업 화면"에서 진짜 문제들이 나왔다.
Step 2. 문제 ① — 이력서 비교 화면이 모바일에선 스크롤 지옥
핵심 화면인 워크스페이스는 한국어 이력서(편집)와 영문 이력서(AI 번역)를 좌우 2단으로 놓고 비교하는 구조다. 데스크톱에선 완벽한데, 모바일에선 grid-cols-1로 접히면서 이렇게 된다.
한국어 이력서 전체 (스크롤 한참)
↓
영문 이력서 전체 (스크롤 한참)
↓
AI 채팅 (맨 아래)
이력서 두 벌 분량을 다 지나야 AI 채팅에 닿는데, 정작 채팅으로 "경력 요약 고쳐줘"라고 보내면 갱신되는 문서는 화면 밖 저 위에 있다. 뭐가 바뀌었는지 보이지도 않는다.
해결: 탭 전환 + 하단 고정 시트
모바일에서만 동작하는 UI 상태 두 개를 추가했다.
// 모바일(lg 미만): 원본/영문 탭 전환 + AI 채팅 하단 시트
const [mobilePane, setMobilePane] = useState<'ko' | 'en'>('ko')
const [chatOpen, setChatOpen] = useState(false)
문서 패널은 Tailwind의 반응형 유틸리티로 토글한다. 핵심은 hidden lg:block — 모바일에선 선택된 탭만 보이고, 데스크톱에선 상태와 무관하게 둘 다 보인다.
{/* 좌: 한국어 원본 */}
<div className={mobilePane === 'ko' ? '' : 'hidden lg:block'}>...</div>
{/* 우: 영문 */}
<div className={mobilePane === 'en' ? '' : 'hidden lg:block'}>...</div>
AI 채팅은 모바일에서 하단 고정 시트로 만들었다. 같은 마크업을 두 벌 만들 필요 없이, 포지셔닝만 반응형으로 갈아탄다.
{/* 모바일: 하단 고정 시트 / lg 이상: 본문 하단 정적 카드 */}
<div className="fixed inset-x-0 bottom-0 z-30 lg:static lg:z-auto lg:mx-auto lg:max-w-[1320px]">
<button
onClick={() => setChatOpen(o => !o)}
className="flex w-full items-center ... lg:pointer-events-none"
>
이력서 AI 어시스턴트 {/* 모바일: 탭하면 열림 / 데스크톱: 항상 열려 있음 */}
</button>
<div className={chatOpen ? 'block' : 'hidden lg:block'}>
{/* 대화 영역: 모바일은 36dvh — 키보드가 올라와도 이력서가 위로 보인다 */}
<div className="max-h-[36dvh] overflow-y-auto lg:max-h-[280px]">...</div>
</div>
</div>
한 가지 주의: fixed 시트는 본문 마지막 콘텐츠를 가린다. 문서 영역에 모바일 전용 하단 패딩(pb-32 lg:pb-6)을 잊지 말자.
Step 3. 문제 ② — iOS 키보드가 입력창을 삼킨다 (vh vs dvh)
온보딩 채팅 컨테이너가 h-[70vh]였다. 데스크톱에선 문제없지만, iOS Safari에서 키보드가 올라오면 vh는 줄어들지 않는다. 뷰포트의 아래쪽 절반이 키보드에 덮여도 컨테이너는 원래 높이를 고집하고, 맨 아래 붙어 있는 입력창이 키보드 뒤로 사라진다.
수정은 한 글자다.
- <div className="... flex flex-col h-[70vh]">
+ <div className="... flex flex-col h-[70dvh]">
| 단위 | 기준 | 키보드 올라오면 |
|---|---|---|
vh | 최초 뷰포트 높이 (고정) | 안 줄어듦 → 입력창 가려짐 |
dvh | 현재 보이는 뷰포트 높이 (동적) | 함께 줄어듦 → 입력창 유지 |
svh / lvh | 최소/최대 뷰포트 (고정) | 각각 항상 작게/크게 |
채팅·폼처럼 하단에 입력 UI가 붙는 세로 플렉스 레이아웃이라면 기본값을 dvh로 잡는 게 안전하다. Tailwind는 h-[70dvh]처럼 임의 값으로 바로 쓸 수 있다.
Step 4. 문제 ③ — 칸반 드래그가 터치에서 스크롤과 싸운다
지원 현황 칸반 보드는 dnd-kit으로 카드를 드래그해 상태를 바꾼다. 그런데 센서가 이것뿐이었다.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
)
PointerSensor의 distance: 8은 마우스에선 "8px 움직여야 드래그 시작"이라 클릭과 잘 구분되지만, 터치에선 재앙이다. 손가락으로 스크롤하려고 카드를 쓸어올리는 순간 8px이 넘어가며 스크롤이 아니라 드래그가 시작된다.
터치에는 거리 기준이 아니라 시간 기준이 맞다. TouchSensor를 추가했다.
const sensors = useSensors(
// 마우스: 8px 이상 움직여야 드래그 (클릭·드롭다운 조작 보호)
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
// 터치: 200ms 길게 누른 뒤 드래그 (스크롤 제스처와 충돌 없음)
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
)
delay: 200— 200ms 안에 손가락이 움직이면 스크롤로 판단하고 드래그를 시작하지 않는다tolerance: 8— 길게 누르는 동안 8px까지의 미세한 흔들림은 허용
카드 요소에는 touch-manipulation(Tailwind 클래스)을 얹었다. 더블탭 줌 같은 브라우저 제스처를 꺼서 길게 누르기 → 드래그 전환이 안정적으로 이어진다. touch-action: none을 쓰면 카드 위에서 페이지 스크롤 자체가 막히니, delay 기반 센서에는 manipulation이 맞다.
그리고 안전망 하나 — 터치 드래그가 아무리 좋아져도 모바일에서 세로로 쌓인 4개 컬럼을 가로지르는 드래그는 힘들다. 카드마다 상태 변경 드롭다운을 함께 두는 것(우리는 이미 있었다)이 진짜 해법이고, 드래그는 보너스다.
Step 5. 흐름 개선 — 코드 한 줄보다 문구·동선이 UX를 바꾼다
레이아웃 말고도, 리뷰에서 나온 P1·P2 흐름 문제들을 함께 정리했다. 기술적으론 소소하지만 효과는 크다.
이메일 인증 후 자동 로그인. 기존 흐름은 가입 → 메일 인증 → 다시 돌아와서 수동 로그인이었다. Supabase signUp에 emailRedirectTo만 지정하면 인증 링크가 콜백 라우트로 떨어지고, 거기서 세션 교환이 일어나 바로 로그인된다.
await supabase.auth.signUp({
email, password,
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
})
콜백에선 온보딩 미완료 유저를 온보딩으로 보낸다. 왕복 이탈 포인트 하나가 사라졌다.
콜드 스타트 3단계 가이드. 아무것도 등록 안 한 신규 유저의 잡 탐색 화면 상단에 "① 이력서 완성 → ② 채용페이지 등록 → ③ 첫 매칭 확인" 체크리스트를 띄웠다. 완료된 단계는 체크 표시, 미완료 단계엔 바로가기 링크. 소스를 하나라도 등록하면 사라진다. 빈 화면에 떨어진 유저에게 "다음에 뭘 해야 하는지"를 시스템이 말해주는 게 핵심이다.
결과 중심 버튼명. "관리 보내기"라는 버튼이 있었는데, 눌렀을 때 무슨 일이 일어나는지 아무도 모른다. 사이드바 메뉴명과 일치하는 "지원 현황에 추가"로 바꿨다. 페이지 제목도 메뉴명과 통일("이력서 스튜디오" → "내 이력서"). 버튼과 메뉴는 개발자가 붙인 기능 이름이 아니라, 유저가 얻는 결과의 이름이어야 한다.
에러에 출구 달기. 무료 한도 초과 에러가 "(요금제 페이지 /pricing)"이라는 경로 텍스트로 끝나고 있었다. 유저더러 주소창에 타이핑하라는 건가. 에러 메시지에 "업그레이드" 문구가 포함되면 실제 링크 버튼을 함께 렌더하도록 바꿨다.
데모 데이터 현지화. 서비스는 호주·뉴질랜드 타겟인데 랜딩 데모에는 베를린·토론토·싱가포르 공고가 떠 있었다. 시드니(Atlassian)·오클랜드(Xero)·멜번(Culture Amp)으로 교체. 타겟 유저가 랜딩에서 "어, 내가 가고 싶은 회사네"라고 느끼는 것과 아닌 것의 차이는 생각보다 크다.
정리 — 모바일 점검 체크리스트
이번에 겪은 걸 데스크톱 우선 프로젝트용 체크리스트로 정리하면:
- 가로 오버플로를 수치로 측정하라. Playwright 모바일 뷰포트 +
scrollWidth - clientWidth한 줄이면 된다. - 2단 비교 레이아웃은 모바일에서 탭으로.
hidden lg:block토글이면 데스크톱을 건드리지 않고 해결된다. - 하단 입력 UI가 있는 레이아웃은
vh대신dvh. iOS 키보드가 올라와도 입력창이 살아남는다. - 터치 드래그는 거리(distance)가 아니라 시간(delay) 기준. dnd-kit이라면
TouchSensor+delay+touch-manipulation, 그리고 드래그 없이도 되는 대체 수단. - 레이아웃만 UX가 아니다. 인증 후 동선, 빈 화면 가이드, 버튼 이름, 에러의 출구 — 코드 몇 줄짜리 수정들이 체감을 바꾼다.
데스크톱에서 멀쩡하다는 건 아무것도 보장하지 않는다. 유저가 실제로 들고 있는 건 390px짜리 화면이다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.