1. 개요
React 18 동시성(Concurrent) 모드가 적용된 모던 프론트엔드 빌드 환경에서, Axios 공통 통신 인프라의 뼈대를 이루고 있던 useAxiosInterceptor 커스텀 훅을 통합 검증하는 과정 중 예기치 못한 빌드 린터 오류를 마주쳤습니다.
기존 코드는 API 인터셉터가 안전하게 마운트 완료되어 네트워크 통신 준비를 완수했는지를 전통적인 useState와 useEffect 조합으로 감시하고 있었습니다. 하지만 이 구조는 브라우저가 화면 드로잉 페인팅을 끝마친 직후 곧바로 연쇄적인 가상 DOM 리렌더링을 뿜어내는 Cascading Renders 오작동을 초래했습니다.
나아가 비동기 렌더 스레드 속에서 상태 불일치가 격발되는 상태 찢어짐(Tearing) 결함의 불씨가 될 수 있었습니다. 이를 극복하고자 React 18 공식 스펙인 useSyncExternalStore 훅을 전격 도입하여, 리액트 외부의 시스템 상태를 안전하고 견고하게 구독(Subscribe)하도록 대대적으로 리팩토링한 여정을 기록합니다.
2. 핵심 내용
2-1. 🚨 React 18 동시성 모드와 Cascading Renders 병목
- 기존 레거시 코드의 오작동:
- 기존에는 아래와 같은 무해해 보이는 일반적인 방식으로 Axios 인터셉터 적재 완료 여부를 판정하고 상태를 주입했습니다.
const [isReady, setIsReady] = useState(false); useEffect(() => { // Axios 인터셉터 장착... setIsReady(true); // ⚠️ Cascading Renders 및 Tearing 위험 경고 점등! }, []); - 원인과 치명적 리스크:
- 화면 강제 재드로잉 (Cascading Renders):
useEffect내부에서setIsReady(true)로 상태를 흔드는 순간, 브라우저는 최초 렌더링에 의한 픽셀 페인팅(Paint)을 완료하자마자 가상 DOM 디프(Diff)를 거쳐 화면을 부자연스럽게 다시 그리게 됩니다. 이는 모바일 환경에서 미세한 떨림과 FPS 저하를 촉발합니다. - 상태 찢어짐 (Tearing) 리스크: React 18 동시성 렌더링은 무겁고 복잡한 연산이 포함된 렌더 단계를 여러 조각으로 쪼개어 가볍게 비동기 병렬 처리합니다. 이때 외부 스토어의 데이터를
useState방식으로 억지 매핑하려 시도하면, 서로 다른 두 개의 컴포넌트가 동일 시간에 서로 다른 상태값을 화면에 그려 내어 일관성이 무참히 깨지는 상태 찢어짐 현상이 발생합니다.
- 화면 강제 재드로잉 (Cascading Renders):
2-2. 💡 useSyncExternalStore 도입 및 리팩토링 구현
- 외부 스토어(External Store)의 격리 인식:
- “Axios 인터셉터가 정상적으로 적재되어 구동 중인가?”라는 명제는 리액트 컴포넌트 라이프사이클 내부에서 자급자족하는 순수 UI 상태가 아닙니다. Axios 통신 패키지와 브라우저 윈도우 인터렉션이라는 **‘React 영토 외부 시스템’**의 엄격한 런타임 결과물입니다.
- 이 외부의 이방인을 무리하게 React 내부의
useState래핑으로 다듬으려다 연쇄 렌더링 병목이 촉발된 것임을 인지하고, 외부 전역 시스템 상태와 가상 DOM 단계를 찢어짐 없이 핀포인트로 직결해 주는useSyncExternalStore를 전진 배치했습니다.
graph LR subgraph React 영토 A["React 18 동시성 렌더러"] B["UI Component"] end subgraph 외부 영역 (External Store) C["Axios Interceptor 모듈"] end B -- "1. 레거시 useState로 강제 동기화" --> A Note over A,C: [문제] 비동기 동시성 렌더링 시 값 불일치 (Tearing) 격발 B -- "2. useSyncExternalStore 구독 채널 가설" --> C C -- "3. 안전 리스너 콜백 전파 (listeners.forEach)" --> B Note over B,C: [개선] 렉 없고 상태 찢어짐 없는 초고속 실시간 정합성 보장
- Before & After 코드 리팩토링 명세:
- React 외부에 순수 JS 리스너 세트(
Set)를 가꾸고, 서버 사이드 렌더링 시의 Hydration Mismatch를 차단하기 위해getServerSnapshot안전핀을 바인딩했습니다.
"use client" import { useEffect, useSyncExternalStore } from "react" import axios from "axios" // 리액트 바깥 영역에 전역 스토어 정의 let isInterceptorReady = false; const listeners = new Set<() => void>(); const subscribe = (onStoreChange: () => void) => { listeners.add(onStoreChange); return () => { listeners.delete(onStoreChange); }; }; const getSnapshot = () => isInterceptorReady; const getServerSnapshot = () => false; // SSR 대응용 디폴트 복원선 export function useAxiosInterceptor() { // 리액트 엔진에 외부 스토어 구독 채널 주입 const isReady = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); useEffect(() => { const interceptorId = axios.interceptors.response.use( (res) => res, (err) => Promise.reject(err) ); isInterceptorReady = true; listeners.forEach((callback) => callback()); // 리스너 일제 전파 return () => { axios.interceptors.response.eject(interceptorId); isInterceptorReady = false; listeners.forEach((callback) => callback()); }; }, []); return isReady; } - React 외부에 순수 JS 리스너 세트(
2-3. ✅ 결과 검증 및 모던 프론트엔드 상태 라이브러리 검토
- 결과 검증:
- 빌드 린트 정화:
useEffect내에서 어색하게 렌더링을 터뜨리던 찌꺼기가 전면 증발하여set-state-in-effect린트 에러가 영구 제거되었습니다. - 하이드레이션 불일치 완전 진압: SSR 구동 시 서버 스냅샷 값인
false를 매끄럽게 물고 마운트된 뒤, 클라이언트 마운트 즉시 하이드레이션 mismatch 경고 없이 유연하게 실제 인터셉터를 인계받음을 보장받았습니다. - 하위 호환성 철저 완수: 훅 내부 동역학만 현대화했을 뿐 리턴 타입은 기존의
boolean형을 정합성 있게 보존하여, 이를 활용하던 상단providers.tsx등 결합 지점들은 코드 수정이 일절 필요 없는 무장애 배포를 달성했습니다.
- 빌드 린트 정화:
- 추천 오픈소스 라이브러리 검토:
- Zustand (by Poimandres)
- 평가: Redux의 기형적인 코드량과 Context API의 전역 리렌더링 버벅임 현상을 파괴한 명품 상태 관리 도구입니다.
- Zustand 역시 내부 코어 아키텍처가 리액트 외부의 경량 JS 메모리 클로저 스택에 변수들을 가둔 뒤, 컴포넌트 단위로 슬라이스해 리액트 엔진으로 주입하는
useSyncExternalStore스펙을 코어 엔진 내부에 완벽히 장착하고 있습니다. - 이 라이브러리를 차기 상태 스토어 엔진으로 주입하신다면, 본 트러블슈팅에서 수동으로 짜 넣은
Set리스너 및 구독 로직을 완벽하게 내장 스펙으로 누릴 수 있어 장기 유틸리티 상태 제어 시 절대 필수로 도입해보실 것을 강력 권장합니다.
- Zustand (by Poimandres)