포스트

[이제와서 시작하는 React 마스터하기 #12] 에러 처리와 로딩 화면 - 사용자 경험 챙기기

[이제와서 시작하는 React 마스터하기 #12] 에러 처리와 로딩 화면 - 사용자 경험 챙기기

100일 챌린지 Day 12 - 앱이 깨지지 않게! 에러가 나도 괜찮은 화면을 보여주고, 로딩 중에도 멋진 화면을 만들어봐요. 🛡️

배울 내용

이번 시간에는 에러와 로딩 상태를 잘 처리하는 방법을 배워봐요.

  • 에러가 났을 때 앱 전체가 멈추지 않게 하기
  • Error Boundary로 에러 화면 예쁘게 보여주기
  • Suspense로 로딩 화면 만들기
  • 코드 분할(Code Splitting)로 앱 가볍게 만들기

시작하기 전에

이번 내용을 더 잘 이해하려면 아래 내용을 먼저 보고 오세요:


일상 비유로 이해하기

에러 처리와 로딩을 일상으로 비유하면 이렇습니다:

🏪 편의점 운영하기

  • 에러 없이: 모든 물건이 진열돼 있고 손님이 쇼핑해요
  • 에러 발생: 전기가 나가서 가게가 완전히 멈춰요 (최악!)
  • Error Boundary: “임시 휴업” 안내문을 붙여요 (친절)
  • Suspense: “잠시만 기다려주세요~” 안내문 (로딩 중)

🚗 자동차 계기판

  • 에러: 엔진 경고등이 켜져요
  • Error Boundary: 경고등 대신 “서비스센터 방문 권장” 메시지
  • Loading: “시동 거는 중…” 표시
  • Suspense: “내비게이션 로딩 중…” 스피너

1️⃣ 기본 에러 처리

에러가 나면 어떻게 되나요?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function BrokenComponent() {
  const user = null;

  return (
    <div>
      <h1>{user.name}</h1> {/* 💥 에러! user가 null이에요 */}
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>내 앱</h1>
      <BrokenComponent />
      <p>이 글은 절대 보이지 않아요</p> {/* 에러 때문에 앱 전체가 멈춤 */}
    </div>
  );
}

결과: 하얀 화면만 보여요! 사용자는 무슨 일이 일어났는지 몰라요.

조건문으로 에러 방지하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SaferComponent() {
  const user = null;

  // ✅ 조건문으로 체크
  if (!user) {
    return <div>사용자 정보가 없습니다</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
    </div>
  );
}

useState로 에러 상태 관리하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(response => {
        if (!response.ok) {
          throw new Error('사용자를 찾을 수 없습니다');
        }
        return response.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  // 로딩 중
  if (loading) {
    return <div>로딩 중...</div>;
  }

  // 에러 발생
  if (error) {
    return (
      <div style=>
        <h2>오류가 발생했습니다</h2>
        <p>{error}</p>
      </div>
    );
  }

  // 정상
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

패턴:

  1. loading: 데이터를 가져오는 중
  2. error: 에러가 발생했을 때
  3. 정상: 데이터가 있을 때

2️⃣ Error Boundary - 에러 안전망

Error Boundary란?

컴포넌트에서 에러가 나도 앱 전체가 멈추지 않게 하는 안전망이에요.

중요: Error Boundary는 클래스 컴포넌트로만 만들 수 있어요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  // 에러가 발생하면 이 메서드가 호출돼요
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  // 에러 정보를 기록할 수 있어요
  componentDidCatch(error, errorInfo) {
    console.error('에러 발생:', error);
    console.error('에러 위치:', errorInfo.componentStack);
  }

  render() {
    // 에러가 발생했으면 대체 UI를 보여줘요
    if (this.state.hasError) {
      return (
        <div style=>
          <h2>😢 문제가 발생했습니다</h2>
          <p>잠시 후 다시 시도해주세요</p>
          <button onClick={() => window.location.reload()}>
            새로고침
          </button>
        </div>
      );
    }

    // 정상이면 자식 컴포넌트를 렌더링해요
    return this.props.children;
  }
}

export default ErrorBoundary;

Error Boundary 사용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import ErrorBoundary from './ErrorBoundary';

function BrokenComponent() {
  throw new Error('일부러 에러를 냈어요!');
}

function App() {
  return (
    <div>
      <h1>내 앱</h1>

      {/* ErrorBoundary로 감싸기 */}
      <ErrorBoundary>
        <BrokenComponent />
      </ErrorBoundary>

      <p>이 글은 보여요! ✅</p>
    </div>
  );
}

결과: BrokenComponent에서 에러가 나도 앱의 다른 부분은 정상 작동해요!

실습: 사용자 카드 목록 보호하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useState } from 'react';
import ErrorBoundary from './ErrorBoundary';

function UserCard({ user }) {
  // 데이터가 이상하면 에러를 던져요
  if (!user || !user.name) {
    throw new Error('사용자 데이터가 올바르지 않습니다');
  }

  return (
    <div style=>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

function UserList() {
  const users = [
    { id: 1, name: '김철수', email: 'kim@example.com' },
    { id: 2, name: '이영희', email: 'lee@example.com' },
    { id: 3 }, // 💥 이름이 없어서 에러!
    { id: 4, name: '박민수', email: 'park@example.com' }
  ];

  return (
    <div>
      <h2>사용자 목록</h2>
      {users.map(user => (
        // 각 카드를 ErrorBoundary로 보호
        <ErrorBoundary key={user.id}>
          <UserCard user={user} />
        </ErrorBoundary>
      ))}
    </div>
  );
}

결과: 3번 카드만 에러 메시지가 보이고, 나머지는 정상적으로 표시돼요!


3️⃣ Suspense - 로딩 화면 쉽게 만들기

Suspense란?

컴포넌트가 준비될 때까지 로딩 화면을 보여주는 기능이에요.

lazy로 컴포넌트 지연 로딩하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Suspense, lazy } from 'react';

// lazy: 필요할 때만 컴포넌트를 불러와요
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h1>내 앱</h1>

      {/* Suspense로 감싸고 fallback 제공 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

동작 방식:

  1. 처음에는 “로딩 중…” 메시지가 보여요
  2. HeavyComponent가 불러와지면 실제 컴포넌트가 보여요

실습: 멋진 로딩 스피너 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Suspense, lazy } from 'react';

// 로딩 스피너 컴포넌트
function LoadingSpinner() {
  return (
    <div style=>
      <div style=></div>
      <p style=>
        페이지를 불러오는 중...
      </p>

      <style>{`
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      `}</style>
    </div>
  );
}

// 페이지 컴포넌트들
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));

function App() {
  const [page, setPage] = useState('home');

  return (
    <div>
      <nav style=>
        <button onClick={() => setPage('home')}></button>
        <button onClick={() => setPage('about')}>소개</button>
        <button onClick={() => setPage('profile')}>프로필</button>
      </nav>

      <Suspense fallback={<LoadingSpinner />}>
        {page === 'home' && <HomePage />}
        {page === 'about' && <AboutPage />}
        {page === 'profile' && <ProfilePage />}
      </Suspense>
    </div>
  );
}

4️⃣ Error Boundary + Suspense 함께 사용하기

두 기능을 함께 사용하면 완벽해요!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { Suspense, lazy } from 'react';
import ErrorBoundary from './ErrorBoundary';
import LoadingSpinner from './LoadingSpinner';

const UserDashboard = lazy(() => import('./UserDashboard'));
const Analytics = lazy(() => import('./Analytics'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <div style=>
      <h1>대시보드</h1>

      {/* 에러도 잡고, 로딩도 처리 */}
      <ErrorBoundary>
        <Suspense fallback={<LoadingSpinner />}>
          <UserDashboard />
        </Suspense>
      </ErrorBoundary>

      <div style=>
        <ErrorBoundary>
          <Suspense fallback={<div>분석 자료 로딩 중...</div>}>
            <Analytics />
          </Suspense>
        </ErrorBoundary>

        <ErrorBoundary>
          <Suspense fallback={<div>설정 로딩 중...</div>}>
            <Settings />
          </Suspense>
        </ErrorBoundary>
      </div>
    </div>
  );
}

장점:

  • 각 섹션이 독립적으로 로딩돼요
  • 하나가 에러 나도 다른 부분은 작동해요
  • 사용자가 무슨 일이 일어나는지 알 수 있어요

5️⃣ 종합 실습: 블로그 앱

모든 개념을 사용한 완전한 예제예요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { Suspense, lazy, useState } from 'react';
import ErrorBoundary from './ErrorBoundary';

// 지연 로딩 컴포넌트들
const PostList = lazy(() => import('./PostList'));
const PostDetail = lazy(() => import('./PostDetail'));

// 스켈레톤 로딩 (더 멋진 로딩 화면)
function PostListSkeleton() {
  return (
    <div>
      {[1, 2, 3].map(i => (
        <div key={i} style=>
          <div style=></div>
          <div style=></div>
        </div>
      ))}
    </div>
  );
}

function PostDetailSkeleton() {
  return (
    <div style=>
      <div style=></div>
      <div style=></div>
      <div style=></div>
    </div>
  );
}

function BlogApp() {
  const [selectedPostId, setSelectedPostId] = useState(null);

  return (
    <div style=>
      <header style=>
        <h1>내 블로그</h1>
        <button onClick={() => setSelectedPostId(null)}>
          전체 글 보기
        </button>
      </header>

      <main>
        {selectedPostId ? (
          // 글 상세 페이지
          <ErrorBoundary>
            <Suspense fallback={<PostDetailSkeleton />}>
              <PostDetail
                postId={selectedPostId}
                onBack={() => setSelectedPostId(null)}
              />
            </Suspense>
          </ErrorBoundary>
        ) : (
          // 글 목록 페이지
          <ErrorBoundary>
            <Suspense fallback={<PostListSkeleton />}>
              <PostList onSelectPost={setSelectedPostId} />
            </Suspense>
          </ErrorBoundary>
        )}
      </main>
    </div>
  );
}

export default BlogApp;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// PostList.js
function PostList({ onSelectPost }) {
  const posts = [
    { id: 1, title: 'React 시작하기', excerpt: 'React의 기초를 배워봐요' },
    { id: 2, title: 'Hook 마스터하기', excerpt: 'useState, useEffect 완벽 정복' },
    { id: 3, title: '에러 처리하기', excerpt: '안정적인 앱 만들기' }
  ];

  return (
    <div>
      <h2>전체 글</h2>
      {posts.map(post => (
        <div
          key={post.id}
          onClick={() => onSelectPost(post.id)}
          style=
        >
          <h3>{post.title}</h3>
          <p style=>{post.excerpt}</p>
        </div>
      ))}
    </div>
  );
}

export default PostList;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// PostDetail.js
function PostDetail({ postId, onBack }) {
  // 실제로는 API에서 데이터를 가져와요
  const post = {
    id: postId,
    title: `포스트 ${postId}`,
    content: '여기에 긴 내용이 들어가요...',
    author: '김철수',
    date: '2024-01-15'
  };

  return (
    <article>
      <button onClick={onBack} style=>
        ← 목록으로
      </button>

      <h2>{post.title}</h2>
      <div style=>
        <span>작성자: {post.author}</span>
        <span style=>날짜: {post.date}</span>
      </div>

      <div style=>
        <p>{post.content}</p>
      </div>
    </article>
  );
}

export default PostDetail;

흔한 실수와 해결 방법

실수 1: Error Boundary가 에러를 못 잡아요

1
2
3
4
5
6
7
8
// ❌ 이벤트 핸들러의 에러는 못 잡아요
function BrokenButton() {
  const handleClick = () => {
    throw new Error('클릭 에러!'); // Error Boundary가 못 잡아요
  };

  return <button onClick={handleClick}>클릭</button>;
}

해결:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 이벤트 핸들러는 try/catch 사용
function SafeButton() {
  const handleClick = () => {
    try {
      throw new Error('클릭 에러!');
    } catch (error) {
      console.error('에러 발생:', error);
      alert('처리 중 문제가 발생했습니다');
    }
  };

  return <button onClick={handleClick}>클릭</button>;
}

Error Boundary가 잡는 에러:

  • 렌더링 중 에러
  • 생명주기 메서드 에러
  • 자식 컴포넌트의 constructor 에러

Error Boundary가 못 잡는 에러:

  • 이벤트 핸들러 (onClick 등)
  • 비동기 코드 (setTimeout, Promise)
  • 서버 사이드 렌더링
  • Error Boundary 자체의 에러

실수 2: Suspense에서 데이터 페칭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 이렇게 하면 안 돼요
function BadComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data').then(res => setData(res));
  }, []);

  return <div>{data}</div>;
}

// Suspense는 이걸 감지 못 해요
<Suspense fallback={<div>로딩...</div>}>
  <BadComponent />
</Suspense>

해결:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ✅ lazy 사용
const LazyComponent = lazy(() => import('./MyComponent'));

<Suspense fallback={<div>로딩...</div>}>
  <LazyComponent />
</Suspense>

// ✅ 또는 수동으로 로딩 상태 관리
function GoodComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>로딩 중...</div>;
  return <div>{data}</div>;
}

실수 3: lazy를 컴포넌트 안에서 호출

1
2
3
4
5
6
7
8
9
10
// ❌ 렌더링마다 새로 만들어져요
function App() {
  const LazyComponent = lazy(() => import('./Component')); // 잘못됨!

  return (
    <Suspense fallback={<div>로딩...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

해결:

1
2
3
4
5
6
7
8
9
10
// ✅ 컴포넌트 밖에서 한 번만 선언
const LazyComponent = lazy(() => import('./Component'));

function App() {
  return (
    <Suspense fallback={<div>로딩...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

정리

오늘 배운 내용

  • try/catch와 조건문으로 기본 에러 처리를 할 수 있어요
  • Error Boundary로 컴포넌트 에러를 잡을 수 있어요
  • Suspense로 로딩 화면을 쉽게 만들 수 있어요
  • lazy로 컴포넌트를 필요할 때만 불러올 수 있어요
  • 에러와 로딩을 함께 처리할 수 있어요

핵심 정리

Error Boundary:

  • 컴포넌트 에러를 잡아요
  • 클래스 컴포넌트로만 만들 수 있어요
  • 이벤트 핸들러 에러는 못 잡아요

Suspense:

  • 로딩 화면을 쉽게 만들어요
  • lazy와 함께 사용해요
  • fallback으로 로딩 UI를 지정해요

다음 단계

  • React Query로 더 쉽게 데이터 관리하기
  • react-error-boundary 라이브러리 사용하기
  • Sentry로 에러 모니터링하기

숙제

필수 과제

  1. Error Boundary 만들기
    • ErrorBoundary 컴포넌트 작성
    • “다시 시도” 버튼 추가
    • 에러 메시지 표시
  2. 로딩 스피너 만들기
    • LoadingSpinner 컴포넌트 작성
    • CSS 애니메이션 사용
    • Suspense fallback으로 사용

선택 과제

  1. 사용자 목록 앱
    • lazy로 UserList 컴포넌트 만들기
    • ErrorBoundary로 감싸기
    • Suspense로 로딩 처리
  2. 다중 페이지 앱
    • 3개 페이지 만들기 (lazy 사용)
    • 각 페이지별 ErrorBoundary
    • 페이지별 다른 로딩 화면

“에러는 피할 수 없어요. 하지만 잘 처리할 수 있어요! 💪”

좋은 앱은 에러가 나도 사용자에게 친절한 메시지를 보여줍니다.


React 완벽 가이드 시리즈

  1. React란 무엇인가? 시작하기 전 알아야 할 모든 것
  2. useState와 useEffect - 상태와 생명주기 기초
  3. 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
  4. Props - 컴포넌트끼리 대화하기
  5. useEffect - 생명주기 이해하기
  6. 조건부/리스트 렌더링 - 똑똑하게 보여주기
  7. Context API - Props 지옥 탈출하기
  8. Custom Hooks - 나만의 Hook 만들기
  9. React Router - 페이지 이동하기
  10. 폼 처리 - 사용자 입력 받기
  11. 성능 최적화 - 똑똑하게 렌더링하기
  12. 에러 처리와 로딩 화면 - 사용자 경험 챙기기 ← 현재 글
  13. 상태 관리 라이브러리 - Redux, Zustand, Recoil 비교
  14. 서버 상태 관리 - React Query/TanStack Query 활용
  15. React와 TypeScript - 타입 안전한 React 개발
  16. Next.js 입문 - SSR/SSG와 풀스택 React
  17. React 테스팅 전략 - Jest, React Testing Library, E2E
  18. 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
  19. 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
  20. React 배포와 DevOps - 빌드 최적화와 CI/CD

관련 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.