UX 가이드라인 심화편 — 내비게이션부터 AI 인터랙션까지
이 시리즈는 UI/UX Pro Max 스킬의 디자인 데이터베이스를 기반으로 작성되었다. 자세한 배경은 1편을 참고하자.
1편에서 접근성, 터치, 퍼포먼스, 레이아웃, 타이포그래피, 애니메이션을 다뤘다. 기초 체력이 갖춰졌다면, 이제 사용자 경험의 구체적인 시나리오를 파고들 차례다.
내비게이션은 어떻게 설계해야 하는지, 폼 에러는 어디에 보여줘야 하는지, 검색 결과가 없을 때 어떻게 해야 하는지 — 이 글은 그런 질문들에 대한 답이다. 12개 카테고리, 코드 예시와 함께.
Navigation: 길을 잃지 않게
사용자가 "지금 어디에 있고, 어디로 갈 수 있는지" 항상 알 수 있어야 한다. 내비게이션이 불명확하면 사이트 전체가 미궁이 된다.
Smooth Scroll
앵커 링크를 클릭하면 해당 섹션으로 부드럽게 스크롤되어야 한다. 갑자기 점프하면 사용자가 맥락을 잃는다.
/* Don't — 점프 이동 */
/* 아무 설정도 안 하면 기본이 instant */
/* Do — 부드러운 스크롤 */
html {
scroll-behavior: smooth;
}
JavaScript에서도 scrollIntoView({ behavior: 'smooth' })로 제어할 수 있다. 다만 prefers-reduced-motion: reduce 설정 시에는 auto로 폴백해야 한다.
Sticky Navigation과 콘텐츠 겹침
고정 내비게이션은 편리하지만, 본문 첫 섹션을 가리는 실수가 흔하다.
/* Don't — nav 높이만큼 콘텐츠가 가려진다 */
nav {
position: fixed;
top: 0;
height: 64px;
}
/* Do — body에 nav 높이만큼 패딩 */
nav {
position: fixed;
top: 0;
height: 64px;
}
main {
padding-top: 64px;
}
Tailwind에서는 pt-16(64px)으로 간단하게 해결된다. scroll-mt-16도 함께 넣어야 앵커 링크 이동 시 내용이 nav 아래로 가리지 않는다.
Active 상태 표시
현재 페이지가 어디인지 내비게이션에서 시각적으로 알려줘야 한다. 모든 링크가 같은 스타일이면 사용자는 자기 위치를 잃는다.
/* Don't — 모든 링크가 동일 스타일 */
<nav>
<a href="/home">홈</a>
<a href="/blog">블로그</a>
<a href="/about">소개</a>
</nav>
/* Do — 현재 페이지 하이라이트 */
<nav>
<a href="/home" className={pathname === '/home' ? 'text-blue-500 font-semibold border-b-2 border-blue-500' : 'text-zinc-400'}>
홈
</a>
{/* ... */}
</nav>
직접 클릭해서 차이를 느껴보자.
각 탭을 클릭해보세요. 왼쪽은 현재 어디에 있는지 알 수 없다. 오른쪽은 선택된 탭이 명확하게 표시된다.
Back 버튼 동작 보존
SPA에서 흔한 실수다. location.replace()를 쓰면 히스토리가 덮어씌워져서 뒤로 가기가 깨진다.
/* Don't — 히스토리 대체 */
window.location.replace("/next-page");
/* Do — 히스토리 추가 */
router.push("/next-page");
// 또는
history.pushState(null, "", "/next-page");
Deep Linking
URL이 현재 상태를 반영해야 공유와 북마크가 가능하다. 필터, 탭, 모달 상태를 URL에 반영하자.
/* Don't — 상태가 URL에 반영되지 않음 */
const [tab, setTab] = useState("overview");
/* Do — query param으로 상태 반영 */
const searchParams = useSearchParams();
const tab = searchParams.get("tab") ?? "overview";
// URL: /dashboard?tab=analytics
Forms: 사용자가 포기하지 않게
폼은 사용자에게 작업을 요구하는 유일한 UI다. 잘못 설계하면 이탈률이 치솟는다. 핵심은 즉각적이고 구체적인 피드백이다.
라벨은 항상 보이게
placeholder만으로는 부족하다. 입력을 시작하면 사라지고, 스크린 리더도 제대로 인식하지 못한다.
/* Don't — placeholder만 사용 */
<input type="email" placeholder="이메일을 입력하세요" />
/* Do — 라벨 + placeholder */
<label htmlFor="email" className="text-sm font-medium">
이메일
</label>
<input
id="email"
type="email"
placeholder="example@email.com"
autoComplete="email"
/>
인라인 검증 (blur 시)
제출 버튼을 누른 후에야 에러를 보여주면 늦다. 사용자가 필드를 떠날 때(blur) 검증하는 것이 가장 자연스럽다.
/* Don't — 제출 시에만 검증 */
const handleSubmit = () => {
const errors = validate(formData);
setErrors(errors); // 에러가 폼 상단에 몰려 나온다
};
/* Do — blur 시 인라인 검증 */
<input
onBlur={() => {
const error = validateEmail(email);
setFieldError("email", error);
}}
/>
{errors.email && (
<p className="text-sm text-red-500" role="alert">
{errors.email}
</p>
)}
입력 필드에 텍스트를 넣고 빠져나와 보자. 검증 타이밍의 차이를 느낄 수 있다.
왼쪽은 제출 버튼을 눌러야 에러를 알 수 있고, 에러 위치도 폼 상단이라 찾기 어렵다. 오른쪽은 입력 직후 바로 피드백을 주고, 에러가 해당 필드 바로 아래에 표시된다.
올바른 Input Type
type="text"로 모든 입력을 처리하면 모바일 키보드가 최적화되지 않는다.
<!-- Don't — 전부 text -->
<input type="text" /> <!-- 이메일 -->
<input type="text" /> <!-- 전화번호 -->
<input type="text" /> <!-- 숫자 -->
<!-- Do — 적절한 type + inputmode -->
<input type="email" inputMode="email" autoComplete="email" />
<input type="tel" inputMode="tel" autoComplete="tel" />
<input type="text" inputMode="numeric" pattern="[0-9]*" />
type="email"은 모바일에서 @ 키가 포함된 키보드를 띄운다. inputMode="numeric"은 숫자 키패드를 띄운다. 작은 차이가 입력 속도를 크게 바꾼다.
필수 필드 표시
어떤 필드가 필수인지 추측하게 만들면 안 된다.
/* Don't — 필수 표시 없음 */
<label>이름</label>
/* Do — 별표 또는 텍스트로 필수 표시 */
<label>
이름 <span className="text-red-500">*</span>
</label>
/* 또는 선택 필드에 (선택) 표시 — 필수가 대부분일 때 */
<label>닉네임 <span className="text-zinc-500">(선택)</span></label>
비밀번호 표시 토글
사용자가 비밀번호를 직접 확인할 수 있어야 한다. 특히 모바일에서 오타가 잦다.
/* Do — show/hide 토글 */
<div className="relative">
<input
type={showPassword ? "text" : "password"}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2"
aria-label={showPassword ? "비밀번호 숨기기" : "비밀번호 보기"}
>
{showPassword ? <EyeOff /> : <Eye />}
</button>
</div>
토글 버튼을 직접 눌러보자.
오타가 있어도 알 수 없다
아이콘을 클릭해 비밀번호를 확인할 수 있다
오른쪽 눈 아이콘을 클릭해보세요. 비밀번호를 직접 확인할 수 있어 오타를 방지한다. 특히 모바일에서 유용하다.
제출 후 피드백
폼 제출 후 아무 반응이 없으면 사용자는 "눌린 건가?" 불안해한다. 로딩 → 성공/실패 흐름을 명확하게 보여줘야 한다.
/* Don't — 아무 피드백 없음 */
<button onClick={handleSubmit}>저장</button>
/* Do — 상태별 피드백 */
<button onClick={handleSubmit} disabled={status === "loading"}>
{status === "loading" && <Spinner />}
{status === "loading" ? "저장 중..." : "저장"}
</button>
{status === "success" && (
<p className="text-emerald-500">저장되었습니다.</p>
)}
{status === "error" && (
<p className="text-red-500" role="alert">
저장에 실패했습니다. 다시 시도해주세요.
</p>
)}
성공과 실패 시나리오를 직접 비교해보자.
버튼을 눌러보세요
왼쪽 버튼을 누르면 아무 반응이 없어 눌린 건지 알 수 없다. 오른쪽은 로딩 스피너, 성공/실패 메시지로 현재 상태를 명확하게 전달한다.
Interaction: 반응하는 인터페이스
모든 인터랙티브 요소는 사용자의 행동에 반응해야 한다. 피드백이 없으면 인터페이스가 죽어 보인다.
Hover, Active, Disabled 삼총사
세 가지 상태를 모두 갖춰야 인터랙션이 완성된다.
/* Do — 3단계 피드백 */
.button {
background: #2563eb;
cursor: pointer;
transition: all 150ms ease;
}
.button:hover {
background: #1d4ed8;
}
.button:active {
transform: scale(0.97);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Tailwind에서는 hover:bg-blue-700 active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed로 한 줄에 끝난다.
버튼 위에 마우스를 올리고 클릭해보자. 차이가 확실하다.
호버, 클릭 시 아무 변화 없음
호버 → 색상 변화, 클릭 → 눌림 효과
위쪽 버튼에 마우스를 올리고, 클릭해보세요. 왼쪽은 반응이 없어 죽은 느낌이다. 오른쪽은 hover(색상 변화), active(눌림), disabled(비활성) 세 가지 상태가 모두 살아있다.
성공 피드백
에러만 보여주고 성공은 침묵하는 경우가 많다. "저장되었습니다"라는 확인 한 줄이 사용자에게 안도감을 준다.
/* Don't — 성공 시 아무 반응 없음 */
await saveData(data);
/* Do — 토스트로 성공 알림 */
await saveData(data);
toast.success("변경사항이 저장되었습니다.");
확인 다이얼로그
삭제 같은 되돌릴 수 없는 작업 전에는 반드시 확인을 받아야 한다.
/* Don't — 바로 삭제 */
<button onClick={() => deleteItem(id)}>삭제</button>
/* Do — 확인 후 삭제 */
<button
onClick={() => {
if (confirm("정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.")) {
deleteItem(id);
}
}}
>
삭제
</button>
실제 프로덕션에서는 window.confirm 대신 커스텀 모달을 쓰는 것이 UX에 더 좋다. 삭제 버튼 색상은 빨간색으로, 취소를 기본 포커스로 두자.
삭제 버튼을 눌러서 두 방식의 차이를 체감해보자.
왼쪽은 삭제 버튼을 누르면 즉시 사라진다. 실수로 누르면 복구할 수 없다. 오른쪽은 확인 다이얼로그로 한 번 더 물어본다.
Feedback: 시스템이 살아있음을 보여주기
사용자는 시스템이 지금 뭘 하고 있는지 알아야 한다. 아무 반응이 없으면 고장이라고 생각한다.
300ms 이상이면 로딩 표시
300ms 미만의 지연은 인간이 인식하지 못한다. 그 이상이면 무언가 진행 중이라는 신호를 줘야 한다.
/* Don't — 화면이 얼어붙는다 */
const data = await fetchData(); // 2초 걸림
setItems(data);
/* Do — 스켈레톤으로 공간 확보 */
{isLoading ? (
<div className="animate-pulse space-y-3">
<div className="h-4 w-3/4 rounded bg-zinc-800" />
<div className="h-4 w-1/2 rounded bg-zinc-800" />
</div>
) : (
<Content data={data} />
)}
Empty State
데이터가 없는 화면을 빈 채로 두면 사용자는 "오류인가?" 혼란스러워한다. 무엇을 해야 하는지 안내해야 한다.
/* Don't — 빈 화면 */
{items.length === 0 && null}
/* Do — 안내 메시지 + 액션 */
{items.length === 0 && (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="rounded-full bg-zinc-800 p-4">
<InboxIcon className="h-8 w-8 text-zinc-500" />
</div>
<div>
<p className="text-sm font-medium text-zinc-300">
아직 항목이 없습니다
</p>
<p className="mt-1 text-xs text-zinc-500">
첫 번째 항목을 만들어보세요.
</p>
</div>
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white">
새 항목 만들기
</button>
</div>
)}
로딩과 빈 상태 처리를 직접 비교해보자.
0 items
버튼을 눌러보세요
버튼을 눌러보세요. 왼쪽은 로딩 중에도 빈 후에도 아무 피드백이 없다. 오른쪽은 스켈레톤으로 로딩을 보여주고, 데이터가 없으면 안내 메시지와 액션을 제안한다.
에러 복구 경로
에러를 보여주는 것만으로는 부족하다. 다음에 뭘 해야 하는지 알려줘야 한다.
/* Don't — 에러 메시지만 */
<p>오류가 발생했습니다.</p>
/* Do — 복구 경로 제시 */
<div className="text-center space-y-3">
<p className="text-sm text-red-400">데이터를 불러오지 못했습니다.</p>
<div className="flex gap-2 justify-center">
<button onClick={retry} className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white">
다시 시도
</button>
<a href="/help" className="rounded-lg border border-white/10 px-3 py-1.5 text-sm text-zinc-400">
도움말
</a>
</div>
</div>
Toast 알림: 3~5초 후 자동 사라짐
Toast는 비핵심 정보를 전달하는 용도다. 영구적으로 남아있으면 성가시고, 너무 빨리 사라지면 읽을 수 없다.
/* Don't — 사라지지 않는 toast */
toast("저장됨"); // auto-dismiss 없음
/* Do — 3\~5초 후 자동 사라짐 */
toast.success("저장되었습니다.", {
duration: 4000, // 4초
});
에러 toast는 자동으로 사라지면 안 된다. 사용자가 직접 닫을 수 있게 해야 한다.
Progress Indicator
여러 단계를 거치는 프로세스에서는 현재 어디인지 보여줘야 한다.
/* Don't — 진행 상황 표시 없음 */
<form>
{/* 5단계 폼인데 몇 번째인지 모른다 */}
</form>
/* Do — 단계 표시 */
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4, 5].map((step) => (
<div
key={step}
className={cn(
"h-2 flex-1 rounded-full",
step <= currentStep ? "bg-blue-500" : "bg-zinc-700"
)}
/>
))}
</div>
<p className="text-sm text-zinc-400">
{currentStep} / 5 단계
</p>
이전/다음 버튼으로 단계를 이동하며 차이를 느껴보자.
기본 정보
1 / 4 단계 — 기본 정보
기본 정보
이전/다음 버튼을 클릭해보세요. 왼쪽은 지금 몇 번째 단계인지, 남은 단계가 얼마나 되는지 알 수 없다. 오른쪽은 진행 바와 단계 라벨로 현재 위치를 명확하게 보여준다.
Search: 찾는 것을 빠르게
검색은 사용자가 "지금 당장 필요한 것"을 찾으려는 행동이다. 빠르고 관대해야 한다.
Autocomplete
사용자가 타이핑하는 동안 예측 결과를 보여주면 검색 속도가 빨라진다.
/* Don't — 전부 입력하고 Enter */
<input
type="text"
onKeyDown={(e) => e.key === "Enter" && search(query)}
/>
/* Do — debounce + 드롭다운 제안 */
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value); // 300ms debounce
}}
/>
{suggestions.length > 0 && (
<ul className="absolute mt-1 w-full rounded-lg border border-white/10 bg-zinc-900 shadow-xl">
{suggestions.map((item) => (
<li
key={item.id}
className="cursor-pointer px-3 py-2 text-sm hover:bg-white/5"
onClick={() => selectItem(item)}
>
{item.title}
</li>
))}
</ul>
)}
debounce는 300ms가 적당하다. 너무 짧으면 요청이 과다하고, 너무 길면 느리다고 느낀다.
No Results 처리
검색 결과가 없을 때 "0 results"만 보여주면 사용자는 막다른 길에 선다.
0 results
검색 결과가 없습니다.
다른 키워드로 검색해보세요:
검색 결과가 없을 때 빈 화면은 사용자를 막다른 길에 세운다. 대안 키워드나 인기 검색어를 제안하면 탐색을 이어갈 수 있다.
Responsive: 모든 화면에서 작동하게
반응형은 "화면 크기에 맞추기"가 아니라 "모든 맥락에서 사용 가능하게"이다.
Mobile First
CSS를 모바일부터 작성하고 큰 화면에서 확장하는 것이 자연스럽다. 반대로 하면 모바일에서 덮어쓸 CSS가 많아진다.
/* Don't — 데스크톱 우선 */
.container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
}
}
/* Do — 모바일 우선 */
.container {
display: grid;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.container {
grid-template-columns: 1fr 1fr 1fr;
}
}
Tailwind의 md:, lg: 프리픽스가 이미 모바일 우선으로 설계되어 있다. grid-cols-1 md:grid-cols-3이면 끝이다.
Viewport Units 주의
100vh는 모바일 브라우저의 주소창 높이를 무시한다. iOS Safari에서 콘텐츠가 주소창 아래로 잘리는 원인이다.
/* Don't — 모바일에서 주소창 문제 */
.hero {
height: 100vh;
}
/* Do — dvh 사용 */
.hero {
height: 100dvh; /* Dynamic Viewport Height */
}
/* 폴백 */
.hero {
height: 100vh;
height: 100dvh;
}
dvh(Dynamic Viewport Height)는 브라우저 UI가 보이거나 숨겨질 때 동적으로 변한다. 2023년부터 모든 주요 브라우저가 지원한다.
테이블 반응형 처리
넓은 테이블은 모바일에서 화면을 넘친다.
/* Don't — 테이블이 뷰포트를 넘침 */
<table className="w-full">
{/* 10개 컬럼... */}
</table>
/* Do — 가로 스크롤 래퍼 */
<div className="overflow-x-auto -mx-4 px-4">
<table className="w-full min-w-[600px]">
{/* 10개 컬럼... */}
</table>
</div>
또는 모바일에서 테이블을 카드 레이아웃으로 전환하는 것도 좋은 방법이다.
Onboarding: 자유롭게, 빠르게
건너뛰기 허용
사용자가 원할 때 언제든 튜토리얼을 건너뛸 수 있어야 한다. 강제 온보딩은 이탈의 원인이다.
/* Don't — 강제 투어, 나갈 수 없음 */
<div className="fixed inset-0 z-50 bg-black/80">
<TourStep step={currentStep} />
<button onClick={nextStep}>다음</button>
{/* 닫기 버튼 없음 */}
</div>
/* Do — 건너뛰기 + 되돌아가기 */
<div className="fixed inset-0 z-50 bg-black/80">
<TourStep step={currentStep} />
<div className="flex gap-2">
{currentStep > 1 && (
<button onClick={prevStep}>이전</button>
)}
<button onClick={nextStep}>다음</button>
<button onClick={closeTour} className="text-zinc-400">
건너뛰기
</button>
</div>
</div>
Content: 콘텐츠를 깔끔하게
텍스트 말줄임
긴 텍스트가 레이아웃을 깨지 않도록 말줄임 처리해야 한다. 하지만 전체 내용을 볼 수 있는 방법도 함께 제공해야 한다.
/* Don't — 넘치거나 잘림 */
<p>{longText}</p>
/* Do — 말줄임 + 펼치기 */
<p className="line-clamp-2">{longText}</p>
<button
onClick={() => setExpanded(!expanded)}
className="text-sm text-blue-400"
>
{expanded ? "접기" : "더 보기"}
</button>
"더 보기" 버튼을 직접 눌러보자.
React 19에서는 서버 컴포넌트가 기본이 되고, use() 훅으로 비동기 데이터를 직접 읽을 수 있으며, Actions API를 통해 폼 처리가 한결 단순해졌다. Suspense 경계와 스트리밍의 조합으로 사용자 체감 성능도 크게 향상되었다.
잘린 부분을 볼 수 있는 방법이 없다
React 19에서는 서버 컴포넌트가 기본이 되고, use() 훅으로 비동기 데이터를 직접 읽을 수 있으며, Actions API를 통해 폼 처리가 한결 단순해졌다. Suspense 경계와 스트리밍의 조합으로 사용자 체감 성능도 크게 향상되었다.
오른쪽의 '더 보기' 버튼을 클릭해보세요. 말줄임으로 레이아웃을 유지하면서도 전체 내용을 확인할 수 있는 경로를 제공한다.
날짜 포맷
"01/02/03"이 1월 2일인지 2월 1일인지 2003년인지 알 수 없다. 상대적 시간이나 로케일 형식을 쓰자.
/* Don't — 모호한 날짜 */
<span>01/02/03</span>
/* Do — 상대적 시간 또는 로케일 포맷 */
<span>{formatRelativeTime(date)}</span>
// → "2시간 전", "어제", "2026년 4월 8일"
// Intl API 활용
const formatted = new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
// → "2026년 4월 8일"
숫자 포맷
큰 숫자는 읽기 어렵다. 천 단위 구분자나 약어를 쓰자.
/* Don't */
<span>1234567</span>
/* Do */
<span>{new Intl.NumberFormat("ko-KR").format(1234567)}</span>
// → "1,234,567"
// 또는 약어
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toString();
}
// 1234567 → "1.2M"
AI Interaction: AI와 사용자 사이의 신뢰
AI 기능이 들어간 제품에서 가장 중요한 것은 투명성이다.
AI 라벨 필수
사용자가 AI와 대화하고 있다면, 그 사실을 명확히 알려야 한다. AI를 사람처럼 위장하면 신뢰가 깨진다.
/* Don't — AI임을 숨김 */
<div className="chat-bubble">
<span className="font-bold">Aria</span>
<p>{response}</p>
</div>
/* Do — AI 라벨 표시 */
<div className="chat-bubble">
<div className="flex items-center gap-1.5">
<span className="font-bold">Aria</span>
<span className="rounded-full bg-purple-500/20 px-1.5 py-0.5 text-[10px] font-medium text-purple-400">
AI
</span>
</div>
<p>{response}</p>
</div>
Streaming 응답
AI 응답을 전부 기다렸다가 한 번에 보여주면, 사용자는 10초 넘게 스피너만 바라보게 된다.
/* Don't — 전체 응답 대기 */
const response = await ai.generate(prompt);
setMessage(response); // 10초 후 한 번에 표시
/* Do — 토큰 단위 스트리밍 */
const stream = await ai.stream(prompt);
for await (const chunk of stream) {
setMessage((prev) => prev + chunk);
}
스트리밍은 체감 대기 시간을 극적으로 줄여준다. 첫 글자가 0.5초 안에 나오면 "빠르다"고 느낀다.
피드백 루프
AI 출력에 대해 사용자가 피드백할 수 있어야 한다. 이 데이터가 모델 개선의 핵심이다.
/* Don't — 읽기 전용 출력 */
<p>{aiResponse}</p>
/* Do — 피드백 UI */
<div>
<p>{aiResponse}</p>
<div className="mt-2 flex gap-2">
<button
onClick={() => submitFeedback("positive")}
className="text-zinc-400 hover:text-emerald-400"
aria-label="도움이 됐어요"
>
<ThumbsUp className="h-4 w-4" />
</button>
<button
onClick={() => submitFeedback("negative")}
className="text-zinc-400 hover:text-red-400"
aria-label="도움이 안 됐어요"
>
<ThumbsDown className="h-4 w-4" />
</button>
<button
onClick={regenerate}
className="text-zinc-400 hover:text-blue-400"
aria-label="다시 생성"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
Spatial UI: 공간 인터페이스 (visionOS)
visionOS 같은 공간 컴퓨팅 환경에서는 전통적인 2D 규칙이 달라진다.
Gaze Hover
시선이 닿으면 요소가 반응해야 한다. 터치 전에 시각적 피드백을 준다.
/* Don't — 핀치할 때만 반응 */
Button("Action") {
// tap only
}
/* Do — 시선 호버 반응 */
Button("Action") {
// tap action
}
.hoverEffect() // 시선이 닿으면 하이라이트
Depth Layering
평면적인 불투명 패널은 공간 환경에서 어색하다. Glass material과 Z축 깊이를 활용해야 한다.
/* Don't — 2D 평면 패널 */
VStack { /* ... */ }
.background(.white)
/* Do — Glass + 깊이감 */
VStack { /* ... */ }
.glassBackgroundEffect()
.offset(z: 20) // Z축으로 띄움
Sustainability: 지속 가능한 웹
웹도 탄소 발자국을 남긴다. 불필요한 리소스 소비를 줄이는 것이 UX이자 책임이다.
자동 재생 비디오 금지
사용자 동의 없이 영상을 재생하면 데이터를 소비하고, 배터리를 잡아먹고, 접근성도 해친다.
/* Don't — 자동 재생 + 루프 */
<video autoPlay loop src="/hero.mp4" />
/* Do — 클릭 재생 + lazy loading */
<video
playsInline
muted
preload="none"
poster="/hero-poster.webp"
controls
>
<source src="/hero.mp4" type="video/mp4" />
</video>
preload="none"은 사용자가 재생 버튼을 누를 때까지 영상을 다운로드하지 않는다.
에셋 최적화
3D 모델, 고해상도 이미지 등 무거운 에셋은 압축과 lazy loading이 필수다.
/* Don't — 원본 3D 모델 로드 */
<Canvas>
<Model url="/model.obj" /> {/* 50MB */}
</Canvas>
/* Do — 압축 + lazy loading */
<Suspense fallback={<LoadingPlaceholder />}>
<Canvas>
<Model url="/model.glb" /> {/* Draco 압축: 2MB */}
</Canvas>
</Suspense>
glTF/GLB 형식 + Draco 압축으로 3D 모델 크기를 90% 이상 줄일 수 있다.
카테고리별 체크리스트
배포 전에 해당 기능에 맞는 항목을 빠르게 훑어보자.
Navigation
- 현재 페이지가 내비게이션에서 하이라이트되는가?
- 뒤로 가기가 정상 동작하는가?
- URL이 현재 상태를 반영하는가?
- 앵커 링크가 smooth scroll로 이동하는가?
Forms
- 모든 입력에 보이는 라벨이 있는가?
- blur 시 인라인 검증이 동작하는가?
- 올바른 input type/inputmode를 사용하는가?
- 필수 필드가 명확히 표시되는가?
- 제출 후 로딩/성공/에러 피드백이 있는가?
Feedback
- 300ms 이상 걸리는 작업에 로딩 표시가 있는가?
- 빈 상태에 안내 메시지가 있는가?
- 에러 메시지에 복구 경로가 있는가?
- 성공 시 확인 메시지가 나오는가?
Search
- 타이핑 중 자동완성이 동작하는가?
- "결과 없음" 시 대안을 제안하는가?
Responsive
- 모바일(375px)에서 모든 기능이 사용 가능한가?
- dvh를 사용해 모바일 주소창 문제를 해결했는가?
- 테이블이 가로 스크롤 또는 카드로 처리되는가?
마치며
1편이 "무엇을 챙겨야 하는가"에 대한 답이었다면, 이 글은 "구체적으로 어떻게 챙기는가"에 대한 답이다.
내비게이션의 Active 상태, 폼의 인라인 검증, 검색의 No Results 처리, AI의 스트리밍 응답 — 하나하나는 사소하지만, 모이면 사용자가 "이 앱 잘 만들었다"고 느끼는 결정적 차이가 된다.
다음 편에서는 UI 스타일 카탈로그를 다룬다. 같은 기능이라도 어떤 시각적 스타일을 입히느냐에 따라 전혀 다른 인상을 주는, 구체적인 패턴들을 정리할 예정이다.