Learning
레슨 9 / 12·3개 토픽

데이터 페칭과 폼 관리

fetch API로 데이터 불러오기

useEffectfetch API를 조합하면 컴포넌트가 마운트될 때 서버에서 데이터를 불러올 수 있습니다. 로딩, 에러, 성공 세 가지 상태를 관리하는 것이 핵심 패턴입니다.

tsx
import { useState, useEffect } from 'react';

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

function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setLoading(true);
        setError(null);
        const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
        if (!res.ok) throw new Error('데이터를 불러오지 못했습니다.');
        const data: Post[] = await res.json();
        setPosts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : '알 수 없는 오류');
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []);

  if (loading) return <p>로딩 중...</p>;
  if (error) return <p className="text-red-500">오류: {error}</p>;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

커스텀 useFetch Hook

데이터 페칭 로직이 여러 컴포넌트에서 반복된다면 커스텀 Hook으로 추출하여 재사용할 수 있습니다.

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

  useEffect(() => {
    const controller = new AbortController();

    const fetchData = async () => {
      try {
        setLoading(true);
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const json: T = await res.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : '오류 발생');
      } finally {
        setLoading(false);
      }
    };

    fetchData();
    return () => controller.abort(); // 언마운트 시 요청 취소
  }, [url]);

  return { data, loading, error };
}

// 사용 예
function UserList() {
  const { data: users, loading, error } = useFetch<User[]>('/api/users');

  if (loading) return <p>로딩 중...</p>;
  if (error) return <p>에러: {error}</p>;
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Controlled 폼 컴포넌트

React에서 폼은 각 입력 필드의 값을 state로 관리하는 Controlled 방식이 권장됩니다. valueonChange를 짝지어 사용하며, 제출 시 e.preventDefault()로 기본 동작을 막습니다.

tsx
import { useState, FormEvent } from 'react';

interface FormData {
  name: string;
  email: string;
  message: string;
}

interface FormErrors {
  name?: string;
  email?: string;
  message?: string;
}

function ContactForm() {
  const [form, setForm] = useState<FormData>({
    name: '', email: '', message: '',
  });
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitting, setSubmitting] = useState(false);

  const validate = (): FormErrors => {
    const errs: FormErrors = {};
    if (!form.name.trim()) errs.name = '이름을 입력하세요.';
    if (!/^[^@]+@[^@]+\.[^@]+$/.test(form.email)) errs.email = '유효한 이메일을 입력하세요.';
    if (form.message.length < 10) errs.message = '메시지는 10자 이상이어야 합니다.';
    return errs;
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
    // 입력 시 해당 필드 에러 초기화
    setErrors(prev => ({ ...prev, [name]: undefined }));
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const errs = validate();
    if (Object.keys(errs).length > 0) {
      setErrors(errs);
      return;
    }

    setSubmitting(true);
    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });
      if (!res.ok) throw new Error('전송 실패');
      alert('전송 완료!');
      setForm({ name: '', email: '', message: '' });
    } catch {
      alert('오류가 발생했습니다.');
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input name="name" value={form.name} onChange={handleChange} placeholder="이름" />
        {errors.name && <span className="text-red-500">{errors.name}</span>}
      </div>
      <div>
        <input name="email" value={form.email} onChange={handleChange} placeholder="이메일" />
        {errors.email && <span className="text-red-500">{errors.email}</span>}
      </div>
      <div>
        <textarea name="message" value={form.message} onChange={handleChange} placeholder="메시지" />
        {errors.message && <span className="text-red-500">{errors.message}</span>}
      </div>
      <button type="submit" disabled={submitting}>
        {submitting ? '전송 중...' : '보내기'}
      </button>
    </form>
  );
}
💡

AbortController를 사용하면 컴포넌트가 언마운트될 때 진행 중인 fetch 요청을 취소할 수 있습니다. 메모리 누수와 상태 업데이트 경고를 방지하는 필수 패턴입니다.