Part 5.3 사용성 높이기
배포에 성공했습니다. URL도 생겼습니다. 하지만 우리는 이걸 알아야 합니다.
작동하는 앱 ≠ 사용할 수 있는 앱
만드는 우리는 "몇 초 기다리면 로딩 끝나는데..."를 알지만, 사용자는 모릅니다. 아무 피드백 없이 3초가 지나면 "뭐지?" 하고 나가버립니다. 로딩이 길다는 것도 문제가 될 수도 있지만, 사용자에게 로딩 중이라는 걸 표시하는 게 더 중요하다는거죠.
이번 글에서는 이런 사용자를 배려할 수 있는 지점들을 알아가 보겠습니다.
1. 에러 핸들링: 문제가 생겨도 당황하지 않게
사용자가 에러를 만나면 어떻게 될까요? (일단 끔찍하죠..)
나쁜 경험:
- 하얀 화면만 나옴
- 영어로 된 기술적 에러 메시지
- 아무 버튼도 안 눌림
좋은 경험:
- "문제가 발생했습니다. 다시 시도해주세요." 메시지
- "새로고침" 또는 "홈으로" 버튼 제공
- 무엇이 잘못됐는지 간단히 안내
기본 에러 처리 패턴
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); // 에러 상태 추가
useEffect(() => {
fetchProducts()
.then(data => setProducts(data))
.catch(err => setError(err.message)) // 에러 저장
.finally(() => setLoading(false));
}, []);
// 에러 발생 시 사용자 친화적 메시지
if (error) {
return (
<div className="error-container">
<p>상품을 불러오는 데 실패했습니다.</p>
<button onClick={() => window.location.reload()}>
다시 시도
</button>
</div>
);
}
if (loading) return <p>로딩 중...</p>;
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
폼 에러 처리
const handleSubmit = async (e) => {
e.preventDefault();
setError(null); // 이전 에러 초기화
try {
await submitForm(data);
setSuccess(true);
} catch (err) {
// 사용자가 이해할 수 있는 메시지로 변환
if (err.message.includes('email')) {
setError('이메일 형식이 올바르지 않습니다.');
} else if (err.message.includes('network')) {
setError('네트워크 연결을 확인해주세요.');
} else {
setError('오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
}
};
return (
<form onSubmit={handleSubmit}>
{error && <p className="error-message">{error}</p>}
{/* 폼 필드들 */}
</form>
);
2. 로딩 상태: 기다리는 동안 안심시키기
사용자는 피드백 없이 기다리는 걸 싫어합니다. 0.5초만 지나도 "멈춘 건가?" 생각합니다.
기본 로딩 표시
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="loading">
<div className="spinner"></div>
<p>대시보드를 불러오는 중...</p>
</div>
);
}
return <div>{/* 대시보드 내용 */}</div>;
}
간단한 CSS 스피너 (요새는 LLM이 간단한 디자인은 정말 잘합니다. '힙한 스피너 만들어줘' 라고만 해도 잘 만들어주니까, 잘 사용해보시길 바랍니다)
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
버튼 로딩 상태
버튼을 눌렀을 때도 피드백이 필요합니다.
function SubmitButton() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleClick = async () => {
setIsSubmitting(true);
try {
await submitData();
} finally {
setIsSubmitting(false);
}
};
return (
<button onClick={handleClick} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장하기'}
</button>
);
}
스켈레톤 UI
로딩 중에 실제 레이아웃과 비슷한 "뼈대"를 보여줍니다.
// 로딩 중
<div className="card skeleton">
<div className="skeleton-image"></div>
<div className="skeleton-text"></div>
<div className="skeleton-text short"></div>
</div>
// CSS
.skeleton {
background: #e0e0e0;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
위 모든 내용은 이미 LLM이 당연히 더 잘 작성합니다. 우리는 이런 내용을 머릿속에 집어넣고, 필요할 때, '이거이거 만들어줘' 라고 명령만 해주시면 됩니다.
3. 모바일 반응형: 어디서든 편하게
사용자의 절반 이상이 모바일로 접속합니다. 모바일에서 깨지면 절반을 잃는 겁니다.
뷰포트 설정 확인
index.html에 이 태그가 있어야 합니다:
<meta name="viewport" content="width=device-width, initial-scale=1.0">
반응형 CSS 기본
/* 기본: 모바일 */
.container {
padding: 16px;
font-size: 16px;
}
/* 태블릿 이상 */
@media (min-width: 768px) {
.container {
padding: 24px;
max-width: 720px;
margin: 0 auto;
}
}
/* 데스크톱 이상 */
@media (min-width: 1024px) {
.container {
max-width: 960px;
}
}
Tailwind CSS 사용 시
Tailwind는 반응형이 기본 내장되어 있습니다:
<div className="p-4 md:p-6 lg:p-8">
<h1 className="text-xl md:text-2xl lg:text-3xl">제목</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 카드들 */}
</div>
</div>
-
p-4: 기본 (모바일) -
md:p-6: 768px 이상 -
lg:p-8: 1024px 이상
모바일 체크 포인트
□ 버튼이 손가락으로 누를 만큼 큰가? (최소 44x44px)
□ 텍스트가 너무 작지 않은가? (최소 16px)
□ 가로 스크롤이 생기지 않는가?
□ 입력창이 너무 작지 않은가?
□ 팝업/모달이 화면을 벗어나지 않는가?
모바일 테스트 방법
-
브라우저 개발자 도구: F12 → 기기 아이콘 클릭 → 기기 선택
-
실제 핸드폰: 배포 URL을 핸드폰 브라우저에서 열기
모바일에서는 '반응형'을 먼저 하고, 손가락으로 누를 만큼 큰지, 텍스트크기는 어떤지 등은 약간 뒤로 미루셔도 괜찮습니다. LLM한테 위 요소를 동시에 맡기면 작업이 깔끔하게 처리가 안되더라구요.
그리고 프로젝트를 만들 때, Tailwind를 쓰겠다고 미리 프롬프트로 주는 것도 방법입니다. 자연스레 반응형이 잡힐거에요.
4. 성능 최적화: 빠르게 로딩되게
느린 앱은 사용자가 기다려주지 않습니다. 3초 이상 걸리면 53%가 이탈합니다.
이미지 최적화
이미지가 성능의 가장 큰 적입니다.
// ❌ 원본 이미지 그대로
<img src="/huge-image.png" />
// ✅ 크기 지정 + lazy loading
<img
src="/optimized-image.webp"
width={400}
height={300}
loading="lazy" // 화면에 보일 때 로딩
alt="상품 이미지"
/>
Next.js 사용 시:
import Image from 'next/image';
<Image
src="/image.png"
width={400}
height={300}
alt="설명"
/>
// 자동으로 최적화됨
불필요한 리렌더링 방지
// ❌ 매번 새 객체 생성 → 불필요한 리렌더링
<Child style={{ color: 'red' }} />
// ✅ 고정 값은 밖으로
const style = { color: 'red' };
<Child style={style} />
번들 크기 확인
배포 후 사이트가 느리다면:
-
브라우저 개발자 도구 → Network 탭
-
새로고침 → 파일 크기 확인
-
큰 파일(1MB 이상)이 있다면 최적화 필요
AI에게 요청:
내 앱 번들 크기가 너무 커.
어떻게 줄일 수 있는지 알려줘.
5. 사용자 피드백: 행동에 반응하기
사용자의 모든 행동에는 피드백이 있어야 합니다. 꼭 다 고려하는 걸 추천드립니다. 빠르게 개발할 때는 성공피드백만 있어도 되기는 하지만, 좀 더 나은 사용성을 고려하다면, 실패피드백도 넣는 것을 추천드립니다.
성공/실패 피드백
const handleSave = async () => {
try {
await saveData();
// ✅ 성공 피드백
setMessage({ type: 'success', text: '저장되었습니다!' });
} catch (error) {
// ✅ 실패 피드백
setMessage({ type: 'error', text: '저장에 실패했습니다.' });
}
};
// 메시지 표시
{message && (
<div className={`toast ${message.type}`}>
{message.text}
</div>
)}
토스트 메시지 CSS
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 8px;
color: white;
animation: fadeInOut 3s forwards;
}
.toast.success { background: #4caf50; }
.toast.error { background: #f44336; }
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(20px); }
10% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; }
100% { opacity: 0; }
}
버튼/링크 호버 효과
button {
transition: all 0.2s ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
빈 상태 표시
데이터가 없을 때도 안내가 필요합니다.
if (todos.length === 0) {
return (
<div className="empty-state">
<img src="/empty-illustration.svg" alt="" />
<h3>아직 할 일이 없어요</h3>
<p>위의 입력창에서 첫 번째 할 일을 추가해보세요!</p>
</div>
);
}
위에서도 말씀드렷듯 이미 코드는 LLM이 더 잘 작성합니다. 우리는 '이런 게 있구나!' 라는 것만 알고 필요할 때 꺼내쓰면 됩니다
Related Articles
Thank you for reading.