프론트엔드 UI/UX 체크리스트 — 실전 가이드
이 시리즈는 UI/UX Pro Max — 50개 이상의 디자인 스타일, 97개 컬러 팔레트, 57개 폰트 페어링, 99개 UX 가이드라인 등 방대한 디자인 데이터베이스를 갖춘 디자인 인텔리전스 스킬 — 의 이론적 내용을 바탕으로 작성되었다. 그 데이터를 프론트엔드 개발자가 실무에서 바로 쓸 수 있는 형태로 재구성한 것이 이 가이드의 목적이다.
각 규칙에는 Do/Don't 코드 예시를 붙였다. 먼저 챙겨야 할 것부터 순서대로 정리했으니, 위에서부터 하나씩 적용하면 된다.
접근성 (Accessibility)
접근성은 "장애인을 위한 것"이 아니다. 키보드로만 조작하는 파워유저, 마우스가 고장 난 사람, 햇빛 아래서 폰을 보는 사람 — 모두가 접근성의 대상이다. 그래서 가장 먼저 챙긴다.
색상 대비 4.5:1 이상
WCAG AA 기준이다. 일반 텍스트는 4.5:1, 큰 텍스트(18px bold 이상)는 3:1 이상을 맞춘다.
/* Don't — 대비가 부족하다 */
.text-muted {
color: #aaaaaa; /* 배경 #ffffff 대비 2.3:1 */
}
/* Do — 충분한 대비 */
.text-muted {
color: #767676; /* 배경 #ffffff 대비 4.54:1 */
}
크롬 DevTools의 색상 피커에서 대비 비율을 바로 확인할 수 있다. 습관적으로 체크하자.
실제로 어떤 차이인지 아래에서 직접 비교해보자.
이 텍스트를 읽을 수 있나요?
보조 텍스트는 더 읽기 어렵습니다
WCAG AA 미통과이 텍스트를 읽을 수 있나요?
보조 텍스트도 명확하게 읽힙니다
WCAG AA 통과어두운 배경(#09090b)에서 #aaaaaa는 대비 2.3:1로 기준 미달. #a1a1aa는 대비 6.3:1로 충분하다.
포커스 상태는 반드시 보여야 한다
outline: none을 전역으로 넣는 코드를 종종 본다. 키보드 사용자에게는 화면에서 커서가 사라지는 것과 같다.
/* Don't — 포커스를 완전히 제거 */
*:focus {
outline: none;
}
/* Do — 마우스일 때만 숨기고, 키보드는 유지 */
*:focus:not(:focus-visible) {
outline: none;
}
*:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
:focus-visible은 키보드 포커스일 때만 활성화된다. 마우스 클릭 시에는 아웃라인이 안 보이고, Tab 키로 이동할 때만 보인다. 둘 다 만족시키는 해법이다.
Tab 키를 눌러서 직접 확인해보자.
이미지에는 alt, 아이콘에는 aria-label
/* Don't */
<img src="/hero.png" />
<button><TrashIcon /></button>
/* Do */
<img src="/hero.png" alt="대시보드 메인 히어로 이미지" />
<button aria-label="항목 삭제"><TrashIcon aria-hidden="true" /></button>
장식용 이미지라면 alt=""로 빈 값을 넣는다. alt를 아예 생략하면 스크린 리더가 파일명을 읽어버린다.
키보드 내비게이션
모든 인터랙티브 요소는 Tab 키로 접근 가능해야 한다. div에 클릭 이벤트를 넣었다면, 키보드 사용자는 그 요소에 도달할 수 없다.
/* Don't — div는 탭 순서에 포함되지 않는다 */
<div onClick={handleClick}>메뉴 열기</div>
/* Do — button은 기본적으로 포커스 가능 + Enter/Space로 활성화 */
<button onClick={handleClick}>메뉴 열기</button>
시맨틱 HTML을 쓰면 접근성의 절반은 자동으로 해결된다. <button>, <a>, <input>, <select> — 이 요소들은 키보드, 스크린 리더, 포커스를 전부 기본 지원한다.
Tab 키를 눌러 직접 비교해보자.
div는 포커스가 잡히지 않아 키보드 사용자가 접근할 수 없다. button은 기본적으로 Tab + Enter/Space를 지원한다.
폼 라벨
/* Don't — 라벨 없는 인풋 */
<input type="email" placeholder="이메일을 입력하세요" />
/* Do — 라벨 연결 */
<label htmlFor="email">이메일</label>
<input id="email" type="email" placeholder="example@email.com" />
placeholder는 라벨이 아니다. 입력을 시작하면 사라진다. 스크린 리더도 placeholder를 라벨로 인식하지 않는 경우가 많다.
직접 입력해보면 차이를 느낄 수 있다.
입력하면 힌트가 사라진다
라벨이 항상 보인다
텍스트를 입력해보세요. 왼쪽은 입력하면 placeholder가 사라져 필드명을 알 수 없다. 오른쪽은 label이 항상 표시되어 스크린 리더와 사용자 모두에게 명확하다.
터치 & 인터랙션
모바일 사용자의 손가락은 마우스 포인터보다 훨씬 크다. 터치 타겟이 작으면 오탭이 나고, 피드백이 없으면 "눌린 건가?" 불안해진다.
최소 44x44px 터치 타겟
Apple과 Google 모두 권장하는 최소 사이즈다. 아이콘이 24px이어도, 터치 영역은 44px 이상이어야 한다.
/* Don't — 아이콘 크기 = 터치 영역 */
.icon-button {
width: 24px;
height: 24px;
}
/* Do — 패딩으로 터치 영역 확보 */
.icon-button {
width: 24px;
height: 24px;
padding: 10px;
/* 실제 터치 영역: 44x44px */
}
혹은 min-width와 min-height를 44px로 잡는 방법도 있다.
점선 영역이 실제 터치 가능 범위다. 차이를 비교해보자.
점선이 실제 터치 영역이다. 24px 버튼은 모바일에서 오탭 확률이 높다. 44px는 Apple/Google 권장 최소 사이즈.
로딩 중 버튼 비활성화
폼 제출 버튼을 더블 클릭하면 요청이 두 번 간다. 로딩 상태를 명확히 표시해야 한다.
/* Don't — 로딩 상태 없음 */
<button onClick={handleSubmit}>저장</button>
/* Do — 로딩 피드백 */
<button onClick={handleSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Spinner className="mr-2 h-4 w-4 animate-spin" />
저장 중...
</>
) : (
"저장"
)}
</button>
에러 피드백은 즉시, 구체적으로
"오류가 발생했습니다"는 피드백이 아니다. 사용자가 뭘 고쳐야 하는지 알려줘야 한다.
/* Don't */
{error && <p className="text-red-500">오류가 발생했습니다</p>}
/* Do */
{error && (
<p className="text-red-500" role="alert">
이메일 형식이 올바르지 않습니다. example@email.com 형태로 입력해주세요.
</p>
)}
role="alert"를 넣으면 스크린 리더가 에러 메시지를 즉시 읽어준다.
제출 버튼을 눌러서 에러 메시지의 차이를 비교해보자.
제출 버튼을 눌러보세요. 왼쪽은 뭐가 잘못인지 모른다. 오른쪽은 문제와 해결 방법을 알려주고, role='alert'로 스크린 리더가 즉시 읽는다.
버튼을 직접 클릭해서 로딩 피드백의 차이를 느껴보자.
클릭해보세요. 왼쪽은 누른 건지 알 수 없고 더블 클릭 위험이 있다. 오른쪽은 상태가 명확하고 중복 제출을 방지한다.
클릭 가능한 요소에는 cursor: pointer
사소하지만 빠지면 티 난다. 클릭 가능한 요소가 기본 커서로 남아있으면, 사용자는 "이거 누를 수 있는 건가?" 헷갈린다.
/* 클릭 가능한 모든 요소에 적용 */
button,
[role="button"],
a,
.clickable {
cursor: pointer;
}
/* 비활성 상태 */
button:disabled,
.clickable[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.5;
}
마우스를 올려보면 차이가 확연하다.
카드 제목
이 카드를 클릭하면 상세 페이지로 이동합니다
카드 제목
이 카드를 클릭하면 상세 페이지로 이동합니다
마우스를 올려보세요. 왼쪽은 클릭 가능한지 알 수 없고, 오른쪽은 hover만으로 인터랙티브 요소임을 알 수 있다.
퍼포먼스
3초 안에 로드되지 않으면 53%의 모바일 사용자가 이탈한다(Google 통계). 퍼포먼스는 UX다.
이미지 최적화 3종 세트
이미지는 대부분의 웹 페이지에서 가장 무거운 리소스다. 최적화하지 않으면 로딩 시간의 절반 이상을 이미지가 차지한다. 핵심은 세 가지다.
- WebP 포맷 — PNG/JPEG 대비 25~35% 더 작다. 모든 모던 브라우저가 지원한다.
- srcset + sizes — 디바이스 너비에 맞는 이미지만 다운로드한다. 모바일에서 1200px짜리 원본을 받을 이유가 없다.
- loading="lazy" — 뷰포트에 보일 때만 로드한다. 스크롤하지 않으면 다운로드하지 않는다.
/* Don't — 원본 PNG, lazy loading 없음 */
<img src="/hero.png" />
/* Do — WebP + srcset + lazy loading */
<img
src="/hero.webp"
srcSet="/hero-480.webp 480w, /hero-768.webp 768w, /hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
loading="lazy"
alt="히어로 이미지"
/>
srcSet은 브라우저에게 "이 이미지는 480px, 768px, 1200px 버전이 있다"고 알려주고, sizes는 "이 이미지가 뷰포트의 몇 %를 차지하는지" 알려준다. 브라우저가 둘을 조합해서 최적 크기를 자동 선택한다.
Next.js를 쓴다면 next/image가 이 세 가지를 전부 자동으로 해준다. 프레임워크의 이미지 컴포넌트를 적극 활용하자.
콘텐츠 점핑(Layout Shift) 방지
이미지나 광고가 뒤늦게 로드되면서 텍스트가 밀려나는 현상. CLS(Cumulative Layout Shift)로 측정된다.
/* Don't — 높이 미지정 */
img {
width: 100%;
}
/* Do — aspect-ratio로 공간 확보 */
img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
aspect-ratio를 지정하면 이미지가 로드되기 전에도 올바른 공간을 차지한다.
버튼을 눌러 이미지 로딩을 시뮬레이션하면 차이를 확인할 수 있다.
아래 텍스트가 밀려난다
텍스트 위치 고정
버튼을 눌러 이미지 로딩을 시뮬레이션하세요. 왼쪽은 이미지가 나타나며 텍스트가 밀린다(CLS). 오른쪽은 aspect-ratio로 공간이 미리 확보되어 점핑이 없다.
prefers-reduced-motion 존중
전정 기관 장애가 있는 사용자는 애니메이션으로 어지러움을 느낀다. 시스템 설정에서 "모션 줄이기"를 켠 사용자를 존중해야 한다.
/* 모션 줄이기 설정 시 애니메이션 제거 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
토글을 눌러서 "모션 줄이기" 설정을 시뮬레이션해보자.
항상 바운스
모션 허용 → 바운스
오른쪽은 시스템 설정에 따라 애니메이션을 끈다. 토글로 시뮬레이션해보자.
레이아웃 & 반응형
viewport meta 태그
이게 빠지면 모바일에서 데스크톱 레이아웃이 축소되어 보인다. Next.js 같은 프레임워크에서는 자동 처리되지만, 직접 HTML을 작성할 때는 잊기 쉽다.
<meta name="viewport" content="width=device-width, initial-scale=1" />
본문 최소 16px
모바일 Safari는 폰트 크기가 16px 미만인 <input>을 포커스하면 자동으로 확대한다. 사용자 의도가 아닌 줌이 발생하면 UX가 깨진다.
/* Don't */
body {
font-size: 14px;
}
/* Do */
body {
font-size: 16px;
}
/* 데스크톱에서 더 큰 폰트를 원하면 미디어 쿼리로 */
@media (min-width: 768px) {
body {
font-size: 18px;
}
}
가로 스크롤 방지
모바일에서 가로 스크롤이 생기면 사용자 경험이 심각하게 나빠진다.
/* 루트 레벨에서 overflow 제어 */
html,
body {
overflow-x: hidden;
}
/* 원인을 찾아 고치는 게 더 좋다 */
/* 흔한 원인: 100vw (스크롤바 포함), 음수 마진, 고정폭 요소 */
overflow-x: hidden은 응급처치다. 근본 원인을 찾아야 한다. 크롬 DevTools에서 * { outline: 1px solid red } 를 넣으면 어떤 요소가 뷰포트를 넘어가는지 바로 보인다.
z-index 관리
/* Don't — 숫자 경쟁 */
.modal {
z-index: 99999;
}
.tooltip {
z-index: 999999;
}
/* Do — 단계별 토큰 */
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-popover: 500;
--z-toast: 600;
}
.modal {
z-index: var(--z-modal);
}
z-index 값을 CSS 변수로 체계화하면, 새로운 레이어를 추가할 때도 충돌 없이 관리할 수 있다.
z: 999
z: 9999
z: 99999
--z-modal: 400
--z-popover: 500
--z-toast: 600
왼쪽은 숫자가 무한히 커지는 z-index 전쟁. 오른쪽은 100단위 토큰으로 체계화하면 새 레이어를 추가해도 충돌 없이 관리할 수 있다.
타이포그래피 & 색상
읽기 편한 텍스트는 사용자가 의식하지 못한다. 읽기 불편한 텍스트는 바로 느낀다.
line-height: 1.5 ~ 1.75
/* Don't — 줄 간격이 좁아 답답하다 */
p {
line-height: 1.2;
}
/* Do — 본문에 적합한 줄 간격 */
p {
line-height: 1.6;
}
/* 제목은 더 타이트하게 */
h1,
h2,
h3 {
line-height: 1.2;
}
본문 텍스트는 1.5~1.75, 제목은 1.1~1.3이 적당하다. 줄 간격은 폰트에 따라 다르니, 눈으로 확인하면서 조절하자.
같은 텍스트로 비교해보면 차이가 확연하다.
프론트엔드 UI/UX를 챙기라는 말은 많이 듣는다. 그런데 막상 구현할 때 보면, 뭘 먼저 챙겨야 하는지 기준이 없다. 색상 대비부터 터치 타겟까지, 전부 중요하다고 하니까 전부 놓치게 된다.
프론트엔드 UI/UX를 챙기라는 말은 많이 듣는다. 그런데 막상 구현할 때 보면, 뭘 먼저 챙겨야 하는지 기준이 없다. 색상 대비부터 터치 타겟까지, 전부 중요하다고 하니까 전부 놓치게 된다.
같은 텍스트인데 줄 간격만 달라도 가독성 차이가 크다. 본문 텍스트는 1.5~1.75가 적당하다.
한 줄 65~75자
한 줄이 너무 길면 시선이 다음 줄로 넘어갈 때 길을 잃는다. 영어 기준 65~75자, 한글 기준 35~45자가 적당하다.
/* Do — max-width로 줄 길이 제어 */
.article-body {
max-width: 65ch;
margin: 0 auto;
}
ch 단위는 해당 폰트의 0 문자 너비 기준이라, 폰트가 바뀌어도 대략적인 글자 수가 유지된다.
폰트 페어링
제목과 본문에 같은 폰트를 쓰면 밋밋하다. 대비가 있어야 시각적 계층이 생긴다.
/* 클래식 조합: Sans-serif 제목 + Serif 본문 */
h1,
h2,
h3 {
font-family: "Pretendard", sans-serif;
font-weight: 700;
}
p,
li {
font-family: "Noto Serif KR", serif;
font-weight: 400;
}
/* 모던 조합: 같은 패밀리 내 웨이트 대비 */
h1 {
font-family: "Pretendard", sans-serif;
font-weight: 800;
letter-spacing: -0.02em;
}
p {
font-family: "Pretendard", sans-serif;
font-weight: 400;
letter-spacing: 0;
}
페어링의 핵심은 대비다. 웨이트, 스타일(Serif vs Sans-serif), 크기에서 차이를 만들어야 한다.
애니메이션
애니메이션은 양념이다. 적당히 넣으면 고급스럽고, 과하면 어지럽다.
마이크로인터랙션: 150~300ms
버튼 호버, 토글 전환, 드롭다운 열기 같은 마이크로인터랙션은 150~300ms가 적당하다. 100ms 미만이면 변화를 인식하기 어렵고, 500ms 이상이면 느리다고 느낀다.
/* Don't — 너무 느리다 */
.button {
transition: all 0.5s ease;
}
/* Do — 적절한 속도와 특정 속성 */
.button {
transition: background-color 200ms ease, transform 150ms ease;
}
transition: all은 피하자. 의도치 않은 속성까지 애니메이션되고, 성능에도 좋지 않다. 변경되는 속성을 명시하는 게 좋다.
마우스를 올려보면 체감 속도 차이를 느낄 수 있다.
마우스를 올리거나 탭해보세요. 왼쪽은 500ms라 반응이 느리고 답답하다. 오른쪽은 200ms로 즉각적이면서 자연스럽다.
transform과 opacity만 애니메이션
width, height, top, left를 애니메이션하면 매 프레임마다 레이아웃을 다시 계산한다(reflow). transform과 opacity는 GPU 가속을 받아 60fps를 유지할 수 있다.
/* Don't — reflow 발생 */
.card:hover {
margin-top: -4px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
/* Do — GPU 가속 */
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
스켈레톤 스크린
로딩 스피너보다 스켈레톤 UI가 체감 속도를 높인다. 사용자가 "곧 콘텐츠가 나오겠구나"라고 예측할 수 있기 때문이다.
/* 스켈레톤 컴포넌트 예시 */
function PostCardSkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-48 rounded-lg bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700" />
</div>
);
}
스켈레톤의 형태가 실제 콘텐츠와 비슷할수록 효과가 좋다.
직접 비교해보면 체감 대기 시간이 다르다.
스피너는 '무언가 로딩 중'만 전달한다. 스켈레톤은 곧 나타날 콘텐츠의 구조를 미리 보여줘 체감 대기 시간을 줄인다.
스타일 일관성
아이콘은 SVG, 이모지는 쓰지 않는다
이모지는 OS마다 렌더링이 다르다. Windows에서 이쁜 이모지가 Linux에서는 깨진 사각형이 될 수 있다.
/* Don't — OS 의존적 */
<button>🗑️ 삭제</button>
/* Do — SVG 아이콘 라이브러리 */
import { Trash2 } from "lucide-react";
<button>
<Trash2 className="h-4 w-4" />
삭제
</button>
lucide, heroicons, phosphor 같은 SVG 아이콘 라이브러리를 쓰면 크기, 색상, 스트로크를 자유롭게 제어할 수 있다.
이모지는 OS마다 렌더링이 다르고 크기/색상 제어가 불가능하다. SVG 아이콘은 일관되고 커스터마이징이 자유롭다.
일관된 아이콘 사이징
/* Don't — 아이콘마다 크기가 다르다 */
<Home className="h-5 w-5" />
<Settings className="h-6 w-6" />
<User className="h-4 w-4" />
/* Do — 맥락별 사이즈 토큰 */
const ICON_SIZE = {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
} as const;
<Home className={ICON_SIZE.md} />
<Settings className={ICON_SIZE.md} />
<User className={ICON_SIZE.md} />
라이트/다크 모드에서 모두 확인
다크 모드를 지원한다면, 양쪽 모드에서 모두 확인해야 한다. 특히 자주 빠지는 것들:
/* Glass card — 다크 모드에서 투명도 조정 */
.glass-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
@media (prefers-color-scheme: dark) {
.glass-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
/* 텍스트 대비 — 다크 배경에서 회색 텍스트 */
.text-secondary {
color: #6b7280; /* 라이트 모드 OK */
}
.dark .text-secondary {
color: #9ca3af; /* 다크 모드에서 대비 보강 */
}
Tailwind CSS를 쓴다면 dark: 프리픽스로 쉽게 관리할 수 있다.
차트 & 데이터 시각화
데이터를 시각화할 때도 규칙이 있다. 잘못된 차트 타입을 쓰면 데이터가 왜곡된다.
데이터 타입에 맞는 차트
| 데이터 유형 | 적합한 차트 | 부적합한 차트 |
|---|---|---|
| 시간에 따른 변화 | Line, Area | Pie |
| 카테고리 비교 | Bar (수직/수평) | Line |
| 비율/구성 | Pie, Donut, Stacked Bar | Scatter |
| 상관관계 | Scatter, Bubble | Bar |
| 분포 | Histogram, Box Plot | Pie |
접근성 팔레트
색상만으로 데이터를 구분하면 색각 이상자는 차트를 읽을 수 없다.
/* Don't — 색상만으로 구분 */
const colors = ["#ef4444", "#22c55e", "#3b82f6"];
/* Do — 색상 + 패턴 + 라벨 */
const series = [
{ color: "#2563eb", pattern: "solid", label: "매출" },
{ color: "#dc2626", pattern: "dashed", label: "비용" },
{ color: "#16a34a", pattern: "dotted", label: "이익" },
];
패턴(실선, 점선, 파선)이나 형태(원, 삼각형, 사각형)를 함께 사용하면 색상 없이도 구분 가능하다.
차트 대신 테이블
복잡한 차트보다 간단한 테이블이 나을 때가 많다. 정확한 수치 비교가 필요하거나, 데이터 포인트가 적을 때는 테이블이 더 효과적이다. 스크린 리더 사용자에게도 테이블이 훨씬 접근성이 좋다.
마치며
이 목록의 모든 항목을 한 번에 챙기기는 어렵다. 접근성과 터치 타겟부터 챙기자. 이 두 가지만 확실히 해도, 이전보다 훨씬 많은 사용자가 편하게 쓸 수 있는 인터페이스가 된다. 나머지는 그 다음이다.
완벽한 UI/UX는 없다. 하지만 체크리스트가 있으면, 최소한 "이건 빠뜨렸네"라는 후회는 줄일 수 있다.