프론트엔드 상태 관리 최적화하기: React Query와 커스텀 훅 활용 전략

이번 아티클에서는 뤼이도팀에 도입하여 성공적으로 활용 중인 프론트엔드 상태 관리 전략에 대해 공유해 드리려고 합니다. 프론트엔드 개발에서 상태 관리는 항상 중요한 과제인데요, 저희는 서버 데이터의 중복 관리와 불필요한 보일러플레이트 코드, 데이터 일관성 유지 문제를 서버 상태와 클라이언트 상태를 명확히 구분하는 접근법을 통해 해결할 수 있었습니다.
1. 상태 관리의 두 가지 영역 이해하기
많은 개발자분들이 종종 모든 전역 상태를 하나의 도구(Redux, Zustand 등)로 관리하곤 합니다. 저희 팀도 기존에는 모든 전역 상태를 Redux 하나로 관리하고 있었는데, 이는 불필요한 복잡성을 야기했습니다.
1) 기존 문제점
모든 전역 상태를 하나의 도구로 관리할 때 직면했던 문제들은 다음과 같습니다:
- 비효율적인 데이터 동기화: 서버 데이터를 Redux에 저장할 때 데이터 갱신 시마다 수동으로 액션을 Dispatch 해야 함
- 관리의 어려움: Refetching, 에러 처리 등을 직접 구현해야 함
- 상태 관리 복잡성 증가: 여러 종류의 상태를 하나의 Store에서 관리하면 코드베이스가 커질수록 관리가 어려워짐
- 과도한 보일러플레이트 코드: API 요청 결과를 Redux에 저장하기 위해 액션 타입 정의, 액션 생성자 함수, Reducer 로직 등 많은 코드가 필요했습니다.
예를 들어, 공통으로 사용되는 데이터를 Redux에 저장해 API의 중복 호출을 방지하고자 했습니다. 하지만, 이 데이터는 여러 화면에서 사용되다 보니, 사용자가 데이터를 변경하는 순간마다 다시 데이터를 Slice에 상태를 갱신해 주어야 하는 번거로운 작업이 필요했습니다. 결과적으로는 오히려 관리해야 하는 부분이 더 늘어나게 되었습니다.
2) 데이터 상태의 성격에 따른 분류
위 문제를 데이터 상태의 성격에 따라 구분해서 관리한다면 불필요한 관리 절차가 줄어들 수 있습니다. 데이터 상태는 성격에 따라 크게 두 가지 종류로 구분할 수 있으며, 각각 다른 관리 방식이 필요합니다:
2-1) Server State (서버 상태)
- 정의: 서버에서 제공되고 관리되는 데이터, API 호출을 통해 가져오는 모든 데이터
- 특성: 캐싱, 동기화, 만료, Refetching 등의 관리가 필요
- 도구: React Query, SWR 등의 데이터 페칭 라이브러리
2-2) Client State (클라이언트 상태)
- 정의: UI 상태, 사용자 설정, 로컬 폼 상태 등 클라이언트에서만 적용되는 데이터
- 특성: 서버와 동기화가 필요 없으며, 사용자 세션 내에서만 유지됩니다.
- 도구: Context API, Redux, Zustand 등
이 차이점을 이해하면 상태에 맞는 도구를 사용할 수 있습니다.
서버 상태에 해당하는 데이터는 React Query의 캐싱 시스템을 잘 활용하면, Query key (예를 들면 워크스페이스 ID)가 바뀔 때마다 새로 불러와진 정보를 별도로 Store에 저장하지 않아도 API의 중복 호출 없이 여러 페이지에서 같은 데이터를 사용할 수 있습니다.
2. 서버 상태 관리: React Query와 커스텀 훅의 시너지
저희 팀에서는 서버 상태 관리를 위해 React Query를 사용 중이었는데, 이를 커스텀 훅으로 만들면 더 통일성 있고 효율적으로 관리할 수 있다는 것을 깨달았습니다. 커스텀 훅을 결합하여 여러 컴포넌트에서 반복되는 API 호출 로직을 추상화하고, 비즈니스 로직과 UI 로직을 깔끔하게 분리했습니다.
1) 기존 구조:
const UserList = () => {
// API 로직이 컴포넌트에 직접 포함되어 있음
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetchUsers(),
select: (data) => data.map(transformUserData),
staleTime: 1000 * 60 * 5,
enabled: !!condition
});
// UI 로직도 같은 컴포넌트에서 처리
return (
<div>
{users?.map(user => <UserCard key={user.id} user={user} />)}
</div>
);
}
이 구조의 문제점:
- 동일한 데이터를 호출하는 여러 컴포넌트에서 코드 중복 발생
- Query key와 캐싱 규칙 등 Query option에 대한 설정이 컴포넌트별로 상이해짐
- API 변경 시 여러 파일을 수정해야 함
- 테스트가 어려움
2) 개선된 구조:
// API 로직을 커스텀 훅으로 분리
export const useUsers = (params) => {
return useQuery({
queryKey: ['users', params],
queryFn: () => fetchUsers(params),
select: (data) => data.map(transformUserData),
staleTime: 1000 * 60 * 5,
});
};
// 컴포넌트에서는 깔끔하게 사용
const UserList = () => {
const { data: users } = useUsers({ role: 'admin' });
// UI 로직에만 집중
return (
<div>
{users?.map(user => <UserCard key={user.id} user={user} />)}
</div>
);
}
이 구조의 장점:
- 관심사 분리: API 로직과 UI 로직 분리
- 재사용성: 여러 컴포넌트에서 통일성 있는 동일한 훅 사용 가능
- 유지 보수성: API 변경 시 한곳만 수정하면 됨
- 테스트 용이성: 비즈니스 로직을 별도로 테스트 가능
3. 더 나은 재사용성과 일관성을 위한 전략
1) 기본 설정 중앙화
모든 쿼리에 적용될 기본 설정을 중앙에서 관리하면 일관성을 유지할 수 있습니다.
// 공통 쿼리 설정을 한 곳에서 관리
const defaultQueryConfig = {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 3,
};
// 베이스 훅 팩토리
const createQueryHook = (queryFn, options) => {
return (params) => useQuery({
queryFn: () => queryFn(params),
...defaultQueryConfig,
...options,
});
};
// 실제 훅 정의
export const useUsers = createQueryHook(getUsersApi, {
queryKey: (params) => ['users', params],
select: (data) => data.map(transformUserData),
});
이렇게 하면 모든 API 쿼리에 일관된 설정을 적용하면서도, 필요한 경우 개별 쿼리마다 옵션을 오버라이드 할 수 있습니다.
2) 폴더 구조
API 훅을 효율적으로 관리하기 위해 폴더 구조를 다음과 같이 구성했습니다.
src/
├── hooks
│ ├── api
│ │ ├── meetings
│ │ │ ├── Daily.ts // 각 모듈 내부에 endpoint별로 복수 queryHook 구성
│ │ │ ├── Templates.ts
│ │ ├── backlogs
│ │ │ ├── Impact.ts
│ │ │ ├── Members.ts
API 도메인별 파일을 만들고 내부에 endpoint 별로 hook 을 구성합니다.
이런 구조는 API 도메인별로 훅을 모듈화하여 관리할 수 있게 해주며, 새로운 기능이나 API가 추가될 때 확장이 쉽습니다.
4. API method 별 Query Hook 패턴 예시
1) 조회 API Query Hook
interface UseGetSprintsProps {
// 쿼리 옵션과 DTO는 props 로 전달
teamId?: string;
sprintId?: string;
enabled?: boolean;
}
export const useGetSprint = ({ teamId, sprintId, enabled = true }: UseGetSprintsProps) => {
const {
data: sprintData,
isPending: isSprintDataPending,
isError: isSprintDataError,
} = useQuery<GetSprintResponse>({
queryKey: ['sprint', teamId, sprintId],
queryFn: () => getSprintApi({ teamId, sprintId }),
enabled: !!(enabled && teamId && sprintId),
});
// 상태의 이름을 미리 정의하고 사용할 수 있단 것도 장점
return {
sprintData,
isSprintDataPending,
isSprintDataError,
};
};
2) 수정 API Mutation Hook
mutation 또한 Hook으로 관리한다면, Success 시점에 공통으로 업데이트해야 하는 캐시를 관리하기 쉬워집니다.
interface UseAddBacklogProps {
teamId: string;
onSuccess?: () => void;
onSettled?: () => void;
}
export const useAddBacklog = ({
teamId,
onSuccess,
onSettled,
}: UseAddBacklogProps) => {
const queryClient = useQueryClient();
const { mutate, isPending, isError } = useMutation({
mutationKey: ['addBacklog'],
mutationFn: addBacklogApi,
onSuccess: () => {
// 일관된 API 사용으로 캐시 관리에 용이함
queryClient.invalidateQueries({ queryKey: ['backlogs', teamId] });
queryClient.invalidateQueries({
queryKey: ['recentBacklogs', teamId],
});
onSuccess?.();
},
onSettled: onSettled,
});
return {
mutateAddBacklog: mutate,
isAddBacklogPending: isPending,
isAddBacklogError: isError,
};
};
5. 클라이언트 상태 관리: 순수 UI 관련 상태에 집중하기
서버 상태와 달리 클라이언트 상태는 여전히 Redux나 Zustand 같은 상태 관리 라이브러리를 활용하는 것이 적합합니다. 다만, 이제 상태 Store에는 정말 클라이언트에서만 필요한 상태만 보관합니다:
- 로컬 테마 설정
- UI 상태 (토글 상태, 선택된 뷰 상태 등)
- 폼 상태 (멀티 스텝 폼의 진행 상태 등)
이렇게 구분하면 상태 관리 Store의 크기와 복잡성이 크게 줄어들고, 상태 관리가 더 명확해집니다.
6. 상태 관리 전략 도입의 성과
서버 상태와 클라이언트 상태를 명확히 구분하고 React Query와 커스텀 훅을 활용한 서버 상태 관리 전략을 도입한 후, 저희 팀은 다음과 같은 실질적인 성과를 얻을 수 있었습니다:
- 코드 중복 감소: 동일한 API 호출 로직이 여러 컴포넌트에 흩어져 있던 부분 개선
- 일관된 데이터 관리: 캐싱, Refetching, 에러 처리 등을 일관되게 유지 보수 가능
- 개발 생산성 향상: 새로운 API 엔드포인트 연동 시 기존 패턴을 쉽게 적용 가능
- 코드베이스 확장성: 기능이 추가되어도 상태 관리 구조가 복잡해지지 않음
7. 결론 및 앞으로의 계획
마지막으로 저희 팀의 상태 관리 전략을 지속적으로 발전시키기 위해 앞으로는 다음과 같은 최적화를 계획하고 있습니다:
- API 별로 적합한 캐싱 규칙 정의: 데이터의 특성에 맞게 staleTime, gcTime(cacheTime) 등을 최적화
- Redux Store 구성 최적화: 불필요한 상태는 제거하고, 필요한 상태만 유지
- 선택적 구독 패턴 도입: 상태 변화에 따른 불필요한 리렌더링 방지
상태 관리는 단순한 도구 선택의 문제가 아니라, 데이터의 성격을 이해하고 적절한 전략을 적용하는 것이 중요합니다. 서버 상태와 클라이언트 상태를 명확하게 구분하고, 각각에 적합한 도구와 패턴을 사용함으로써 더 유지 보수하기 쉽고, 확장 가능하며, 개발자 경험이 향상된 코드베이스를 구축할 수 있습니다.
어떤 규모의 프로젝트든 상태 관리는 복잡한 과제이지만, 이 글에서 소개한 패턴을 적용한다면 훨씬 더 관리하기 쉬운 코드를 작성할 수 있을 것입니다. 이 글이 여러분의 프로젝트에도 도움이 되었길 바랍니다!
뤼이도에서 우리 팀 프로젝트 관리하러 가기👇

Riido Frontend Developer 고희주