[React 코드 까보기] useRef는 DOM에 접근할 때 뿐만 아니라 다양하게 응용할 수 있어요.

useRef() 번역 및 useRef의 다른 사용법

FlyingSquirrel
16 min readSep 8, 2020
React 코드 까보기, useRef 함수는 돔 접근 외에도 활용도가 높아요

얼마 전에 화상으로 한 프론트엔드 개발자분하고 페어 프로그래밍을 하고 있었는데, axios.createaxios 인스턴스를 생성할 때 useRef를 이용하시더라고요. 왜 useRef를 쓰는지 여쭸더니 위 링크를 공유해주셨어요.

위 링크를 읽고, 저도 실제로 react 코드를 살펴보았고, 나름의 깨달음(?)을 기록한 글입니다.

🚨 이 글에서 react코드와 react-dom코드를 읽고 적은 내용들은 검증받은 게 아니라 주니어의 섣부른 판단일 수 있습니다. 잘못 이해하고 있다면 꼮꼮꼮꼮 피드백 부탁드립니다!

목차
- TL;DR
- 📄 React 문서에 있는 useRef 설명
- 🔍 먼저 React code를 까봅시다.
- 🧪 실제로 실험해봅시다.

(이것조차 좀 길지만) TL;DR

  • 우리가 사용하는 useRef()react의 내부 코드에 있는 함수인데, 사실 이 함수는 react-dom에서 만든 함수입니다.
  • 우리가 사용하는 useRef() 함수를 이용하면 IIFE로 실행됐던 react-dom코드 내부의 전역변수에 접근할 수 있게 됩니다. 전역변수에 여러 개가 있지만, useRef와 관련해서는 currentlyRenderingFiber$1라는 FiberNode가 중요합니다. component가 mount 될 때 currentlyRenderingFiber$1.memoizedState에 초기값을 저장했다가, update가 되어도 계속 이 값을 바라보고 있어서 라이프사이클동안 같은 값이 유지가 됩니다.
  • 실제로 Math.random()를 이용해서 동일한 값이 유지되는지 실험해보았는데, 진짜로 동일한 값이 유지되었습니다.
  • useRef()는 re-render를 일으키지 않기 때문에 constant 느낌으로 관리될만한 것들이나 instance를 useRef()를 이용해서 생성하면 불필요한 메모리 사용을 줄일 수 있을 것 같습니다. 실제로 react-redux내부 코드를 살펴보면 useRef()를 애용하고 있었습니다.

📄 React 문서에 있는 useRef 설명

React 문서에 있는 useRef에 대한 설명에서 일부 문단을 가져왔습니다.

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

(…중략 영어영어 블라블라)

useRef() creates a plain JavaScript object. The only difference between useRef() and creating a {current: ...} object yourself is that useRef will give you the same ref object on every render.

Keep in mind that useRef doesn’t notify you when its content changes. Mutating the .current property doesn’t cause a re-render. If you want to run some code when React attaches or detaches a ref to a DOM node, you may want to use a callback ref instead.

요약하면…

  • useRef(매개변수)라는 함수를 호출하면 plain JavaScript object를 return하고, { current: 매개변수 }로 초기값이 설정됩니다.
  • 이렇게 useRef()를 호출해서 만들어진 object는 그 컴포넌트의 전체 라이프사이클 동안 유지가 됩니다.
  • current의 값을 변경해도 re-render가 되지 않습니다.

진짜 useRef()로 한 번 만들면 그 값이 잘 유지가 되는지 궁금해서 실험을 통해 확인해보았습니다.

🔍 먼저 React code를 까봅시다.

이 부분은 읽기에 지루할 수 있습니다. 결론만 보고싶으면 스크롤을 좀 더 많이 내려서 실제로 실험해봅시다. 부분을 봐주세요.

우선 큰 흐름은 아래와 같습니다.

react-dom 렌더(마운트)
→ react 내부코드 읽음
→ hook API를 이용하여 component 업데이트 발생
→ react-dom에서 update hook 관련 작업들 진행함

시작은 react-dom부터

CRA로 프로젝트를 하나 만들어고, src/index.js에서 react-dom을 import한다는 것부터 시작해야 헛발질을 덜합니다. react 코드의 시작은 ReactDOM.render이니까요!

react-dom 코드를 전부 import한다는 것입니다.

.

node_modules에서 라이브러리 코드를 찾아보자

node_modules/react-dom/cjs/react-dom.development.js

development 환경일 때는 node_modules/react-dom/cjs/react-dom.development.js 파일을 사용한다는 것을 알 수 있습니다.

.

IIFE로 실행되는 react-dom 코드를 만났다

IIFE 어디쓰려나 싶었는데, 이런데서 잘 사용되고 있었다.

react-dom.development.js파일을 열어보니 IIFE로 실행된다는 것을 알 수 있습니다. 25,000줄이 넘는 코드를 다 읽는 것은 좀 힘드니까, 필요한 것만 읽도록 노력해봅니다.

.

react-dom이 react에 있는 ReactSharedInternals객체를 변경한다

node_modules/react-dom/cjs/react.development.js

react-dom이 IIFE로 실행이 될 때 React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED라는 무서운 이름이 보입니다. 이것을 ReactSharedInternals라는 IIFE 내에 있는 전역 변수에 할당합니다.

node_modules/react/cjs/react.development.js

ReactSharedInternals가 어디에서 왔는지 살펴보니, __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIREDreact 내부코드(node_modules/react/cjs/react.development.js )에서 export를 하고 있다는 것을 확인할 수 있었습니다.

결론을 이야기하자면 react-dom은 component가 mount를 거나 update될 때 ReactSharedInternals (=mutable한 객체)에 있는 hook 함수를 이용해서 react-dom의 어떤 전역변수에 접근할 수 있게 되고, component가 업데이트되어도 그 어떤 전역변수에서 값을 가져다 씁니다. 그래서 라이프 사이클 동안 같은 값을 유지할 수 있는 것입니다 🎉

그 어떤 전역변수의 존재는 좀 더 아래에 등장합니다.

.

갑자기 분위기 renderWithHooks

한 줄 한 줄 읽기에는 코드의 양이 너무 방대해서 중간과정은 구글링을 통해 체득했습니다. 하지만 중요한 것은 render가 일어날 때는 react-dom에서renderWithHooks가 호출된다는 것입니다!

renderWithHooks 함수에서 처음 component가 mount를 할 때는 ReactCurrentDispatcher.currentHooksDispatcherOnMountInDEV를 할당해줍니다.

그래서 ReactCurrentDispatcherHooksDispatcherOnMountInDEV 이 어디서 왔는지를 살펴보려고합니다!

react에서 온 ReactCurrentDispatcher

김수한무거북이와두루미 같이 이름이 긴 변수들이 어디서 왔는지 살펴보니, 조금 더 윗 줄에서 ReactCurrentDispatcher 를 선언해주고 있는 것을 찾을 수 있습니다. ReactCurrentDispatcher 에는 아까 전 단계에서 만났던 ReactSharedInternals 객체에서 ReactCurrentDispatcher만 넘겨주네요!

HooksDispatcherOnMountInDEV은 여러 hook API 함수를 모아놓은 객체

HooksDispatcherOnMountInDEV 를 살펴보면 이런식으로 hook API를 호출했을 때 작동될 함수들이 모아있었습니다.

제가 CRA에서 useRef()함수를 호출하면 결국에는 이 함수들이 호출되는 것이었습니다.

한 가지 중요한 것은 지금까지 내부코드들이 가져다 쓴 것이 모두 객체라는 점입니다. JS에서 객체는 변수에 할당하면 주소값을 참조합니다. 내가 만든 변수에 할당했지만 그걸 수정하면 객체에도 변경이 발생합니다.

예.
const exampleObject = { current: null };
const copy = exampleObject;
copy.current = '안녕!';console.log(exampleObject.current) // '안녕!'

그래서 react-domreact에서 export하고 있는 것을 가져다 쓸 때 객체의 주소값을 참조하는 방식으로 사용하고 있습니다. 그래서 react-dom에서 그 객체를 변경하면 react에서도 그 객체를 사용할 때는 변경된 객체를 쓰게 됩니다.

뭔가 복잡스러운데 조금 정리를 하자면,

  1. react-dom코드가 IIFE로 실행될 때 react에서 export하는 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED를 가져와서ReactSharedInternals 라는 변수에 할당해서 뭔가 일을 열심히 합니다.
  2. 1번에서 react-dom이 제 할 일을 열심히 할 때 객체에 변경을 하게 되면 react의 객체도 변경됩니다. 객체를 깊은 복사를 한 것이 아닌 참조를 하는 구조이기 때문입니다.

.

mountRef함수를 봅니다 👀

아까 봤던 HooksDispatcherOnMountInDEV객체에 있는 useRef는 살펴보니 mount할 때 작동하는 함수이더라고요! 이 useRefmountRef라는 함수를 호출하고 있으니 이제 mountRef를 보겠습니다.

ref를 return 하고 있네요. ref가 어디서 많이 보던 모양이네요. 우리가 보통 useRef()를 호출하면 return 되는 { current: null }객체입니다.

여담인데, 이 current를 ‘pizza’로 바꾸면 useRef()호출했을 때 return 되는 객체가 { pizza: null }이 되는 재미난 모습을 볼 수 있습니다.

mountWorkInProgressHook()라는 함수가 리턴하는 값을 hook이라는 변수에 할당하고, hook.memoizedStateref 넣었군요.

.

거의 다왔습니다! mountWorkInProgressHook 👏

mountWorkInProgressHook 를 보니 workInProgressHooknull이면 currentlyRenderingFiber$1에 뭔가 넣어주네요.

hookworkInProgressHook 에 넣고, 이걸 또 currentlyRenderingFiber$1.memoizedState 담아주네요.

참고로 workInProgressHook 는 조금 더 위에 코드라인에서 null로 선언된 변수입니다. 아직 뭐 hook이 업데이트 된 게 없고 그냥 mount되는 단계니까 null일 경우에 currentlyRenderingFiber$1 객체를 변경하는 걸로 이해했습니다.

.

찾았다 요녀석! currentlyRenderingFiber$1는 전역변수

hook들은 fiber의 memoizedState에 linked list의 형태로 저장된다라는 주석도 같이 볼 수 있다.

currentlyRenderingFiber$1 라는 변수를 찾아보면 react-dom이 IIFE로 코드가 실행될 때 그 내부에 있는 변수라는 것을 알 수 있습니다. 친절하게 설명된 주석도 있네요. (사실 막 그렇게 친절하게 느껴지진 않았습니다.)

아무튼 또 정리를 해보자면,

  • 우리가 사용하는 useRef()react의 내부 코드에 있는 함수이고, 이 함수는 사실 react-dom에서 만든 함수입니다.
  • useRef() 함수를 이용해서 react-dom의 IIFE로 실행된 코드 내부에 선언된 전역변수에 접근할 수 있는 것이고, useRef()의 경우에는 이 여러 전역변수들 중에서 currentlyRenderingFiber$1.memoizedState 의 값을 사용하고 있습니다.
  • 전역에 있는 객체의 값을 사용하고 있다보니 전체 라이프사이클이 유지되는 동안 항상 같은 값이 유지됩니다.
  • 하.. 힘들다.

🧪 실제로 실험해봅시다.

실험에서는 state가 업데이트 되어서 re-render가 일어났을 때도 useRef()의 값은 여전히 같은 값을 유지하는지?를 살펴보려 합니다.

CRA로 간단한 프로젝트를 만듭니다

슝슝슝슝
$ npx create-react-app testing
$ cd testing/
$ webstorm .

저는 웹스톰을 써서 webstorm .이렇게 했는데 vs code 쓰시는 분들 중 터미널로 열 수있도록 설정한 분들은 그 커맨드로 여시면 됩니다. 보통은 vs code는 code .으로 실행합니다.

.

(필수 아님) <React.StrictMode>를 주석처리합니다.

src/index.js

src/index.js에 있는 <React.StrictMode>를 주석처리해줍니다. 주석처리를 하지 않아도 useRef를 실험하는데는 전혀 지장이 없습니다. 다만, development 환경에서는 콘솔이 2번씩 찍히는 모습을 보게 될 것이기 때문에 그게 보기 싫어서 주석처리했습니다.

.

App.js에 custom hook을 추가해봅시다.

useCustomHooks라는 함수를 만들어서 App에서 호출해줬습니다. App이 렌더되면 무조건 useCustomHook이 호출 될 거고, useCustomHook이 호출되면 항상 console.log를 남길겁니다.

Math.random() 특징상 호출되면 계속 새로운 랜덤한 숫자를 리턴하기 때문에 라이프사이클이 유지되는 동안 useRef가 항상 동일한 값을 유지하는 것인지 알 수 있을 것 같습니다.

mount되고 찍힌 콘솔에 나온 0.07708522082513003

이제 state를 변경해서 useCustomHook 도 호출되게 해보겠습니다.

<App />의 state를 변경하면 re-render가 되면서 useCustomHook이 호출될 겁니다. 그러면 function의 새로운 scope가 생겨서 useRef(Math.random())한 게 다른 값이 나올지, 기존처럼 0.07708522082513003이 나와줄지 살펴보겠습니다.

0.07708522082513003! 똑같다! 야호!

useRef를 쓰지 않고 Math.random()을 한 변수는 값이 바꼈는데, useRef를 사용한 변수는 값이 유지되었습니다. 문서를 의심한 것은 아니지만, 그래도 눈으로 확인하니 기분이 좋네요! 👏

--

--