React Hooks가 내부적으로 어떻게 동작하는지, 상태 관리는 어떤 기준으로 고르는지, 렌더링 성능은 어떻게 잡는지 — 자주 헷갈리는 주제들을 한 곳에 모았습니다.


Hooks 규칙 — 왜 조건문 안에서 못 쓰나

React Hooks에는 두 가지 절대 규칙이 있어요.

  1. 최상위(top level)에서만 호출할 것 — 반복문, 조건문, 중첩 함수 안에서 호출 금지
  2. React 함수 컴포넌트 또는 커스텀 Hook 안에서만 호출할 것

이 규칙이 왜 존재하는지 이해하려면, React가 Hooks를 내부적으로 어떻게 저장하는지 알아야 합니다.

Linked List 기반 구현

React는 각 컴포넌트의 Hooks를 호출 순서 에 의존하는 연결 리스트(linked list)로 관리합니다. 컴포넌트가 처음 마운트될 때 Hook이 호출되면, React는 내부적으로 { memoizedState, next } 형태의 노드를 생성해서 순서대로 연결해요.

PLAINTEXT
Hook 1 (useState) → Hook 2 (useEffect) → Hook 3 (useMemo) → null

재렌더링 시에는 같은 순서로 이 리스트를 순회하면서 각 Hook의 상태를 꺼내옵니다. 그런데 만약 조건문 안에 Hook이 있으면 어떻게 될까요?

JSX
// 절대 이렇게 하면 안 된다
function BadComponent({ isLoggedIn }) {
  const [name, setName] = useState('');

  if (isLoggedIn) {
    useEffect(() => {
      fetchProfile();
    }, []);
  }

  const [count, setCount] = useState(0);
  // ...
}

isLoggedIntrue일 때와 false일 때 Hook 호출 순서가 달라집니다. React는 "세 번째로 호출된 Hook은 useState(0)이겠지"라고 가정하고 리스트를 순회하는데, 조건에 따라 두 번째가 될 수도 있으니 상태가 엉키게 되는 거예요.

JSX
// 올바른 방법 — Hook은 항상 호출하되, 내부에서 분기
function GoodComponent({ isLoggedIn }) {
  const [name, setName] = useState('');

  useEffect(() => {
    if (isLoggedIn) {
      fetchProfile();
    }
  }, [isLoggedIn]);

  const [count, setCount] = useState(0);
}

핵심 포인트: "React가 Hook을 linked list로 관리하고 호출 순서에 의존하기 때문에, 조건부 호출 시 인덱스가 어긋나서 상태 불일치가 발생합니다."


useState — 상태 업데이트 배칭과 함수형 업데이트

기본 사용

JSX
const [count, setCount] = useState(0);

간단해 보이지만, 내부 동작을 모르면 실수하기 쉽습니다.

배칭 (Batching)

React 18부터 자동 배칭(Automatic Batching) 이 도입됐습니다. 이전에는 이벤트 핸들러 안에서만 배칭이 됐는데, 이제는 setTimeout, Promise, 네이티브 이벤트 핸들러 등 어디서든 배칭돼요.

JSX
function handleClick() {
  setCount(c => c + 1);    // 리렌더링 안 함
  setFlag(f => !f);         // 리렌더링 안 함
  setName('React');          // 여기까지 모아서 한 번만 리렌더링
}

배칭을 강제로 풀고 싶다면 flushSync를 쓸 수 있지만, 정말 특수한 경우 아니면 쓸 일 없습니다.

JSX
import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  });
  // 여기서 이미 DOM 업데이트 완료
  flushSync(() => {
    setFlag(f => !f);
  });
}

함수형 업데이트

이전 상태를 기반으로 새 상태를 계산할 때는 반드시 함수형 업데이트를 써야 합니다.

JSX
// 잘못된 예 — 같은 값을 세 번 set하는 셈
function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // count가 0이었으면, 결과는 1 (3 아님)
}

// 올바른 예 — 이전 상태를 받아서 계산
function handleTripleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  // count가 0이었으면, 결과는 3
}

배칭이 되더라도 함수형 업데이트는 큐에 쌓여서 순서대로 실행됩니다. count + 1은 클로저 시점의 count 값을 캡처하기 때문에 세 번 호출해도 같은 값으로 세팅되는 거예요.

핵심 포인트: setState는 비동기적으로 동작하며, React 18부터는 모든 컨텍스트에서 배칭된다. 이전 상태 기반 업데이트는 함수형 업데이트를 사용해야 정확하다.


useEffect — 의존성 배열, cleanup, 실행 타이밍

기본 구조

JSX
useEffect(() => {
  // Side effect 로직
  const subscription = someAPI.subscribe(data);

  return () => {
    // Cleanup 함수
    subscription.unsubscribe();
  };
}, [dependency1, dependency2]);

의존성 배열의 세 가지 형태

JSX
// 1. 매 렌더링마다 실행
useEffect(() => { /* ... */ });

// 2. 마운트 시 한 번만 실행
useEffect(() => { /* ... */ }, []);

// 3. 특정 값이 변경될 때 실행
useEffect(() => { /* ... */ }, [userId, page]);

빈 배열 []을 넣으면 마운트 시에만 실행되는데, 이건 클래스 컴포넌트의 componentDidMount와 비슷하면서도 다릅니다. useEffect의 클로저는 마운트 시점의 props/state를 캡처하기 때문에, 이후 변경된 값을 참조하지 못해요.

Cleanup 실행 순서

cleanup은 언제 실행될까요? 이 부분을 헷갈려 하는 경우가 많습니다.

JSX
useEffect(() => {
  console.log('effect 실행: ', count);

  return () => {
    console.log('cleanup 실행: ', count);
  };
}, [count]);

count가 0 → 1 → 2로 바뀔 때 콘솔 출력 순서:

PLAINTEXT
effect 실행: 0
cleanup 실행: 0     ← 이전 effect의 cleanup이 먼저
effect 실행: 1
cleanup 실행: 1
effect 실행: 2

cleanup은 다음 effect가 실행되기 직전 에, 이전 렌더링 시점의 값 으로 호출됩니다. 언마운트 시에도 마지막 cleanup이 호출돼요.

실행 타이밍 — paint 이후

useEffect는 브라우저가 화면을 그린 후(paint 이후) 비동기적으로 실행됩니다. 렌더 → 커밋 → 브라우저 페인트 → useEffect 순서예요. 그래서 useEffect 안에서 DOM을 읽어도 사용자는 이미 화면을 보고 있는 상태라, 깜빡임(flicker)이 발생할 수 있습니다.


useLayoutEffect vs useEffect

PLAINTEXT
렌더링 → DOM 업데이트 → useLayoutEffect → 브라우저 paint → useEffect
구분useEffectuseLayoutEffect
실행 시점paint 이후paint 이전
동기/비동기비동기동기
용도데이터 fetch, 구독, 로깅DOM 측정, 레이아웃 계산
주의점일반적인 side effect여기서 오래 걸리면 화면이 멈춤
JSX
function Tooltip({ text, targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  // useLayoutEffect를 쓰는 대표적인 케이스
  // DOM 측정 후 위치를 잡아야 깜빡임 없이 표시됨
  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({
      top: rect.bottom + 8,
      left: rect.left,
    });
  }, [targetRef]);

  return (
    <div style={{ position: 'absolute', ...position }}>
      {text}
    </div>
  );
}

useEffect를 썼다면 tooltip이 (0, 0)에 한 프레임 보였다가 올바른 위치로 이동하는 깜빡임이 생깁니다. 하지만 99%의 경우 useEffect로 충분하고, useLayoutEffect는 정말 DOM 레이아웃을 측정해서 즉시 반영해야 할 때만 쓰면 돼요.


useRef — DOM 참조, 값 유지

useRef가 반환하는 객체는 { current: initialValue } 형태입니다. 이 current 값을 바꿔도 리렌더링이 발생하지 않는다 는 게 핵심이에요.

DOM 참조

JSX
function TextInput() {
  const inputRef = useRef(null);

  const handleFocus = () => {
    inputRef.current.focus();
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>포커스</button>
    </>
  );
}

리렌더링 없이 값 유지

useState와 달리 값이 바뀌어도 컴포넌트가 다시 그려지지 않습니다. 타이머 ID, 이전 값 저장, 렌더링 횟수 추적 같은 용도에 적합해요.

JSX
function Timer() {
  const intervalRef = useRef(null);
  const [seconds, setSeconds] = useState(0);

  const start = () => {
    if (intervalRef.current !== null) return;
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };

  useEffect(() => {
    return () => clearInterval(intervalRef.current);
  }, []);

  return (
    <div>
      <p>{seconds}초</p>
      <button onClick={start}>시작</button>
      <button onClick={stop}>정지</button>
    </div>
  );
}

이전 값 저장 패턴

JSX
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <p>
      현재: {count}, 이전: {prevCount}
    </p>
  );
}

useEffect는 렌더링 이후에 실행되므로, ref.current를 반환하는 시점에는 아직 이전 값이 들어있고, 렌더링이 끝나면 새 값으로 업데이트됩니다. 이 타이밍 차이를 이용한 패턴이에요.


useMemo vs useCallback — 언제 써야 하나

둘 다 메모이제이션이지만, 대상이 다릅니다.

JSX
// useMemo — 값을 메모이제이션
const sortedList = useMemo(() => {
  return items.sort((a, b) => a.price - b.price);
}, [items]);

// useCallback — 함수를 메모이제이션
const handleClick = useCallback((id) => {
  setSelected(id);
}, []);

useMemo(() => fn, deps)useCallback(fn, deps)는 사실상 같은 것입니다. useCallbackuseMemo의 함수 버전 단축형일 뿐이에요.

언제 써야 하나

여기서 많은 분들이 실수하는데, 무조건 쓰는 건 오히려 성능을 해칩니다.

쓸 필요 없는 경우:

  • 단순한 계산 (배열 필터링 몇 개, 문자열 조합 등)
  • 자식에게 전달하지 않는 콜백
  • 이미 충분히 빠른 컴포넌트

쓸 가치가 있는 경우:

  • React.memo로 감싼 자식에게 전달하는 콜백 (참조 동일성 유지)
  • 정말 무거운 계산 (수천 개 아이템 정렬, 복잡한 필터링)
  • 다른 Hook의 의존성 배열에 들어가는 값/함수
JSX
// 이건 과하다 — useMemo의 비교 비용이 오히려 더 클 수 있음
const greeting = useMemo(() => `안녕 ${name}`, [name]);

// 이건 의미가 있다 — 자식 컴포넌트의 불필요한 리렌더링 방지
const MemoizedChild = React.memo(ChildComponent);

const handleSubmit = useCallback(() => {
  submitForm(formData);
}, [formData]);

return <MemoizedChild onSubmit={handleSubmit} />;

과도한 메모이제이션은 코드 복잡도만 올리고 실제 성능 개선 효과는 미미하다. React 공식 문서에서도 "먼저 프로파일링하고, 병목이 확인되면 그때 적용하라"고 권장한다.


useReducer — 복잡한 상태 로직

useState로 감당이 안 될 정도로 상태 전환 로직이 복잡해지면 useReducer가 답입니다.

JSX
const initialState = {
  status: 'idle',      // 'idle' | 'loading' | 'success' | 'error'
  data: null,
  error: null,
};

function fetchReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, status: 'loading', error: null };
    case 'FETCH_SUCCESS':
      return { status: 'success', data: action.payload, error: null };
    case 'FETCH_ERROR':
      return { status: 'error', data: null, error: action.payload };
    case 'RESET':
      return initialState;
    default:
      throw new Error(`알 수 없는 액션: ${action.type}`);
  }
}

function UserProfile({ userId }) {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCH_START' });

    fetchUser(userId)
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
  }, [userId]);

  if (state.status === 'loading') return <Spinner />;
  if (state.status === 'error') return <Error message={state.error} />;
  if (state.status === 'success') return <Profile data={state.data} />;

  return null;
}

useState vs useReducer 선택 기준

상황추천
독립적인 상태 1~2개useState
서로 연관된 상태 여러 개useReducer
상태 전환에 비즈니스 로직이 필요useReducer
다음 상태가 이전 상태에 의존useReducer

useReducer의 dispatch는 리렌더링 사이에 identity가 변하지 않아서, 의존성 배열에 넣어도 불필요한 재실행을 일으키지 않는다는 장점도 있습니다.


Custom Hook — 로직 재사용, 관심사 분리

Custom Hook은 use로 시작하는 함수일 뿐이지만, 컴포넌트 로직을 재사용 가능한 단위로 추출할 수 있게 해줍니다.

활용 예시: API 호출 Hook

JSX
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// 사용
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');

  if (loading) return <Spinner />;
  if (error) return <p>에러: {error}</p>;

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

활용 예시: 디바운스 Hook

JSX
function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 검색에 적용
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

활용 예시: 로컬스토리지 상태 동기화

JSX
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    localStorage.setItem(key, JSON.stringify(valueToStore));
  }, [key, storedValue]);

  return [storedValue, setValue];
}

Custom Hook의 핵심은 관심사 분리 입니다. 컴포넌트는 "무엇을 보여줄 것인가"에만 집중하고, "데이터를 어떻게 가져오고 관리할 것인가"는 Hook에 위임하는 구조예요.


상태 관리 — Context API 한계와 라이브러리 비교

Context API의 한계

Context는 원래 상태 관리 도구가 아닙니다. 의존성 주입(DI) 메커니즘이에요. 테마, 로케일, 인증 정보처럼 자주 바뀌지 않는 값을 전달하기에는 좋지만, 빈번하게 변하는 상태를 넣으면 문제가 생깁니다.

JSX
const AppContext = React.createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  return (
    <AppContext.Provider value={{ user, theme, notifications, setUser, setTheme, setNotifications }}>
      {children}
    </AppContext.Provider>
  );
}

이렇게 하나의 Context에 여러 상태를 넣으면, notifications만 바뀌어도 theme만 쓰는 컴포넌트까지 전부 리렌더링됩니다. Provider의 value가 새 객체로 바뀌기 때문이에요.

해결법은 Context를 쪼개거나, useMemo로 value를 감싸는 것이지만, 상태가 많아지면 Provider 지옥(Provider Hell)이 됩니다.

JSX
// Provider 지옥
<AuthProvider>
  <ThemeProvider>
    <NotificationProvider>
      <CartProvider>
        <App />
      </CartProvider>
    </NotificationProvider>
  </ThemeProvider>
</AuthProvider>

라이브러리 비교

항목Redux (Toolkit)ZustandJotaiRecoil
철학Flux, 단일 스토어단일 스토어, 간결한 APIatomic, bottom-upatomic, React 네이티브
보일러플레이트중간 (RTK로 줄어듦)매우 적음매우 적음적음
번들 크기~11kB (RTK)~1.5kB~3kB~20kB
DevTools강력함Redux DevTools 연동별도별도
비동기 처리RTK Query, thunk내장내장selector
학습 곡선높음낮음낮음중간
React 외부 사용가능가능불가불가

Zustand 예시

JSX
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));

function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);

  return <button onClick={increment}>{count}</button>;
}

셀렉터로 필요한 상태만 구독하기 때문에 불필요한 리렌더링이 없습니다. Provider도 필요 없고, 코드량도 확연히 줄어들어요.

Jotai 예시

JSX
import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2); // 파생 atom

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubled] = useAtom(doubleAtom);

  return (
    <div>
      <p>{count} x 2 = {doubled}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

Jotai는 atom 단위로 구독이 이뤄지니까, 해당 atom을 쓰는 컴포넌트만 리렌더링됩니다. useState 감각으로 쓸 수 있어서 러닝 커브가 거의 없어요.

어떤 걸 써야 하나

  • 규모가 크고 팀이 많다면: Redux Toolkit — 패턴이 정해져 있어 일관성 유지에 유리
  • 빠르게 만들고 싶다면: Zustand — 보일러플레이트 최소, 직관적
  • 컴포넌트 단위 상태가 많다면: Jotai — atom 기반이라 세밀한 구독 가능
  • Recoil: Meta에서 만들었지만 업데이트가 느려서 신규 프로젝트에는 추천하기 어렵습니다

렌더링 최적화

리렌더링이 발생하는 조건

  1. state가 변경 됐을 때
  2. props가 변경 됐을 때
  3. 부모 컴포넌트가 리렌더링 됐을 때 — 이게 가장 큰 원인
  4. Context value가 변경 됐을 때

3번이 핵심입니다. 부모가 리렌더링되면 자식은 props가 안 바뀌어도 무조건 다시 그려져요.

React.memo

JSX
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList 렌더링');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

React.memo는 props를 얕은 비교(shallow comparison)해서, 바뀌지 않았으면 리렌더링을 건너뜁니다. 그런데 주의할 점이 있어요.

JSX
function Parent() {
  const [count, setCount] = useState(0);

  // 이러면 React.memo가 무력화됨 — 매 렌더링마다 새 함수 생성
  const handleSelect = (id) => console.log(id);

  // 이러면 React.memo가 무력화됨 — 매 렌더링마다 새 배열 생성
  const items = data.filter(d => d.active);

  return (
    <ExpensiveList items={items} onSelect={handleSelect} />
  );
}

콜백은 useCallback, 계산 결과는 useMemo로 감싸야 React.memo가 제대로 동작합니다.

JSX
function Parent() {
  const [count, setCount] = useState(0);

  const handleSelect = useCallback((id) => {
    console.log(id);
  }, []);

  const items = useMemo(() => {
    return data.filter(d => d.active);
  }, [data]);

  return (
    <ExpensiveList items={items} onSelect={handleSelect} />
  );
}

컴포넌트 분리 전략

메모이제이션보다 효과적인 건 컴포넌트 구조를 잘 잡는 것 입니다.

JSX
// 나쁜 구조 — 마우스 위치가 바뀔 때마다 HeavyComponent도 리렌더링
function Page() {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handler = (e) => setMousePos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);

  return (
    <div>
      <p>마우스: {mousePos.x}, {mousePos.y}</p>
      <HeavyComponent />
    </div>
  );
}

// 좋은 구조 — 마우스 추적을 별도 컴포넌트로 분리
function MouseTracker() {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handler = (e) => setMousePos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);

  return <p>마우스: {mousePos.x}, {mousePos.y}</p>;
}

function Page() {
  return (
    <div>
      <MouseTracker />
      <HeavyComponent />
    </div>
  );
}

상태를 가진 부분만 따로 빼면, React.memouseMemo 없이도 불필요한 리렌더링을 막을 수 있어요.

또 하나 잘 쓰이는 패턴이 children을 활용한 상태 격리 입니다.

JSX
function ScrollTracker({ children }) {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <header style={{ opacity: scrollY > 100 ? 0.5 : 1 }}>헤더</header>
      {children}
    </div>
  );
}

// children은 부모에서 이미 생성된 엘리먼트이므로
// ScrollTracker가 리렌더링돼도 children은 다시 생성되지 않는다
function App() {
  return (
    <ScrollTracker>
      <HeavyContent />
    </ScrollTracker>
  );
}

childrenScrollTracker가 아니라 App에서 생성된 React element이기 때문에, ScrollTracker의 state 변경에 영향받지 않습니다.


React DevTools Profiler 활용

성능 최적화는 감이 아니라 측정에서 시작해야 합니다.

Profiler 기본 사용법

  1. React DevTools 설치 (크롬 확장)
  2. Profiler 탭 선택
  3. 녹화 시작 → 앱 조작 → 녹화 종료
  4. Flamegraph 에서 각 컴포넌트의 렌더링 시간 확인

체크할 것들

  • 회색 컴포넌트: 렌더링되지 않음 (좋은 것)
  • 노란색/빨간색 컴포넌트: 렌더링 시간이 긴 컴포넌트 (최적화 대상)
  • "Why did this render?": 설정에서 켜면 리렌더링 원인을 알려줍니다

Profiler 컴포넌트로 코드에서 측정

JSX
import { Profiler } from 'react';

function onRenderCallback(
  id,           // Profiler 트리의 id
  phase,        // "mount" | "update"
  actualDuration, // 렌더링에 걸린 시간 (ms)
  baseDuration,   // 메모이제이션 없이 걸리는 예상 시간
  startTime,
  commitTime
) {
  if (actualDuration > 16) { // 60fps 기준 한 프레임 16ms
    console.warn(`느린 렌더링: ${id}${actualDuration.toFixed(2)}ms`);
  }
}

function App() {
  return (
    <Profiler id="UserList" onRender={onRenderCallback}>
      <UserList />
    </Profiler>
  );
}

주의할 점

Suspense와 lazy loading

JSX
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

lazy는 동적 import()를 감싸서 해당 컴포넌트가 실제로 필요할 때만 번들을 로드합니다. Suspense는 그 로딩 중에 fallback UI를 보여주는 경계(boundary) 역할이에요.

React 18부터는 데이터 fetching에도 Suspense를 쓸 수 있게 되고 있습니다. 아직 완전한 공식 API는 아니지만, 프레임워크(Next.js 등)에서는 이미 활용 중이에요.

Error Boundary

Error Boundary는 클래스 컴포넌트 로만 구현할 수 있습니다. getDerivedStateFromErrorcomponentDidCatch를 사용해요.

JSX
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스로 전송
    logErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>문제가 발생했습니다</h2>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            다시 시도
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 사용
<ErrorBoundary>
  <UserProfile />
</ErrorBoundary>

주의: Error Boundary는 렌더링 중 발생한 에러 만 잡습니다. 이벤트 핸들러, 비동기 코드(setTimeout, fetch), 서버 사이드 렌더링에서 발생한 에러는 잡지 못해요.

실제로는 react-error-boundary 라이브러리를 쓰면 함수형 컴포넌트에서도 편하게 쓸 수 있습니다.

Server Components vs Client Components

React Server Components(RSC)는 React 18에서 도입된 개념으로, Next.js 13+ App Router에서 본격적으로 쓰이고 있습니다.

구분Server ComponentClient Component
실행 환경서버에서만브라우저 (+ 서버 SSR)
번들 포함미포함포함
상태/이벤트사용 불가 (useState, onClick 등)사용 가능
DB/파일 접근직접 가능API를 통해서만
선언 방법기본값 (아무 지시어 없으면)'use client' 선언
JSX
// ServerComponent.jsx — 기본값이 Server Component
async function UserList() {
  // 서버에서 직접 DB 쿼리 가능
  const users = await db.query('SELECT * FROM users');

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

// ClientComponent.jsx — 'use client' 지시어 필요
'use client';

import { useState } from 'react';

function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '좋아요 취소' : '좋아요'}
    </button>
  );
}

Server Component의 핵심 장점은 번들 크기 제로 입니다. 서버에서 렌더링되고 결과(RSC Payload)만 클라이언트로 전달되니까, 해당 컴포넌트의 JavaScript는 브라우저에 전송되지 않아요.

핵심 포인트: "Server Component는 JavaScript 번들에 포함되지 않아서 초기 로딩 성능에 유리하고, DB 접근 같은 서버 작업을 컴포넌트 안에서 직접 수행할 수 있습니다. 대신 상태나 이벤트 핸들러는 사용할 수 없어서, 인터랙션이 필요한 부분은 Client Component로 분리해야 합니다."


파생 개념 연결

이 글에서 다룬 내용과 연결되는 주제들:

  • Virtual DOM과 Reconciliation — React가 어떻게 변경 사항을 감지하고 실제 DOM에 반영하는지. Fiber 아키텍처와 diffing 알고리즘을 이해하면 렌더링 최적화의 근거가 명확해진다.
  • 웹 성능 최적화 — Core Web Vitals (LCP, FID, CLS), 코드 스플리팅, 이미지 최적화, 번들 분석 등 React 바깥에서의 성능 개선.
  • Next.js와 SSR/SSG — Server Components의 실제 활용 환경. SSR, SSG, ISR의 차이와 각각의 적합한 사용 사례.

정리

주제핵심 키워드
Hooks 규칙linked list, 호출 순서 의존
useState배칭, 함수형 업데이트, 클로저
useEffectpaint 이후, cleanup 타이밍, 의존성
useLayoutEffectpaint 이전, DOM 측정
useRef리렌더링 없이 값 유지, .current
useMemo/useCallback참조 동일성, 과도한 사용 주의
useReducer복잡한 상태 전환, dispatch identity
Custom Hook로직 재사용, 관심사 분리
상태 관리Context 한계, Zustand/Jotai 추천
렌더링 최적화컴포넌트 분리 > memo > 메모이제이션
Profiler측정 먼저, 최적화는 그 다음
댓글 로딩 중...