react

[React] 리엑트 살펴보기8: useRef를 사용하는 이유(컴포넌트 사이클에 독립적인 객체 저장, 비제어 컴포넌트)

tea-tea 2024. 3. 11. 00:21

useRef


useRef는 자바스크립트의 DOM API중 셀렉터와 유사하다. 셀렉터는 getElementId나 querySelector 처럼 엘리먼트의 속성을 근거로 접근한다. 하지만, 이런 방식에 문제가 있어 대안으로서 useRef를 사용한다.

 

React에서 자바스크립트의 DOM API의 사용을 지양하는 이유

리엑트에서도 때때로 이렇게 돔에 직접 접근하고 무언가를 수정해야할 때가 있다. 스크롤같은 요소의 속성에 접근하던지 focus 등의 api를 사용할 때 말이다. 하지만, React 디자인의 이상적인 형태에 방해가 될 수 있다.

 

이유1: 가상돔 업데이트 메커니즘과 우회 문제

리엑트는 가상돔을 사용한다. 이 방식은 dom에 사용되는 상태값의 변경을 메모리 상에서 가상돔이라 불리는 객체 내에서만 작업한 후, 최종 변경사항을 실제 dom에 반영한다. 이를 통해 dom 변경에서 발생하는 무거운 작업(리플로우, 리페인팅)을 최소화할 수 있다.

2024.03.04 - [react] - [React] 리엑트 살펴보기1: 가상 돔과 리엑트의 렌더링 과정

 

그래서 상태값을 변경하고 로직을 실행할 때는 가상돔 메커니즘, 즉 라이프 사이클을 고려하여야 한다. 그런데, js의 dom api를 이용하면 이 사이클을 우회해서 접근하고 변경하기 때문에, 예상치 못한 사이드 이펙트를 일으킬 수 있다. 즉, 이걸 사용하면 무조건 성능이 악화된다기 보단 예상치 못한 버그를 일으킬 수 있다는 것이다.

 

이유2: 컴포넌트 기반 구조

리엑트의 개발 철학인 컴포넌트는 모든 ui를 재사용가능한 컴포넌트로 조직하며 각각은 독립적이고 재사용가능해야 한다. 이런 컴포넌트에서 id나 class, dataset 기반의 셀렉터를 사용한다면 당연히 동일한 attribute를 가진 재사용 컴포넌트에도 문제가 생길 수 있다.

 

이유3: 외부 변경 사항 관리의 어려움

react는 상태 변화를 감지 시 자동으로 리렌더링을 하기 때문에 개발자가 상태값과 view의 동기화를 신경쓰지 않게 해준다. 하지만, dom을 직접 업데이트 할 시, 리엑트가 리렌더링을 발생시키지 않기 때문에 개발자가 동기화 문제를 다시 신경쓰게 되고 유지보수가 어려운 코드를 만들게 된다.


 

사용 방법

useRef는 dom api 셀랙터의 대안으로서 아래 장점을 가지고 있다.

  • 리엑트의 가상돔 렌더링 메커니즘에 호환이 되는 돔 접근
  • 컴포넌트의 리렌더링과 독립적으로 유지되는 변수 저장
  • useState와 다르게, ref는 업데이트해도 리렌더링이 일어나지 않음

 

  const inputRef= useRef(null);
  /// ref는 current라는 프로퍼티 소유
  /// ref.current에 초기 값을 null로 초기화
  
  useEffect(()=>{
  console.dir(inputRef)
  },[])
  // ref에 요소를 최초로 할당하는 건, jsx 요소가 마운팅된 이후의 시점이므로 useEffect를 이용해서
  // 콘솔 출력 시점 설정
  
  <input type="text" ref={inputRef} />

 

  • 컴포넌트 mount시, inputRef.current에 DOM엘리먼트 대입
  • 컴포넌트 dismount 시, inputRef.current를 null로 전환

 

그럼 useRef는 어떤 이유로 주로 사용될까

 

사용1. 돔 참조


실제 돔 속성을 참조하면 스크롤 같은 속성을 참조하거나 focus 등의 api를 사용할 수 있다. 특히 form 구성 요소나 비디오, 오디오 관련 api는 자주 사용하니 유용하다. 

 

useRef에 복수의 dom 할당하기(ref callback function)

form의 인풋 요소처럼 복수의 구성 요소를 관리할 때는 하나의 ref에 할당해주는 것이 유용하다. 

  const inputRef = useRef([]);

<form action="">
      <input
        onChange={handleChangeInput}
        name="0"
        type="text"
        ref={(ref) => (inputRef.current[0] = ref)}
      />
      <input
        onChange={handleChangeInput}
        name="1"
        type="text"
        ref={(ref) => (inputRef.current[1] = ref)}
      />
      <input
        onChange={handleChangeInput}
        name="2"
        type="text"
        ref={(ref) => (inputRef.current[2] = ref)}
      />

 

ref로 하위 컴포넌트에 접근하기(미완성)

 

react가 ref를 부여할 때

리엑트가 최초로 화면을 보여주는 과정, 그리고 업데이트하는 과정은 두 가지 프로세스로 이뤄진다.

  • 렌더링 단계 | 컴포넌트를 호출하여 가상돔을 구성하고 이전의 가상돔과 비교하여 최종적인 변경사항을 산출한다.
  • 커밋 단계 | 위에서 산출된 변경사항을 실제 돔에 반영한다.

ref에 dom이 부여되는 순간은 렌더링 단계 이후, 다르게 말하자면 컴포넌트 함수의 호출이 끝난 시점이다. 그래서 렌더링 단계에서 ref를 호출하면 null일 가능성이 크다. 갱신에 의한 리렌더링 때는 dom에 갱신된 state가 반영되기 이전이다. 따라서, state에 의존적인 dom을 ref가 참조하여야 한다면 렌더링이 끝난 이후의 시점이어야 dom의 최신 변경 사항을 참조할 수 있도록 보장할 수 있다.

그 시점은 주로 이벤트 핸들러나 useEffect이다.

불가피하게 함수 호출 단계에서 ref로 dom 참조한다면 그 값은 state에 의존적이지 않는 것이어야 안전하다.

 

 

ref는 접근한 돔에 비파괴적 동작만을 수행해야 한다.

react 공식문서에서는 ref로 접근한 돔에 오로지 비파괴적 동작만을 수행해야 한다고 명시한다. 그렇지 않으면 심각한 오류를 초래할 수 있다. 그렇다면 비파괴적 동작의 기준이란 무엇일까?

 

이는 "심각한 오류"란 무엇인가를 생각해봐야 한다. 이 오류가 생기는 근본적인 이유는 이렇다.

  • react는 state와 리엑트 엘리먼트(jsx), 가상돔 비교 알고리즘을 이용하여 렌더링 작업을 한 후  실제 돔에 반영한다.
  • useRef는 실제 돔을 참조하여 바로 무언가를 수정한다.
  • 1의 작업에서 산출한 변경사항을 실제 돔에 반영 할 때, useRef가 그 돔에 이미 "특정 작업"을 한 시점이라면 오류가 발생한다. react는 1의 작업이 메인이고 2의 작업으로 변경된 돔까지 처리하는 방법은 모르기 때문이다.

대표적인 경우는 state로 마운트와 언마운트를 관리하던 돔이 ref로 이미 삭제된 시점이다.

아래 코드를 보자.

 

 

출처: https://ko.react.dev/learn/manipulating-the-dom-with-refs#best-practices-for-dom-manipulation-with-refs

 

 

hello world 텍스트가 있다. 이를 버튼 하나는 state로 렌더링 여부를 결정한다. 그리고 다른 버튼 하나는 ref로 텍스트의 실제 돔을 삭제한다. ref로 돔을 삭제한 후, state로 다시 삭제 명령을 내리면 에러가 발생한다.

이는 돔 노드의 렌더링 여부를 결정할 때 react-dom의 removechild 함수가 타겟 노드를 찾지 못해 발생한다.

즉, ref로 노드를 직접 추가하거나 삭제하면 커밋 단계에서 작업해야할 노드를 찾지 못하거나 잘못 찾는 오류가 발생한다.

 

그렇다면 프로퍼티를 직접 수정하는 건 어떨까?

이는 리엑트의 ref 관련 항목에서 직접 명시하지 않고, 내게는 좀 애매모호해서 추측만 가능하지만

노드 자체를 파괴하지 않는 api인 focus()나 style같은 input의 속성을 직접 수정하는 것이 위의 사례처럼 심각한 오류를 가져오진 않는다.

하지만, state가 업데이트되고 리렌더링되며 재실행된 로직에서 ref가 돔을 참조하고 state의 변경사항이 dom에 반영되는 건 순서를 직관적으로 파악하기 어렵다. 그래서 이런 식의 코드가 많아지면 유지보수가 어려워질 것이다.

 

정리하자면

  • ref로 노드를 직접 수정하든 프로퍼티를 만지든 바로 오류가 터지진 않는다.
  • 하지만, 그 돔 노드가 state 및 리엑트 엘리먼트와 연결되어 있다면 오류가 날 수 있다. 
  • 특히 ref로 돔 노드를 직접 추가하거나 제거하면, react의 가상돔은 ref가 건드린 실제 돔의 변경사항을 모르므로 오류가 날 가능성이 크다.
  • 프로퍼티나 메소드는 돔 노드를 변경하는 것이 아니라면 괜찮지만,  "state 변경=> 리렌더링=> 변경된 state가 돔에 반영"되고 ref는 마지막 단계에서야 dom의 갱신사항을 참조가능하다. 이 구조가 그리 직관적이지 못하므로, 잘 다루지 못하면 디버깅하기 어려운 버그를 생산한다는 점 주의하자.

 

주의사항1. react가 ref를 부여할 때

리엑트가 최초로 화면을 보여주는 과정, 그리고 업데이트하는 과정은 두 가지 프로세스로 이뤄진다.

  • 렌더링 단계 | 컴포넌트를 호출하여 가상돔을 구성하고 이전의 가상돔과 비교하여 최종적인 변경사항을 산출한다.
  • 커밋 단계 | 위에서 산출된 변경사항을 실제 돔에 반영한다.

ref에 dom이 부여되는 순간은 렌더링 단계 이후, 다르게 말하자면 컴포넌트 함수의 호출이 끝난 시점이다. 그래서 렌더링 단계에서 ref를 호출하면 null일 가능성이 크다. 갱신에 의한 리렌더링 때는 dom에 갱신된 state가 반영되기 이전이다. 따라서, state에 의존적인 dom을 ref가 참조하여야 한다면 렌더링이 끝난 이후의 시점이어야 dom의 최신 변경 사항을 참조할 수 있도록 보장할 수 있다.

그 시점은 주로 이벤트 핸들러나 useEffect이다.

 

사용2. 라이프 사이클 내에서 유지되는 변수 저장


useRef에는 변수를 할당하여 저장할 수 있다. 이 방식은 다른 변수 저장 방식과 다음 차이가 있다.

  • 함수형 컴포넌트 내에서 일반 변수는 리렌더링 때 마다 초기화된다. ref 변수는 리렌더링 시에도 유지된다.
  • 함수형 컴포넌트 밖에 있는 일반 변수는 컴포넌트의 라이프 사이클에 상관없이 유지된다. ref 변수는 컴포넌트의 마운트 해제 시 null로 초기화된다.
  • state에 저장된 변수는 변경 시 리렌더링을 일으킨다. ref 변수는 변경 시 리렌더링을 일으키지 않는다.

즉, ref는 컴포넌트의 리렌더링과는 독립적으로 유지되지만, 마운트 해제시 초기화되고, 변경 시 리렌더링이 필요없는 변수를 저장하는 데 좋다.

 

 

사용3.  비제어 컴포넌트, 제어 컴포넌트, 신뢰가능한 단일 출처


ref는 비제어 컴포넌트를 이벤트 핸들러에서 참조할 때 도움이 된다.

이 때 제어컴포넌트, 비제어 컴포넌트란 무엇일까.

 

모든 dom은 내부적으로 상태값을 가지고 있다. 예컨대 p 태그의 innerText라던가. input의 value, selector의 selectedIndex 등이 있다. 이런 값들을 이벤트 핸들러 등에서 필요할때마다 참조하거나 변경하는 것을 비제어 컴포넌트라고 한다. 반면, 내부 상태값을 js로 주입해서 사용하는 방식은 내부 값이 dom 내부의 동작 방식 대신, 외부의 코드에 의해 제어를 받는데 이를 제어 컴포넌트라고 한다.

 

신뢰가능한 단일 출처

js를 이용한 웹 개발 패턴에서는 유저와의 주요 상호작용 시점마다 이벤트핸들러에서 돔 의 주요 정보를 받고 이를 특정 변수에 저장한다. 그리고 이 변수의 데이터에 의존하여 다른 로직을 작동시킨다. 이처럼 변수에 할당되고 로직을 작동하기 위해서는 근거가 되는 주요 소스(출처)가 필요하다.

'신뢰가능한 단일 출처'란, dom 정보 등의 주요 소스를 할당하는 변수는 최신 정보를 항상 최신 값으로 업데이트되어야 하며 유지보수를 위해 하나로 통합한다는 방침이다

 

비제어 컴포넌트  예시: react의 useState

useState 예시

import { useState } from "react";

const UseRefEx = () => {
  const [value, setValue] = useState("");

  const handleClickReset = () => {
    if (value) {
      setValue("");
    }
  };

  return (
    <div>
      <p>value: {value}</p>
      <input
        type="text"
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />

      <button type="button" onClick={handleClickReset}>
        Reset
      </button>
    </div>
  );
};

export default UseRefEx;
  • 위 예시는 input.value 돔 정보를 value state에서 관리한다.(useState가 신뢰가능한 단일 출처로 설정)
  • value state는 onChange마다 업데이터되며, 이와 연결된 로직으로 현재 state를 보여주는 로직은 늘 최신 값을 가지고 리렌더링된다.
  • reset 버튼에서는 state를 이용해서 이와 연결된 dom을 제어한다.
  • 이처럼 신뢰가능한 단일 출처가 컴포넌트를 제어하는 패턴을 제어 컴포넌트라고 부른다.

 

비제어 컴포넌트: react의 useRef 예시

  const inputRef: any = useRef(null);

  const handleClickReset = () => {
    if (inputRef.current?.value) {
      inputRef.current.value = "";
    }
  };

  return (
    <div>
      <p>value: {inputRef.current?.value}</p>
      <input type="text" ref={inputRef} />

      <button type="button" onClick={handleClickReset}>
        Reset
      </button>
    </div>
  );
  • state 사용 예제와 동일한 기능을 목적으로 만든 예제이다. 단, state 대신, ref를 사용했다.
  • 이 코드의 문제는 p 태그가 신뢰가능한 출처로 ref를 사용한다는 점이다. ref 값의 변경은 리렌더링을 일으키지 않기 때문에 상태값과 view간에 동기화 불일치 문제를 일으킨다.
  • 이처럼 ref가 신뢰가능한 단일 출처가 되는 컴포넌트는 스스로 리렌더링을 제어하지 못하기 때문에 비제어 컴포넌트라고 부른다.

정리

  제어 컴포넌트 비제어 컴포넌트
신뢰가능한 단일 출처 useState(React) useRef(실제 돔)
리렌더링 시점의 제어 setState 함수로 제어가능 직접 제어 불가
다른 로직과의 연동 상태값 변경 시 연동된 로직이 실행 + 리렌더링 연동된 로직이 실행될 순 있어도 리렌더링x(view와 상태값의 불일치 발생 가능)
용도 렌더링 유발, 연동된 엘리먼츠의 수정 실제 돔 api 사용(비파괴적 api)

 

 

  • 제어 컴포넌트 | react에서 주체적으로 리렌더링 시점을 제어하여 연동된 로직과 ui를 갱신할 수 있는 컴포넌트
  • 비제어 컴포넌트 | react의 가상 돔이 아닌 실제 돔을 참조하며, 리렌더링 시점을 제어하진 못하지만(혹은 지양하지만) 돔 API를 사용할 수 있게 해주는 컴포넌트
  • 최신 데이터 반영이 필요한 컴포넌트이면 제어 컴포넌트, 리렌더링 빈도를 줄이고 성능을 조금이라도 올리려면 비제어 컴포넌트 

 

forwardRef


리엑트에서는 이미 특수한 목적으로 사용되어 props가 될 수 없는 이름이 존재한다. key, ref, className 등이 대표적이다. 만약 부모 컴포넌트의 useRef를 자식 컴포넌트에게 전달하려면 ref라는 이름을 사용하지 못하는데, 이는 복잡한 컴포넌트에서는 직관성을 해칠 수 있다.

이럴 때 사용하는 것이 forwardRef이다. 

 

//@App.tsx
function App() {
  const inputRef = useRef<any>(null);

  useEffect(() => {
    console.log(inputRef);
    if (inputRef.current) {
      inputRef.current.value = "2313";
    }
  }, [inputRef]);
  return (
    <div>
      <Input ref={inputRef} />
    </div>
  );
}



//@Input.tsx
forwardRef(function Input(props: any, ref: any) {
  console.log(ref);
  return <input ref={ref} type="text" />;
});
  • forwardRef는 이 컴포넌트가 부모 컴포넌트에게 ref를 상속받는다는 의미로도 보일 수 있다.
  • 하지만, ref는 부모 컴포넌트와의 결합성을 증가시키므로, 컴포넌트의 재사용 측면에서는 지양할 필요가 있다.
모든 컴포넌트가 ref를 사용할 수 있도록 컴포넌트 선언 시 forwardRef로 감싸면 되지 않을까?
그러면 ref를 전달해야 할 때 컴포넌트를 forwardRef로 감싸서 재작성할 필요가 없어지니 말이다.
하지만 이 방식에는 단점이 있다.

1. 가독성 측면
forwardRef를 사용하면 컴포넌트 코드 자체가 몇 줄이 더 늘어난다.
또한 타입 추측 시, props가 래핑되며 복잡해질 수 있고, 협업자 입장에서는 ref를 내리지 않음엗 굳이 사용한 이유를 모를 수 있다.(즉, 이해해야할 맥락이 늘어남)

2. 필요성 측면
의외로 컴포넌트 간에 상태 공유로 해결 가능한 문제가 많다.
ref는 리엑트의 단방향 상태 전달 패턴을 역으로 갈 수 있어서 정말 dom에 접근이 필요하거나 리렌더링 최적화를 할 때가 아니라면 지양하는 것이 dx 측면에서 나을 것이다.

3. 성능 측면
hoc를 아무 이유 없이 미리 적용해놓으면, 아주 미미하지만 성능에서 좋지 않고 디버깅 시 스택 트레이스가 길어진다.

따라서 그냥 필요할 때에만 사용하고,
팀 내 컨벤션으로 forwardRef로 감쌀 때 컴포넌트 이름에 수식언을 붙인다던지의 방식을 고려하자.

주의사항1. react가 ref를 부여할 때

리엑트가 최초로 화면을 보여주는 과정, 그리고 업데이트하는 과정은 두 가지 프로세스로 이뤄진다.

  • 렌더링 단계 | 컴포넌트를 호출하여 가상돔을 구성하고 이전의 가상돔과 비교하여 최종적인 변경사항을 산출한다.
  • 커밋 단계 | 위에서 산출된 변경사항을 실제 돔에 반영한다.

ref에 dom이 부여되는 순간은 렌더링 단계 이후, 다르게 말하자면 컴포넌트 함수의 호출이 끝난 시점이다. 그래서 렌더링 단계에서 ref를 호출하면 null일 가능성이 크다. 갱신에 의한 리렌더링 때는 dom에 갱신된 state가 반영되기 이전이다. 따라서, state에 의존적인 dom을 ref가 참조하여야 한다면 렌더링이 끝난 이후의 시점이어야 dom의 최신 변경 사항을 참조할 수 있도록 보장할 수 있다.

그 시점은 주로 이벤트 핸들러나 useEffect이다.

 

주의사항2. useEffect의 clean up function 내에서 useRef 사용 시


useRef에 컴포넌트를 할당해서 돔을 참고하고 이벤트를 등록할 때 사용할 수 있다.

이때 useEffect의 클린 업 함수에서 리소스를 해제하려 useRef를 참고하면 다음 경고가 뜬다.

WARNING in [eslint] 
src/component/Test.jsx
  Line 12:15:  The ref value 'testRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'testRef.current' to a variable inside the effect, and use that variable in the cleanup function  react-hooks/exhaustive-deps

webpack compiled with 1 warning

 

원인

리엑트17버전부터 clean up 함수는 비동기적으로 작동한다. 즉, 실행 시점은 컴포넌트의 언마운트 시작 시점이지만 
내부 코드는 컴포넌트가 이미 언마운트되었을 때 실행될 수도 있다.

그런데, ref.current는 가변성이라 이 시점에 이미 null로 세팅된다. 따라서 원하는 참조를 얻지 못하게 된다.

 

방법

useEffect 내부에서 current 속성을 미리 다른 변수에 할당시켜주고 그 변수를 cleanup 함수 내에서 사용하면 된다.

 

참고자료


https://fedev-kim.medium.com/react-getelementby-ebe19ca0ace5

 

[React] getElementBy…

현재 스타트업에 재직중이기 때문에 개발 일정이 촉박한 개발을 하는 경우가 매우 많다(사실 항상 그렇다). 다른 동료 개발자들도 그 상황은 마찬가지인데, 일정이 촉박할 경우 로직이나 사용하

fedev-kim.medium.com

 

https://careerly.co.kr/qnas/2184

 

React에서 document.getElementById 써도 괜찮은가요

리액트를 공부하던중에 문제가 생겼는데요, 요소의 절대위치를 가져와여하는데 useRef를 쓰자니 컴포넌트끼리의 거리가 좀 멉니다. 그래서 recoil을 사용해서 r...

careerly.co.kr

 

https://velog.io/@cnsrn1874/%EB%B2%88%EC%97%AD-callback-refs-%EC%82%AC%EC%9A%A9%EC%9C%BC%EB%A1%9C-useEffect-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0

 

[번역] callback refs 사용으로 useEffect 방지하기

리액트 요소의 ref 속성에 콜백함수를 넘김으로써 필요없는 useEffect를 하나 줄일 수 있다.

velog.io

 

https://www.youtube.com/watch?v=PBgQKK6nelo&t=316s

 

https://velog.io/@cjhlsb/kencland

 

[React] useEffect 내부의 cleanup 함수에서 useRef의 current 속성 이용하기

useEffect 내부에서의 cleanup 함수

velog.io