: 생각해보니 항상 당연하다고만 알고 쓰고 있었던 이 부분에 대해서 내가 명확한 이유를 잘모르고 있다는 생각이 들어서 이에 대해 테스팅과 무엇이 포인트인지를 짚고 넘어가고자 글을 써봤다.
그러면 이 key값을 쓰지 않았을 때 console창에서의 경고 메시지 말고는 어떤 사이드 이펙트가 있을까?- 각 엘리먼트에 안정적인 고유성을 부여하기 위해서 key값을 써야한다. - 이러한 고유성을 통해서 React가 이름이 똑같은 컴포넌트들 각각의 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕는다.
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로 주면 해결이 된다.
: 위에서는 실제 로직을 수행해보고 어떤 결과가 나타나는지에 대해서 분석해봤는데, 그러면 이게 리액트 내에서 어떤 식으로 동작을 하고 있는건지 좀 더 구체적으로 파헤쳐보면,
먼저, 뭔가를 추가(끼워넣기 포함) 혹은 삭제(중간에 있는거 삭제 포함) 했을 때 해당 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