리액트(React) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 Props Drilling입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.

Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.


1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)

많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 컴포넌트 합성입니다.

1.1 children을 활용한 구조 개선

데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.

JavaScript
 
// 기존 방식 (Drilling 발생)
<Parent data={data}>
  <Intermediate data={data}>
    <Child data={data} />
  </Intermediate>
</Parent>

// 컴포넌트 합성 방식
<Parent>
  <Intermediate>
    <Child data={data} />
  </Intermediate>
</Parent>

이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.


2. 두 번째 단계: Context API (의존성 주입)

합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 Context API를 사용합니다.

2.1 Context API의 목적

Context는 '전역 상태 관리' 도구라기보다 '의존성 주입(Dependency Injection)' 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.

2.2 주의할 점: 리렌더링 최적화

Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 변경 빈도가 낮은 설정 데이터를 관리하는 데 적합합니다.

JavaScript
 
const UserContext = createContext();

function App() {
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return <div>{user.name}</div>;
}

3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)

복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 Zustand가 각광받고 있습니다.

3.1 왜 Zustand인가?

  • 보일러플레이트 제로: Redux처럼 복잡한 초기 설정이 필요 없습니다.
  • 선택적 구독 (Selector): 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.
  • 리액트 외부에서도 접근 가능: 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.

3.2 실전 예시

JavaScript
 
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>담기</button>;
}

4. 어떤 상황에서 무엇을 선택해야 할까?

가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.

  1. 컴포넌트 합성: Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)
  2. Context API: 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.
  3. Zustand: 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.

리액트(React) 생태계에서 성능 최적화는 언제나 뜨거운 감자입니다. 특히 useMemo와 useCallback은 리액트 개발자라면 반드시 마주하게 되는 도구이지만, 동시에 가장 오용되기 쉬운 도구이기도 합니다. 많은 개발자가 "성능에 좋겠지"라는 막연한 추측으로 모든 함수와 연산에 이 훅들을 적용하곤 합니다.

하지만 리액트 팀의 댄 아브라모프(Dan Abramov)를 비롯한 수많은 시니어 엔지니어들은 "섣부른 최적화(Premature Optimization)는 만악의 근원"이라고 경고합니다. 오늘은 useMemo와 useCallback의 내부 메커니즘을 낱낱이 파헤치고, 실무에서 성능 이득을 정량적으로 측정하여 적용하는 기준을 제시하겠습니다.


1. 메모이제이션(Memoization)의 배신: 공짜 점심은 없다

우선 우리가 사용하는 이 훅들이 공짜가 아니라는 점을 명확히 인지해야 합니다. useMemo나 useCallback을 호출할 때마다 리액트 내부에서는 다음과 같은 비용이 발생합니다.

1.1 메모리 비용 (Memory Overhead)

메모이제이션은 기본적으로 '메모리를 써서 시간을 사는' 행위입니다. 이전 계산 결과나 함수 참조를 메모리에 유지해야 하므로, 앱의 전체적인 메모리 사용량이 늘어납니다. 아주 미미해 보일 수 있지만, 수백 개의 컴포넌트에서 무분별하게 사용될 경우 가비지 컬렉션(GC)에 부담을 줄 수 있습니다.

1.2 비교 비용 (Comparison Overhead)

리액트는 매 렌더링마다 의존성 배열(deps)의 요소들을 하나씩 꺼내 이전 값과 비교하는 얕은 비교(Shallow Compare)를 수행합니다.

  • 만약 연산 자체가 매우 단순하다면(예: 두 숫자의 합), 그 연산을 다시 하는 것보다 의존성 배열을 순회하며 비교하는 비용이 더 클 수 있습니다.

2. useMemo: 무거운 연산의 기준과 참조 동일성

useMemo는 특정 연산의 결괏값을 저장합니다. 이 훅의 적용 기준은 크게 두 가지로 나뉩니다.

2.1 연산의 복잡도 (Computational Expense)

가장 흔한 오해는 모든 배열 처리(filter, map, reduce)에 useMemo를 써야 한다는 생각입니다. 하지만 자바스크립트 엔진은 생각보다 훨씬 빠릅니다.

성능 측정 기법: performance.now() 어떤 연산이 useMemo를 쓸 만큼 무거운지 알고 싶다면, 브라우저 콘솔에서 직접 측정해 보세요.

JavaScript
 
const startTime = performance.now();
doSomethingExpensive(data);
const endTime = performance.now();
console.log(`연산 소요 시간: ${endTime - startTime}ms`);

일반적으로 1ms 이상 소요되는 연산은 useMemo를 고려할 만한 '유의미한' 비용으로 간주합니다. 만약 0.1ms 미만의 연산이라면 useMemo를 사용하는 것이 오히려 손해일 가능성이 높습니다.

2.2 참조 동일성 유지 (Referential Identity)

연산 속도보다 실무에서 더 중요한 이유는 참조값 고정입니다. 리액트에서 객체({})나 배열([])은 내용이 같아도 매번 새로운 참조를 가집니다.

  • 이 값이 useEffect의 의존성 배열에 들어간다면? -> 무한 루프나 불필요한 이펙트 실행의 원인이 됩니다.
  • 이 값이 React.memo로 감싸진 자식 컴포넌트의 Props로 전달된다면? -> 자식은 내용이 변하지 않았음에도 리렌더링 됩니다.

이런 경우, 연산의 복잡도와 상관없이 참조를 일관되게 유지하기 위해 useMemo를 반드시 사용해야 합니다.


3. useCallback: 함수 리렌더링과 자식 컴포넌트의 관계

useCallback은 함수 참조를 고정합니다. 많은 초보자가 "함수 생성을 방지해서 성능을 높인다"라고 생각하지만, 자바스크립트에서 함수를 새로 생성하는 비용은 현대 브라우저에서 거의 무시해도 될 수준입니다.

3.1 React.memo와의 환상적인 조합

useCallback이 효과를 발휘하는 유일한 시점은 자식 컴포넌트가 리렌더링을 방어하고 있을 때입니다.

JavaScript
 
// 자식 컴포넌트가 memo로 최적화되어 있음
const ExpensiveList = React.memo(({ onItemClick }) => {
  console.log("목록 리렌더링 중...");
  return (
    <ul>
      {/* 복잡한 리스트 아이템들 */}
    </ul>
  );
});

const Parent = () => {
  const [text, setText] = useState("");

  // 이 함수가 useCallback으로 감싸져 있지 않다면,
  // 부모가 글자를 입력할 때마다 참조가 바뀌어 ExpensiveList가 리렌더링됨
  const handleClick = useCallback((id) => {
    console.log(id);
  }, []); // 의존성이 없으므로 단 한 번만 생성됨

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <ExpensiveList onItemClick={handleClick} />
    </>
  );
};

만약 ExpensiveList가 React.memo로 감싸져 있지 않다면, 부모가 렌더링 될 때 자식도 어차피 렌더링 되므로 useCallback은 메모리만 낭비하는 꼴이 됩니다.


4. 실무 트러블슈팅: 최적화가 오히려 성능을 해치는 신호

블로그 독자들에게 실질적인 도움을 주기 위해, 최적화 훅을 제거해야 할 때의 신호를 정리해 봅니다.

  1. 의존성 배열이 너무 빈번하게 바뀜: deps에 포함된 값이 렌더링마다 바뀐다면 메모이제이션은 작동하지 않고 비교 연산만 추가될 뿐입니다.
  2. 기본형 데이터 타입에 사용: 문자열, 숫자, 불리언은 값을 직접 비교하므로 참조 고정이 필요 없습니다.
  3. 컴포넌트 하위 계층이 단순함: 자식 컴포넌트들이 매우 가볍다면, 리렌더링 되는 비용이 최적화 로직을 타는 비용보다 저렴합니다.

5. 정량적 측정: React Profiler 활용하기

"느낌적인 느낌"으로 코드를 짜지 마세요. 리액트 개발자 도구의 Profiler 탭은 가장 강력한 무기입니다.

  1. Record 버튼을 누르고 서비스의 주요 동작(클릭, 입력 등)을 수행합니다.
  2. Flamegraph 차트를 확인합니다.
  3. "Why did this render?" 섹션을 통해 특정 컴포넌트가 리렌더링 된 이유가 단순히 Props의 참조값 변화 때문인지 확인합니다.

만약 참조값 변화가 원인이라면, 그때가 바로 useMemo나 useCallback을 투입할 "골든 타임"입니다.


6. 결론: 유연하고 영리한 최적화 전략

프런트엔드 성능 최적화는 기술이라기보다 '트레이드오프(Trade-off)'에 가깝습니다. 가독성을 희생하고 메모리를 더 써서 렌더링 성능을 얻을 것인가, 아니면 약간의 렌더링을 허용하고 깔끔한 코드를 유지할 것인가의 선택이죠.

+ Recent posts