[React] key 값을 쓰는 이유(1)

yongkini ·2023년 2월 23일
4

React

목록 보기
16/19
post-custom-banner

React에서 배열을 map을 통해 렌더링할 때 고유값으로 key 값을 넣어줘야하는 이유

: 생각해보니 항상 당연하다고만 알고 쓰고 있었던 이 부분에 대해서 내가 명확한 이유를 잘모르고 있다는 생각이 들어서 이에 대해 테스팅과 무엇이 포인트인지를 짚고 넘어가고자 글을 써봤다.

  • React에서 map 메서드를 이용해서 배열을 렌더링할 때, 각 요소에 고유한 key값을 설정하도록 해놓은 이유에 대해서 좀 더 자세히 알아본 결과
    - 각 엘리먼트에 안정적인 고유성을 부여하기 위해서 key값을 써야한다.
    - 이러한 고유성을 통해서 React가 이름이 똑같은 컴포넌트들 각각의 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕는다. 
    그러면 이 key값을 쓰지 않았을 때 console창에서의 경고 메시지 말고는 어떤 사이드 이펙트가 있을까?
    위와 같은 인터페이스를 만들고, 배열에 unshift, push 를 통해 먼저 뒤에 3개, 앞에 3개 이렇게 자료를 추가해봤다. 결과적으로 리렌더링 관점에서 봤을 때
    위와 같이 뒤에 push 해줄 때는 그 push 해주는 요소 하나만 리렌더링을 하는데, 앞에 unshift를 해주면 모든 배열 요소를 전부다 리렌더링하는걸 볼 수 있었다. 즉, 본래 리액트는 달라진 부분만 리렌더링을 하는데, key값을 넣어주지 않으니까 위와 같이 달라진 부분을 구분하지 않고 전체를 리렌더링하는 방식으로 진행한걸 볼 수 있었다(뒤에 넣어줄 때는 맨 끝에 넣어준다는 로직이라서 상관이 없는 것 같다). 그러면 여기에 map(value, index) 에서 index 값을 넣어줘보면 달라질까?
{array.map((el, idx) => {
  return <div key={idx}>{el} 님 환영합니다.</div>
})}

이런식으로 idx 값을 key로 줬다고 했을 때 결과는,, 똑같았다. 결론적으로 idx 값을 넣어줘도 리액트에서는 제대로 인식을 못하는 것 같다. 실제로 공식 블로그에서도 map의 index값은 key에 쓰는 걸 지양하도록 하자 라고 써있었다. 그러면 리액트에서 지향하는데로 key값에 고유한 값을 넣어줘보자. map의 index값은 리렌더링되면서 리셋을 하기 때문에 고유값을 갖지 못하는 것 같다는(예상) 생각이든다. 일단 다른 고유값을 따로 넣어줘보도록 한다. 이번에는 배열 내의 요소 타입 자체를

{id: 1, name: '0715yk'}

다음과 같은 형태로 줬다. 이유는 map의 index 값도 고유한 값이라는 것은 똑같기에 고유성만이 포인트가 아니라고 생각했고, 각각의 요소마다 고유한 값을 줘보고자 저렇게 했다. 예를 들어, name이 0715yk 인 자료의 id는 항상 1인 것이다. 즉, 특정 요소마다 고유값을 줬다고 생각하는 것이다. 아까 map에서는 특정 name에 무조건 key 값이 1이 아닌 map이 리렌더링을 하면서 index값을 다시 넣었기에 앞에다 자료를 추가하면 자동으로 0715yk가 name인 요소의 key값은(0부터 시작했다는 전제로) 1이된다.

그러면 여기서 더 나아가서 리액트가 key값을 필요로 하는게 위의 예시로 봤을 때 컴포넌트를 그릴 때 순서를 기억하는 용도로 쓰는 것 같다는 생각이 드니까(?) 순서를 변경하는 등의 로직을 추가해서 테스팅해보기로 한다.
위의 결과를 보면 특정 순서의 컴포넌트 이후로 모두 리렌더링하고 있는걸 볼 수 있다. 설계 단계에서는 아까와 같이 map(value, index) 에서 index 값을 key값으로 줬고,
이런식의 UX를 제공해서 N번째에 X값을 추가하도록 했다(splice이용). 이렇게 했을 때 index 상으로 동일한 부분, 즉, 수정한 부분 전까지는 리렌더링을 하지 않고(index가 그대로니까), 수정한 순서부터 전부 리렌더링 하는 방식으로 리액트가 작동함을 알게 됐다. 아까도 중간에 껴넣는게 아닌 unshift를 할 때 맨 앞에 새로 값을 추가하니까 그 값을 기준으로 모든 요소가 리렌더링 됐는데, 이것도 똑같이 새로 추가한 값을 기준으로 뒤의 값을 모두 리렌더링 했다. 그럼 여기에 아까처럼 요소 각각에 고유한 값(id)를 넣는 방식으로 해보자.
그렇게 하니까 수정된 부분 즉, 끼워넣어진 부분만 리렌더링을 하고 나머지는 리렌더링을 하지 않는걸 보게됐다. Okay! 아까 했던 예측이 어느정도 맞아떨어진다는걸 알았다. 그럼 여기까지 왔으니 CRUD 에서 Update, Delete 과정도 테스트해본다.

먼저 delete를 테스트해봤다. 역시 똑같이 map 의 index로 해본결과 특정 순서에 끼워넣었을 때처럼 삭제된 이후의 모든 요소가 리렌더링됐다. 이쯤이면 확실히 어떤 로직으로 체킹을 하는지 알 수 있을 것 같다. 0,1,2,3,4,5 이렇게 index값(map에서 리턴해주는)을 이용해서 렌더링을 한다음에 예를 들어, 3번째 요소를 제거했을 때 리액트는 이를 리렌더링하면서 다시 map의 index를 써서 그릴텐데 0,1 까지는 그대로니까 냅두고, 3,4,5에서 3을 지우고, 4,5를 앞으로 하나씩 당기면서 아예 새로 그리는 식으로 가는 것 같다.
다시 id 값을 제대로 주고나서 실행해본 결과 깔끔하게 해당 컴포넌트만 제거됨을 볼 수 있었다. 그럼 마지막으로 특정 값을 수정해보자.

결론적으로 수정하는건 따로 컴포넌트 자체를 끼워넣거나(추가), 중간에 있는걸 빼거나(삭제)하는 로직이 아니라서 쓸데없는 리렌더링은 일어나지 않았다.

  • 하지만 위의 테스팅도 뭔가 찝찝함이 남아있어서 구글링을 좀 더 해보니까 다른 테스팅을 해본 분의 포스팅을 보게됐고, 또 다른 측면에서의 문제점을 배울 수 있었다(참고 블로그)


위의 테스트에서 해본건 추가 버튼을 누르면 맨앞에 정국이란 데이터를 러프하게 {name:string}[] 타입의 배열인데, 그 안에 {name:'정국'}의 테이터를 추가하고, 삭제를 하면 {name:'철수'} 데이터를 삭제하도록 했다. 그리고 key값 세팅은 map(value, index) 에서 index값을 key값으로 해줬다. 그리고 일단 처음에 영희에 해당하는 컴포넌트 input에 영희의 input에 글을 써봤습니다를 쓴다음에 정국의 데이터를 추가했더니 영희의 input에 써줬던 데이터가 이전에 idx값이 0이었던 철수(추가된 후에는 idx가 1인)의 input에 옮겨져있었다. 그리고 삭제를 해보니(철수의 데이터) 다시 철수의 input에 있었던 영희의 input에 글을 써봤습니다 텍스트가 다시 영희로 돌아왔다.

: 무슨일일까?.. 일단 직관적으로 판단해보면, idx = 1에 있던 input의 value가 리렌더링되면 그대로 현재 기준 idx=1로 들어간거라고 볼 수 있다. 즉, 본래 영희가 idx=1이었지만, 정국 데이터가 추가되면서 철수가 idx=1이되고, 영희의 input value였던 텍스트가 철수의 input value로 가게된 것이다.

이러한 현상을 보면 이전에 테스팅할 때는 DOMElement를 따로 요소마다 주지 않았는데, 즉, key를 가진 div 태그 내에 다른 엘리먼트를 주면서 테스팅을 안해봤는데, 이렇게 input element를 추가해서 테스팅 해보니 index를 다시 mapping 할 때 동일한 index 혹은 동일한 key값을 넣어줄 경우 동일한 DOM Element를 보여주는 로직인 것 같다. 그래서 제대로 요소마다 고유값을 key로 주면 해결이 된다.

  • 그럼 key값에 map의 index는 절대로(never)쓰면 안될까? 그건 아니다. 다음과 같은 케이스에서는 사용해도 상관없다고 한다.
    - 배열과 각 요소가 static이며 computed 되지 않고 변하지 않는 경우(테스트했을 때 처음에는 input과 같은 변하는 요소를 넣지 않고 테스팅 했었는데, 그 때에는 리렌더링 이슈는 있었지만, 데이터가 하나씩 밀리는 현상은 없었다. 하지만, input의 value 값이 변하는 등의 로직이 추가되자 문제가 생겼다).
    - 데이터가 결코 reordered or filtered 되지 않을 경우(배열 자체가 static한 케이스 이경우엔 배열 자체를 const 선언자로 선언했을 경우라고 할 수 있다.)

추가 탐색

: 위에서는 실제 로직을 수행해보고 어떤 결과가 나타나는지에 대해서 분석해봤는데, 그러면 이게 리액트 내에서 어떤 식으로 동작을 하고 있는건지 좀 더 구체적으로 파헤쳐보면,
먼저, 뭔가를 추가(끼워넣기 포함) 혹은 삭제(중간에 있는거 삭제 포함) 했을 때 해당 element를 기점으로 이후의 모든 엘리먼트를 다시 그린다는 현상을 분석해보자. 본래 key값을 토대로 리액트가 판단을 한다고 했을 때 예를 들어, 맨앞에 특정 요소를 추가한다고 해보자. 그랬을 때 맨앞에 요소 하나만 추가하고 끝나면 깔끔할텐데 왜 그 뒤의 엘리먼트 모두를 리렌더링할까?. 그 답은 이 글의 주제인 key값에 있다. map(value, idx)를 통해서 렌더링했을 경우 처음에 0, 1, 2, 3, 4, 5 이렇게 키값이 세팅돼있었다고 해보자. 이 때, 맨앞에 요소를 추가하면 splice를 쓰듯이 0,1,2,3,4,5,6 0쪽에 데이터만 쏙? 껴넣는게 아니라 다른 방식으로 이뤄진다.

<>
< 엘리먼트 key = 0> data = 1
< 엘리먼트 key = 1> data = 2
< 엘리먼트 key = 2> data = 3
< 엘리먼트 key = 3> data = 4
< 엘리먼트 key = 4> data = 5
</>

이렇게 있을 때, 맨앞에 (idx=0에) 요소를 추가하면

<>
< 엘리먼트 key = 0> data = new data
< 엘리먼트 key = 1> data = 1
< 엘리먼트 key = 2> data = 2
< 엘리먼트 key = 3> data = 3
< 엘리먼트 key = 4> data = 4
</>

위와 같이 기존에 data=1을 품고 있던 엘리먼트는 new data로 대체되고, data=2를 품고 있던 엘리먼트는 3으로 이런식으로 데이터가 바뀌고, 마지막 요소 다음에 새로운 엘리먼트가 새롭게 삽입되고, 거기에 기존에 data=4가 들어간다.

<>
< 엘리먼트 key = 0>
< 엘리먼트 key = 1>
< 엘리먼트 key = 2>
< 엘리먼트 key = 3>
< 엘리먼트 key = 4>
< 엘리먼트 key = 5>
</>

그래서 실질적으로 리액트 입장에선 끝에 새로운 엘리먼트를 생성하고, 맨 처음부터(혹은 추가된 엘리먼트부터) 끝까지 리렌더링을 다시하게 된다. 그래서 맨끝에 데이터를 push 하는 로직에서는 별다른 문제가 없어보였던거다. 어차피 어떻게 추가를 해도 맨끝에 요소를 추가하고 하나씩 밀어내는 형태이므로.

계획

: 솔직히 아직 완벽하게 리액트가 어떻게 처리를 하는지 이해를 못한 것 같아서 이번주를 활용해서 좀더 딥하게 공부해봐야겠다

테스트 코드 공유

import './App.css'
import {useRef, useState} from 'react'

type arrType = {
  id: number
  name: string
}
let idvalue = 2

const Example = () => {
  const [list, setList] = useState([{name: '철수'}, {name: '영희'}, {name: '민수'}])

  const addItem = () => {
    setList([{name: '정국'}, ...list])
  }

  const delItem = () => {
    setList(list.filter(l => l.name != '철수'))
  }

  return (
    <>
      {/* 추가 버튼과 삭제 버튼*/}
      <input type="button" value="추가" onClick={addItem} />
      <input type="button" value="삭제" onClick={delItem} />

      <h2> Show Problem Example</h2>
      {list.map((v, index) => (
        /*  div 태그의 key로 배열의 index 사용*/
        <div key={index}>
          {' '}
          {v.name}, idx: {index} <input type="text" />{' '}
        </div>
      ))}
    </>
  )
}

function App() {
  const [array, setArray] = useState<arrType[]>([{id: 1, name: '0715yk'}])
  const unshiftInputRef = useRef<HTMLInputElement | null>(null)
  const pushInputRef = useRef<HTMLInputElement | null>(null)
  const numberInputRef = useRef<HTMLInputElement | null>(null)
  const numberInputValueRef = useRef<HTMLInputElement | null>(null)
  const numberDeleteInputRef = useRef<HTMLInputElement | null>(null)
  const numberUpdateInputValueRef = useRef<HTMLInputElement | null>(null)
  const numberUpdateInputRef = useRef<HTMLInputElement | null>(null)

  const unshiftElement = () => {
    setArray((prev: arrType[]) => {
      const prevCopy = prev.slice()
      if (unshiftInputRef.current) {
        prevCopy.unshift({id: idvalue, name: unshiftInputRef.current.value})
        idvalue++
      }

      return prevCopy
    })
  }

  const pushElement = () => {
    setArray((prev: arrType[]) => {
      const prevCopy = prev.slice()
      if (pushInputRef.current) {
        prevCopy.push({id: idvalue, name: pushInputRef.current.value})
        idvalue++
      }
      return prevCopy
    })
  }

  const insertThirdElement = () => {
    setArray((prev: arrType[]) => {
      const prevCopy = prev.slice()
      if (numberInputRef.current && numberInputValueRef.current) {
        prevCopy.splice(parseInt(numberInputRef.current.value), 0, {
          id: idvalue,
          name: numberInputValueRef.current.value
        })
        idvalue++
      }
      return prevCopy
    })
  }

  const deleteElement = () => {
    setArray((prev: arrType[]) => {
      const prevCopy = prev.slice()
      if (numberDeleteInputRef.current) {
        prevCopy.splice(parseInt(numberDeleteInputRef.current.value), 1)
      }
      return prevCopy
    })
  }

  const mapElement = () => {
    setArray((prev: arrType[]) => {
      const prevCopy = prev.slice()

      prevCopy.map((el, idx) => {
        if (
          numberUpdateInputRef.current &&
          numberUpdateInputValueRef.current &&
          idx === parseInt(numberUpdateInputRef.current.value)
        ) {
          el.name = numberUpdateInputValueRef.current.value
        } else {
          return el
        }
      })

      return prevCopy
    })
  }

  return (
    <>
      <div
        style={{
          border: '2px solid black',
          width: '300px',
          height: '500px',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          alignItems: 'center'
        }}>
        {array.map((el, idx) => {
          return (
            <div key={idx}>
              id: {el.id} ::
              <div>
                {el.name} 님 환영합니다.{idx}
                <input />
              </div>
            </div>
          )
        })}
      </div>
      <div style={{marginTop: '50px'}}>
        <div>
          <input ref={unshiftInputRef} />
          <button onClick={unshiftElement}>앞에 추가</button>
        </div>
        <div>
          <input ref={pushInputRef} />
          <button onClick={pushElement}>뒤에 추가</button>
        </div>
        <div>
          <input ref={numberInputRef} /> 번째에
          <input ref={numberInputValueRef} /> 데이터
          <button onClick={insertThirdElement}>추가</button>
        </div>
        <div>
          <input ref={numberDeleteInputRef} /> 번째에
          <button onClick={deleteElement}>삭제</button>
        </div>
        <div>
          <input ref={numberUpdateInputRef} type="number" /> 번째에 데이터를
          <input ref={numberUpdateInputValueRef} /><button onClick={mapElement}>수정</button>
        </div>
      </div>
    </>
  )
}

export default App
profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자
post-custom-banner

0개의 댓글