[번역] Custom Hooks와 React Query로 로직과 UI 분리하기

null_ong2·2026년 3월 26일

원문

📌 Introduction

많은 React 애플리케이션에서 흔히 발생하는 문제는 데이터 fetching, 상태 관리, 비즈니스 로직을 컴포넌트 안에 모두 넣어버리는 것이다.

다음과 같은 코드를 본 적 있을 것이다:

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

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${username}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [username]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;

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

이 방식은 다음 문제를 만든다:

  • 데이터 fetching
  • 상태 관리
  • 에러 처리
  • 로딩 상태 처리
  • UI 렌더링

→ 모든 책임이 컴포넌트에 몰림

🚨 Problem: 관심사 혼합 (Mixed Concerns)

이 구조의 문제:

  • 테스트 어려움
  • 재사용성 낮음
  • 유지보수 어려움
  • 캐싱 없음
  • 상태 관리 복잡

→ ✅ Solution: Custom Hooks + React Query

해결 방법:

로직 → Custom Hook으로 분리
UI → 순수 컴포넌트로 유지

장점:

  • 관심사 분리
  • 자동 캐싱
  • 테스트 용이
  • 재사용성 증가

🏗 Architecture 구조

[ Presentation Layer ]

  • UI만 담당
  • props 기반

[ Logic Layer ]

  • Custom Hooks
  • 상태 관리
  • 데이터 fetching
  • 비즈니스 로직

1️⃣ 순수 UI 컴포넌트 만들기

// src/components/UserCard.tsx
import type { User, UserSearchItem } from "../types/User";

interface UserCardProps {
  user: User | UserSearchItem;
  onClick: () => void;
}

export const UserCard = ({ user, onClick }: UserCardProps) => {
  return (
    <div onClick={onClick}>
      <img src={user.avatar_url} />
      <h3>{"name" in user ? user.name : user.login}</h3>
      <p>@{user.login}</p>
    </div>
  );
};

핵심:

  • 상태 없음
  • side effect 없음
  • props → JSX

2️⃣ Custom Hook으로 로직 분리

// src/hooks/useSearchUsers.ts
import { useQuery } from "@tanstack/react-query";
import { githubApi } from "../services/githubApi";

export const useSearchUsers = (query: string) => {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ["users", query],
    queryFn: async () => {
      const response = await githubApi.get(
        `/search/users?q=${encodeURIComponent(query)}`
      );

      // 비즈니스 로직 (정렬)
      return response.data.items.sort((a, b) => b.score - a.score);
    },
    enabled: query.length > 2,
    staleTime: 5 * 60 * 1000,
  });

  return {
    users: data || [],
    isLoading,
    error,
    refetch,
  };
};

핵심:

  • API 호출
  • 캐싱
  • 정렬 로직

→ 모두 Hook 내부

3️⃣ 페이지 로직 조합 (Composition)

// src/hooks/useHomePage.ts
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSearchUsers } from "./useSearchUsers";

export const useHomePage = () => {
  const [searchQuery, setSearchQuery] = useState("");
  const { users, isLoading, error } = useSearchUsers(searchQuery);
  const navigate = useNavigate();

  const handleSelectUser = (username: string) => {
    navigate(`/user/${username}`);
  };

  return {
    searchQuery,
    setSearchQuery,
    users,
    isLoading,
    error,
    handleSelectUser,
  };
};

4️⃣ 페이지 컴포넌트 (완전 단순화)

// src/pages/HomePage.tsx
import { useHomePage } from "../hooks/useHomePage";

export const HomePage = () => {
  const {
    searchQuery,
    setSearchQuery,
    users,
    isLoading,
    error,
    handleSelectUser,
  } = useHomePage();

  return (
    <div>
      <input onChange={(e) => setSearchQuery(e.target.value)} />

      {isLoading && <div>Loading...</div>}
      {error && <div>Error</div>}

      {users.map(user => (
        <div key={user.login} onClick={() => handleSelectUser(user.login)}>
          {user.login}
        </div>
      ))}
    </div>
  );
};

⚙️ Advanced: Repo 정렬 Hook

// src/hooks/useFetchRepos.ts
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";

export const useFetchRepos = (username: string) => {
  const [sortCriteria, setSortCriteria] = useState("stars");

  const { data, isLoading, error } = useQuery({
    queryKey: ["repos", username],
    queryFn: async () => {
      const response = await fetch(`/users/${username}/repos`);
      return response.json();
    },
    enabled: !!username,
  });

  const sortedRepos = data
    ? [...data].sort((a, b) => {
        if (sortCriteria === "stars") {
          return b.stargazers_count - a.stargazers_count;
        }
        return a.name.localeCompare(b.name);
      })
    : [];

  return {
    repos: data || [],
    sortedRepos,
    isLoading,
    error,
    sortBy: setSortCriteria,
  };
};

🧪 테스트 예시

Hook 테스트

import { renderHook } from "@testing-library/react";

test("useSearchUsers works", async () => {
  const { result } = renderHook(() => useSearchUsers("react"));
});
컴포넌트 테스트
test("UserCard renders", () => {
  render(<UserCard user={{ login: "test" }} onClick={() => {}} />);
});

🔄 마이그레이션 전략

Before
function UserProfile({ id }) {
  useEffect(() => {
    fetchUser(id);
  }, []);
}

After
function useUser(id) {
  return useQuery({
    queryKey: ["user", id],
    queryFn: () => fetchUser(id),
  });
}

🎯 결론

→ 컴포넌트 = UI 전용
→ Hook = 로직 전용

결과:

  • 유지보수 ↑
  • 테스트 ↑
  • 재사용 ↑
  • 코드 가독성 ↑
  • 📌 핵심 요약
  • ❌ 컴포넌트에 로직 넣지 말 것
  • ✅ Custom Hook으로 분리
  • ✅ React Query 활용
  • ✅ UI는 pure하게 유지

0개의 댓글