Vanilla JS로 React Hook 구현하기 - useRef

q1q1·2026년 6월 19일

Mini React

목록 보기
2/6

저번 글에서는 useState를 구현하면서 "렌더 함수 밖에 값을 저장해두는" 원리를 익혔는데, 이번에는 다른 hook인 useRef를 구현해보았다.
구현하면서 막혔던 지점들, 그리고 그 지점에서 했던 고민들을 위주로 정리해보려고 한다! 고고싱 🥸🥸🥸


useRef는 왜 필요할까?

타이머 ID를 저장하고 싶은 상황을 생각해봤다. (강제로 상황을 만들어보자; 🙄💦) 버튼을 누르면 setInterval을 시작하고, 다른 버튼을 누르면 clearInterval로 멈추고 싶은 거다.
근데 이 ID를 그냥 일반 변수로 선언하면 어떻게 될까?

function App() {
  let timerId = null; // 매 렌더마다...?
  ...
}

App()은 그냥 함수다. 함수가 호출될 때마다 새로운 실행 컨텍스트가 생기고, 그 안의 let/count 같은 변수는 호출이 끝나면 사라진다.

function App() {
  let count = 0;
  count = count + 1;
  console.log(count);
}

App(); // 1
App(); // 1
App(); // 1

이렇게 실행시킬 경우 매번 1이 찍힌다. 타이머 ID도 마찬가지다. 리렌더마다 App()이 다시 호출되니, setInterval이 반환한 진짜 timerId는 다음 렌더에서 사라져버린다. clearInterval을 호출할 방법이 없어지는 거다.
useStatestates[] 배열이 App() 함수 밖 (모듈 스코프)에 있어서 값이 유지됐던 것처럼, useRef도 같은 원리로 풀어야 한다는 게 첫번째 포인트였다.


그럼 useState와 뭐가 다르지?

그럼 "렌더 함수 밖에 저장공간을 둔다"는 점에서는 useState와 똑같은데, 둘이 왜 다른 hook으로 나뉘어 있을까?
답은 값을 바꿨을 때 리렌더가 일어나는가에 있었다.

  • setState(newValue) -> 리렌더 트리거
  • ref.current = newValue -> 그냥 값만 바뀜, 리렌더 없음
    타이머 ID처럼 "화면에 보여줄 필요는 없고, 내부 로직에서만 쓰는 값"은 굳이 리렌더를 일으킬 필요가 없으니 useRef가 그 역할을 맡는 거였다.

useRef의 구현 목표

  1. 렌더 함수 밖에 값을 유지하는 저장공간을 만든다. (useState와 공유하는 원리)
  2. 값이 바뀌어도 리렌더를 트리거하지 않는다.
  3. 첫 렌더에서 만든 객체를 계속 같은 참조로 반환한다.
  4. useState와 hook 호출 순서를 공유한다.(실제 React처럼 hook 종류와 무관하게 하나의 인덱스를 사용)

하나씩 해보자고!

렌더 함수 밖에 값을 유지하는 저장공간을 만들기

useStatestates[]와 똑같은 방식으로, useRef.js 모듈 스코프에 refs[] 배열을 따로 만들었다.

let refs = [];

App() 함수가 매 렌더마다 새로 호출되어도, 이 배열 자체는 모듈이 살아있는 동안 계속 유지된다.


값이 바뀌어도 리렌더를 트리거 하지 않기

useStatesetState는 내부에서 scheduleRerender()를 호출해서 리렌더를 트리거했다. useRef는 그런 setter 함수 자체가 없다.
ref.current = x처럼 컴포넌트에서 객체 속성을 직접 바꾸는 거라, useRef 구현 내부 어디에도 scheduleRerender를 호출하는 코드가 없다. "안 만든다"가 곧 구현인 셈이다.


같은 참조를 계속 반환하기

export function useRef(initialRef) {
  const currentIndex = getNextHookIndex();

  if (refs[currentIndex] === undefined) {
    refs[currentIndex] = { current: initialRef };
  }
  return refs[currentIndex];
}

refs[currentIndex]가 비어있을 때만 새 객체를 만들고, 그 다음부터는 이미 저장된 객체를 그대로 반환한다. 이 가드 하나가 "매 렌더마다 새 객체가 생기면 참조가 끊긴다"는 문제를 막아준다.


hookIndex 공유

처음엔 useRef도 자기만의 hookIndex를 따로 가지면 되지 않을까 생각했다. useStatestates[]+자기 인덱스, useRefrefs[]+자기 인덱스로 완전히 독립시키면 구현이 더 간단해지니까.
근데 알아보니 실제 React는 useStateuseRefhook 종류와 무관하게 하나의 인덱스(하나의 리스트)를 공유한다. 그 이유를 생각해보면:

const [count, setCount] = useState(0);   // index 0
const timerRef = useRef(null);            // index 1
const [step, setStep] = useState(100);   // index 2

이렇게 호출 순서대로 인덱스가 매겨지는데, 만약 useStateuseRef가 각자 독립된 인덱스를 갖고 있다면, 컴포넌트 안에서 if문 같은 조건문으로 hook 호출 순서가 매 렌더마다 달라져도 적어도 각 hook 종류 내부에서는 들키지 않을 수 있다.
즉 독립된 인덱스 방식은 실제보다 "관대한" 구조가 되어버리는 거다. "Hook을 조건문 안에서 호출하면 안 된다"는 React의 규칙이 왜 존재하는지도 이 부분에서 이해가 됐다. 호출 순서가 곧 저장 위치라서, 순서가 흔들리면 다른 hook의 값을 잘못 가져오게 된다.

그래서 실제 동작 방식에 더 가깝게, hookIndex를 공유하는 쪽으로 결정했다.

// hookCore.js
let hookIndex = 0;

export function getNextHookIndex() {
  const currentHookIndex = hookIndex;
  hookIndex++;
  return currentHookIndex;
}

그런데.. 공유하려니까 문제가 발생했다.😉


공유하려니 생긴 문제 - 순환의 순환의 순환

hookIndex는 원래 useState.js 파일 안에만 있는 비공개 변수였다. useRef.js라는 새 파일에서 같은 인덱스에 접근하려면 구조를 바꿔야 했다.

처음엔 "그냥 main.js에 두면 되지 않나?" 싶었는데,

  • useState.js가 hookIndex를 쓰려고 main.js를 import
  • useRef.js도 마찬가지로 main.js를 import
  • 그런데? main.js는 원래 useState.js, useRef.js 를 import 해서 씀

이렇게 되면 양쪽이 서로를 import는 순환 참조(circular dependency)가 생긴다. 그래서 hookCore.js라는 별도의 공유 모듈을 새로 만들어서, useState.jsuseRef.js 둘 다 거기서만 가져다 쓰고, main.js는 필요한 것만 골라 쓰는 구조로 정리했다.

공유하려니 생긴 문제2 - hookIndex 값 올리기

순환참조로 인해 공유 모듈을 새로 옮기고 나서 다시 보니, hookCore.jsresetHookIndex도 같이 옮겨야 했다. 렌더 시작 전에 인덱스를 0으로 되돌리는 함수라서, hookIndex를 다루는 다른 함수들이랑 같은 모듈에 있어야 말이 됐다.

// hookCore.js
let hookIndex = 0;

export function resetHookIndex() {
  hookIndex = 0;
}

export function getNextHookIndex() {
  const currentHookIndex = hookIndex;
  hookIndex++;
  return currentHookIndex;
}

여기서 하나 더 막혔던 부분이 있다. 처음엔 hookIndex를 이렇게 그냥 내보내려고 했다.

export let hookIndex = 0; // 🙅ㅎㅎ 왜 안되는 것은 해보고 아는가..

근데 이렇게 하면 다른 모듈에서 hookIndex++처럼 직접 값을 바꾸려고 할 때 에러가 난다. ES 모듈의 import binding은 읽기 전용이기 때문이다. (모듈 안에서 직접 바꾸는 건 되고, 그 변경 사항이 다른 모듈에서 읽을 때도 반영되긴 하는데.. 외부에서 재할당하는 건 막혀있다.)

그래서 변수 자체는 모듈 안에 숨기고, 그 변수를 다루는 함수(getNextHookIndex(), resetHookIndex())만 export하는 식으로 캡슐화했다. 덕분에 useState.js도 더 이상 hookIndex를 직접 들고 있지 않고, getNextHookIndex()만 호출해서 인덱스를 받아오는 식으로 깔끔해졌다.

- let hookIndex = 0;
+ import { getNextHookIndex } from "./hookCore";
...
- const currentIndex = hookIndex;
+ const currentIndex = getNextHookIndex();
...
- hookIndex++;

타이머로 실제 동작 검증하기

원리랑 구조를 다 잡았으니, 실제로 타이머 기능을 붙여서 동작을 확인해봤다. useRefsetInterval의 ID를 저장하고, useState로 화면에 보여줄 경과 시간을 관리하는 구조다.
startstop 버튼에 클릭 이벤트를 줘서 구현했다.

	const timerId = useRef(0);
	const [timer, setTimer] = useState(0);

	//...
			clickStartButton: () => {
				if (timerId.current !== 0) {
					return;
				}
				const id = setInterval(() => {
					console.log("1초씩 tick tock tick tock");
					setTimer((prev) => prev + 1);
				}, 1000);

				timerId.current = id;
			},
			clickStopButton: () => {
				clearInterval(timerId.current);
				timerId.current = 0;
			},


useState처럼 최적화할 부분이 더 있을까?

useState를 구현할 때는 lazy initializer, batching이라는 두 가지 보완 단계가 있었다. useRef도 비슷하게 더 다듬을 부분이 있을까 고민해봤는데, 결론은 없었다. useRef가 실제로 하는 일을 뜯어보면 딱 두 가지뿐이다.

if (refs[currentIndex] === undefined) {
  refs[currentIndex] = { current: initialRef };
}
return refs[currentIndex];

배열 인덱스 하나를 확인하고, 비어있으면 객체 하나를 만들고, 아니면 그냥 돌려준다. 여기엔 비교 로직도, 스케줄링도, 다른 함수를 호출하는 분기도 없다. 최적화할 대상이 되려면 일단 "비용이 드는 과정"이 있어야 하는데, 이 구현엔 그런 과정 자체가 없다.

useStatesetState를 생각해보면 다르다. 거기엔 이전 값과 새 값을 비교하는 로직(Object.is), 리렌더를 예약하는 로직(scheduleRerender), 그리고 그 예약이 쌓이지 않게 막는 로직(isRenderScheduled)이 계속 등장한다. 이게 다 "리렌더"라는, 비용이 큰 작업을 다루기 위해 붙은 보호 장치들이다.
useRef엔 리렌더라는 비용이 드는 작업 자체가 없으니, 그걸 보호할 장치도 필요 없는 것이라고 생각했다.



정리

  • useRefuseState와 "렌더 밖에 값을 저장한다"는 점은 같지만, 값 변경이 리렌더를 트리거하지 않는다는 점이 다르다.
  • 객체로 감싸서 반환하는 이유는 자바스크립트의 참조 의미론을 활용해 "같은 저장공간을 계속 가리키게" 하기 위함이다.
  • 여러 hook이 호출 순서를 보장하려면 인덱스를 공유해야 하고, 이 과정에서 순환 참조 문제와 ES 모듈의 live binding 제약을 같이 배울 수 있었다!
  • useState와 달리 useRef는 추가 최적화가 필요 없는데, 이는 리렌더를 트리거하지 않는다는 설계 자체에서 나온 결과라는 것을 확인할 수 있었다. 굿굿굿~
profile
끄적끄적

0개의 댓글