[React 코드 까보기] useRef는 DOM에 접근할 때 뿐만 아니라 다양하게 응용할 수 있어요.
useRef() 번역 및 useRef의 다른 사용법
얼마 전에 화상으로 한 프론트엔드 개발자분하고 페어 프로그래밍을 하고 있었는데, axios.create
로 axios 인스턴스
를 생성할 때 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 betweenuseRef()
and creating a{current: ...}
object yourself is thatuseRef
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이니까요!
.
node_modules
에서 라이브러리 코드를 찾아보자
development 환경일 때는 node_modules/react-dom/cjs/react-dom.development.js
파일을 사용한다는 것을 알 수 있습니다.
.
IIFE로 실행되는 react-dom 코드를 만났다
react-dom.development.js
파일을 열어보니 IIFE로 실행된다는 것을 알 수 있습니다. 25,000줄이 넘는 코드를 다 읽는 것은 좀 힘드니까, 필요한 것만 읽도록 노력해봅니다.
.
react-dom이 react에 있는 ReactSharedInternals객체를 변경한다
react-dom이 IIFE로 실행이 될 때 React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
라는 무서운 이름이 보입니다. 이것을 ReactSharedInternals
라는 IIFE 내에 있는 전역 변수에 할당합니다.
ReactSharedInternals
가 어디에서 왔는지 살펴보니, __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
는 react
내부코드(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.current
에 HooksDispatcherOnMountInDEV
를 할당해줍니다.
그래서 ReactCurrentDispatcher
과 HooksDispatcherOnMountInDEV
이 어디서 왔는지를 살펴보려고합니다!
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-dom
이 react
에서 export하고 있는 것을 가져다 쓸 때 객체의 주소값을 참조하는 방식으로 사용하고 있습니다. 그래서 react-dom
에서 그 객체를 변경하면 react
에서도 그 객체를 사용할 때는 변경된 객체를 쓰게 됩니다.
뭔가 복잡스러운데 조금 정리를 하자면,
react-dom
코드가 IIFE로 실행될 때 react에서 export하는__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
를 가져와서ReactSharedInternals
라는 변수에 할당해서 뭔가 일을 열심히 합니다.- 1번에서
react-dom
이 제 할 일을 열심히 할 때 객체에 변경을 하게 되면react
의 객체도 변경됩니다. 객체를 깊은 복사를 한 것이 아닌 참조를 하는 구조이기 때문입니다.
.
mountRef함수를 봅니다 👀
아까 봤던 HooksDispatcherOnMountInDEV
객체에 있는 useRef
는 살펴보니 mount할 때 작동하는 함수이더라고요! 이 useRef
는 mountRef
라는 함수를 호출하고 있으니 이제 mountRef
를 보겠습니다.
ref
를 return 하고 있네요. ref가 어디서 많이 보던 모양이네요. 우리가 보통 useRef()
를 호출하면 return 되는 { current: null }
객체입니다.
여담인데, 이 current를 ‘pizza’로 바꾸면 useRef()호출했을 때 return 되는 객체가 { pizza: null }
이 되는 재미난 모습을 볼 수 있습니다.
mountWorkInProgressHook()
라는 함수가 리턴하는 값을 hook이라는 변수에 할당하고, hook.memoizedState
에 ref
넣었군요.
.
거의 다왔습니다! mountWorkInProgressHook 👏
mountWorkInProgressHook
를 보니 workInProgressHook
이 null
이면 currentlyRenderingFiber$1
에 뭔가 넣어주네요.
hook
을 workInProgressHook
에 넣고, 이걸 또 currentlyRenderingFiber$1.memoizedState
담아주네요.
참고로 workInProgressHook
는 조금 더 위에 코드라인에서 null로 선언된 변수입니다. 아직 뭐 hook이 업데이트 된 게 없고 그냥 mount되는 단계니까 null일 경우에 currentlyRenderingFiber$1
객체를 변경하는 걸로 이해했습니다.
.
찾았다 요녀석! currentlyRenderingFiber$1는 전역변수
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에 있는 <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이 나와줄지 살펴보겠습니다.
useRef를 쓰지 않고 Math.random()을 한 변수는 값이 바꼈는데, useRef를 사용한 변수는 값이 유지되었습니다. 문서를 의심한 것은 아니지만, 그래도 눈으로 확인하니 기분이 좋네요! 👏