React
UI/UX
Web Development
Accessibility
Mobile
시작: 작은 불편함의 발견
블로그에 태그 필터 기능을 추가하고 모바일에서 테스트하던 중 이런 생각이 들었습니다.
"태그 버튼이 너무 작아서 터치하기 어렵다..."
이 작은 불편함이 모바일 UX 개선의 시작이었습니다.
문제 1: 모바일 터치 영역 부족
증상
// 초기 구현
<Badge className="px-2 py-1">
{tag}
</Badge>
- 태그 버튼 높이: 약 28px
- iOS/Android 권장 최소 터치 영역: 44px
- 결과: 정확한 터치가 어려움
해결: 터치 영역 확보
<Badge
className="min-h-[2.5rem] touch-manipulation px-3 py-2"
onClick={() => handleTagClick(tag)}
>
{tag}
</Badge>
적용한 최적화:
min-h-[2.5rem]: 최소 높이 40px 보장touch-manipulation: 터치 지연 제거 (더블탭 줌 비활성화)px-3 py-2: 충분한 내부 여백으로 터치 영역 확대
결과
터치 성공률이 눈에 띄게 향상되었고, 사용자가 의도한 태그를 정확히 선택할 수 있게 되었습니다.
문제 2: 태그 클릭 시 이벤트 전파
증상
PostCard 내부의 태그를 클릭하면 카드 전체가 클릭되어 상세 페이지로 이동
<Link href={`/blog/${post.slug}`}>
<div className="post-card">
{/* ... */}
<Badge onClick={() => handleTagClick(tag)}>
{tag}
</Badge>
</div>
</Link>
해결: 이벤트 전파 차단
const handleTagClick = (e: React.MouseEvent, tag: string) => {
e.preventDefault(); // 기본 동작 방지
e.stopPropagation(); // 부모 요소로 이벤트 전파 차단
router.push(`/blog?tag=${encodeURIComponent(tag)}`);
};
적용:
<Badge
onClick={(e) => handleTagClick(e, tag)}
className="cursor-pointer hover:bg-primary/10"
>
{tag}
</Badge>
문제 3: URL 파라미터 관리
왜 URL 파라미터인가?
태그 필터 상태를 관리하는 방법은 여러 가지가 있습니다:
- 컴포넌트 상태 (useState)
- 전역 상태 (Zustand, Redux)
- URL 파라미터 ✅
URL 파라미터를 선택한 이유:
- 북마크 가능:
/blog?tag=react를 저장하면 필터 상태 유지 - 공유 가능: 링크를 공유하면 동일한 필터 적용
- 새로고침 안전: 페이지를 새로고침해도 상태 유지
구현: 태그 토글 기능
const handleTagClick = (tag: string) => {
const params = new URLSearchParams();
if (selectedTag === tag) {
// 같은 태그 재클릭 → 필터 해제
params.delete("tag");
} else {
// 새 태그 선택
params.set("tag", tag);
}
// 태그 변경 시 첫 페이지로
params.delete("page");
router.push(`/blog?${params.toString()}`);
window.scrollTo({ top: 0, behavior: "smooth" });
};
URL 인코딩 처리
// 특수문자가 포함된 태그 안전하게 처리
router.push(`/blog?tag=${encodeURIComponent(tag)}`);
// 예시:
// "C++" → ?tag=C%2B%2B
// "Node.js" → ?tag=Node.js
문제 4: 태그 목록 UI 설계
요구사항
- 모든 태그를 한눈에 볼 수 있어야 함
- 현재 선택된 태그 시각적 표시
- 모바일에서도 자연스러운 줄바꿈
구현
<div className="flex flex-wrap gap-2 -mx-1 px-1">
{allTags.map((tag) => {
const isSelected = selectedTag === tag;
return (
<Badge
key={tag}
variant={isSelected ? "default" : "gray"}
className={`
px-3 py-2 text-sm font-normal cursor-pointer
transition-all touch-manipulation
min-h-[2.5rem] flex items-center
${isSelected
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "hover:bg-primary/10 hover:text-primary"
}
`}
onClick={() => handleTagClick(tag)}
>
{tag}
</Badge>
);
})}
</div>
핵심 CSS:
flex-wrap: 태그가 많을 때 자동 줄바꿈gap-2: 태그 사이 간격 균일하게transition-all: 부드러운 상태 전환 애니메이션- 조건부 스타일: 선택 여부에 따라 다른 색상
필터 초기화 버튼
{selectedTag && (
<Button
variant="outline"
size="sm"
onClick={() => {
router.push("/blog");
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
필터 초기화 ✕
</Button>
)}
문제 5: 접근성 고려
ARIA 레이블 추가
<Badge
role="button"
aria-pressed={isSelected}
aria-label={`${tag} 태그로 필터링`}
onClick={() => handleTagClick(tag)}
>
{tag}
</Badge>
키보드 네비게이션
<Badge
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTagClick(tag);
}
}}
>
{tag}
</Badge>
엣지 케이스 처리
1. 태그가 없는 포스트
// 옵셔널 체이닝으로 안전하게 처리
const matchTags = post.tags?.some(tag =>
tag.toLowerCase().includes(searchLower)
) || false;
// UI에서도 안전하게
{post.tags?.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
2. 빈 검색 결과
{filteredPosts.length > 0 ? (
filteredPosts.map((post) => <PostCard key={post.id} post={post} />)
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-20 text-muted-foreground"
>
<p className="text-lg">검색 결과가 없습니다</p>
<p className="text-sm mt-2">다른 검색어를 시도해보세요</p>
</motion.div>
)}
최종 사용자 경험
Before
- 태그 클릭이 부정확함
- 카드 전체가 클릭됨
- URL에 상태가 없어 공유 불가
After
- 정확한 터치 입력
- 태그만 독립적으로 클릭 가능
- URL로 필터 상태 공유 가능
- 모바일에서도 편안한 사용
결론
UI 개선은 작은 디테일의 합입니다.
핵심 교훈:
- 모바일 터치 영역은 최소 44px
- 이벤트 전파를 항상 고려하라
- 상태는 URL에 담아 공유 가능하게
- 접근성은 선택이 아닌 필수
작은 불편함을 발견하고 개선하는 과정이 좋은 사용자 경험을 만듭니다.