React 코드가 복잡해졌을 때, 단순화 시점을 가르는 4가지 기준 (2025)
솔직히 말하면, React를 몇 년째 써온 개발자도 가끔 이런 생각이 든다고 합니다. "이걸 왜 이렇게 복잡하게 만들고 있지?" useEffect 안에 비즈니스 로직이 쌓이고, useMemo와 useCallback이 습관처럼 달라붙고, 상태 관리 라이브러리를 고르다가 하루가 끝나는 경험 — 저도 처음엔 이게 "React를 잘 모르는 것"이라고 생각했습니다. 그런데 어느 순간 깨달았습니다. 이건 실력 문제가 아니라, 기준의 문제입니다.
2025년 현재 React는 사용률 44.7%로 압도적 1위 프레임워크지만, 커뮤니티 안에서는 조용한 균열이 생기고 있습니다. 8년 경력의 React 개발자가 스타트업 프론트엔드 전체를 Rails로 재작성했다는 이야기가 광범위하게 공유되고, "지루한 모놀리스"로 돌아가자는 목소리도 커지고 있죠. 이게 바로 React 복잡성 피로(React Complexity Fatigue) — React 생태계의 끊임없는 변화와 과도한 추상화가 만들어내는 인지적 소진입니다.
이 글에서는 이 현상이 왜 생기는지, 그리고 언제 복잡성이 정당하고 언제 단순화해야 하는지를 판단하는 4가지 기준을 실제 코드와 함께 살펴봅니다. TypeScript를 사용하고 React를 어느 정도 써본 개발자라면 바로 적용해볼 수 있을 겁니다.
복잡성이 쌓이는 세 가지 경로
useEffect 지옥
useEffect는 원래 외부 시스템과의 동기화를 위한 훅입니다. 그런데 어느 순간부터 비즈니스 로직, 데이터 패칭, 파생 상태 처리까지 모든 걸 담는 쓰레기통이 되어버렸습니다. 무한 루프, 스테일 클로저, 메모리 누수가 반복되는 코드베이스를 한 번이라도 디버깅해봤다면 이 고통이 뭔지 바로 알 겁니다.
가장 흔한 패턴이 "렌더링 중에 계산할 수 있는 값을 굳이 useState + useEffect로 관리하는 것"입니다. 저도 초기엔 이렇게 했는데, 이게 사실 불필요한 복잡성의 가장 큰 원천 중 하나입니다.
컴포넌트 과잉 분리
"모든 것을 컴포넌트로"라는 관행이 극단화되면, optional prop이 수십 개 달린 "Ultimate Component"가 탄생합니다. 기반 컴포넌트를 건드리는 게 두려운 구조, 파일 간 의존성이 폭발적으로 늘어나는 구조가 여기서 나옵니다. 코드를 열자마자 어디서부터 읽어야 할지 모를 때, 이미 과잉 분리가 진행되고 있다는 신호입니다.
생태계 결정 피로
Redux → Context API → Zustand → Jotai → TanStack Query로 이어지는 상태 관리 선택지의 홍수. React 자체를 배우기 전에 서드파티 생태계를 먼저 학습해야 하는 역설이 초보자와 시니어 모두를 지치게 만듭니다. 솔직히, 새 프로젝트를 시작할 때마다 "이번엔 뭘 써야 하지?"부터 시작하는 게 이상한 일이 아닌 게 됐습니다.
React 복잡성 피로 — "프론트엔드 피로(Frontend Fatigue)"의 하위 개념으로, React 생태계의 의사결정 부담(decision fatigue)과 도구 과잉(tooling overload)에서 비롯되는 인지적·정서적 소진 상태
단순화를 판단하는 4가지 기준
언제 리팩토링이 필요한지 팀 내에서 공유 가능한 기준이 있으면 코드 리뷰가 훨씬 쉬워집니다. 이 기준을 PR 체크리스트에 넣고 나서 팀 내 리뷰 시간이 눈에 띄게 줄었다는 이야기를 자주 듣습니다.
| 상황 | 판단 기준 | 대응 방향 |
|---|---|---|
렌더링 중 계산 가능한 값을 useEffect로 관리 |
useEffect 불필요 |
파생 계산으로 대체 |
연동된 useState 호출이 3개 이상 |
동기화 버그 위험 | useReducer 또는 단일 객체로 통합 |
| prop이 3계층 이상 전달 (prop drilling) | 구조적 설계 문제 | Context 또는 상태 끌어올리기 재검토 |
측정 없이 습관적 useMemo/useCallback |
과잉 최적화 | React Compiler 도입 또는 제거 검토 |
2025년 React가 만들어가는 변화
React 19와 함께 사용할 수 있는 별도 opt-in 도구인 React Compiler가 가장 눈에 띄는 변화입니다. 컴포넌트와 표현식을 자동으로 최적화해서 수동 useMemo·useCallback·React.memo 필요성을 대폭 줄여줍니다. 대규모 팀에서 React Compiler를 도입한 뒤 코드베이스에 산재하던 메모이제이션 코드를 대거 삭제하고도 성능이 동일하게 유지되었다는 사례가 보고되고 있습니다.
use() 훅도 주목할 만합니다. 비동기 리소스를 조건부로 처리할 수 있어, 기존의 useEffect + 로딩 상태 패턴을 상당히 간결하게 만들어줍니다.
Signals 패러다임도 2025년 React 생태계에 영향을 미치고 있습니다. 지금 당장 React에서 Signals를 쓸 일은 없더라도, 이 방향이 의미하는 바는 명확합니다.
Signals — SolidJS, Preact에서 주도한 세밀한 반응성 프리미티브. 컴포넌트 전체를 리렌더링하는 대신 변경된 DOM 요소만 업데이트해 Virtual DOM 비용 없이 반응성을 구현합니다. "불필요한 리렌더링을 줄이는 것"이 프론트엔드 복잡성의 핵심 과제라는 점을 다시 확인해주는 흐름이며, React Compiler가 자동 메모이제이션으로 같은 문제를 풀려는 것도 같은 맥락입니다.
지금 당장 바꿀 수 있는 세 가지 패턴
예시 1: useEffect를 파생 계산으로 대체하기
저도 처음엔 파생 상태를 무조건 useState + useEffect 조합으로 관리했는데, 이게 사실 가장 흔하면서도 가장 불필요한 복잡성의 원천입니다. fullName 같은 단순한 케이스부터, 선택된 항목 목록에서 완료된 것만 필터링하는 상황까지 같은 원칙이 적용됩니다.
// Before: 불필요한 useEffect로 파생 상태 관리
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// After: 렌더링 중 직접 계산
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // state 불필요| 비교 항목 | Before | After |
|---|---|---|
| 렌더링 횟수 | 상태 변경 시 2회 (state 업데이트 → effect 실행) | 1회 |
| 동기화 버그 위험 | 있음 | 없음 |
| 코드 라인 수 | 5줄 | 1줄 |
React 공식 문서의 "You Might Not Need an Effect"가 이 패턴을 잘 정리해두었습니다. 렌더링 중 계산 가능한 값은 state로 저장할 필요가 없습니다. 물론, 계산 비용이 매우 큰 경우라면 useMemo를 쓰는 게 맞습니다 — 그때는 React DevTools Profiler로 실제 병목을 먼저 확인해보시면 좋습니다.
예시 2: 연관된 상태를 useReducer로 통합하기
데이터 패칭 관련 상태가 세 개 이상 연동될 때, 각각을 useState로 관리하면 동기화 버그가 생기기 쉽습니다. 실무에서 자주 맞닥뜨리는 상황인데, useReducer로 상태 머신처럼 관리하면 훨씬 안전합니다.
// Before: 연관 상태 3개를 별도로 관리 → 동기화 버그 위험
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<User[] | null>(null);
// isLoading과 error가 동시에 true인 불가능한 상태가 생길 수 있음
// After: useReducer로 단일 상태 머신화
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: Error };
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: User[] }
| { type: 'FETCH_ERROR'; error: Error };
function fetchReducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START': return { status: 'loading' };
case 'FETCH_SUCCESS': return { status: 'success', data: action.data };
case 'FETCH_ERROR': return { status: 'error', error: action.error };
default: return state;
}
}
const [state, dispatch] = useReducer(fetchReducer, { status: 'idle' });불가능한 상태 조합(예: isLoading: true이면서 error가 있는 상태)이 타입 레벨에서 차단됩니다. 상태 전환 로직이 한 곳에 모이기 때문에 디버깅도 훨씬 쉬워집니다. 이 패턴으로 바꾼 뒤 처음으로 PR에서 "와, 이거 훨씬 읽기 쉽다"는 리뷰를 받았던 기억이 납니다.
단, 상태가 정말 단순하고 전환 로직이 없다면 useReducer는 과한 도구일 수 있습니다. isOpen, isDirty 같은 독립적인 불리언 두 개는 그냥 useState 두 개가 더 자연스럽습니다.
예시 3: TanStack Query로 useEffect 기반 데이터 패칭 제거하기
서버에서 데이터를 가져오는 로직을 useEffect로 직접 관리하면, 캐싱·재시도·로딩 상태를 전부 손으로 짜야 합니다. 그리고 한 가지 자주 놓치는 함정이 있는데, fetch는 4xx/5xx 응답에서 자동으로 reject되지 않습니다. 아래 Before 코드처럼 작성하면 서버가 500을 내려도 에러가 catch되지 않는 버그가 숨어 있습니다.
// Before: useEffect + 상태 관리 조합 (fetch 에러 처리 주의 필요)
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
fetch('/api/users')
.then(res => {
// fetch는 4xx/5xx에서 reject되지 않으므로 명시적 체크 필수
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then(data => {
setUsers(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
}
// After: TanStack Query로 서버 상태 분리
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
});
// 캐싱, 백그라운드 갱신, 재시도 로직이 자동으로 포함됨
}서버 상태 vs 클라이언트 상태 — 서버에서 가져오는 데이터(서버 상태)와 UI 인터랙션으로 관리하는 데이터(클라이언트 상태)는 성격이 다릅니다. TanStack Query는 서버 상태 전용 관리 도구로, 이 둘을 섞지 않도록 도와줍니다.
TanStack Query가 맞지 않는 상황도 있습니다. 실시간 WebSocket 스트림이나 단순한 일회성 요청만 있는 경우엔 오히려 과한 도구일 수 있습니다. 팀의 서버 상태 패턴이 단순하다면 React 19의 use() 훅부터 먼저 살펴보는 것도 좋은 선택입니다.
장단점 분석
이 섹션은 "왜 아직 React인가"에 대한 솔직한 정산이기도 합니다. 복잡성 피로의 맥락에서 보면 React의 강점과 약점이 더 선명하게 보입니다.
장점
| 항목 | 내용 |
|---|---|
| 생태계 규모 | 2025년 사용률 44.7%로 1위, 채용·라이브러리·참고 자료가 가장 풍부 |
| 점진적 단순화 | React Compiler, use() 훅 등 공식 차원의 복잡성 감소가 실질적으로 진행 중 |
| 유연성 | 특정 아키텍처를 강제하지 않아 팀 상황에 맞는 선택이 가능 |
| 장기 안정성 | Meta, Vercel의 지속적 투자로 장기 지원이 보장된 상황 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 설정 비용 | Webpack 설정, Hydration 불일치, useEffect 디버깅에 과도한 시간 소요 |
Vite 전환으로 설정 복잡도 감소 |
| 번들 크기 | JS 번들 1MB 초과가 흔하며 Svelte(~15KB) 대비 구조적 열세 | Code Splitting, React Server Components 활용 — 팀마다 체감이 다르지만 Code Splitting만으로도 절반 이상 줄어드는 경우가 많습니다 |
| 학습 곡선 | React 외에도 라우터·상태관리·폼 라이브러리를 별도로 학습해야 함 | 팀 표준 스택을 먼저 정하고 점진적으로 확장 |
| 결정 피로 | 모든 레이어에서 선택지가 과잉 공급됨 | Zustand + TanStack Query 조합을 기본값으로 고정하고 시작하는 것이 가장 현실적입니다 |
Hydration 불일치 — 서버에서 렌더링된 HTML과 클라이언트에서 React가 생성하는 DOM이 다를 때 발생하는 오류. React Server Components 환경에서 특히 디버깅이 까다롭습니다.
실무에서 가장 흔한 실수
-
파생 가능한 값을 state로 저장하는 것 — 기존 state에서 계산할 수 있는 값을 별도 state로 관리하면, 동기화 오류와 불필요한 리렌더링이 함께 따라옵니다.
eslint-plugin-react-hooks의 의존성 배열 경고를 활성화해두면 이런 패턴을 조기에 잡는 데 도움이 됩니다. -
성능 측정 없이
useMemo/useCallback을 습관적으로 달기 — React DevTools Profiler로 실제 병목을 확인하지 않은 메모이제이션은 코드만 복잡하게 만들 뿐입니다. React Compiler가 이 문제를 자동으로 처리해주는 방향으로 가고 있으니, 지금 습관적으로 달고 있다면 한 번쯤 재검토해볼 만합니다. -
상태 관리 라이브러리를 먼저 선택하고 아키텍처를 맞추는 것 — 라이브러리가 문제를 해결해주는 게 아니라, 문제를 정의한 뒤 적합한 도구를 고르는 순서가 맞습니다. 서버 상태라면 TanStack Query, 글로벌 클라이언트 상태라면 Zustand, 컴포넌트 로컬 상태라면
useState/useReducer로 분리해서 생각해보시면 좋습니다.
마치며
React 복잡성 피로의 해결책은 "더 나은 라이브러리 선택"이 아니라, 단순화 기준을 명확히 세우고 팀 전체가 공유하는 것입니다. 지금 바로 시작해볼 수 있는 세 가지입니다.
-
현재 코드베이스에서
useEffect를 검색해서 "이게 정말 외부 동기화인가?"를 하나씩 확인해볼 수 있습니다. 렌더링 중 계산 가능한 파생 값이라면 바로const선언으로 바꿀 수 있습니다.eslint-plugin-react-hooks의 의존성 배열 경고도 이 과정에서 같이 정리해두면 좋습니다. -
PR 체크리스트에 이 글의 단순화 기준 네 가지를 추가해볼 수 있습니다. "이
useEffect는 파생 계산으로 대체 가능한가?", "연동된useState가 3개 이상인가?", "prop이 3계층 이상 전달되는가?" — 이 질문들이 코드 리뷰에서 반복되면 자연스럽게 팀 전체의 기준이 됩니다. -
React Compiler를 사이드 프로젝트나 개발 환경에서 먼저 실험해볼 수 있습니다. 공식 문서의 React Compiler 소개를 참고해서 활성화해보시면, 기존
useMemo/useCallback코드가 얼마나 줄어드는지 직접 확인할 수 있습니다.
참고 자료
- Why I Decided to Stop Working with React.js in 2026 | Medium
- The Frontend Fatigue Is Real: Why 2025 Is the Year Developers Simplify | Medium
- The React Component Pyramid Scheme: An Over-Engineering Crisis | The New Stack
- You Might Not Need an Effect | React 공식 문서
- React State Management in 2025: What You Actually Need | Developer Way
- React Compiler & React 19 - Forget About Memoization Soon? | Developer Way
- React Compiler 공식 문서 | React
- Framework Fatigue 2025: Why Developers Are Ditching React for Boring Monoliths | XYZBytes
- Optimizing JavaScript Delivery: Signals v React Compiler | RedMonk
- Frontend Developers Are Burned Out, Not Lazy | LogRocket Blog
- Managing State | React 공식 문서