프론트엔드에서 Restful API 데이터 동기화 패턴의 진화를 통해 서버 상태를 관리하기: Prop Drilling에서 React Query까지
Restful API 데이터 동기화 패턴의 진화: Prop Drilling에서 React Query까지
웹 프론트엔드 개발에서 서버 데이터를 여러 컴포넌트에 동기화하는 방법은 끊임없이 진화해 왔으며, 저 또한 다양한 프로젝트를 거치며 데이터 관리/제공에 대한 최적의 방법을 직접 체감하고 성장해 왔다고 생각합니다.
특히 Restful API 기반의 서비스에서는 “API로 받아온 데이터를 어떻게 동기화하고, 여러 컴포넌트에 최적화해 전달/관리할 것인가?”가 생산성과 유지보수성을 좌우하는 핵심 요소이기 때문에, 실제 프로젝트 경험을 바탕으로 단계별로 저의 성장 과정과 진화를 예시와 함께 소개하고자 합니다.
예를 들어, 리프레시 버튼을 누를 때마다 일정과 유저 목록을 가져온다고 가정하겠습니다.
A 컴포넌트는 일정 UI, B 컴포넌트는 유저 목록, C 컴포넌트는 그리드 데이터나 필터 항목을 담당하면서 새로고침(리프레시) 버튼을 구현한다고 할 때, 각각의 API를 어떻게 리프레시할 수 있을지 단계별로 설명합니다.
1. 1단계: Prop Drilling 시절 – 모든 데이터를 부모에서 자식으로
(1) 코드 패턴
function Parent() {
const [todos, setTodos] = useState([]);
const [users, setUsers] = useState([]);
const fetchTodos = useCallback(() => axios.get('/api/todos').then(r => setTodos(r.data)), []);
const fetchUsers = useCallback(() => axios.get('/api/users').then(r => setUsers(r.data)), []);
useEffect(() => { fetchTodos(); fetchUsers(); }, []);
// 두 컴포넌트에 상태와 리프레시 함수 전달
return (
<>
<ComponentA todos={todos} />
<ComponentB users={users} />
<ComponentC onRefreshTodos={fetchTodos} onRefreshUsers={fetchUsers} />
</>
);
}
// ComponentA 컴포넌트
function ComponentA({ todos }) {
return (
<div>
<ul>{todos.map(...)} </ul>
</div>
);
}
// ComponentB 컴포넌트
function ComponentB({ users }) {
return (
<div>
<ul>{users.map(...)} </ul>
</div>
);
}
// ComponentC 컴포넌트
export function ComponentC({ onRefreshTodos, onRefreshUsers }) {
// 리프레시!
const handleClick = () => {
Promise.all([
onRefreshTodos(),
onRefreshUsers()
])
....
}
return (
<div>
<h2>리프레시 테스트</h2>
<button onClick={onRefreshTodos}>전체 리프레시</button>
</div>
);
}(2) 문제점
Parent컴포넌트로 부터 종속된 처리로 인하여 관리하는 함수/상태가 많아지면 하위 컴포넌트에 props로 계속 넘겨야 합니다.- 리프트 업 패턴의 전형적인 단점입니다.
- 위와 비슷한 역할의 문제이지만 관심사 분리의 부족 문제 입니다.
- Parent가 비대해지고, 역할이 모호해지며 각 상태(혹은 fetch 함수)가 독립적으로 분리되어야 유지보수가 쉬워집니다.
- 리렌더/최적화 한계
- Parent에서 상태가 바뀔 때마다 모든 자식이 무조건 리렌더가 됩니다.
2. 2단계: 전역상태 관리 시절 – 전역 store로 데이터 트리거
(1) 코드 패턴
const useGlobalStore = create(set => ({
todoVersion: 0,
userVersion: 0,
incTodoVersion: () => set(s => ({ todoVersion: s.todoVersion + 1 })),
incUserVersion: () => set(s => ({ userVersion: s.userVersion + 1 })),
}));
function Parent() {
return (
<>
<ComponentA />
<ComponentB />
<ComponentC />
</>
);
}
function ComponentA() {
const todoVersion = useGlobalStore(s => s.todoVersion);
const [todos, setTodos] = useState([]);
useEffect(() => {
axios.get('/api/todos').then(r => setTodos(r.data));
}, [todoVersion]);
return (
<div>
<ul>{todos.map(...)} </ul>
</div>
);
}
function ComponentB() {
const userVersion = useGlobalStore(s => s.userVersion);
const [users, setUsers] = useState([]);
useEffect(() => {
axios.get('/api/users').then(r => setUsers(r.data));
}, [userVersion]);
return (
<div>
<ul>{users.map(...)} </ul>
</div>
);
}
function ComponentC() {
const incTodoVersion = useGlobalStore(s => s.incTodoVersion);
const incUserVersion = useGlobalStore(s => s.incUserVersion);
return (
<div>
<button onClick={() => { incTodoVersion(); incUserVersion(); }}>전체 리프레시</button>
</div>
);
}(2) 문제점
- 각 컴포넌트는 버전(카운트) 값이 바뀔 때마다 자신의 데이터를 갱신하여 서로의 리프레시 함수(버전 증가 함수)를 전역에서 호출해줄 수 있으나 구조는 단순하지만, 상태가 많아질수록 변수 관리가 난잡해집니다.
- 관심사 분리의 부족 문제는 해결되었으나 실제적인
전역상태관리의 장점을 살리지 못하여 흐름을 파악하기 어려운 문제점이 발견됩니다.
- 관심사 분리의 부족 문제는 해결되었으나 실제적인
3. 3단계: React-Query – 서버 상태 관리 라이브러리를 통한 관심사 분리
(1) 본질적인 문제점 다시 한번 재정의 해보면
- 상태 소유권의 문제
- Prop Drilling과 전역 상태 관리는 상태의 소유권을 부모 컴포넌트 또는 글로벌로 두어 상태가 많아질수록 책임 소재가 불분명해지고 관리가 어렵습니다.
- 관심사의 분리 부족
- 서버 상태(fetch 데이터)와 클라이언트 상태(로컬 UI 상태)가 혼합되면 관심사 분리가 어렵고 유지보수가 어려워집니다.
- 불필요한 리렌더링
- 부모 상태가 자주 변경되면, 그 부모에 속한 자식 컴포넌트까지 무조건 리렌더링되어 성능과 최적화의 문제가 발생합니다.
- 복잡한 데이터 리프레시 로직
- 버전 카운터 패턴 등 직접 상태를 관리하는 방식은, 상태가 많아질수록 불필요한 코드가 난립하여 데이터 흐름이 복잡해지고 유지보수성이 저하됩니다.
(2) 본질적 문제 해결 및 왜 React-Query를 사용 해야 하는지 핵심 원칙 다시 파악 하기
- 상태의 소유권을 명확히
- 서버 상태는 서버 상태 관리 라이브러리로, 클라이언트 상태는 클라이언트 전용 상태로 관리
- 관심사 분리를 명확히
- 데이터 fetching은 독립된 로직(커스텀 훅 등)으로 분리
- 리렌더링은 필요한 컴포넌트만
- 관심사를 분리했기 때문에 fetching 이후 캐시 무효화 시 쿼리 키 기반의 구독 구조로, 구독하지 않은 불필요한 불필요한 리렌더 방지가 React Query로 자연스럽게 해결 가능
(3) 코드 패턴
- React-router-dom의
loader기능 활용 하여ensureQueryData로 서버 데이터 캐싱- 기본은 캐싱된 데이터가 있는지 조회(
getQueryData)를 하고 구독된 캐싱 정보가 없다면fetchQuery로 실행 하게 되어 있습니다.
- 기본은 캐싱된 데이터가 있는지 조회(
import { createBrowserRouter } from 'react-router-dom';
import { fetchTodosQuery, fetchUsersQuery, fetchGridPageQuery } from '@/hooks/queries';
import { QueryClient } from '@tanstack/react-query';
// loader 함수 정의 (ensureQueryData 활용)
export const parentLoader = (queryClient: QueryClient) => async () => {
// a, b 컴포넌트에서 필요한 데이터 미리 캐싱
await queryClient.ensureQueryData(fetchTodosQuery()); // ['todos']
await queryClient.ensureQueryData(fetchUsersQuery()); // ['users']
// C(grid)는 페이지네이션이므로, prefetch 활용 (아래에서 처리)
return null; // or return 필요한 data
};
// 라우터 정의
export const router = (queryClient: QueryClient) =>
createBrowserRouter([
{
path: '/',
element: <ParentPage />, // A, B, C, D 컴포넌트 모두 포함
loader: parentLoader(queryClient), // loader 등록!
},
]);- 클라이언트 상태가 같은 경우 재 호출을 위한
Custom Hook도입refetchQueries를 활용하면, 바로 즉시 만료(무효) 상태로 만들지 않고 내부적으로 Query 객체의 isInvalidated 플래그를 true로 설정한 후, 캐시에 있던 데이터는 그대로 두면서 staleTime이나 fetch 타이밍을 무시하고 최대한 빠르게 refetch하도록 유도할 수 있습니다.- 캐시를 유지하는 이유는 UI 깜빡임(플리커링) 방지입니다!
invalidateQueries내부에도refetch옵션이 있지 않나요?- 비용이 얼마가 들던 통계 및 데이터 결과에 따른 선택된 쿼리들을 무조건 바로 서버에서 다시 받아옴!
즉시성을 강조! - 또한!
refetchQueries와 달리 특정 조건(예: queryKey)에 맞는 쿼리를 "무효" 처리에 사용 되는 것이 적절하다 판단 되었습니다.- 예를 들면
mutation후, 해당 데이터가 갱신 필요한 경우 입니다.
- 예를 들면
- 비용이 얼마가 들던 통계 및 데이터 결과에 따른 선택된 쿼리들을 무조건 바로 서버에서 다시 받아옴!
import { QueryClient } from '@tanstack/react-query';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
export const useStaleQueryRefetch = () => {
const queryClient = useQueryClient();
const staleQueryRefetch = async (queryKeys: QueryKey | QueryKey[]) => {
const keys = Array.isArray(queryKeys[0]) ? queryKeys : [queryKeys];
await Promise.all(
keys.map(key =>
queryClient.refetchQueries(
{ queryKey: key as QueryKey, stale: true, type: 'active' },
{ cancelRefetch: false }
)
)
);
};
return { staleQueryRefetch };
};- 코드 조립
import ComponentA from './ComponentA';
import ComponentB from './ComponentB';
import ComponentC from './ComponentC';
import ComponentD from './ComponentD';
export default function ParentPage() {
const page = 1; // 예시. 실제로는 useState/useParams 등으로 동적 관리
return (
<div>
<h2>React Query 패턴 총정리 예제</h2>
<ComponentA />
<ComponentB />
<ComponentC page={page} />
<ComponentD />
</div>
);
}
import { useQuery } from '@tanstack/react-query';
function ComponentA() {
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodosQuery(),
});
return (
<div>
<h3>Todos</h3>
<ul>
{todos?.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
import { useQuery } from '@tanstack/react-query';
function ComponentB() {
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetchUsersQuery(),
});
return (
<div>
<h3>Users</h3>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
function ComponentC({ page }) {
const queryClient = useQueryClient();
// 현재 페이지 데이터
const { data: pageData, isLoading } = useQuery({
queryKey: ['grid', page],
queryFn: () => fetchGridPageQuery(page),
});
// 다음 페이지 미리 prefetch (예: page 이동 예상될 때)
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ['grid', page + 1],
queryFn: () => fetchGridPageQuery(page + 1),
});
}, [page, queryClient]);
return (
<div>
<h3>Grid Table (Page {page})</h3>
<ul>
{pageData?.items?.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
{/* 페이지네이션 버튼 등 */}
... 코드 생략 ...
</div>
);
}
function ComponentD() {
const { staleQueryRefetch } = useStaleQueryRefetch();
const onSearch = () => {
// 예: todos, users Client 상태가 동일해도 최신화
// button 디바운싱 조건 추가 및 new QueryClient 시 staleTime의 5000 부여하여 refetchQueries를 지연 호출 한다.
staleQueryRefetch([['todos'], ['users']]);
};
return (
<div>
... 검색 조건 컴포넌트 생략 ....
<button onClick={onSearch}>검색 조건 </button>
</div>
);
}마무리 – Restful API 동기화 패턴의 진화, 그리고 나의 성장
서버 데이터 동기화는 프론트엔드 개발자에게 있어 끊임없는 고민이자, 프로젝트를 거치며 가장 많이 “아! 이렇게도 할 수 있구나”를 느꼈던 분야 중 하나입니다.
처음에는 부모에서 모든 상태를 관리하는 Prop Drilling 패턴으로 시작했지만, 상태가 많아지고 컴포넌트가 분리될수록 관심사 분리, 리렌더, 유지보수의 어려움을 직접 겪었습니다. 이후 전역 상태 관리(Zustand, Redux, Recoil 등)로 옮기면서, “버전 카운터” 패턴으로 트리거와 동기화 문제를 해결해보려 했지만, 점점 상태 변수 난립, 흐름의 복잡성을 뼈저리게 경험했습니다.
결국, 서버 상태와 클라이언트 상태는 본질적으로 다르다는 점을 깨달았고, React Query와 같은 “서버 상태 동기화 라이브러리”를 실무로 활용하면서 데이터 흐름/동기화는 “간단한 코드”보다 실전 경험이 쌓여야 진짜 문제와 한계를 체감한다고 생각이 듭니다.
이외에도 예시와 같이 실무에 React-Router-Dom 의 lorder + React-Query 의 조합을 통해 API 캐싱 및 데이터 prefetch를 적절하게 사용 함으로서 데이터 비용에 대한 절감 및 사용자로 부터 더 나은 UI/UX를 제공할 수 있었으며 서버 상태 지연 상태들을 통해 따른 플리커링을 막고자 스켈리톤 UI 반영 등 단 하나의 라이브러리 도입으로 많은 이슈를 단방에 해결 할 수도 있었습니다.
그때 그때 상황에 따라 해결했던 자신을 돌이켜보면, 매번 새로운 인사이트를 얻을 수 있었던 소중한 성장의 시간이었다고 생각합니다.