Medium
리액트는 이미 변했습니다. 훅 역시 변해야 합니다.
훅은 클래스 메서드의 새 문법이 아니라 아키텍처 패턴이다. useEffect 남용을 줄이고 파생 상태·useSyncExternalStore·use()·서버 액션으로 책임을 분리해야 React 18/19 시대를 따라잡는다.
핵심 요약
훅을 다시 설계할 때 다음 규칙을 적용한다. (1) 파생 상태는 렌더링 단계에서 계산 — useMemo/그냥 변수로. (2) useEffect 는 외부 세계와의 동기화 에만 — 네트워크 구독, DOM API, 정리 함수가 필요한 작업. (3) 의존성 배열에 함수를 넣어 무한 루프가 되면 useEffectEvent 로 최신 props/state 를 안전하게 읽는다. (4) matchMedia/외부 store/스크롤은 useSyncExternalStore — tearing 과 SSR 일관성이 자동으로 풀린다. (5) 데이터 페칭은 가능하면 서버 컴포넌트의 async 또는 use(promise) + <Suspense> 로. (6) 입력 반응성을 유지하면서 비싼 계산은 useDeferredValue/startTransition 으로 양보. 면접관이 듣고 싶은 것은 이 도구들을 언제 어느 것을 쓰는가 의 판단 기준이다.
훅을 'lifecycle 메서드의 함수형 버전' 으로 보는 2020년 모델에서, '데이터 흐름과 경계(클라이언트/서버, 동기/비동기)를 표현하는 아키텍처 단위' 로 갈아탄다. 모든 로직을 useEffect 에 욱여넣지 않고 — 파생 상태는 렌더링 단계, 외부 구독은 useSyncExternalStore, 비동기 데이터는 use() 나 서버 컴포넌트 에 — 책임을 분리하는 프레임이 핵심이다.
React 18/19 의 동시성 기능과 RSC 가 도입되면서 useEffect 만으로 모든 사이드 이펙트를 다루던 패턴은 한계가 분명해졌다.
- 데이터 패칭을
useEffect에 두면 워터폴/중복 요청/경쟁 조건이 뒤섞인다 - 파생 값을 effect 로 계산하면 리렌더 → effect → setState → 또 리렌더 의 불필요한 사이클이 생긴다
matchMedia, 외부 store 처럼 동기 일관성이 필요한 구독은useSyncExternalStore가 정답이다- 서버 데이터는
use()+ Suspense 또는 서버 컴포넌트로 끌어내려야 클라이언트 번들과 워터폴을 동시에 줄일 수 있다
면접에서 '훅을 어떻게 설계하나' 라는 질문에 useEffect 외 도구의 선택 기준 을 구체적으로 말할 수 있어야 React 의 현재 위치를 이해한다는 신호가 된다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
`useEffect` 는 외부 동기화 전용
effect 의 정의는 '외부 세계와 React 트리를 동기화'. 파생 값 계산은 렌더 단계 책임이다.
// ❌ effect 로 파생 상태
useEffect(() => {
setFiltered(data.filter(d => d.includes(query)));
}, [data, query]);
// ✅ 렌더 단계에서 계산
const filtered = useMemo(
() => data.filter(d => d.includes(query)),
[data, query]
);
effect 로 파생을 만들면 렌더 → setState → 또 렌더 두 번 그리는 비용이 매번 발생한다.
useEffect 안에서 setState 로 파생 값을 만들고 '어차피 한 번 더 렌더되니까 괜찮다' 고 넘기는 것. 의존성이 늘어날수록 깨지는 지점이 기하급수로 늘어난다.
`useSyncExternalStore` 는 tearing 의 정답
matchMedia, Redux/Zustand, 스크롤 위치처럼 동기 일관성 이 필요한 구독은 useState + useEffect 로 만들면 동시성 렌더 중 tearing(렌더 도중 값이 바뀌어 화면이 부분적으로 어긋남) 이 발생한다.
function useMediaQuery(query) {
return useSyncExternalStore(
(cb) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
},
() => window.matchMedia(query).matches,
() => false // SSR 초기값
);
}
세 번째 인자(SSR snapshot) 를 넘겨야 hydration mismatch 가 사라진다.
useEffect 안에서 addEventListener + setState 로 직접 구독을 짜는 것. 동시성 모드에서 렌더 도중 값이 바뀌면 tearing 이 생긴다.
서버 데이터는 effect 가 아니라 `use()`/서버 컴포넌트로
RSC 환경에서는 서버 컴포넌트에서 await 로 직접 패칭하고, 클라이언트에선 use(promise) 로 Suspense 와 함께 처리하는 것이 표준이 됐다.
// 서버 컴포넌트
async function UserCard({ id }) {
const user = await fetchUser(id);
return <Card>{user.name}</Card>;
}
// 클라이언트 컴포넌트
function UserCardClient({ promise }) {
const user = use(promise); // Suspense 와 결합
return <Card>{user.name}</Card>;
}
useEffect 패칭과 비교해 워터폴 제거 + 번들 축소 + Suspense 통합 을 한 번에 얻는다.
use() 를 모든 비동기 호출에 끼워넣는 것. use() 는 호출자 컴포넌트가 Suspense 경계 안에 있을 때만 의미가 있고, 매 렌더마다 새 Promise 를 만들면 무한 fetch 가 된다.
읽는 순서
- 1이론
React 공식 문서의 You Might Not Need an Effect 와
useSyncExternalStore페이지를 정독하고, 언제 effect 가 정답이 아닌가 의 6가지 패턴을 정리한다. - 2구현
기존 프로젝트의
useEffect5개를 골라 — 파생 상태/외부 구독/이벤트 핸들러로 — 분류하고, 각각useMemo/useSyncExternalStore/이벤트 콜백으로 리팩터링한 PR 을 만들어본다. - 3실무
useDeferredValue와startTransition을 사용해 검색/필터 UI 를 한 번 만들어보고, Profiler 로 입력 반응성 변화 를 측정한다. - 4설명
팀 리뷰에서 '이 effect 를 어떻게 더 좋게 만들 수 있을까' 5분 발표를 준비. 축: (1) 파생 vs 사이드 이펙트, (2) 동기 일관성, (3) 서버/클라 경계.
면접 연결 질문
[감점 답변] 'effect 안에서 최신 값을 읽기 위한 것' 한 줄. [좋은 답변] useEffectEvent 는 effect 내부 호출 에 최적화된 도구로, 의존성 배열에 포함되지 않으면서 최신 props/state 를 읽게 해준다. 하지만 effect 자체가 언제 다시 실행될지 를 결정하는 의존성에서 빠지면 동기화가 깨진다 — 예컨대 roomId 가 바뀌었는데 reconnect 가 안 된다. 즉 useEffectEvent 는 부수 작업 에만, 재실행 트리거 는 의존성 배열에 두라.
[감점 답변] 'SSR 용' 한 줄. [좋은 답변] 세 번째 인자는 서버에서 사용할 snapshot 이다. 빠뜨리면 SSR 에서 에러를 던지거나, 초기 렌더와 클라이언트 첫 렌더 값이 달라 hydration mismatch 가 발생한다. 일반적으로 () => false 나 결정론적 기본값을 넘긴다.
[감점 답변] '아무거나 써도 된다'. [좋은 답변]
use(promise): Suspense 경계가 가까운 곳에 있을 때 적합. 비동기 트리의 일부만 지연시키고 나머지는 먼저 그릴 수 있다. 단, 매 렌더마다 새 Promise 를 만들면 무한 fetch.- props 로 내려주기: 서버 컴포넌트에서
await후 직렬화 가능한 값만 전달. 워터폴이 단순 하지만 클라이언트에서 일부만 새로고침 하기 어렵다.
자기 점검
'마운트 시 한 번만 실행되니 안전하다'. 실제로는 클로저로 캡처된 값이 stale 해져 오래된 props/state 로 동작하는 버그의 핫스팟이다.
'결국 둘 다 똑같다'. 동시성 렌더가 인터럽트되는 순간 후자는 렌더 도중 값이 변하면 tearing 이 생긴다.