저번 글에서는 useState를 구현하면서 "렌더 함수 밖에 값을 저장해두는" 원리를 익혔는데, 이번에는 다른 hook인 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을 호출할 방법이 없어지는 거다.
useState의 states[] 배열이 App() 함수 밖 (모듈 스코프)에 있어서 값이 유지됐던 것처럼, useRef도 같은 원리로 풀어야 한다는 게 첫번째 포인트였다.
그럼 "렌더 함수 밖에 저장공간을 둔다"는 점에서는 useState와 똑같은데, 둘이 왜 다른 hook으로 나뉘어 있을까?
답은 값을 바꿨을 때 리렌더가 일어나는가에 있었다.
setState(newValue) -> 리렌더 트리거ref.current = newValue -> 그냥 값만 바뀜, 리렌더 없음useRef가 그 역할을 맡는 거였다.useState와 공유하는 원리)useState와 hook 호출 순서를 공유한다.(실제 React처럼 hook 종류와 무관하게 하나의 인덱스를 사용)useState의 states[]와 똑같은 방식으로, useRef.js 모듈 스코프에 refs[] 배열을 따로 만들었다.
let refs = [];
App() 함수가 매 렌더마다 새로 호출되어도, 이 배열 자체는 모듈이 살아있는 동안 계속 유지된다.
useState의 setState는 내부에서 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]가 비어있을 때만 새 객체를 만들고, 그 다음부터는 이미 저장된 객체를 그대로 반환한다. 이 가드 하나가 "매 렌더마다 새 객체가 생기면 참조가 끊긴다"는 문제를 막아준다.
처음엔 useRef도 자기만의 hookIndex를 따로 가지면 되지 않을까 생각했다. useState는 states[]+자기 인덱스, useRef는 refs[]+자기 인덱스로 완전히 독립시키면 구현이 더 간단해지니까.
근데 알아보니 실제 React는 useState든 useRef든 hook 종류와 무관하게 하나의 인덱스(하나의 리스트)를 공유한다. 그 이유를 생각해보면:
const [count, setCount] = useState(0); // index 0
const timerRef = useRef(null); // index 1
const [step, setStep] = useState(100); // index 2
이렇게 호출 순서대로 인덱스가 매겨지는데, 만약 useState와 useRef가 각자 독립된 인덱스를 갖고 있다면, 컴포넌트 안에서 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를 importuseRef.js도 마찬가지로 main.js를 importmain.js는 원래 useState.js, useRef.js 를 import 해서 씀이렇게 되면 양쪽이 서로를 import는 순환 참조(circular dependency)가 생긴다. 그래서 hookCore.js라는 별도의 공유 모듈을 새로 만들어서, useState.js와 useRef.js 둘 다 거기서만 가져다 쓰고, main.js는 필요한 것만 골라 쓰는 구조로 정리했다.
순환참조로 인해 공유 모듈을 새로 옮기고 나서 다시 보니, hookCore.js엔 resetHookIndex도 같이 옮겨야 했다. 렌더 시작 전에 인덱스를 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++;
원리랑 구조를 다 잡았으니, 실제로 타이머 기능을 붙여서 동작을 확인해봤다. useRef로 setInterval의 ID를 저장하고, useState로 화면에 보여줄 경과 시간을 관리하는 구조다.
start와 stop 버튼에 클릭 이벤트를 줘서 구현했다.
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를 구현할 때는 lazy initializer, batching이라는 두 가지 보완 단계가 있었다. useRef도 비슷하게 더 다듬을 부분이 있을까 고민해봤는데, 결론은 없었다. useRef가 실제로 하는 일을 뜯어보면 딱 두 가지뿐이다.
if (refs[currentIndex] === undefined) {
refs[currentIndex] = { current: initialRef };
}
return refs[currentIndex];
배열 인덱스 하나를 확인하고, 비어있으면 객체 하나를 만들고, 아니면 그냥 돌려준다. 여기엔 비교 로직도, 스케줄링도, 다른 함수를 호출하는 분기도 없다. 최적화할 대상이 되려면 일단 "비용이 드는 과정"이 있어야 하는데, 이 구현엔 그런 과정 자체가 없다.
useState의 setState를 생각해보면 다르다. 거기엔 이전 값과 새 값을 비교하는 로직(Object.is), 리렌더를 예약하는 로직(scheduleRerender), 그리고 그 예약이 쌓이지 않게 막는 로직(isRenderScheduled)이 계속 등장한다. 이게 다 "리렌더"라는, 비용이 큰 작업을 다루기 위해 붙은 보호 장치들이다.
useRef엔 리렌더라는 비용이 드는 작업 자체가 없으니, 그걸 보호할 장치도 필요 없는 것이라고 생각했다.
useRef는 useState와 "렌더 밖에 값을 저장한다"는 점은 같지만, 값 변경이 리렌더를 트리거하지 않는다는 점이 다르다.useState와 달리 useRef는 추가 최적화가 필요 없는데, 이는 리렌더를 트리거하지 않는다는 설계 자체에서 나온 결과라는 것을 확인할 수 있었다. 굿굿굿~