FEInterview Prep

Medium

리액트는 이미 변했습니다. 훅 역시 변해야 합니다.

훅은 클래스 메서드의 새 문법이 아니라 아키텍처 패턴이다. useEffect 남용을 줄이고 파생 상태·useSyncExternalStore·use()·서버 액션으로 책임을 분리해야 React 18/19 시대를 따라잡는다.

2025-12-08·8분 읽기
React
원문 보기 ↗

핵심 요약

훅을 다시 설계할 때 다음 규칙을 적용한다. (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파생 상태useMemo외부 동기화
자주 하는 오해

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 가 사라진다.

useSyncExternalStoretearing외부 storeSSR snapshot
자주 하는 오해

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()서버 컴포넌트Suspense워터폴
자주 하는 오해

use() 를 모든 비동기 호출에 끼워넣는 것. use()호출자 컴포넌트가 Suspense 경계 안에 있을 때만 의미가 있고, 매 렌더마다 새 Promise 를 만들면 무한 fetch 가 된다.

읽는 순서

  1. 1이론

    React 공식 문서의 You Might Not Need an EffectuseSyncExternalStore 페이지를 정독하고, 언제 effect 가 정답이 아닌가 의 6가지 패턴을 정리한다.

  2. 2구현

    기존 프로젝트의 useEffect 5개를 골라 — 파생 상태/외부 구독/이벤트 핸들러로 — 분류하고, 각각 useMemo/useSyncExternalStore/이벤트 콜백으로 리팩터링한 PR 을 만들어본다.

  3. 3실무

    useDeferredValuestartTransition 을 사용해 검색/필터 UI 를 한 번 만들어보고, Profiler 로 입력 반응성 변화 를 측정한다.

  4. 4설명

    팀 리뷰에서 '이 effect 를 어떻게 더 좋게 만들 수 있을까' 5분 발표를 준비. 축: (1) 파생 vs 사이드 이펙트, (2) 동기 일관성, (3) 서버/클라 경계.

면접 연결 질문

hard`useEffect` 와 `useEffectEvent` 의 차이를 설명하고, 의존성 배열을 줄이기 위한 *편법* 으로 `useEffectEvent` 를 쓰면 안 되는 이유를 말해보세요.
힌트

[감점 답변] 'effect 안에서 최신 값을 읽기 위한 것' 한 줄. [좋은 답변] useEffectEvent 는 effect 내부 호출 에 최적화된 도구로, 의존성 배열에 포함되지 않으면서 최신 props/state 를 읽게 해준다. 하지만 effect 자체가 언제 다시 실행될지 를 결정하는 의존성에서 빠지면 동기화가 깨진다 — 예컨대 roomId 가 바뀌었는데 reconnect 가 안 된다. 즉 useEffectEvent부수 작업 에만, 재실행 트리거 는 의존성 배열에 두라.

medium`useSyncExternalStore` 의 세 번째 인자는 왜 필요한가요? 빠뜨리면 어떤 증상이 나타납니까?
힌트

[감점 답변] 'SSR 용' 한 줄. [좋은 답변] 세 번째 인자는 서버에서 사용할 snapshot 이다. 빠뜨리면 SSR 에서 에러를 던지거나, 초기 렌더와 클라이언트 첫 렌더 값이 달라 hydration mismatch 가 발생한다. 일반적으로 () => false 나 결정론적 기본값을 넘긴다.

hardRSC 에서 클라이언트 컴포넌트의 데이터 패칭 패턴 두 가지(`use(promise)` vs 부모에서 props 로 데이터 내려주기)의 트레이드오프를 말해보세요.
힌트

[감점 답변] '아무거나 써도 된다'. [좋은 답변]

  • use(promise): Suspense 경계가 가까운 곳에 있을 때 적합. 비동기 트리의 일부만 지연시키고 나머지는 먼저 그릴 수 있다. 단, 매 렌더마다 새 Promise 를 만들면 무한 fetch.
  • props 로 내려주기: 서버 컴포넌트에서 await 후 직렬화 가능한 값만 전달. 워터폴이 단순 하지만 클라이언트에서 일부만 새로고침 하기 어렵다.

자기 점검

`useEffect` 에 `[]` 를 의존성으로 둔 코드 중 *반드시 분리해야 하는 케이스* 를 한 가지 본인 코드에서 찾아보세요.
외부 동기화파생 상태useMemouseEffectEvent
자주 하는 오해

'마운트 시 한 번만 실행되니 안전하다'. 실제로는 클로저로 캡처된 값이 stale 해져 오래된 props/state 로 동작하는 버그의 핫스팟이다.

`useSyncExternalStore` 와 `useEffect`+`useState` 조합으로 똑같이 동작해 보이는 코드의 차이점을 동시성 모드 관점에서 설명해보세요.
tearing동기 snapshotSSRsubscribe
자주 하는 오해

'결국 둘 다 똑같다'. 동시성 렌더가 인터럽트되는 순간 후자는 렌더 도중 값이 변하면 tearing 이 생긴다.