Dev Thinking
32완료

DOM을 직접 만나다 - useRef로 값 참조하기

2025-08-24
8분 읽기

리액트 공식문서 기반의 리액트 입문기

들어가며

지금까지 useState를 통해 컴포넌트에 "기억력"을 부여하고, 상태 변화에 따라 UI를 업데이트하는 선언적인 방식에 대해 깊이 있게 살펴보았습니다.

리액트는 기본적으로 "무엇을 그릴지"를 선언하고, "어떻게 그릴지"는 리액트가 알아서 처리해 주는 방식이죠. 하지만 개발을 하다 보면 때로는 UI에 직접적인 영향을 주지 않으면서 컴포넌트 생애 주기 동안 특정 값을 보존해야 하는 경우가 발생할 수 있습니다.

이러한 상황에서 리액트가 제공하는 "탈출구(Escape Hatch)" 중 하나가 바로 useRef 훅입니다. useReducerContext API가 복잡한 상태 관리와 props drilling 해결에 중점을 두었다면, useRef는 선언적 패러다임 속에서 리렌더링 없이 값을 보존하는 유연성을 제공한다고 생각해 볼 수 있을 것 같습니다. 이번 편에서는 useRef의 핵심적인 역할, 즉 리렌더링 없는 값 저장에 대해 깊이 있게 탐구해 보고자 합니다. (DOM 직접 참조는 5-2편에서 다룹니다.)

리액트 고유 기능 – useRef: 리렌더링 없는 값 저장소

useRef는 이름 그대로 "참조(Reference)"를 다루는 훅으로, 이번 편에서는 리렌더링을 유발하지 않는 값 저장소로서의 역할에 집중합니다.

컴포넌트의 생애 주기 동안 유지되어야 하지만, 그 값이 변경되어도 컴포넌트가 다시 렌더링될 필요가 없는 데이터를 저장할 때 사용합니다. 마치 클래스 컴포넌트의 인스턴스 변수처럼 동작하며, 컴포넌트가 다시 렌더링되어도 값은 초기화되지 않고 유지됩니다.

이러한 용도는 React의 일반적인 "선언적" UI 업데이트 방식과는 다소 거리가 있지만, 특정 상황에서 매우 유용하게 사용되는 "탈출구"의 역할을 한다는 점이 특징이라고 할 수 있습니다. 이제 "리렌더링 없는 값 저장"을 더 자세히 살펴보겠습니다.

useRef의 핵심 역할: 리렌더링 없는 값 저장

useRef 훅을 호출하면 ref 객체를 반환합니다. 이 객체는 current라는 속성을 가지고 있으며, 이 current 속성을 통해 저장하고 싶은 값에 접근하고 변경할 수 있습니다. useRef(initialValue)와 같이 초기값을 전달하면 ref.current는 해당 초기값을 가지게 됩니다.

여기서 중요한 점은 ref.current 값을 변경해도 컴포넌트가 리렌더링되지 않는다는 것입니다. 이는 useStateset 함수를 호출할 때와 가장 큰 차이점이라고 생각합니다. useState는 상태가 변경되면 컴포넌트를 다시 렌더링하여 UI를 업데이트하는 것을 목적으로 하지만, useRef는 UI와 직접적인 연관 없이 내부적인 값을 보존하는 데 중점을 둡니다.

"왜 useState가 있는데 useRef로 값을 저장할까요?"

라는 궁금증이 들 수 있습니다. useState는 UI 변화와 리렌더링을 목적으로 합니다. 예를 들어, 사용자의 입력값이 변경되면 UI에 즉시 반영되어야 하므로 useState를 사용합니다. 반면, useRef는 값이 변경되어도 UI를 다시 그릴 필요가 없는 데이터를 관리할 때 이상적입니다. 예를 들어, 네트워크 요청 횟수, 컴포넌트의 마운트 여부 플래그, 타이머 ID 등은 변경되어도 화면에 직접적으로 보여줄 필요가 없기 때문에 useRef로 관리하면 불필요한 리렌더링을 방지하고 성능을 최적화할 수 있습니다. useRef는 리액트의 렌더링 시스템 외부에 존재하는 가변적인 "컨테이너" 역할을 하며, 이로 인해 컴포넌트의 생애 주기 동안 안정적으로 값을 유지할 수 있습니다.

리액트로 "리렌더링 없는 값 저장" 해보기 – useRef 버전(JSX)

이 예제는 화면에 표시되는 카운트(useState)와는 별개로, "카운트 증가 및 시도 기록" 버튼이 총 몇 번 클릭 시도되었는지를 useRef로 기록하는 모습을 보여줍니다. 여기에 추가로 count이전 값useRef로 저장하여, 현재 값과 이전 값을 비교하는 기능을 구현했습니다. 이 시도 횟수와 이전 count 값은 UI 리렌더링을 유발할 필요가 없는 내부적인 값으로 관리됩니다.

import { useState, useRef } from 'react';
 
export default function ClickCounter() {
  const [count, setCount] = useState(0); // 화면에 표시될 카운트 상태 (리렌더링 유발)
  const clickAttemptsRef = useRef(0); // 클릭 시도 횟수를 저장할 ref (리렌더링 없음)
  const prevCountRef = useRef(count); // 이전 count 값을 저장할 ref
 
  // 컴포넌트가 렌더링될 때마다 현재 count 값을 prevCountRef에 저장합니다.
  // 이 변경은 리렌더링을 유발하지 않습니다.
  prevCountRef.current = count;
 
  const handleClick = () => {
    clickAttemptsRef.current += 1; // ref.current 값 변경: 이 변경은 리렌더링을 유발하지 않습니다.
    setCount(prevCount => prevCount + 1); // useState 값 변경: 이 변경은 컴포넌트 리렌더링을 유발합니다.
    console.log(`
      현재 화면 카운트: ${count},
      이전 화면 카운트 (ref): ${prevCountRef.current},
      총 클릭 시도 (ref): ${clickAttemptsRef.current}
    `);
  };
 
  return (
    <div>
      <h1>useRef로 리렌더링 없는 값 참조하기</h1>
      <p>화면 카운트: {count}</p>
      <p>
        (개발자 도구 콘솔을 확인해 보세요. '이전 화면 카운트'와 '총 클릭 시도'는 현재 화면
        카운트보다 한 박자 늦게 반영될 수 있습니다. 이는 ref.current 값이 변경되어도 리렌더링이
        일어나지 않기 때문입니다.)
      </p>
      <button onClick={handleClick}>카운트 증가 및 시도 기록</button>
      {/* 
        useRef로 저장된 값은 그 자체의 변경만으로는 리렌더링을 유발하지 않으므로,
        여기서는 prevCountRef.current나 clickAttemptsRef.current를 직접 렌더링하지 않습니다.
        만약 이 값을 화면에 즉시 렌더링하려면 useState를 사용해야 합니다.
      */}
    </div>
  );
}

이 코드에서 clickAttemptsRef.currentprevCountRef.currenthandleClick 함수가 호출될 때마다 또는 컴포넌트가 렌더링될 때마다 변경되지만, 이 값들의 변경만으로는 컴포넌트가 다시 렌더링되지 않습니다. setCount 호출로 count 상태가 업데이트될 때 컴포넌트가 리렌더링되며, 이때 prevCountRef.currentclickAttemptsRef.current의 최신 값이 콘솔에 반영됨을 볼 수 있습니다. 이 예제는 useRef가 UI 변화와는 독립적으로 값을 보존하고 이전 값을 참조하는 용도로 사용될 수 있음을 보여주는 실용적인 예시라고 생각합니다.

useRef의 다양한 값 저장 사례 (DOM 직접 조작 없이)

useRef는 UI에 직접적인 영향을 주지 않는 다양한 종류의 값을 컴포넌트 생애 주기 동안 유지하는 데 활용될 수 있습니다. 다음은 AI를 통해 알아본 useRef의 값 저장 기능이 유용하게 사용될 수 있는 몇 가지 시나리오입니다. 이 모든 사례들은 DOM에 직접 접근하지 않고 자바스크립트 값만을 저장하고 관리하는 예시입니다.

  1. 타이머 ID 저장: setTimeout이나 setInterval과 같은 자바스크립트 타이머 함수는 고유한 ID를 반환합니다. 이 ID를 useRef에 저장해 두면, 컴포넌트가 언마운트되거나 특정 조건에서 타이머를 clearTimeout 또는 clearInterval로 중지시켜야 할 때 유용합니다. (타이머 시작/중지와 클린업 로직은 보통 useEffect와 함께 사용되지만, useRef는 그 ID를 보존하는 역할을 합니다.)
  2. 스크롤 위치 저장: 사용자가 특정 스크롤 위치를 벗어났다가 다시 돌아왔을 때 이전 스크롤 위치로 복원하거나, 스크롤 이벤트 발생 시 특정 지점을 debounce 처리할 필요가 있을 때 useRef를 활용할 수 있습니다. 예를 들어, window.scrollY 값을 useRef에 저장해두고, 컴포넌트가 다시 렌더링되어도 그 값을 유지하여 나중에 참조할 수 있습니다.
  3. 애니메이션 핸들러: requestAnimationFrame과 같은 브라우저의 애니메이션 API를 사용할 때, 반환되는 애니메이션 프레임 ID를 useRef에 저장하여 애니메이션을 시작하고 중지하는 데 활용할 수 있습니다. 이렇게 하면 애니메이션의 상태(시작/중지)를 UI 렌더링과 독립적으로 관리할 수 있습니다.
  4. 외부 라이브러리 인스턴스: React 컴포넌트 내에서 D3.js, Three.js, Chart.js와 같은 서드파티 라이브러리를 사용할 때, 해당 라이브러리가 생성하는 복잡한 객체 인스턴스를 useRef에 저장할 수 있습니다. 이렇게 하면 리액트의 렌더링 사이클과 별개로 해당 인스턴스에 접근하여 라이브러리 고유의 API를 호출할 수 있습니다.

리액트 방식의 특징 (useRef)

useRef는 다음과 같은 특징들을 가집니다.

  • 1. 리렌더링 없는 값 유지의 중요성: useRef로 관리되는 current 속성의 값은 변경되어도 컴포넌트의 리렌더링을 유발하지 않습니다. 이는 불필요한 UI 업데이트를 방지하여 애플리케이션의 성능을 최적화하는 데 중요한 역할을 합니다. 특히 UI와 직접적으로 연관되지 않는 내부 데이터(예: 타이머 ID, 스크롤 위치, 애니메이션 핸들러)를 다룰 때, useState를 사용하면 불필요한 리렌더링 비용이 발생할 수 있으므로 useRef의 이러한 특성을 활용하는 것이 효율적입니다.
  • 2. current 속성을 통한 명시적 접근: useRef 훅이 반환하는 ref 객체의 current 속성을 통해서만 실제 값에 접근하고 변경할 수 있습니다. current 값은 컴포넌트가 마운트될 때 할당되고 컴포넌트의 생애 주기 동안 유지됩니다. 이처럼 .current를 통해 명시적으로 접근하는 방식은, 리액트의 선언적 패러다임 속에서 가변적인 값에 대한 직접적인 제어권을 부여하는 '탈출구' 역할을 하며, 개발자가 값을 어떻게 다루고 있는지 명확히 인지하게 돕는다고 생각합니다. (DOM 직접 참조는 5-2편에서 심화됩니다.)
  • 3. useState와의 명확한 목적 분리: useState는 UI 상태의 변화와 그에 따른 리렌더링이 주요 목적입니다. 반면 useRef는 UI와 직접 관련 없는 내부 값 관리라는 목적을 가집니다. 이 두 훅의 역할을 명확히 구분하여 사용함으로써, 우리는 UI 업데이트 로직과 비(非)UI 데이터 관리 로직을 분리하여 코드의 가독성과 유지보수성을 높일 수 있습니다. 이는 각 훅의 본래 의도에 맞게 리액트의 기능을 최대한 활용하는 방식이라고 볼 수 있습니다.
  • 4. 외부 시스템 연동을 위한 브릿지 역할 (5-2편에서 심화): useRef는 브라우저의 웹 API(예: window.setInterval 반환값)나 서드파티 DOM 라이브러리(예: D3.js, Chart.js 인스턴스)와 같은 React 외부 시스템과 연동해야 할 때 유용하게 활용될 수 있습니다. 특히 React 컴포넌트 내부에서 DOM 관련 로직을 외부 라이브러리에 위임해야 할 때, useRef를 통해 실제 DOM 요소나 외부 라이브러리 인스턴스에 직접 접근함으로써 리액트의 선언적 영역을 벗어나 명령형 제어가 가능해집니다. 이 내용은 5-2편에서 더 자세히 다룰 예정입니다.

useState vs useRef 차이 – 무엇이 다르고, 언제 선택할까요?

useStateuseRef는 모두 React에서 컴포넌트의 생애 주기 동안 값을 "기억"하고 "유지"하는 데 사용될 수 있지만, 그 목적과 동작 방식에는 근본적인 차이가 있습니다. useState는 주로 컴포넌트의 UI를 변화시키는 **상태(state)**를 관리하며, 상태가 변경되면 React의 렌더링 과정을 통해 UI를 업데이트합니다. 반면 useRef는 UI와 직접적인 연관 없이 컴포넌트 내부에 **가변적인 값(mutable value)**을 저장하고 리렌더링을 유발하지 않아야 할 때 사용됩니다. 즉, useState는 "UI가 어떻게 보일 것인가"에 초점을 맞추는 선언적인 도구인 반면, useRef는 "어떤 값을 유지할 것인가"에 초점을 맞추며, 필요에 따라 명령형으로 접근할 수 있는 "탈출구"의 역할을 합니다.

어떤 상황에서 어떤 훅을 선택하는 것이 좋을지, 주요 차이점들을 비교표를 통해 함께 정리해 보겠습니다.

구분useStateuseRef
주요 용도UI 상태 관리, 값 변경 시 리렌더링 유발리렌더링 없는 값 저장, DOM 직접 참조 (5-2편)
값 변경set 함수 호출, 리액트가 리렌더링 관리ref.current 직접 변경, 리렌더링 발생 안 함
값 보존컴포넌트 리렌더링 간 상태 보존 및 UI 동기화컴포넌트 생애 주기 동안 값 보존 (리렌더링 무관)
UI 반영상태 변경 시 UI에 자동으로 즉시 반영값 변경 시 UI에 자동 반영 안 함
패러다임선언적 UI 관리 (데이터 기반으로 UI가 그려짐)필요에 따라 명령형 접근 방식 허용

요약

이번 편에서는 리액트의 useRef 훅을 통해 컴포넌트 내에서 리렌더링 없이 값을 보존하는 핵심적인 활용 방법에 집중하여 살펴보았습니다. useRefuseState와 어떻게 다른 목적과 동작 방식을 가지는지 정리하며, React의 선언적 패러다임 속에서 useRef가 어떤 "탈출구" 역할을 하는지 알아보았습니다. 이 편에서 함께 공부한 핵심 내용들을 간단히 묶으면 다음과 같습니다.

  • useRef의 핵심: 리렌더링 없는 값 저장을 위한 훅
  • useState와의 차이점: UI 업데이트 및 리렌더링 유발 여부, 값 보존 목적의 차이
  • 주요 활용 사례: 이전 값 저장, 타이머 ID, 외부 인스턴스, 플래그 등 다양한 내부 값 관리
  • 리액트의 "탈출구": UI와 무관한 가변적 상태를 효율적으로 관리

useRef를 이해하고 적절히 활용함으로써, React 애플리케이션에서 특정 요구사항에 더욱 유연하게 대응하고, 불필요한 리렌더링을 방지하여 성능을 최적화하는 데 도움이 될 수 있을 것이라고 생각합니다. DOM 직접 조작에 대한 내용은 다음 5-2편에서 더 자세히 다룹니다.

참고문서

– [ref로 값 참조하기 (Referencing Values with Refs)]