React 컴포넌트 방어 열셋 — 앱 몫과 라이브러리 몫

언제 — 컴포넌트에 방어를 넣을지 말지 정할 때. 먼저 이게 앱 컴포넌트인지 라이브러리 컴포넌트인지 가른다. 앱 컴포넌트는 자기가 어디서 쓰이는지 알기 때문에 절반은 해당이 없다 — 전부 예방적으로 바르면 그게 새로운 over-engineering 이다.

# 방어 적용
R1 외부 store 는 useSyncExternalStore 공통
R2 FOUC 는 블로킹 스크립트 + 단일 소유권 공통
R3 render 출력을 client-only 값으로 가르지 않는다 공통
R4 파생은 순수 함수, 지속성이 correctness 면 state 공통
R5 SSR 안정 ID 는 useId 공통
R6 자식 주입은 cloneElement 아닌 context 공통
R7 isomorphic layout effect shim 조건부
R8 이벤트는 ownerDocument.defaultView 라이브러리
R9 Activity 로 숨을 때 전역 부수효과를 끈다 라이브러리·조건부
R10 ViewTransition 은 startTransition 으로 조건부
R11 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망 공통(RSC)
R12 effect 는 StrictMode 이중 실행에 멱등 공통
R13 ref 콜백이 cleanup 을 반환한다 (React 19) 공통

공통 여덟 · 조건부/라이브러리 다섯. 앱 컴포넌트면 공통 여덟으로 대부분 끝난다. R1R4(상태·SSR)가 압도적으로 자주 물리고, R8R10 은 진짜 edge case 다.

R1. 외부 store 는 useSyncExternalStore 로 읽는다

증상 — 테마·인증·미디어쿼리처럼 React 밖에 사는 상태를 useState + useEffect 로 읽으면 concurrent 렌더링에서 트리 일부는 옛 값, 일부는 새 값으로 찢어진다(tearing). SSR 이면 초기값이 안 맞아 깜빡인다.

원인useEffect 는 커밋 라 첫 렌더가 항상 stale. concurrent 렌더러는 렌더 도중 외부 값이 바뀌면 일관성을 보장하지 않는다.

const value = useSyncExternalStore(
  subscribe,          // (cb) => unsubscribe
  getSnapshot,        // 클라이언트 현재값
  getServerSnapshot,  // SSR 안정값 — 이게 공짜로 딸려오는 SSR 대응
)

함정getSnapshot 이 객체를 반환하면 매번 새 참조라 무한 루프다. 값이 안 변하면 같은 참조를 캐시해서 돌려줄 것. primitive 면 안전하다. subscribe 의 add/remove 가 멱등이어야 StrictMode 이중 마운트에서 안 샌다(R12).

R2. FOUC 는 블로킹 스크립트 + 단일 소유권으로 막는다

증상 — 다크 테마 사용자가 새로고침하면 흰 화면이 한 프레임 번쩍인다. 서버는 유저 테마(localStorage)를 모르니 기본값으로 렌더하고, 하이드레이션 후에야 교정된다.

절차 — 첫 페인트 실행되는 스크립트가 <html> 에 속성 하나를 쓰고, React 는 그 속성을 JSX 로 렌더하지 않는다.

<html lang="ko" suppressHydrationWarning>
  <head><script dangerouslySetInnerHTML={{ __html: themeScript }} /></head>
// themeScript: import 없는 자기완결 문자열
(function(){try{
  var d = /* resolve dark? */
  var r = document.documentElement, t = d ? "dark" : "light"
  r.dataset.theme = t; r.style.colorScheme = t
}catch(e){}})()

확인 — 스크립트가 건드리는 속성을 React 가 className={...} 으로 다시 렌더하고 있지 않은가. 둘 다 소유하면 mismatch 다.

함정suppressHydrationWarning그 엘리먼트의 속성만 억제한다. 자식에 전파되지 않는다.

R3. render 출력을 client-only 값으로 가르지 않는다

증상localStorage.getItem() 을 컴포넌트 본문에서 부르면 서버에서 크래시하고, typeof window 가드로 우회하면 이번엔 hydration mismatch.

원인 — 문제는 “브라우저 API” 가 아니라 render 단계에서 client-only 값으로 출력을 가르는 것이다. 서버와 클라가 다른 트리를 만든다.

절차 — client-only 값은 effect 나 useSyncExternalStore(R1)를 거쳐 들어오게 하고, 첫 렌더는 서버와 같은 출력을 낸다.

함정useState(() => localStorage...) 의 lazy init 도 서버에서 실행된다. 크래시는 그대로다.

R4. 파생은 순수 함수, 지속성이 correctness 면 state

증상useMemo(getRandomColors, []) 로 만든 값이 HMR·리마운트 후 슬그머니 바뀐다.

원인 — 흔한 오진은 “React 가 캐시를 버려서”. 진짜 원인은 getRandomColors 가 impure 라서다. 순수했다면 재계산돼도 같은 값이라 티가 안 난다. useMemo 는 성능 힌트지 의미 보장이 아니다.

// 파생(순수)은 렌더 중 그냥 계산 — memo 도 과하다
const resolved = resolveTheme(mode, systemDark)

// 한 번 만들고 유지돼야 correctness 면 state (lazy init 로 1회 실행)
const [colors] = useState(getRandomColors)

함정 — 비결정적이거나 부수효과가 있는 계산을 useMemo 에 넣지 않는다.

R5. SSR 안정 ID 는 useId

증상Math.random()·모듈 카운터로 만든 id 가 서버·클라에서 불일치.

const id = useId()  // 트리 위치 기반이라 SSR 안정

함정 — 반환값에 특수문자가 들어갈 수 있다. getElementById(id) 는 안전하지만 querySelector('#' + id) 나 CSS 셀렉터에서는 깨진다. raw id 를 받는 API 를 쓴다.

R6. 자식 주입은 cloneElement 대신 context

증상cloneElement 로 자식에 props 를 꽂는 컴포넌트가 Fragment·lazy·다중 자식에서 부서진다.

return <Ctx value={value}>{children}</Ctx>  // React 19: context 가 곧 provider
// 자식: const v = use(Ctx)

cloneElement 는 타입이 불안정하고 자식 구조에 결합된다. 사실상 항상 context 가 낫다.

R7. useLayoutEffect 의 SSR 경고는 isomorphic shim 으로

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect

적용 — 커밋 전에 DOM 을 만져 flash 를 막아야 하는데 SSR 도 하는 컴포넌트(테마 토글 등). 단순 effect 면 useEffect 로 충분하다.

R8. 이벤트는 올바른 window 에 건다

증상 — 컴포넌트를 window.open·포털로 다른 창에 렌더했더니 window.addEventListener 가 원래 창을 듣는다.

const win = ref.current?.ownerDocument.defaultView ?? window
win.addEventListener(/* ... */)

effect 는 커밋 후라 ref.current 가 채워져 있다.

적용 — 멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트만. 리스너 정리 자체의 1차 도구는 R13 이다.

R9. Activity 로 숨겨질 때 전역 부수효과를 끈다

증상<Activity mode="hidden"> 안의 컴포넌트가 주입한 전역 :root 스타일이 숨긴 뒤에도 남아 샌다.

원인 — Activity 는 숨길 때 effect 를 정리하고 보일 때 재실행하지만, 순수 <style> DOM 노드 자체는 보존한다.

useLayoutEffect(() => {
  const el = ref.current
  if (!el) return
  el.media = 'all'
  return () => { if (ref.current) ref.current.media = 'not all' }  // cleanup 도 가드
}, [])

적용 — Activity(실험적) 경계와 전역 스타일 주입이 겹칠 때만. 매우 니치.

R10. ViewTransition 을 트리거하려면 startTransition

상태를 바꿨는데 <ViewTransition> 애니메이션이 안 도는 이유는 하나다 — ViewTransition 은 transition 으로 표시된 업데이트만 애니메이트한다. startTransition(() => setState(...)).

R11. 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망

증상 — 서버 컴포넌트에서 user 객체를 통째로 클라이언트 컴포넌트에 넘겨 토큰·이메일이 번들에 실린다.

<ClientThing theme={user.theme} />   // user 통째 ❌ → 필드만 ⭕

그 위에 방어층으로:

experimental_taintUniqueValue('서버에만 두세요', user, user.token)

순서가 핵심이다 — prop 좁히기가 1차 해법이고 taint 은 defense-in-depth 다. 지루하고 올바른 수를 건너뛰고 실험적 도구로 점프하지 않는다. experimental_ 이라 API 변동도 있다.

R12. effect 는 StrictMode 이중 실행에 멱등이어야 한다

증상 — dev 에서만 구독이 두 번 걸리거나 애니메이션이 두 번 뛴다.

원인 — StrictMode 는 mount → unmount → mount 로 effect 를 두 번 돌려 정리 누락을 드러낸다. 프로덕션 버그의 조기경보다.

useEffect(() => {
  const un = subscribe(cb)
  return un        // 정리가 곧 멱등성
}, [])

add 에 대응하는 remove, 시작에 대응하는 취소. 경고가 아니라 무료 테스트라서 끄지 않는다.

R13. ref 콜백은 정리 함수를 반환한다 (React 19)

<div ref={(node) => {
  const ctrl = new AbortController()
  node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
  return () => ctrl.abort()   // 노드가 떠날 때 정리
}} />

리스너를 노드 수명에 묶어야 할 때 effect + ref 조합보다 근본적이고 정확하다.

#594raw