
Hooks 실수 방지를 통한 React 코드 개선하기
요약
React Hooks 사용 시 발생하기 쉬운 세 가지 주요 실수와 그 해결 방법을 다룹니다. 의존성 배열 누락, 조건부 Hook 호출, 정리 함수 부재로 인한 버그를 방지하고 효율적인 컴포넌트 설계를 돕는 가이드를 제공합니다.
핵심 포인트
- 의존성 배열을 먼저 수정하고 메모이제이션은 마지막에 적용할 것
- 누락된 의존성으로 인한 Stale Closures 문제를 함수형 업데이트로 해결
- Hook은 반드시 호출 순서가 일정하도록 조건문 외부에서 호출할 것
- useEffect 사용 시 메모리 누수 방지를 위해 반드시 정리 함수를 작성할 것
요약 (Quick Summary):
2026년에는 웹사이트나 앱을 설계하는 것이 상당히 쉬워질 것입니다. 한 가지 더 추가된 것이 바로 React hooks인데, 이 블로그에서도 이를 다룰 예정입니다. 주로 React hooks는 다음과 같은 세 가지 소스에서 비롯됩니다: 컴포넌트로부터 누락된 의존성(dependencies) 또한 'useEffect'의 렌더링 컴포넌트를 깨뜨립니다.
대부분의 팀은 실제 재렌더링 (re-render) 원인을 진단하기 전에 useCallback과 useMemo를 사용하려 하며, 이는 버그를 수정하지 못한 채 복잡성만 더합니다. 의존성 배열 (dependency array)을 먼저 수정하고, 프로파일링 (profile)을 그다음으로 하며, 메모이제이션 (memoize)을 마지막에 하세요.
이 과정을 거치면, 추측 없이 Hooks 관련 버그 리포트를 분류할 수 있게 되며, useMemo가 실제 문제를 해결하고 있는지 아니면 단순히 노이즈를 추가하고 있는지를 결정할 수 있습니다.
컴포넌트 설계에 대한 더 깊은 패턴은 이 React architecture patterns를 참조하세요.
실제로 문제를 일으키는 것: 프로덕션 버그를 유발하는 3가지 Hook 실수
이 세 가지는 코드 리뷰를 통과하여 발생하는 Hooks 버그의 대부분을 차지합니다. 각각은 언뜻 보기에 올바르게 보이지만, 특정 렌더링 조건 하에서만 작동을 멈춥니다.
누락된 의존성으로 인한 오래된 클로저 (Stale closures)
useEffect, useCallback, 또는 useMemo 내부의 클로저 (closure)는 함수가 호출될 때가 아니라 함수가 생성될 당시의 변수를 캡처합니다. 의존성을 누락하면 콜백은 해당 콜백이 정의된 렌더링 시점의 값을 계속 사용하게 됩니다.
function Counter() {
const [count, setCount] = useState(0);
...
해결책은 린트 (lint) 규칙을 무시하는 것이 아닙니다. 의존성 배열 (dependency array)에 count를 추가하거나, 만약 이펙트 (effect)가 실제로 count를 직접 읽을 필요가 없다면 함수형 업데이트 (functional update) 형태(setCount(c => c + 1))로 전환하세요. React 문서 (react.dev, 2025)에 따르면, 의존성 배열은 이펙트 콜백 (effect callbacks)을 최신 렌더링의 값과 동기화하기 위해 특별히 존재합니다.
렌더링 순서를 깨뜨리는 조건부 Hook 호출
React는 Hook 상태를 이름이 아닌 호출 순서 (call order)로 추적합니다. 조건문 뒤에서 호출되는 Hook은 조건이 변경되는 다음 렌더링 시 그 뒤에 오는 모든 Hook의 순서를 뒤섞어 버립니다.
// 잘못된 예 — `id`가 비어있게 되면 Hook 순서가 깨집니다
function Game({ id }) {
if (!id) return <p>Select a game</p>;
...
eslint-plugin-react-hooks (npmjs.com)를 로컬뿐만 아니라 CI(지속적 통합) 환경에서도 실행하세요. 이러한 종류의 버그는 빠른 수동 테스트를 통과하는 경우가 많으며, 프로덕션 환경의 특정 props 전환 상황에서만 실패하곤 합니다.
정리(cleanup) 없는 무제한 useEffect 재실행
타이머, 구독(subscription), 또는 리스너(listener)를 설정하는 모든 useEffect에는 정리 함수(cleanup function)가 필요합니다. 이를 생략하면 매 재실행 시마다 이전 구독 위에 새로운 활성 구독이 계속 쌓이게 됩니다.
useEffect(() => {
const timer = setTimeout(() => doSomething(), 1000);
return () => clearTimeout(timer); // 누수(leaks)와 중복 타이머를 방지합니다
...
이러한 패턴이 강제되지 않은 코드베이스에서는, 세 개의 구독 효과(subscribing effects)가 있는 단일 화면만으로도 몇 분간의 일반적인 탐색 후에 수십 개의 리스너가 누수될 수 있습니다. 증상은 드물게 크래시(crash)로 나타나며, 앱을 실행한 지 한참 지난 후에야 나타나는 성능 저하로 나타나기 때문에 코드 리뷰를 통과하곤 합니다.
useCallback vs useMemo vs 아무것도 하지 않기
대부분의 Hooks 가이드는 메모이제이션(memoization)이 언제 낭비되는 작업인지 설명하지 않은 채 메모이제이션을 하라고만 말합니다. React는 메모이제이션 여부와 상관없이 모든 렌더링 시 컴포넌트 함수를 다시 실행합니다. useCallback과 useMemo는 오직 하위(downstream) 리렌더링이나 재계산(recomputation)만을 중단시키며, 이를 수행하기 위해 매 렌더링마다 비교 검사(comparison check) 비용이 발생합니다.
| 상황 | 사용 | 이유 |
|---|---|---|
React.memo로 감싸진 자식에게 전달되는 함수 | useCallback | 이것이 없으면, 매 렌더링마다 새로운 함수 참조(function reference)가 생성되어 자식의 props 동등성 검사(prop equality check)를 깨뜨립니다 |
| ... |
대규모 React 코드베이스에서 불필요한 useMemo/useCallback 호출은 코드 리뷰의 혼란(churn)을 야기하는 가장 흔한 원인 중 하나입니다. 이는 측정된 적 없는 성능 우려를 암시하며, 측정 가능한 이득 없이 diff를 읽기 어렵게 만듭니다.
무엇인가를 메모이제이션(Memoize)하기 전에 재렌더링(Re-render) 문제를 진단하는 방법
추측하여 메모이제이션하지 마세요. React DevTools를 열고, Profiler 탭을 활성화한 뒤, 느리게 느껴지는 상호작용을 기록하세요. Flame graph는 어떤 컴포넌트가 재렌더링되었는지 보여주며, DevTools는 그 트리거가 props, state, context, 또는 부모의 재렌더링(parent re-render)인지 라벨을 붙여 알려줍니다.
만약 트리거가 "부모가 재렌더링됨(parent re-rendered)"이고 자식의 props가 변경되지 않은 원시 값(primitives)이라면, 먼저 자식을 React.memo로 감싸세요. Profiler가 해당 변경 이후에도 여전히 자식의 재렌더링을 보여준다면, 그때서야 상위(upstream)에 useCallback/useMemo를 추가하세요. 이것은 추측이 아니라, 새로운 참조(new reference)가 메모이제이션 검사를 깨뜨리고 있다는 신호입니다.
이러한 순서(프로파일링, 그 다음 자식 메모이제이션, 필요하다면 소스 메모이제이션)는 직관에 따라 컴포넌트 트리 곳곳에 useCallback을 흩뿌리는 대신 실제 병목 현상(bottleneck)을 잡아냅니다. Profiler 단계를 건너뛰는 팀은 대개 잘못된 계층을 메모이제이션하게 되고, 결국 원래의 성능 저하 문제를 그대로 배포하게 됩니다.
React 19의 컴파일러(Compiler)와 액션(Actions)이 이러한 실수들을 해결해 줄까요?
React Compiler (react.dev, 2025)는 빌드 타임에 컴포넌트와 값을 자동으로 메모이제이션(auto-memoizes)하며, 컴파일러가 활성화되면 대부분의 경우 수동으로 useCallback/useMemo를 사용할 필요가 없습니다. 하지만 이것이 오래된 클로저(stale closures)나 조건부 훅 호출(conditional hook calls)을 해결해주지는 않습니다. 그것들은 컴파일러가 추론하여 없앨 수 없는, 여전히 런타임의 정확성 버그(runtime correctness bugs)이기 때문입니다.
React 19에서 도입된 useActionState와 form Actions는 이전에 폼 제출(form submission) 및 대기 상태(pending states)를 위해 사용되었던 useState + useEffect 패턴의 상당 부분을 대체합니다. 이는 Effect 자체를 제거함으로써 의존성 배열(dependency-array) 실수라는 범주 전체를 없애줍니다. 이 가이드에서 다루는 실수들은 구식이 된 것이 아닙니다. 컴파일러가 성능 측면을 점점 더 많이 처리함에 따라, 실수들의 성격이 성능 문제에서 정확성(클로저(closures), Hook 순서) 문제로 이동하고 있을 뿐입니다.
FAQ
1. useEffect에서 가장 흔한 실수는 무엇인가요?
콜백 함수가 실제로 읽고 있는 의존성(dependency)을 누락하는 것이며, 이는 오래된 클로저(stale closure)를 생성합니다. Effect는 올바른 의존성 배열을 통해 재실행되도록 강제되기 전까지, 최신 값이 아닌 해당 Effect가 생성된 시점의 렌더링 값을 계속 사용하게 됩니다.
2. 왜 제 useEffect가 무한 루프를 발생시키나요?
보통 의존성 배열 안에 객체(object)나 배열 리터럴(array literal)이 있거나, Effect 내부의 상태 세터(state setter)가 Effect가 의존하고 있는 값을 업데이트할 때 발생합니다. 각 렌더링마다 새로운 참조(reference)가 생성되며, 의존성 비교 과정에서 이를 변경된 것으로 간주하여 다시 실행을 트리거하기 때문입니다.
3. 조건문이나 반복문 안에서 Hook을 호출할 수 있나요?
아니요. React는 렌더링 전반에 걸친 호출 순서(call order)를 통해 Hook 상태를 추적하며, 조건부 호출은 조건이 바뀔 때마다 그 순서를 뒤섞어 버립니다. Hook은 반드시 최상위 레벨(top level)에서 무조건적으로 호출하고, 조건부 로직은 Hook 호출 이후로 옮기세요.
4. React Hooks에서 오래된 클로저(stale closure)란 무엇인가요?
의존성 배열에 누락되어 이전 렌더링의 변수 값을 캡처한 후 업데이트되지 않는 클로저를 의미합니다. 이는 컴포넌트가 새로운 상태로 재렌더링되었음에도 불구하고, 콜백 함수가 오래된 값을 로그로 남기거나 사용하는 형태로 나타납니다.
5. useCallback과 useMemo 중 언제 무엇을 사용해야 하나요?
메모이제이션(memoized)된 자식 컴포넌트에 함수를 전달할 때는 useCallback을 사용하세요. 비용이 많이 드는 계산을 수행하거나, props로 전달되는 객체/배열의 참조를 안정화(stabilize)할 때는 useMemo를 사용하세요. 하위 요소 중 참조 안정성(referential stability)에 의존하는 것이 없다면 둘 다 사용하지 마세요.
저자 소개
Amrendra Kumar는 Code with Amrendra의 소프트웨어 엔지니어이자 기술 작가(technical writer)로, React, Next.js, AI 에이전트 (AI Agents), SaaS 아키텍처 (SaaS architecture), 그리고 클라우드 인프라 (cloud infrastructure)를 다룹니다. 그는 프론트엔드 엔지니어링 (frontend engineering), 시스템 디자인 (system design), 그리고 현대적 웹 개발 (modern web development)에 관한 15개 이상의 기술 아티클을 작성했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기