React로 개발하다 보면, 자신도 모르게 코드가 복잡하게 꼬이는 순간을 마주하곤 합니다.
특히 useEffect를 사용할 때 이런 일이 잦습니다. 'A 상태가 바뀌면 useEffect가 B 상태를 바꾸고, 바뀐 B 상태 때문에 또 다른 useEffect가 C 상태를 바꾸는' 식의 연쇄 반응을 만드는 것이죠.
이런 코드는 'A 다음에 B 하고, B 다음에 C 한다'는 식으로 시간 순서에 의존해 작동합니다. 이를 명령형(Imperative) 방식이라고 부릅니다.
하지만 React는 이런 방식보다, "최종적으로 원하는 모습(스냅샷)이 무엇인가?"에 집중할 때 가장 강력합니다. 현재 상태가 주어지면, 다음 상태는 어떻게 '계산'되어야 하는지를 선언(Declare)하는 것이죠. 이것이 바로 React가 추구하는 선언형(Declarative) 방식입니다.
Problem: useEffect라는 '시간 축과의 얽힘'
물론 순수한 렌더링만으로는 완전한 애플리케이션을 만들 수 없습니다. 서버와 통신하는 등 외부 세계와의 상호작용은 필수적이며, useEffect는 이때 사용하는 탈출구입니다.
문제는 이 탈출구가 종종 우리를 '시간 축에 엮인 로직'이라는 덫에 빠뜨린다는 점입니다. 예를 들어, 사용자의 프로필(user)을 불러온 뒤, 그 ID를 사용해 사용자의 게시물(posts)을 불러오는 상황을 보겠습니다.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
// 1. 첫 번째 useEffect: userId가 바뀌면 user 정보를 가져온다.
useEffect(() => {
if (!userId) return;
setLoading(true);
fetchUser(userId).then(data => {
setUser(data); // 첫 번째 setState
});
}, [userId]);
// 2. 두 번째 useEffect: user 정보가 바뀌면 posts를 가져온다.
useEffect(() => {
if (user) {
fetchPosts(user.id).then(data => {
setPosts(data); // 두 번째 setState
setLoading(false);
});
}
}, [user]); // user 상태에 의존
// ... 렌더링 로직
}
TypeScript
복사
이 코드는 "① userId가 변하면 → ② user 상태를 바꾸고 → ③ user가 바뀌었으니 → ④ posts 상태를 바꾼다"는 식의 시간 순서에 따른 연쇄 반응으로 작성되었습니다.
로직이 두 개의 useEffect에 분산되어 있어 이해하기 어렵고, 의도치 않은 버그가 발생하기 쉽습니다.
Solution 1: react-query
useEffect 안에서 비동기 로직을 직접 짜는 대신, "이 컴포넌트는 이러이러한 데이터에 의존한다"고 선언하는 방식입니다.
데이터 페칭 라이브러리인 react-query (@tanstack/react-query)를 사용하면 이 사고방식을 코드로 명확하게 표현할 수 있습니다.
react-query는 로딩, 에러, 캐싱 등 비동기 데이터와 관련된 모든 복잡한 상태 관리를 대신 처리해주므로, 우리는 useEffect와 여러 useState를 완전히 제거할 수 있습니다.
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
// 1. user 데이터에 대한 의존성을 선언한다.
// userId가 바뀔 때마다 useQuery는 이 데이터를 자동으로 다시 가져온다.
const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ['user', userId], // 이 쿼리의 고유 키
queryFn: () => fetchUser(userId), // 데이터를 가져오는 함수
enabled: !!userId, // userId가 있을 때만 쿼리를 실행한다.
});
// 2. posts 데이터에 대한 의존성을 선언한다.
// 이 쿼리는 'user' 데이터가 성공적으로 로드되었을 때만 활성화된다.
const { data: posts, isLoading: arePostsLoading } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user, // ★★★ user 데이터가 있을 때만 이 쿼리를 실행한다.
});
const isLoading = isUserLoading || arePostsLoading;
if (isLoading) {
return <div>로딩 중...</div>
}
if (!user || !posts) {
return <div>데이터가 없습니다.</div>
}
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
TypeScript
복사
•
완벽한 선언형 코드: 더 이상 "언제, 어떻게 데이터를 가져올지"를 명령하지 않습니다. 대신 "이 컴포넌트는 user 데이터와 posts 데이터가 필요하다"고 선언하기만 하면, react-query가 나머지를 모두 알아서 처리합니다.
•
useEffect 완전 제거: 데이터 페칭을 위한 useEffect와 로딩, 데이터 저장을 위한 useState가 모두 사라졌습니다.
•
명확한 의존성 관리: enabled: !!user 옵션이 핵심입니다. 이는 "posts 쿼리는 user 데이터가 존재할 때만 활성화된다"는 규칙을 선언한 것입니다. 이는 useEffect 안에서 if (user)로 분기하던 명령형 로직을 완벽하게 대체합니다.
Solution 2: 페이지 초기화 논리를 컴포넌트에서 분리하기
여기서 한 걸음 더 나아가, 데이터 로딩이라는 책임 자체를 컴포넌트 바깥으로 완전히 분리할 수 있습니다.
앞서 언급했듯 상태 관리는 결국 복잡도와의 싸움입니다. 상태를 폭발시키지 않기 위해서는 서로 상호작용 하는 상태를 줄이고, 이를 관리하기 쉬운 단위로 나누어 정복해야 합니다.
대표적인 예시로 react-router는 데이터를 다루는 웹 앱의 구조를 아래 세 단계로 나눌 것을 제안했습니다.
•
1) UI를 담당하는 Component
•
2) UI에 필요한 데이터 의존성을 호출하는 Loader
•
3) UI에서 서버 레이어로 데이터를 전송하는 Action
자칫 복잡할 수 있는 로직의 덩어리를 이렇게 세 개의 큰 관심사로 나누면 이해하기도 관리하기도 쉬워질 것입니다.
그리고 Loader가 페이지 초기화에 필요한 데이터 의존성을 미리 처리해준 뒤, Promise를 풀어 컴포넌트로 내려주기 때문에 비동기 데이터의 초기화를 위해 useEffect를 쓸 필요가 없어집니다.
// 1. 데이터 로딩 로직 (Remix의 loader 함수 예시)
// 이 함수는 컴포넌트가 렌더링되기 '전'에 실행됩니다.
export async function loader({ params }) {
const user = await fetchUser(params.userId);
const posts = await fetchPosts(user.id);
// 필요한 데이터를 모두 준비해서 한번에 반환합니다.
return { user, posts };
}
// 2. UI 컴포넌트
// loader가 반환한 데이터를 props처럼 받아서 사용합니다.
export default function UserProfile() {
const { user, posts } = useLoaderData();
// useEffect, useState, 로딩 상태가 모두 사라졌습니다!
// 오직 데이터를 받아 UI를 그리는 역할만 합니다.
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
TypeScript
복사
•
완벽한 관심사 분리: ‘데이터를 가져오는 책임(loader)’과 ‘UI를 그리는 책임(Component)’이 완벽하게 분리되었습니다.
•
useEffect 제거: 컴포넌트 내부에 데이터 로딩과 관련된 모든 로직이 사라져, 컴포넌트는 순수하게 UI를 그리는 데만 집중할 수 있습니다.
•
완성된 스냅샷 전달: loader는 컴포넌트가 필요한 데이터의 '완성된 스냅샷' ({ user, posts })을 준비해서 내려줍니다. 컴포넌트는 시간의 흐름이나 데이터 로딩 순서를 전혀 신경 쓸 필요가 없습니다.
지친 뇌를 쉬게 하자
useEffect는 강력하지만, 남용될 경우 로직을 시간의 흐름과 단단히 엮어 복잡성을 폭발시키는 주범이 될 수 있습니다.
'시간 축' 기반의 명령형 사고에서 벗어나, 'A에서 B를 어떻게 계산할까'라는 '스냅샷' 기반의 선언형 사고로 전환해야 합니다.
이를 위해 react-query 같은 라이브러리를 통해 데이터 의존성을 선언하거나, 더 나아가 Data Loader 같은 관심사 분리 수단을 통해 데이터 로딩의 책임을 분리해야 합니다.
우리는 코드를 읽을 때 문자 그대로 읽지 않습니다. 특정한 의미 단위로 묶어가며 읽습니다. 뇌에 가해지는 인지 부하를 덜 수 있다는 장점이 있습니다. 청킹(Chunking)이라고도 합니다.
문제를 풀 때 코드의 모델을 명시적으로 사용하는 것은 두 가지 장점이 있다. 첫째, 모델은 프로그램에 대한 정보를 다른 사람과 공유할 때 유용하다. 상태표를 만들어서 다른 사람에게 보여주면 각 단계에서의 변수의 값을 통해 코드가 어떻게 작동하는지 이해하는 데 도움이 된다. (…)
모델의 두 번째 장점은 문제를 풀 때 도움이 된다는 점이다. 두뇌에서 한 번에 처리할 수 있는 한계에 도달했을 때 모델을 만들면 인지 부하를 줄일 수 있다. - 100p
역할 별로 덩어리를 나누고, 각 덩어리가 서로 엮이지 않도록 프로그래밍 해야 뇌가 쉴 수 있습니다. 적절한 도구를 선택해 지친 개발자의 뇌를 쉬게 해주세요.

