Skip to content

Latest commit

 

History

History
885 lines (747 loc) · 20.6 KB

File metadata and controls

885 lines (747 loc) · 20.6 KB

03. API 통신

1. React에서의 API 호출

React 컴포넌트에서 외부 API와 통신하여 데이터를 가져오고, 생성하고, 수정하고, 삭제할 수 있습니다. API 호출은 useEffect Hook과 함께 사용합니다.

API 호출 시점

  • 컴포넌트가 마운트될 때 (화면에 처음 나타날 때)
  • 특정 상태가 변경될 때
  • 사용자 액션(버튼 클릭 등)에 반응할 때

2. fetch API 사용하기

기본 GET 요청

import { useState, useEffect } from "react";

interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // API 호출 함수
    const fetchUsers = async () => {
      try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users");

        if (!response.ok) {
          throw new Error("데이터를 불러오는데 실패했습니다.");
        }

        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : "알 수 없는 오류");
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []); // 빈 배열: 컴포넌트 마운트 시 1번만 실행

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

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
}

로딩, 에러, 데이터 상태 관리 ⭐

function DataFetchingPattern() {
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true); // 로딩 시작
        setError(null);   // 이전 에러 초기화

        const response = await fetch("https://api.example.com/data");
        if (!response.ok) throw new Error("Failed to fetch");

        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false); // 로딩 종료 (성공/실패 무관)
      }
    };

    fetchData();
  }, []);

  // 로딩 UI
  if (loading) {
    return <div className="spinner">로딩 중...</div>;
  }

  // 에러 UI
  if (error) {
    return (
      <div className="error">
        <p>에러가 발생했습니다: {error}</p>
        <button onClick={() => window.location.reload()}>다시 시도</button>
      </div>
    );
  }

  // 데이터 UI
  return (
    <div>
      {data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

3. POST 요청 (데이터 생성)

interface Post {
  id?: number;
  title: string;
  body: string;
  userId: number;
}

function CreatePost() {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<Post | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      setLoading(true);

      const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          title,
          body,
          userId: 1
        })
      });

      if (!response.ok) throw new Error("Failed to create post");

      const data = await response.json();
      setResult(data);
      setTitle("");
      setBody("");
    } catch (error) {
      console.error("Error:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h2>새 게시글 작성</h2>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="제목"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
        />
        <textarea
          placeholder="내용"
          value={body}
          onChange={(e) => setBody(e.target.value)}
          required
        />
        <button type="submit" disabled={loading}>
          {loading ? "작성 중..." : "작성"}
        </button>
      </form>

      {result && (
        <div className="success">
          <h3>작성 완료!</h3>
          <p>제목: {result.title}</p>
          <p>ID: {result.id}</p>
        </div>
      )}
    </div>
  );
}

4. PUT/PATCH 요청 (데이터 수정)

function UpdatePost() {
  const [postId, setPostId] = useState(1);
  const [title, setTitle] = useState("");
  const [loading, setLoading] = useState(false);

  // 게시글 불러오기
  useEffect(() => {
    const fetchPost = async () => {
      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts/${postId}`
        );
        const data = await response.json();
        setTitle(data.title);
      } catch (error) {
        console.error("Error:", error);
      }
    };

    fetchPost();
  }, [postId]);

  // 게시글 수정
  const handleUpdate = async () => {
    try {
      setLoading(true);

      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${postId}`,
        {
          method: "PUT",
          headers: {
            "Content-Type": "application/json"
          },
          body: JSON.stringify({
            id: postId,
            title,
            body: "Updated body",
            userId: 1
          })
        }
      );

      if (!response.ok) throw new Error("Failed to update");

      const data = await response.json();
      console.log("수정 완료:", data);
      alert("게시글이 수정되었습니다!");
    } catch (error) {
      console.error("Error:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h2>게시글 수정</h2>
      <input
        type="number"
        value={postId}
        onChange={(e) => setPostId(Number(e.target.value))}
        min="1"
      />
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <button onClick={handleUpdate} disabled={loading}>
        {loading ? "수정 중..." : "수정"}
      </button>
    </div>
  );
}

5. DELETE 요청 (데이터 삭제)

function DeletePost() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // 게시글 목록 불러오기
    const fetchPosts = async () => {
      try {
        const response = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data = await response.json();
        setPosts(data.slice(0, 5)); // 처음 5개만
      } catch (error) {
        console.error("Error:", error);
      }
    };

    fetchPosts();
  }, []);

  const handleDelete = async (id: number) => {
    if (!confirm("정말 삭제하시겠습니까?")) return;

    try {
      setLoading(true);

      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${id}`,
        {
          method: "DELETE"
        }
      );

      if (!response.ok) throw new Error("Failed to delete");

      // UI에서 제거
      setPosts(posts.filter(post => post.id !== id));
      alert("삭제되었습니다!");
    } catch (error) {
      console.error("Error:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h2>게시글 목록</h2>
      {posts.map(post => (
        <div key={post.id} style={{ border: "1px solid #ccc", padding: "10px", margin: "10px" }}>
          <h3>{post.title}</h3>
          <button
            onClick={() => handleDelete(post.id!)}
            disabled={loading}
          >
            삭제
          </button>
        </div>
      ))}
    </div>
  );
}

6. axios 라이브러리 사용하기

axios는 fetch보다 더 간편한 API 통신 라이브러리입니다.

설치

npm install axios

axios vs fetch 비교

import axios from "axios";

function AxiosExample() {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    // fetch 방식
    const fetchWithFetch = async () => {
      try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users");
        if (!response.ok) throw new Error("Failed");
        const data = await response.json(); // JSON 파싱 필요
        setUsers(data);
      } catch (error) {
        console.error(error);
      }
    };

    // axios 방식 (더 간결!)
    const fetchWithAxios = async () => {
      try {
        const response = await axios.get("https://jsonplaceholder.typicode.com/users");
        setUsers(response.data); // 자동으로 JSON 파싱
      } catch (error) {
        console.error(error);
      }
    };

    fetchWithAxios();
  }, []);

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

axios CRUD 예제

import axios from "axios";

const API_BASE = "https://jsonplaceholder.typicode.com";

// GET
const getUsers = async () => {
  const response = await axios.get(`${API_BASE}/users`);
  return response.data;
};

// POST
const createPost = async (post: { title: string; body: string; userId: number }) => {
  const response = await axios.post(`${API_BASE}/posts`, post);
  return response.data;
};

// PUT
const updatePost = async (id: number, post: any) => {
  const response = await axios.put(`${API_BASE}/posts/${id}`, post);
  return response.data;
};

// DELETE
const deletePost = async (id: number) => {
  await axios.delete(`${API_BASE}/posts/${id}`);
};

// 사용 예제
function AxiosCRUD() {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await axios.get(`${API_BASE}/posts`);
        setPosts(response.data.slice(0, 5));
      } catch (error) {
        console.error("Error:", error);
      }
    };

    fetchPosts();
  }, []);

  const handleCreate = async () => {
    try {
      const newPost = await createPost({
        title: "새 게시글",
        body: "내용",
        userId: 1
      });
      setPosts([newPost, ...posts]);
    } catch (error) {
      console.error("Error:", error);
    }
  };

  const handleDelete = async (id: number) => {
    try {
      await deletePost(id);
      setPosts(posts.filter(post => post.id !== id));
    } catch (error) {
      console.error("Error:", error);
    }
  };

  return (
    <div>
      <button onClick={handleCreate}>새 게시글 추가</button>
      {posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <button onClick={() => handleDelete(post.id!)}>삭제</button>
        </div>
      ))}
    </div>
  );
}

7. 커스텀 Hook으로 API 로직 분리 ⭐

반복되는 API 로직을 커스텀 Hook으로 만들어 재사용할 수 있습니다.

useFetch Hook

import { useState, useEffect } from "react";

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [trigger, setTrigger] = useState(0);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url);
        if (!response.ok) throw new Error("Failed to fetch");

        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url, trigger]);

  const refetch = () => setTrigger(prev => prev + 1);

  return { data, loading, error, refetch };
}

// 사용 예제
function UserList() {
  const { data: users, loading, error, refetch } = useFetch<User[]>(
    "https://jsonplaceholder.typicode.com/users"
  );

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러: {error}</div>;
  if (!users) return <div>데이터 없음</div>;

  return (
    <div>
      <button onClick={refetch}>새로고침</button>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

useAsync Hook (더 범용적)

interface UseAsyncResult<T> {
  execute: (...args: any[]) => Promise<void>;
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useAsync<T>(asyncFunction: (...args: any[]) => Promise<T>): UseAsyncResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const execute = async (...args: any[]) => {
    try {
      setLoading(true);
      setError(null);
      const result = await asyncFunction(...args);
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Unknown error");
    } finally {
      setLoading(false);
    }
  };

  return { execute, data, loading, error };
}

// 사용 예제
function CreatePostForm() {
  const createPost = async (title: string, body: string) => {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, body, userId: 1 })
    });
    return response.json();
  };

  const { execute, data, loading, error } = useAsync(createPost);

  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    execute(title, body);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <textarea value={body} onChange={(e) => setBody(e.target.value)} />
      <button type="submit" disabled={loading}>
        {loading ? "작성 중..." : "작성"}
      </button>
      {error && <p className="error">{error}</p>}
      {data && <p className="success">작성 완료! ID: {data.id}</p>}
    </form>
  );
}

8. 실전 예제: 검색 기능

interface SearchResult {
  id: number;
  title: string;
  body: string;
}

function SearchPosts() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);

  // 디바운싱: 입력 후 500ms 후에 검색
  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const timeoutId = setTimeout(async () => {
      try {
        setLoading(true);
        const response = await fetch("https://jsonplaceholder.typicode.com/posts");
        const posts = await response.json();

        // 클라이언트 사이드 필터링
        const filtered = posts.filter((post: SearchResult) =>
          post.title.toLowerCase().includes(query.toLowerCase())
        );

        setResults(filtered);
      } catch (error) {
        console.error("Error:", error);
      } finally {
        setLoading(false);
      }
    }, 500); // 500ms 디바운싱

    return () => clearTimeout(timeoutId); // 클린업
  }, [query]);

  return (
    <div>
      <h2>게시글 검색</h2>
      <input
        type="text"
        placeholder="검색어를 입력하세요..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />

      {loading && <p>검색 중...</p>}

      {!loading && results.length > 0 && (
        <div>
          <p>{results.length}개의 결과</p>
          <ul>
            {results.map(post => (
              <li key={post.id}>
                <h3>{post.title}</h3>
                <p>{post.body}</p>
              </li>
            ))}
          </ul>
        </div>
      )}

      {!loading && query && results.length === 0 && (
        <p>검색 결과가 없습니다.</p>
      )}
    </div>
  );
}

9. 실전 예제: 페이지네이션

function PaginatedPosts() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const postsPerPage = 10;

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setLoading(true);
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts?_page=${currentPage}&_limit=${postsPerPage}`
        );
        const data = await response.json();
        setPosts(data);
      } catch (error) {
        console.error("Error:", error);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, [currentPage]);

  const handleNext = () => setCurrentPage(prev => prev + 1);
  const handlePrev = () => setCurrentPage(prev => Math.max(1, prev - 1));

  return (
    <div>
      <h2>게시글 목록</h2>

      {loading ? (
        <p>로딩 중...</p>
      ) : (
        <>
          <ul>
            {posts.map(post => (
              <li key={post.id}>
                <h3>{post.title}</h3>
                <p>{post.body}</p>
              </li>
            ))}
          </ul>

          <div className="pagination">
            <button onClick={handlePrev} disabled={currentPage === 1}>
              이전
            </button>
            <span>페이지 {currentPage}</span>
            <button onClick={handleNext}>다음</button>
          </div>
        </>
      )}
    </div>
  );
}

10. 에러 처리 Best Practices

// 에러 타입 정의
interface ApiError {
  message: string;
  status?: number;
}

// 에러 처리 유틸리티
const handleApiError = (error: unknown): ApiError => {
  if (error instanceof Error) {
    return { message: error.message };
  }
  return { message: "알 수 없는 오류가 발생했습니다." };
};

// 재시도 로직
async function fetchWithRetry<T>(
  url: string,
  options?: RequestInit,
  maxRetries = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
    }
  }
  throw new Error("Max retries reached");
}

// 사용 예제
function RobustDataFetching() {
  const [data, setData] = useState<any[]>([]);
  const [error, setError] = useState<ApiError | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        const result = await fetchWithRetry(
          "https://jsonplaceholder.typicode.com/users"
        );
        setData(result);
      } catch (err) {
        setError(handleApiError(err));
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

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

  if (error) {
    return (
      <div className="error-container">
        <h2>오류가 발생했습니다</h2>
        <p>{error.message}</p>
        {error.status && <p>상태 코드: {error.status}</p>}
        <button onClick={() => window.location.reload()}>
          다시 시도
        </button>
      </div>
    );
  }

  return (
    <div>
      {data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

실습 과제

다음 컴포넌트들을 직접 만들어보세요:

1. User Profile Viewer

// TODO: JSONPlaceholder API를 사용하여 사용자 프로필 표시
// - 사용자 ID를 입력받아 해당 사용자 정보 표시
// - 로딩, 에러 상태 처리
// - 사용자의 게시글도 함께 표시

2. Comment System

// TODO: 댓글 시스템 구현
// - 댓글 목록 표시 (GET)
// - 새 댓글 작성 (POST)
// - 댓글 삭제 (DELETE)
// - 로딩 스피너 추가

3. Real-time Search

// TODO: 실시간 검색 기능
// - 입력 시마다 API 호출 (디바운싱 적용)
// - 검색 결과를 카드 형태로 표시
// - 검색 중 상태 표시

4. Infinite Scroll

// TODO: 무한 스크롤 구현
// - 스크롤이 바닥에 도달하면 다음 페이지 로드
// - IntersectionObserver 사용
// - 로딩 인디케이터 표시

다음 섹션에서는 지금까지 배운 모든 내용을 활용하여 완전한 Todo 앱을 만들어봅니다!