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 조합보다 근본적이고 정확하다.