Back
ESSAY비개발자를 위한 바이브코딩 안내서
DEC 29, 2025

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)
□ 가로 스크롤이 생기지 않는가?
□ 입력창이 너무 작지 않은가?
□ 팝업/모달이 화면을 벗어나지 않는가?

모바일 테스트 방법

  1. 브라우저 개발자 도구: F12 → 기기 아이콘 클릭 → 기기 선택

  2. 실제 핸드폰: 배포 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} />

번들 크기 확인

배포 후 사이트가 느리다면:

  1. 브라우저 개발자 도구 → Network 탭

  2. 새로고침 → 파일 크기 확인

  3. 큰 파일(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이 더 잘 작성합니다. 우리는 '이런 게 있구나!' 라는 것만 알고 필요할 때 꺼내쓰면 됩니다

Thank you for reading.

Based in Seoul
Since 2024