React Learn - useRef 를 이용해 DOM 조작하기

ChoiYongHyeun·2024년 2월 26일
1

리액트

목록 보기
11/31
post-thumbnail

리액트 공식문서 Manipulating the DOM with Refs 를 읽고 내 마음대로 정리해보는 포스트


저번 챕터에서 Ref 에 대한 부분을 공부했었는데

이전 부분까지만 봤을 때는

아 ~ ㅋㅋ 그냥 useState 인데 setter function 안쓰고 직접적으로 값 변경 가능한 mutable object 아니냐고 ~

이랬다.

그런데 이번 챕터를 공부하고 나니 어디에 쓰는지 감이 온다.

useRefDOM 을 조작하기 찰떡이다.

다음과 같은 컴포넌트에서 focus 버튼을 누르면 input 태그가 focus 되도록 하고싶다고 해보자

focus : input element 의 이벤트 종류로 마우스 커서가 input 태그로 가는 이벤트

이렇게 직접 document.querySelector 를 이용해서 태그를 가져와서 focus 시켜버리면 되기는 한다.

다만 이는 몇 가지 문제가 존재한다.

React 에서 직접적으로 Actual DOM 에 접근하면 발생하는 단점

1. Virtual DOM 을 조작하며 얻는 이점을 포기해야 한다.

리액트는 근본적으로 Virtual DOM 들간의 reconciliation 과정을 통해 차이가 나는 노드를 찾아

해당 노드의 수정만 빠르게 하여 렌더링 성능을 최적화 한다는 장점이 있는데

Virtual DOM 을 건너 뛰고 바로 Actual DOM 에 접근하는 행위는 reconciliation 과정에서 예상치 못한 문제를 야기 할 수 있다.

2. 유지보수성이 떨어진다.

컴포넌트 간의 계층적 구조로 이뤄진 Virtual DOM 을 조작하는 것이 아니고

실제 UI 에 렌더링 되는 Actual DOM 을 직접 조작 하는 것이기 때문에

Virtual DOM 만 신경 써야 하는게 아닌, Actual DOM 까지 함께 신경써야 한다.

이는 유지 보수를 더욱 어렵게 하고 확장성을 낮춘다.

3. 성능 저하가 야기된다.

1번에서 말했듯이 Virtual DOM 간의 reconciliation 으로 최대한 reflow , repaint 를 낮추고자 하는데

이러한 과정 없이 Actual DOM 을 조작하게 되면 reconciliation 과정으로 얻는 이점을 얻지 못해 성느이 저하될 수 있다.

4. 컴포넌트의 재사용 및 확장성이 떨어진다.

Virtual DOM 을 조작하는 컴포넌트의 경우 Virtual DOM 을 이용하는 어떤 컴포넌트간의 조합이 쉽지만

Actual DOM 을 조작하는 컴포넌트의 경우 다른 컴포넌트와 조합하여 쓰기 위해선, 필연적으로 해당 Actual Node 가 존재해야 하기 떄문에

확장성이 매우 떨어진다는 문제가 있다.

웬만하면 Virtual DOM 조작 할 때는 Virtual DOM 만 조작하자고 ~!~!

useRef 는 어떻게 Virtual DOM 의 노드를 선택 할까

useRef 는 특히 Virtual DOM 의 노드를 선택하기 가장 안성맞춤이다.

컴포넌트의 반환값인 JSX 는 문자열이 아닌 React 의 객체이다. 해당 객체의 ref props

useRef 객체로 설정해준다면 마치 직접 node 를 설정한 것 처럼 사용 할 수 있다.

이처럼 useRefVirtual DOMNode 를 설정해주는 것은 useRef 를 사용하는

가장 흔한 예시라고 한다.

초기 렌더링 이전까지 ref.currentnode 를 가리키지 못한다.

컴포넌트가 호출되면 rendering phase => reconciliation => commit phase 가 존재한다고 하였다.

Render Phase (virtual DOM 을 생성하는 단계) 에서 ref.currentActual DOM 을 가리키지 못한다.

하지만 Commit Pahse (Actual DOM이 생성되는 단계) 에서 비로소 리액트는 ref.currentVirtual DOM 으로 인해 생성된 Actual DOM 의 노드와 ref.current 값을 바인딩 한다.


useRef callBack

useRef callbackJSX ref 값에 ref 객체를 넣어주는 것이 아니라

콜백함수를 넣어주어 해당 노드가 렌더링 될 때 마다

해당 노드를 인수로 받아 함수를 실행 할 수 있게 한다.

현재 introduce 콜백함수는 노드가 렌더링 되면 본인을 로그하도록 하는 콜백함수이다.

그런데 보면 ref 들이 모두 두 번씩 호출되는 모습을 볼 수 있다.

왜그럴까 ?

ref callbackRender Phase , Commit Phase 때 두 번 호출된다.

키킥 제목 그대로다

컴포넌트가 새로 렌더링 되어 재호출되면 ref 에 있는 callback function

render phase , commit phase 때 모두 호출된다.

만약 Commit Phase 에서만 호출하고 싶다면 다른 훅이 있을지 모르겠지만

null 이 아닐때로 해야겠지 ?

ref 는 해당 노드를 찾지 못하면 null 값을 반환한다.


useRef 를 컴포넌트에 넣어줄 수는 없을까 ?

위에서 <input ref = {..}>처럼 JSX 문법에서 ref 를 선언해주었으니

컴포넌트도 혹시 ref 를 이용해 변수처럼 다룰 수는 없을까 ?

해당 오류가 뜨며 안된다.

Components 에는 ref 를 줄 수 없다고 한다.

리액트는 의도적으로 이와 같은 행위를 금지시켜두었다.

useRefescape hatch 역할을 한다고 했다. (React 외부의 것들에 접근 할 수 있게 해주는)

하지만 React 내부의 함수형, 클래스형 컴포넌트들에게도 useRef 를 이용하는 것은 역할에 맞지 않을 뿐더러

컴포넌트가 컴포넌트를 조종하는 행위들은 컴포넌트간 의존성을 높힐 뿐더러

여러 로직들이 조각 조각 흩뿌려지게 되기 때문에 금지해두었다.

그로 인해 리액트는 div , p , span , input .. 등과 같은 element 에만 ref 를 지정 할 수 있게 해두었다.

하지만 Ref 를 컴포넌트를 통해 건내줄 수는 있다.

하지만 컴포넌트에서 ref 를 받아 해당 컴포넌트를 이루고 있는 element 들에게 건내주는 것은 가능하다.

fowardRef를 이용하면 된다.

forwardRef 를 이용해 컴포넌트를 감싸주면 컴포넌트가 ref 를 인수로 받아

컴포넌트를 구성하는 엘리먼트들에게 ref 를 건내 줄 수 있게 해준다.

이 때 forwardRef 로 감싸지는 함수는 화살표 함수로 하는 것이 더 가독성 양상에서 좋기에

화살표 함수를 이용 할 수 있다.

In design systems, it is a common pattern for low-level components like buttons, inputs, and so on, to forward their refs to their DOM nodes. On the other hand, high-level components like forms, lists, or page sections usually won’t expose their DOM nodes to avoid accidental dependencies on the DOM structure.

다만 디자인 시스템에서 엘리먼트들에게 ref 를 건내주는 것은 자주 보이지만

상위 컴포넌트에의 ref 를 하위 컴포넌트에게 forwardRef 로 건내주는 것은 지양해야 한다고 한다.

그렇게 하면 상위 컴포넌트와 하위컴포넌트의 의존성이 강해지기 때문이다.

엘리먼트에서 ref 를 이용한 로직을 처리하고 싶다면

차라리 ref 를 인수로 받기보다, 컴포넌트 내에서 로직을 처리하는 것이 좋을 것 같다.

useImperativeHandle 는 접근을 제한하는 중개자역할

ref 를 이용하면 element 에 접근이 가능하기 때문에

ref 값으로 element 의 스타일을 변경하거나 식별자를 변경하는 등 어떤 행위가 가능하다.

이에 예기치 못한 행위를 방지하고, 정해진 행위만 할 수 있도록 useImperativeHandle 훅을 이용 할 수 있다.

우선 useImperativeHandle 을 이용하기 위해서는

컴포넌트 내의 localRef 객체를 생성해주어야 한다.

컴포넌트 내에서 생성한 localRef 객체는 엘리먼트의 ref 객체가 된다.
(부모 컴포넌트로부터 내려온 ref 객체를 할당해주지 않는다.)

useImperativeHandle 훅 은 두 가지 인수를 받는다.

  • ref : 부모컴포넌트로부터 받은 ref 객체
  • createHandle : 콜백함수이며 ref 객체가 할당된 엘리먼트에서 사용 가능한 메소드 , 프로퍼티를 담은 객체를 반환한다.

useImperativeHandle 을 통해 컴포넌트 내에서 정의된 localRef 객체를 할당 받은 엘리먼트는

부모 컴포넌트에서 전달한 ref 객체를 통해 조작 할 수 있다.

하지만 createHandle 내에서 정의된 프로퍼티나 메소드만 접근 가능하다.

만약 그 외의 값에 접근하고자 한다면 접근 할 수 없기 때문에 undefined 가 된다.

정리

useImperativeHandle 은 부모 컴포넌트에서 내려준 ref 와 컴포넌트 내에서 정의된 localRef 를 이어주는 중개자 역할을 한다.

중개자 역할이면서, 접근 가능한 값들을 제한해줌으로서 예기치 못한 현상을 방지한다.


리액트는 Noderef 를 언제 부착할까

앞서 살펴보았듯이 노드에 ref 객체들을 부착하면 UI 가 업데이트 되는

render phase , commit phase 에서 모두 ref 가 부착되는 모습을 볼 수 있다.

render phase 에서는 Actual DOM 을 찾을 수 없기 때문에 ref 값이 null 을 가리키고

commit phase이 되어서나 Actual DOM 을 찾을 수 있기에 실제 엘리먼트를 가리킬 수가 있다.

이렇게 render phase 에서의 ref 값이 null 로 호출됨에 의해서 예기치 못한 에러가 발생 할 수 있다.

이러한 방법은 ?. 과 같은 null 병합 연산자 를 이용하여 null 이 아닐 때에만 attribute 를 붙여주는 등의 방법을 이용하여 해결 할 수 있다.

혹은 Commit Phase 에서만 호출될 수 있도록 하는 useEffect 와 같은 훅을 이용 할 수 있는데 이는 다음 페이지에서 공부해보도록 하자


동기적으로 state 를 업데이트 하는 Flushsync

⭐ 을 추가하고 마지막 별을 회전 시키고 싶어

위 코드에서 button 을 클릭하면 stars 에 별이 하나 추가되어 상태가 업데이트 시키고

마지막 별에 식별자를 추가하고 삭제하는 행위를 통해 회전 시키고자 한다.

하지만 결과물을 보면 추가한 후의 마지막 별이 회전하는 것이 아니라

별을 추가 하기 전의 마지막 별이 회전하는 모습을 볼 수 있다.

이는 상태값의 변화가 렌더링 이후에 일어나도록 queued 되었기 때문이다.

setStars 직후에 state 가 업데이트 된다면 lastStar 는 상태가 업데이트 된 배열에서 골라지기 때문에

우리가 원하는 로직을 구현 할 수 있을 것이다.

FlushSync

react-DOM 에서 제공하는 FlushSync 는 상태값 변경을

렌더링 이후에 일어나도록 대기 시키는 것이 아닌, 동기적으로 변경 하도록 한다.

state 가 변경된 이후의 lastChild 를 고르게 하기에 원하는 로직을 구현 할 수 있다.

FlushSync 는 나중에 공식문서를 통해 더 봐봐야겠다.

react-DOM 에서 flushSyncimport 해왔는데
[A5] 왜 나는 React를 사랑하는가 에서 react , react-DOM 등과 같이 리액트가 파일들을 나눠둔 이유를 설명하는데 이것을 참고하면 좋을 것 같다.


useRef 를 이용 할 때 신경써야 할 부분

useRefVirtual DOMref 를 부착한 채 rendering -> commit 시키면

Actual DOM 이 생성되었을 때 해당 실제 엘리먼트와 Virtual DOM 에 존재하는 ref 가 부착된 JSX 객체를 바인딩 하도록 해준다.

refActual DOM 을 조작 할 수 있게 해주지만 결국은

Virtual DOM 에 부착된 후 Actual DOM 에서 해당 엘리먼트를 찾아야 역할을 할 수 있다.

이는 Virtual DOMActual DOM 의 모습이 잘 맞아야 ref 를 사용 할 수 있다는 것이다.

다음과 같은 예시를 들어보자

예제를 통해 알아보기

다음과 같은 컴포넌트들을 만들고 각 버튼이 기능하는 컴포넌트들을 만들어보자

App.js

최상단 컴포넌트인 App 컴포넌트의 경우 Context 를 제공하며 하위 컴포넌트로 Stars , AllButtons 들이 존재한다.

ContextProvider.js

ContextProvider.js 에서는 3가지 Context 를 제공한다.

마지막 엘리먼트를 가리키는 lastStarRefstate , setterFunctionstars , setStars .

각 컨텍스트에 접근 가능하도록 ContextProvider.js 전역 환경에 정의된 Context 에 접근하는 커스텀 훅인 useRefContext , useStarContext , useSetterContext 메소드도 export 해주었다.

Stars.js

상위 컨텍스트에서 정의된 statestars 를 받아 렌더링 하는 컴포넌트이다.

./Buttons/AllButtons.js

AllButtons 컴포넌트는 AddButton , RotateButton , RemoveButtonV , RemoveButtonA

button-wrapper 식별자를 가진 태그로 감싼 컴포넌트이다.

./Buttons/AddButton.js

AddButtonstars 상태를 변경한다.

이 때 마지막 별에 ref 를 부착해주기 위해 마지막 별에 존재하는 ref 를 변경하여 업데이트 하도록 한다.

./Buttons/RotateButton.js

RotateButtonref 객체가 붙어있는 Actual DOM 에 접근하여 식별자를 붙였다 뗌으로서 애니메이션 효과를 가져온다.

./Buttons/RemoveButtonV.js

RemoveButtonV 컴포넌트는 Virtual DOM 에서 상태를 변경하여 별을 제거하는 컴포넌트이다.

statesetter function 에 맞춰 삭제함으로서 ref 가 마지막 별에 잘 부착되도록 한다.

./Buttons/RemoveButtonA.js

RemoveButtonA 컴포넌트는 마지막 별을 가리키는 ref 객체를 받아

Actual DOM APIremoveActual DOM 에서 해당 노드를 강제로 제거한다.

(Virtual DOM 의 변화를 야기하지는 않는다. 딱 봐도 문제를 일으키게 생겼다.)

Virtual DOMActual DOM 의 차이가 존재하지 않을 때

Virtual DOM 을 변경하고 render 시킴으로서 Actual DOM 과의 차이가 존재하지 않을 때에는

모든 기능이 문제 없이 잘 작동하는 모습을 볼 수 있다.

Virtual DOMActual DOM 의 차이가 존재 할 때

하지만 Actual DOMAPIremove 를 이용하여 노드를 제거하면

ref 가 업데이트 되지 않아 rotate last Star 도 작동하지 않고

Actual DOM 을 이용한 추가 삭제가 되지 않는 모습을 볼 수 있다.

하지만 이후

Actual DOMAPI 를 이용 후 Virtual DOM 의 상태를 변경하려고 하니 오류가 뜬다.

왜 이런 일이 발생할까 ?

🤔 현 상황을 짚어보기

Actual DOM 에서 ref 가 참조하는 노드가 사라졌는데 왜 여전히 ref 는 그 노드를 참조할까 ?

현재 상황을 보면 Virtual DOM 에서는 별이 3개가 존재하고 , lastStarRefActual DOM 에서 사라진 를 참조하고 있었다.

그런데 이상하다. 그러면 rotate last Star 버튼을 누르면 Actual DOM 에 존재하지 않는 노드를 불러오니 오류가 나야 하는거 아닌가 ?

Actual DOM 에서 ref 가 참조하는 노드를 삭제한 후 회전 버튼을 눌러보자

이것도 아주 이상하다.

이런 일이 발생하는 이유를 시뮬레이션을 통해 살펴보자

🤩 시뮬레이션을 통해 살펴보기

현재 상황은 이렇다.

Actual DOM 에서 제거하여 Virtual DOMActual DOM 의 괴리가 발생 한 후

Add -> Remove last Start (Virtual DOM) -> Remove last Start (Virtual DOM) (오류 발생)

일련의 과정에서 어떻게 리액트가 일을 처리하고 , ref 값이 변하는지 확인해보자

1. Initial Render

After Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div>

Actual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div>

초기에 렌더링 되면 다음과 같은 모습일 것이다.

2. Actual DOM 에서 별 제거하기

Actual DOM
<div key={1}></div>
<div key={2}></div>
// Actual DOM 에서 별이 제거되어도 Virtual DOM 은 상태가 업데이트 되지 않는다.
After Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div> 
// 이 때 Virtual DOM 의 상태가 없데이트 되지 않았기 때문에 
// 2번 단계에 있는 Virtual DOM 의 ref 는 1번 단계에 있는 Actual DOM 의 node 를 가리키고 있다.

이 때 부터 Virtual DOMActual DOM 의 괴리가 시작된다.

3. Add 로 별 추가하기

Before Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div> 

After Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3}></div>
<div key={4} ref = {ref}></div>

// Before Virtual DOM 과 After Virtual DOM 의 차이를 확인한다.
// 차이점 : key 가 4인 node 가 추가되었다.
// (Virtual DOM 이 update 되면서 Virtual DOM 의 ref 와 Actual DOM 의 ref 가 동기화된다.)

// Actual DOM 에 appendChild({.. key 가 4인 ⭐ 노드})
Actual DOM
<div key={1}></div>
<div key={2}></div>
<div key={4} ref = {ref}></div>

4. Remove (Virtual DOM) 로 별 제거하기

Before Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3}></div>
<div key={4} ref = {ref}></div>

After Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div>

// Before Virtual DOM 과 After Virtual DOM 의 차이를 확인한다.
// 차이점 : key 가 4인 node 가 사라졌다.
// (이 떄 key 가 3인 노드에 ref 를 선언했으나 Actual DOM 에서 3인 노드를 찾을 수 없으니
// 현재 ref.current 의 값은 null 이다. )

// Actual DOM 에서 removeChild({.. key 가 4인 ⭐ 노드})
Actual DOM
<div key={1}></div>
<div key={2}></div>

5. Remove(Virtual DOM) 로 별 제거하기

Before Virtual DOM
<div key={1}></div>
<div key={2}></div>
<div key={3} ref = {ref}></div>

After Virtual DOM
<div key={1}></div>
<div key={2} ref = {ref}></div>

// Before Virtual DOM 과 After Virtual DOM 의 차이를 확인한다.
// 차이점 : key 가 3인 node 가 사라졌다.

// Actual DOM 에서 removeChild({.. key 가 3인 ⭐ 노드}) 
Actual DOM
<div key={1}></div>
<div key={2}></div>
// Actual DOM 에 key 가 3인 노드가 없으니 에러 발생 

이런 일이 벌어진거였다.

나는 사실 한시간 동안 이런 일이 왜 발생할까 ? 생각을 계속했는데

그렇게 멍청하게 이해 못한 이유가

Virtual DOMActual DOM 의 괴리가 시작되어도 상태가 업데이트 되면

1:1 로 대응이 되는줄 알았다.

사실은 그냥 rendering 을 기준으로 Virtual DOM 간의 차이가 나는 부분만 appendChild , removeChild 하는 것인데 말이다 !!

key 를 그려가면서 흘러가는 상황을 그려보니 이해가 드디어 되었다 휴


회고

최근 유튜버인 양동준 님의 동영상 중 useRef 를 잘 이용한 프로젝트가 인상 깊었다는 사람의

합격 수기를 보았다.

그걸 보고 나서 useRef 를 더 열심히 공부해봐야지!! 하고 공부했는데

공부 하다보니 useRef 보다 Virtual DOM , Actual DOM 간의 괴리를 더 깊게 한 기분 ..

그래도 reconciliation 단계에 대한 이해를 드디어 제대로 한 것 같아서 기분이 좋다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글