WEB/React

[React] ref와 useEffect: 핵심 원리부터 활용까지

깜냠미 2025. 10. 23. 17:51
728x90
반응형

React 개발에서 ref와 useEffect는 가장 빈번하게 사용되는 핵심 도구입니다. 하지만 이 두 훅은 자주 사용되는 만큼, 그 내부 동작 원리나 적절한 사용 시점에 대한 오해도 많습니다. 단순히 "DOM에 접근할 땐 ref, API 호출할 땐 useEffect"라는 표면적인 이해를 넘어, 언제, 왜, 그리고 어떻게 사용해야 하는지를 깊이 있게 아는 것이 견고한 애플리케이션을 만드는 전문가의 역량입니다.

이 블로그 게시물은 전문 개발자를 위해 두 훅의 근본적인 차이점부터 시작하여, 시너지를 활용한 고급 패턴과 현장에서 흔히 저지르는 실수까지 체계적으로 분석합니다. 이 글을 통해 ref와 useEffect에 대한 명확한 멘탈 모델을 정립하고, 실무에 즉시 적용 가능한 깊이 있는 인사이트를 얻는 것을 목표로 합니다.


1. ref - React의 렌더링을 벗어난 특별한 주머니

React는 상태(state)가 변경되면 UI를 자동으로 다시 그리는 선언적인 방식으로 동작합니다. 하지만 때로는 이러한 렌더링 흐름에서 벗어나 특정 값을 리렌더링 없이 보존하거나, DOM 요소에 직접 접근해 명령을 내려야 하는 상황이 발생합니다. ref는 바로 이러한 명령형(imperative) 작업을 위해 React가 마련한 공식적인 '탈출구'이자 '특별한 주머니'입니다.

useRef 훅을 호출하면 { current: ... } 형태의 순수 자바스크립트 객체가 반환됩니다. 이 ref 객체는 두 가지 핵심적인 특징을 가집니다.

  1. 안정된 식별성 (Stable Identity): ref 객체는 컴포넌트의 전체 생명주기 동안 절대 변하지 않습니다. 리렌더링이 수백 번 일어나도, React는 항상 최초에 생성된 동일한 객체를 반환합니다.
  2. 리렌더링 미유발: ref.current 속성의 값을 변경해도 state와 달리 리렌더링을 유발하지 않습니다. React의 렌더링 시스템은 ref의 변화를 전혀 신경 쓰지 않습니다.

이것이 바로 ref가 "리액트 바깥 주머니"라고 불리는 이유입니다. 렌더링과 무관하게 무언가를 저장하고 싶을 때 사용하는 공간인 셈입니다.

ref의 핵심 사용 사례

ref의 활용은 크게 두 가지로 나뉩니다.

1. DOM 요소에 직접 접근하기

가장 대표적인 사용 사례는 React가 렌더링한 DOM 노드에 직접 접근하는 것입니다. JSX 태그에 ref 어트리뷰트를 전달하면, React는 해당 DOM 노드를 ref.current 속성에 담아줍니다. 여기서 ref는 React의 선언적 세계에서 브라우저의 명령형 API로 넘어가는 직접적인 '탈출구' 역할을 합니다.

예를 들어, <video> 요소의 재생과 일시정지를 제어하는 버튼을 만든다고 가정해 봅시다.

import { useRef, useState } from 'react';

function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  const videoRef = useRef(null);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);

    if (nextIsPlaying) {
      videoRef.current.play(); // DOM 노드의 내장 API 직접 호출
    } else {
      videoRef.current.pause(); // DOM 노드의 내장 API 직접 호출
    }
  }
  // ...
}

isPlaying이라는 state만으로는 비디오를 실제로 제어할 수 없습니다. state는 UI의 논리적 상태를 관리할 뿐, 브라우저의 <video> 요소를 직접 제어하지는 못합니다. 실제 재생/일시정지는 ref를 통해 얻은 DOM 노드의 play(), pause()와 같은 내장 브라우저 API를 직접 호출해야만 가능합니다. 이처럼 특정 요소에 포커스를 주거나, 스크롤 위치를 제어하거나, 요소의 크기를 측정하는 등의 작업은 ref의 고유한 역할입니다.

2. 리렌더링 없는 값 저장소

state는 값이 변경될 때마다 리렌더링을 유발합니다. 하지만 어떤 값들은 리렌더링을 유발하지 않으면서도 여러 렌더링에 걸쳐 그 값을 보존해야 할 필요가 있습니다. setInterval이나 setTimeout으로 생성된 타이머 ID가 대표적인 예입니다. 여기서 ref는 렌더링 사이클과 완벽하게 분리된 '특별한 주머니' 역할을 합니다.

import { useRef, useState, useEffect } from 'react';

function Timer() {
  const intervalIdRef = useRef(null);
  // ...
  const startTimer = () => {
    intervalIdRef.current = setInterval(() => {
      // ...
    }, 1000);
  };
  // ...
}

타이머 ID는 나중에 clearInterval을 호출하기 위해 어딘가에 저장되어야 하지만, 이 ID 값 자체가 UI에 직접 표시되지는 않습니다. 따라서 ID가 변경될 때마다 컴포넌트를 리렌더링하는 것은 불필요한 낭비입니다. 이럴 때 ref를 사용하면 불필요한 렌더링 없이 값을 조용히 보관할 수 있습니다.

 

ref가 React의 렌더링 흐름에서 벗어난 값을 다룬다면, 이제부터는 컴포넌트의 렌더링 결과와 외부 세계를 동기화하는 역할을 하는 useEffect에 대해 알아보겠습니다.


2. useEffect - 컴포넌트와 외부 세계의 동기화

useEffect는 컴포넌트가 렌더링된 결과로 인해 발생하는 부수 효과(side effects)를 처리하기 위한 훅입니다. 여기서 핵심은 특정 사용자 상호작용(이벤트)이 아닌, 렌더링 자체가 부수 효과의 트리거가 된다는 점입니다. useEffect는 React의 상태와 외부 시스템을 연결하는 '동기화 연결고리'입니다.

"채팅 메시지 보내기"와 "채팅 서버에 연결하기"의 차이를 생각해 보면 명확합니다.

  • 이벤트 핸들러: "보내기" 버튼을 클릭했을 때 메시지를 보내는 것은 명백한 이벤트입니다.
  • Effect: 컴포넌트가 화면에 나타났을 때 채팅 서버에 연결하고, 사라질 때 연결을 끊는 것은 렌더링 결과에 따른 부수 효과입니다.

useEffect의 동작 원리

useEffect의 동작은 세 가지 핵심 요소로 구성됩니다.

1. 실행 시점: 렌더링 후의 부수 효과

Effect는 React가 모든 렌더링을 마치고 브라우저가 화면을 업데이트한 이후에 비동기적으로 실행됩니다. 즉, 사용자가 UI 변화를 먼저 본 후에 Effect가 동작하므로, 무거운 작업이 UI 렌더링을 막는 것을 방지합니다.

2. 의존성 배열(Dependencies) 제어

useEffect의 두 번째 인자인 의존성 배열은 Effect가 언제 다시 실행될지를 결정하는 중요한 제어 장치입니다. 이것은 '동기화 연결고리'의 문지기(gatekeeper)와 같아서, 외부 세계와의 연결을 정말 필요할 때만 다시 설정하도록 보장합니다.

의존성 배열 동작 방식 주요 사용 사례
생략 모든 렌더링이 끝날 때마다 Effect를 실행합니다. 거의 사용되지 않으며, 의도치 않은 무한 루프를 유발할 수 있습니다.
빈 배열 [] 전달 컴포넌트가 처음 마운트될 때 딱 한 번만 Effect를 실행합니다. 데이터 초기 로딩, 외부 라이브러리 초기화 등
의존성 포함 [a, b] 처음 마운트될 때 실행되고, 이후에는 배열 안의 의존성(a 또는 b) 중 하나라도 값이 변경될 때마다 다시 실행됩니다. 특정 props나 state가 변경될 때마다 외부와 동기화가 필요할 때

3. 클린업(Cleanup) 함수: 부수 효과의 뒷정리

Effect가 설정한 부수 효과는 컴포넌트가 사라지거나 Effect가 다시 실행되기 전에 정리되어야 할 수 있습니다. useEffect 내에서 함수를 반환하면, 이 함수가 클린업(cleanup) 로직이 됩니다.

클린업 함수는 다음 Effect가 실행되기 직전컴포넌트가 마운트 해제될 때 호출됩니다. 메모리 누수나 버그를 방지하기 위해 클린업은 매우 중요합니다.

  • 이벤트 리스너 해제: window.addEventListener를 했다면 window.removeEventListener로 해제해야 합니다.
  • 타이머 제거: setInterval을 사용했다면 clearInterval로 반드시 제거해야 합니다.
  • 네트워크 요청 중단: 컴포넌트가 사라졌다면 진행 중인 fetch 요청의 결과를 무시해야 합니다.
  • 외부 라이브러리 인스턴스 정리: 외부 라이브러리의 인스턴스를 생성했다면, destroy와 같은 정리 메서드를 호출해야 합니다.

useEffect의 기본 원리를 이해했으니, 이제 ref와 useEffect를 함께 사용하여 더욱 복잡한 문제를 해결하는 고급 패턴을 살펴보겠습니다.


3. ref와 useEffect의 시너지 및 고급 패턴

ref와 useEffect는 개별적으로도 강력하지만, 함께 사용될 때 React 애플리케이션의 복잡한 상호작용과 상태 관리를 훨씬 효과적으로 처리할 수 있습니다.

ref와 의존성 배열

useEffect의 의존성 배열에 ref 객체를 넣어야 할까요? 답은 ref를 어디서 얻었느냐에 따라 다릅니다.

  • 컴포넌트 내에서 useRef로 생성한 ref: 의존성 배열에서 생략해도 안전합니다. ref 객체는 컴포넌트 생애주기 동안 안정된 식별성을 가지므로 절대 변하지 않기 때문입니다. React는 항상 동일한 객체를 반환함을 보장합니다.
  • 부모로부터 props로 전달받은 ref: 의존성 배열에 반드시 포함해야 합니다. 부모 컴포넌트가 조건부로 다른 ref를 전달할 수 있기 때문입니다.
// 부모 컴포넌트에서
const refA = useRef();
const refB = useRef();
const refToPass = someCondition ? refA : refB;
return <ChildComponent forwardedRef={refToPass} />;

// 자식 컴포넌트에서
useEffect(() => {
  // ... forwardedRef를 사용하는 로직
}, [forwardedRef]); // props로 받은 ref는 의존성 배열에 포함해야 함

위 예시처럼, ChildComponent는 forwardedRef prop이 변경될지 알 수 없습니다. 따라서 부모가 전달하는 ref가 바뀔 때 Effect를 다시 실행시키려면 반드시 의존성 배열에 포함해야 합니다.

Strict Mode는 버그를 유발하지 않는다: 잠재된 버그를 드러낼 뿐

개발 환경의 Strict Mode에서 useEffect가 두 번 실행되는 현상은 버그가 아닙니다. 이는 클린업 로직 누락과 같은 잠재적인 버그를 개발 단계에서 미리 찾아내도록 돕는 React의 의도된 진단 도구입니다. 만약 당신의 Effect가 Strict Mode에서 오작동한다면, 그 코드는 이미 프로덕션 환경에서 미묘한 방식으로 실패할 가능성이 있는 깨진 코드입니다.

setInterval 예시를 다시 보겠습니다. 클린업이 없는 코드는 Strict Mode에서 타이머를 누수시킵니다.

// 잘못된 코드: 클린업이 없음
useEffect(() => {
  setInterval(() => { /* ... 카운터 증가 */ }, 1000);
}, []);

Strict Mode는 컴포넌트를 마운트 -> 마운트 해제 -> 다시 마운트합니다. 위 코드는 첫 마운트에서 setInterval을 설정하고, 마운트 해제 시 정리하지 않습니다. 따라서 두 번째 마운트에서 또 다른 setInterval이 설정되어 타이머가 두 개가 되고 카운터가 두 배로 증가합니다. 문제의 원인은 Strict Mode가 아니라, 애초에 클린업 로직이 없었던 코드 자체입니다.

올바른 클린업을 추가하면 이 문제는 완벽히 해결됩니다.

// 올바른 코드: 클린업 추가
useEffect(() => {
  const intervalId = setInterval(() => { /* ... 카운터 증가 */ }, 1000);
  return () => clearInterval(intervalId); // 클린업
}, []);

이제 React가 컴포넌트를 마운트 해제할 때 첫 번째 타이머가 clearInterval로 정리되므로, 두 번째 마운트에서는 하나의 타이머만 남게 됩니다. 이처럼 Strict Mode는 "어떻게 고칠까?"가 아니라 "어떻게 올바르고 회복력 있는 Effect를 작성할까?"라는 근본적인 질문을 던지게 합니다.

 

네트워크 요청이 두 번 발생하는 문제도 마찬가지입니다. 클린업 함수에서 boolean 플래그를 사용해 이전(stale) 요청의 결과를 무시하도록 처리하는 것이 표준 패턴입니다.

useEffect(() => {
  let ignore = false;
  async function fetchData() {
    const data = await fetchSomething();
    if (!ignore) {
      // state 업데이트
    }
  }
  fetchData();
  return () => { ignore = true; }; // 클린업 시 플래그를 true로 설정
}, [id]);

하지만 더 근본적인 해결책은 데이터 페칭 라이브러리(예: React Query, SWR)를 사용하는 것입니다. 이 라이브러리들은 요청 중복 제거, 응답 캐싱(뒤로 가기 시 즉각적인 UI 표시 가능), 네트워크 워터폴 방지 등 근본적인 해결책을 제공하여 훨씬 더 성능이 뛰어나고 견고한 애플리케이션을 만들 수 있게 해줍니다.

동적 목록의 여러 DOM 요소 참조하기: Ref 콜백

map()과 같은 반복문 안에서는 useRef를 호출할 수 없습니다. 동적으로 생성되는 여러 DOM 요소에 대한 ref를 관리해야 할 때는 Ref 콜백 패턴을 사용해야 합니다.

ref 어트리뷰트에는 ref 객체뿐만 아니라 함수도 전달할 수 있습니다. React는 이 콜백 함수를 두 번 호출합니다. DOM 노드가 마운트될 때 노드 자신을 인자로 한 번, 그리고 컴포넌트가 마운트 해제될 때 null을 인자로 또 한 번 호출합니다. 이 두 단계의 프로세스를 통해 리스트가 변경될 때마다 컬렉션에 ref를 정확하게 추가하고 제거할 수 있습니다.

import { useRef } from 'react';

function ItemList({ items }) {
  const itemsRef = useRef(new Map());

  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          ref={(node) => {
            const map = itemsRef.current;
            if (node) {
              map.set(item.id, node); // 노드 추가/업데이트
            } else {
              map.delete(item.id); // 노드 제거
            }
          }}
        >
          {item.text}
        </li>
      ))}
    </ul>
  );
}

이제 itemsRef.current는 id를 키로, DOM 노드를 값으로 갖는 Map 객체가 되어 모든 항목의 DOM 노드를 안정적으로 관리할 수 있습니다.


4. useEffect를 사용하지 말아야 할 때: 흔한 안티패턴 분석

useEffect는 강력한 만큼 남용될 경우 코드를 복잡하게 만들고 성능을 저하시키는 주범이 될 수 있습니다. Effect를 작성하기 전에, "이 로직이 정말 렌더링의 부수 효과인가?"라고 자문하고 더 간단한 React 패턴을 먼저 고려하는 것이 전문가의 자세입니다.

1. 렌더링을 위한 데이터 변환

props나 state가 변경될 때마다 파생된 데이터를 계산하기 위해 useEffect와 또 다른 state를 사용하는 것은 절대적으로 피해야 할 안티패턴입니다.

// 안티패턴: 불필요한 state와 effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
  • 대안: 렌더링 중에 직접 값을 계산하세요. 이것이 가장 간단하고 올바른 패턴입니다.
// 올바른 패턴
const visibleTodos = getFilteredTodos(todos, filter);
  • 만약 계산 비용이 매우 크다면(예: 1ms 이상 소요), useEffect가 아니라 useMemo 훅을 사용하여 결과를 캐싱(메모이제이션)하는 것이 올바른 최적화 방법입니다.
// 최적화가 필요할 때
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter);
}, [todos, filter]);

2. 다른 컴포넌트의 상태 업데이트

자식 컴포넌트의 useEffect를 사용해 부모의 상태를 변경하려는 시도는 React의 핵심 원칙인 단방향 데이터 흐름을 위반하는 '코드 스멜(code smell)'입니다. 이는 데이터 흐름의 역전을 유발하여 예측 불가능한 코드를 만듭니다.

  • 대안: "상태 끌어올리기(Lifting State Up)"를 사용해야 합니다. 상태를 여러 컴포넌트가 공유해야 한다면, 그들의 가장 가까운 공통 부모로 상태를 옮기세요. 그리고 상태 변경 함수를 props로 자식에게 내려주는 것이 React 아키텍처를 유지하는 올바른 방법입니다. 데이터는 항상 위에서 아래로 흘러야 합니다.

3. 애플리케이션 초기화 로직

앱 전체에서 단 한 번만 실행되어야 하는 초기화 로직(예: 분석 라이브러리 설정, 인증 토큰 로드)을 특정 컴포넌트의 useEffect 안에 넣는 것은 부적절합니다.

  • 대안: 이러한 로직은 컴포넌트 외부, 즉 모듈의 최상위 레벨에 배치하거나 애플리케이션의 진입점(entry point) 파일(예: index.js)에서 처리하여 앱이 로드될 때 단 한 번만 실행되도록 하세요.
    • 프로 팁: 분석과 같이 컴포넌트의 가시성을 정밀하게 추적해야 할 경우, 마운트 시점에 의존하기보다 Intersection Observer API를 Effect 내에서 활용하는 것을 고려하세요.

4. 외부 스토어 구독: useEffect의 숨겨진 함정

외부 스토어(예: Redux, Zustand 또는 브라우저 API)를 구독하기 위해 useEffect를 사용하는 것은 흔하지만, 동시성(Concurrent) 렌더링 환경에서 미묘한 버그인 UI 테어링(tearing)을 유발할 수 있습니다.

  • 대안: React 팀은 이 문제를 해결하기 위해 useSyncExternalStore라는 전용 훅을 제공합니다. 이 훅은 외부 스토어에 대한 구독을 동시성 렌더링에 안전한 방식으로 처리하도록 보장합니다. 외부 데이터 소스와 동기화해야 할 때는 useEffect 대신 이 훅을 사용하는 것이 정답입니다.

결론: ref와 useEffect를 올바르게 사용하기 위한 핵심 원칙

이 글을 통해 우리는 ref와 useEffect의 핵심적인 정신(ethos)을 깊이 있게 탐구했습니다. 두 훅의 본질을 비유로 요약하면 다음과 같습니다.

  • ref: "React의 렌더링 사이클에서 벗어나 DOM을 직접 제어하거나 값을 보존하기 위한 탈출구"입니다. 렌더링과 무관한 명령형 작업이나 값 저장이 필요할 때 사용합니다.
  • useEffect: "React의 상태(state/props)와 외부 세계를 동기화하기 위한 연결고리"입니다. 렌더링의 결과로 인해 외부 시스템(네트워크, 브라우저 API, 외부 라이브러리)과 동기화가 필요할 때 사용합니다.

이 두 훅의 근본적인 차이점을 명확히 이해하고, 각자의 목적에 맞게 적재적소에 사용하는 것이 깔끔하고, 예측 가능하며, 효율적인 React 애플리케이션을 만드는 비결입니다. Effect가 필요 없는 곳에 Effect를 남용하지 않고, ref의 특성을 올바르게 활용할 때, 우리는 React의 진정한 강력함을 경험할 수 있을 것입니다.