---
type: note
kind: Recipe
title: React 컴포넌트 방어 열셋 — 앱 몫과 라이브러리 몫
description: tearing·FOUC·hydration 부터 Activity·ViewTransition 까지, 각 방어가 언제 정당한지 적용 범위와 함께.
tags: ['react', 'ssr', 'hydration', 'concurrent', 'rsc']
status: release
ctime: 2026-09-02
mtime: 2026-09-02
generated: { by: claude/opus-5, at: 2026-09-02T00:00:00Z }
---

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

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

## R1. 외부 store 는 useSyncExternalStore 로 읽는다

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

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

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

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

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

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

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

```tsx
<html lang="ko" suppressHydrationWarning>
  <head><script dangerouslySetInnerHTML={{ __html: themeScript }} /></head>
```

```js
// 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` 는 성능 힌트지 의미 보장이 아니다.

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

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

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

## R5. SSR 안정 ID 는 useId

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

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

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

## R6. 자식 주입은 cloneElement 대신 context

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```ts
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` 객체를 통째로 클라이언트 컴포넌트에 넘겨 토큰·이메일이 번들에 실린다.

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

그 위에 방어층으로:

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

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

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

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

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

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

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

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

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

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