리액트 컴포넌트에서 값을 어떻게 저장해볼까? - useRef()

프론트 깎는 개발자·2022년 12월 28일
6

react hooks

목록 보기
3/5

본 포스팅은 'React Hook' 에 대한 시리즈 게시글 중 3번째 게시글로, useRef에 대해 중점적으로 다루고 있습니다!

Intro

우리가 React 로 컴포넌트들을 만들다 보면 컴포넌트 내부에서 어떤 값들을 저장하고, 유지하고, 그 값들로 계산해야 하는 일들이 자주 발생한다. 처음에 React 를 배울 때는 고민이 되게 많았던 지점이었다. 특히나 React 입문을 하게 되면 가장 먼저 배우는 것이 useState() 인 만큼,

어 React 에서는 그냥 필요한 값들은 전부 state 에 저장하면 되지~! 😃

라고 생각한 적도 있었다.

그러나 state 를 매번 사용하면 이 값이 변할 때마다 필요하지 않아도 매번 rerender 가 일어나는 문제가 생긴다. 성능 저하를 불러오는 것이다.

그러면 다음과 같이 생각할 수도 있다.

엥 그러면 그냥 컴포넌트를 re-render 시키지 않는 그냥 JS 변수 쓰면 되는 것 아닌감? 🙋🏻

JS 변수를 사용하면 state 가 아니기 때문에 당연히 컴포넌트 re-render 는 일어나지 않는다. 그런데 더 큰 문제가 발생한다.

컴포넌트 내 JS 변수의 문제점

다음의 코드를 보자.

import { useRef } from "react";

function UseRefStudy() {
  let myVar = 0;
  const [foo, setFoo] = useState(0);

  function incrementMyVar() {
    myVar += 1;
    console.log("myVar: ", myVar);
  }

  function decrementMyVar() {
    myVar -= 1;
    console.log("myVar: ", myVar);
  }

  return (
    <div>
      <h1>UseRef 스터디</h1>
      <div className="container">
        <div className="box">
          <h2>일반 변수 사용</h2>
          <p>myVar 의 값: {myVar}</p>
          <p>Foo 의 값: {foo}</p>
          <button onClick={incrementMyVar}>myVar 증가시키기</button>
          <button onClick={decrementMyVar}>myVar 감소시키기</button>
          <button
            onClick={() => {
              setFoo(1);
            }}
          >
            Foo 를 1로 변경
          </button>
        </div>
      </div>
    </div>
  );
}

export default UseRefStudy;

위 코드는 각 버튼 클릭을 통해 myVar 이라는 변수를 변경시킨다. 당연히 myVar 는 state 가 아니므로 re-render 을 야기시키지 않아, 화면에 업데이트는 되지 않는다. (그래도 허전해서 myVar 의 값 이렇게 넣어두었다 😆)

따라서 증가를 시켜도 console 상에는 제대로 myVar 의 값이 제대로 표시되는 반면, 화면에는 계속 myVar 의 값은 0 인 상태로 남아있다. 그렇다면 이 컴포넌트를 강제로 re-render 시키면 myVar이 설정된 대로 나올까?

그것도 아니다. 왜냐하면 결국

re-render 시킨다는 것은 결국 컴포넌트 (즉, 함수) 를 재호출하는 것!
즉, 평범한 JS local variable (지역변수) 인 myVar 의 값이 함수 재호출 시 보존될 리 없다.

즉, 아래와 위 실행 이미지와 같이 Foo 라는 state 를 1로 변경하게 만들어 컴포넌트 전체를 임의로 re-render 되게 한 후, 다시 console 에 찍힌 myVar 을 보면 원래 기존의 6 에서부터 시작해서 7이 표시되는 대신, 이미 다시 컴포넌트가 호출되면서 0 으로 초기화돼버린 상태에서 시작하기에, 다시 1 부터 시작하는 것을 알 수 있다.

즉, re-render 이 필요 없는데에 JS 변수를 사용하는 것에 가장 큰 문제는

컴포넌트 re-render 사이에 값이 보존되지 않는다

는 것이다!

useRef() 의 등장

useRef() 은 state 와 state 가 아닌 일반 JS 변수의 특징을 조금씩 가져온 형태이다. 근본적으로 useRef() 는 다음 두 가지 상황에 대응하기 위해 만들어졌다고 볼 수 있다.

  • 값이 변한다 하더라도 컴포넌트가 re-render 될 필요가 없는 경우
  • re-render 될 필요는 없어서 state 로 만들지 않았지만, 다른 state 변화로 인해 컴포넌트 전체가 re-render 되더라도, 일반 JS 변수처럼 초기화는 되지 않고, re-render 이전의 값을 계속 갖고 있어야 하는 경우

즉,state 는 re-render 되더라도 값을 그 값이 보존되는 데에 비해, 값이 변하면 컴포넌트를 무조건! re-render 시켜버리고, 그렇다고 일반 JS 변수를 쓰자니 re-render 사이에 값이 보존이 안 되어 초기화되어버리는 문제가 생겼다.

다음의 코드를 보자

import { useRef, useState } from "react";

function UseRefStudy() {
  const myRef = useRef(0);
  const [foo, setFoo] = useState(0);
  // myRef 관련

  function incrementMyRef() {
    myRef.current += 1;
    console.log("myRef.current: ", myRef.current);
  }

  function decrementMyRef() {
    myRef.current -= 1;
    console.log("myRef.current: ", myRef.current);
  }

  return (
    <div>
      <h1>UseRef 스터디</h1>
      <div className="container">
        <div className="box">
          <h2>ref 사용</h2>
          <p>myRef.current 의 값: {myRef.current}</p>
          <p>Foo 의 값: {foo}</p>
          <button onClick={incrementMyRef}>myRef 증가시키기</button>
          <button onClick={decrementMyRef}>myRef 감소시키기</button>
          <button
            onClick={() => {
              setFoo(1);
            }}
          >
            Foo 를 1로 변경
          </button>
        </div>
      </div>
    </div>
  );
}

export default UseRefStudy;

위 코드는 ref 를 이용한 케이스이다
useState() 와 마찬가지로 초기값을 인자로 받는다!
우선 아래 실행 결과를 먼저 보자.

처음에 버튼을 눌러 myRef 를 증가시킨다. (정확히는 myRef.current 를 증가시킨다. 이 myRef.current 가 왜 나오는지는 곧 설명할 것이다! 😃)

당연히 ref 도 state 가 아니므로, 컴포넌트를 re-rendering 시키지 않아 화면은 변하지 않는다. 대신 여기서도 console 상에 이 myRef.current 값이 변했다고 표시가 된다. 여기까지는 이전의 JS 변수와 동일하다.

그런데 이 다음이 다르다. 이렇게 하고 나서 이전과 같이 Foo 라는 state 를 1로 변경하게 만들어 컴포넌트 전체를 임의로 re-render 되게 해 보자.

우선 바로 눈에 보이는 변화는 일반 변수로 사용했을 때와는 달리 화면 자체에 myRef.current 값이 실제 console 상의 이때까지 우리가 6번 버튼을 클릭하여 증가시킨 바로 그 값으로 업데이트가 되었다. 즉,

re-render 시킨다는 것은 결국 컴포넌트 (즉, 함수) 를 재호출하는 것!

임에도 불구하고,

ref 에 저장했던 값이 서로 다른 함수 호출 사이에 보존 이 되었던 것이다!

즉,

state 처럼 컴포넌트 re-render 사이에 값들이 보존은 되지만, state 와 달리 ref 자체의 값 변화에 의해 re-render 은 발생시키지 않는 것이 바로 ref 이다.

아하! 근데 current 는 뭐지?

그렇다. 우리는 이 부분을 짚고 넘어가야 할 필요가 있다.
ref 에 current 가 왜 붙는지는 문법을 먼저 보면 알 수가 있다. 우리가 ref 의 값에 접근할 때
myRef.current 와 같이 그냥 myRef 가 아니라 항상 .current 를 붙여줬다.

이로 미루어 보아, myRef 자체는 JS object 이고, 그 안에 current 라는 속성이 있으며, 우리는 current 속성에 해당하는 값 을 사용하고 있었던 것이다.

즉,

ref 는 JS object (JS 객체) 이다.

그 객체의 구조는 다음과 같다.

ref = { current: 우리가 저장하고 싶은 값 }

매우 단순하다 😅.
아니 이렇게 할 거면 그냥 저장하지 왜 이렇게 했을까?
그 이유는 이렇게 생각해보면 쉽다.

ref 의 핵심은 컴포넌트 re-render 간 어떤 데이터를 잘 보존하는 것이다.
예를 들어, 이 current 속성을 포함하는 객체를 '보물상자' 라고 생각하고, 그 안에 '상자' 안에 들어가는 '보물'을 우리가 current 속성 안에 저장하는 데이터라고 생각해 보자.

그러면 결국 이 '보물' 을 잘 지키려면, '보물상자' 만 잘 지키면 된다. 그리고 내가 나중에 이 '보물상자' 에 원래 있던 보물이 아니라 다른 보물을 넣는다 하더라도, 그 '보물상자' 만 잘 지켜지는 한, 나의 보물은 안전함을 보장할 수 있다.

JS 에서도 역시나 mutable 한 자료형인 object 에 저장하고 싶었던 것이다. 그래서 이 object 만 잘 보관하면 그 안의 데이터는 덩달아 잘 보존이 되는 것이다. 그런데 객체의 mutable 한 속성은 이용하고 싶은데, 그렇다고 객체 안에 데이터를 넣을 때 key 값 (속성 값) 없이 value 만 넣을 수는 없는 노릇이다. 하나의 데이터를 객체에 넣더라도, key 값이 반드시 필요하다. 즉 아래와 같이 객체를 만들 수는 없다는 것이다.

const ref = { myData } // ?????

하나를 넣더라도

const ref = { foobar: myData }

이렇게 넣어야 문법에 맞는 객체가 된다. 즉, React team 에서 이 foobar 에 해당하는 자리에 넣을 그냥 '아무 key (속성)값'으로 쓰기로 한 것이 바로 current 이고, 결과적으로 아래와 같은 형태가 된 것이다.

const ref = { current: myData }

다 알겠는데 이거를 어디에 쓰지? 🧐

아주... 좋은 질문이다! 😅

원론적인 답변을 하자면,

컴포넌트 re-render 사이에 보존되어야 할 값

을 저장하는 데에 쓰면 된다.

... 라고 하면 너무 당연하니까 몇 가지 사용 예시를 만들어 보자!

1. Render count 세기

이 예시 또한 실용적으로 사용을 할 일은 잘 없을 것 같지만, 이런 로직을 이용할 수가 있다는 demo 목적으로 추가해 보았다!

다음의 코드를 보자.

import { useRef, useState, useEffect } from "react";

function UseRefStudy() {
  const myRef = useRef(0);
  const [foo, setFoo] = useState(0);
  const renderCount = useRef(0);

  useEffect(() => {
    renderCount.current += 1;
  }, [foo]);

  // myRef 관련

  function incrementMyRef() {
    myRef.current += 1;
    console.log("myRef.current: ", myRef.current);
  }

  function decrementMyRef() {
    myRef.current -= 1;
    console.log("myRef.current: ", myRef.current);
  }

  return (
    <div>
      <h1>UseRef 스터디</h1>
      <div className="container">
        <div className="box">
          <h2>ref 사용</h2>
          <p>myRef.current 의 값: {myRef.current}</p>
          <p>Foo 의 값: {foo}</p>
          <button onClick={incrementMyRef}>myRef 증가시키기</button>
          <button onClick={decrementMyRef}>myRef 감소시키기</button>
          <button
            onClick={() => {
              setFoo(foo === 0 ? 1 : 0);
            }}
          >
            Foo 를 1로 변경
          </button>
          <button
            onClick={() => {
              console.log("renderCount: ", renderCount.current);
            }}
          >
            render 횟수 보기
          </button>
        </div>
      </div>
    </div>
  );
}

export default UseRefStudy;

위 코드에서는 renderCount 라는 ref 가 하나 더 추가되었다.
그리고, foo 를 toggle 형식으로 1과 0을 왔다갔다 하게 함으로써 계속 re-render 시킬 수 있도록 만들었다.
즉, 지금까지 render 횟수를 저장하는 ref 이다. ref 를 사용하기 좋은 케이스이다.

아래 사용 영상을 보자

  1. 처음에는 initial render 이 필요하므로 renderCount 가 1 이다. 계속 눌러도 state 가 업데이트 된 적이 없으므로, 계속 1이다.
  2. 그리고 myRef 를 (정확히는 myRef.current 를) 증가시키면 myRef.current 의 값은 증가된 값으로 console 에 찍히고, 그 다음 컴포넌트를 임의로 re-render 시키기 위해 foo 라는 state 를 1로 변경한다.
  3. 그리고 나서 다시 renderCount 를 console 에서 보면 1 증가한 2가 된 것을 알 수 있다. 즉, state 인 foo 값이 업데이트 될 때마다 useEffect 안에서 myRef.current 를 증가시키고 있기 때문이다! (useEffect 는 다음 포스팅에서 자세히 알아보자!)

이렇게 이용을 해 볼 수 있다. 하지만, 실제로 이 ref 가 더 자주 쓰이는 데는 아무래도 DOM Element 를 직접 control 하고 싶을 때일 것이다. 다음 케이스는 아래와 같다.

2. Input 필드 focus 시키기

훨씬 더 실용적인 사용 예시이다.
다음 코드를 보자.

import { useRef } from "react";

function UseRefStudyFocus() {
  const inputRef = useRef();

  function focusInput() {
    inputRef.current.focus();
  }

  return (
    <div>
      <h1>UseRef:: focus Input</h1>
      <div className="container">
        <div className="box">
          <h2>input focus 시키기</h2>
          <input ref={inputRef} />
        </div>

        <div className="box">
          <button onClick={focusInput}>입력하기!</button>
        </div>
      </div>
    </div>
  );
}

export default UseRefStudyFocus;

(참! 그리고 이때까지 코드에서 눈치챘을 수도 있지만, useRef() 를 사용한 ref 들은 컨벤션상 ~Ref 를 붙여주는 것이 일반적이다. 마치 useState 에서 두 번째 setter 함수를 받을 때 set~ 으로 시작하곤 하는 그런 것!)

위의 코드는ref 에 어떤 string 이나 number 같은 단순한 데이터가 아니라, DOM element 그 자체 (지금의 경우에는 <input /> 이 들어간 케이스이다.

중간에 <input ref={inputRef} /> 에서 <input /> 이라는 DOM Element 에 어떠한 '핸들' 역할을 해서 그 DOM element 를 '잡을' 수 있게 해 주는 ref 속성에 inputRef 를 추가했다.

이렇게 되면, inputRef.current 로 우리가 일반적으로 <input /> 에서 쓰는 .focus() 라든지, .blur() 등의 method 를 사용할 수 있다.

이렇게 해서 아래 입력하기 버튼을 누르면 focusInput() 함수가 실행되며, 이 함수 내부에서 inputRef.current.focus() 를 부르며, 내부적으로 이 input 컴포넌트를 실행시켜 준다. 아래 실행 결과를 보자.

아니 이걸 왜 하는거임? 그냥 text field 직접 눌러서 하는게 더 직관적이잔슴

이라고 하는 분들이 계실 것 같다. 😅 (본인도 그렇다)

그래서 이것이 유용하게 쓰일 수 있으려면, 다음과 같이 useEffect() 를 활용하는 것도 한 방법이다. 아래의 변경된 코드를 한번 보자.


import { useRef, useEffect } from "react";

function UseRefStudyFocus() {
  const inputRef = useRef();

  function focusInput() {
    inputRef.current.focus();
  }

  // 추가된 코드
  // 페이지 로딩 시 자동으로 input focus
  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return (
    <div>
      <h1>UseRef:: focus Input</h1>
      <div className="container">
        <div className="box">
          <h2>input focus 시키기</h2>
          <input ref={inputRef} />
        </div>

        <div className="box">
          <button onClick={focusInput}>입력하기!</button>
        </div>
      </div>
    </div>
  );
}

export default UseRefStudyFocus;

이번에는 useEffect() 가 추가되면서, 이 안에서 inputRef.current.focus() 를 불러주었다. 아직 useEffect() 에 대한 자세한 포스팅은 없었지만, 위와 같이 useEffect() 다음 빈 배열을 하나 넣어주면, component 가 최초 render 된 직후 한 번만 실행된다!

즉, 컴포넌트가 render 된 직후, input field 에다가 focus 를 잡아주는 것이다. UX 적인 측면에서 봤을 때 이러한 요소가 필요할 수도 있는 것이다! 그러면, 사용자가 별도의 클릭 없이도, 바로 input field 에서 키보드로 입력을 진행할 수 있다.

이것을 잘 활용하면, 이를테면 신용카드 번호 입력란과 같이, 특정 길이나 형식의 문자열이 입력된 경우, 다음 입력 칸으로 자동으로 넘어가게 하는 등의 기능도 구현할 수 있다!

그런데 왜 ref 에 DOM element 를 지정하는 것일까?

우선 state 에 저장하면, 이 요소가 변할 때마다 re-rendering 이 일어나므로 변화무쌍(?) 한 DOM element 를 두기에는 성능 및 기능면에서 큰 이슈가 있다.

그렇다고 일반 변수에 저장하면, 컴포넌트가 re-render 될 때 마다, 새롭게 이 DOM element를 불러오게 되서 DOM element 자체가 component re-render 사이마다 보존이 되지 않는다.

그러면 ref 의 가능성은 무궁무진한가?

ref 는 어떠한 DOM element 도 직접적으로 다룰 수 있게 해 주기 때문에, 초기에 ref 를 배우게 되면, 이를 남용하는 경우가 발생한다. 그리고 이는 React 의 dataflow 컨셉에 맞지 않다.
(부모 컴포넌트에서 자식 컴포넌트로 데이터 플로우가 일어나는 단방향 데이터 바인딩!) 뿐만 아니라, 단순히 ref 만으로는 state 와 달리 이 ref 의 변화를 react 가 내부적으로는 감지할 수 없는 문제도 있다.

React 공식 문서에서도 ref 는 정말 부득이한 (imperative) 경우에만 사용하라고 하고 있으며, ref 를 사용하지 않고 state 나 다른 callback 함수들을 전달함으로써 설계가 가능하다면, 그렇게 하는 것을 권장하고 있다.

inputRef.focus() 와 같이 기존에 있는 element 에서는 우리가 무언가를 변경할 수 없으므로, '부득이' ref 를 사용하긴 하나, 이 경우에도 최대한 사용을 자제해야 하는 것은 맞다.

Conclusion

ref 와 관련한 이야기는 앞서 이야기 했듯이, 단방향 데이터 바인딩, controlled VS uncontrolled element, callback ref 등 다룰 내용이 매우 많다.

여기서는 아주 기본적인 내용과 개념만 소개하였다. useRef() 와 관련해서 더욱 심화된 내용을 다루는 포스팅도 후속 포스팅으로 업로드할 예정이다!

profile
Comfort Zone 에서 벗어나자!

0개의 댓글