개발 중에 갑자기 생각난 이슈. 요즘 새로운 프로젝트를 시작하고 있는데, React에서 자주 찾게 되는 Array.prototype.map() 함수에 대하여 고민해 보게 되었다.
map()이 어떤 역할을 하는 함수인지, 어떻게 사용하는지에 대한 설명은 생략하고자 한다. 그 내용을 정리하기 위해서 쓴 게시글이라기보다는, 그 내용을 알면서도 활용할 때마다 항상 고민되는 주제에 대해 다루고자 한다.
React에서는 map() 사용 시 key props를 사용하는 것을 권장하고 있다. 사실, ESLint 등을 통하여 작업을 하다 보면 설정에 따라서는 map() 사용 시 key를 부여하지 않는 경우 컴파일 타임에서 에러가 나는 경우도 있다.
지금 진행 중인 프로젝트에서도 ESLint를 초기화만 하고 별다른 rule을 변경하지 않았음에도 불구하고, map()에서 key를 빼먹으니 에러를 뿜는 모습이다.
map() 함수 사용 시 key 값을 쓰는 이유
먼저 key prop을 사용하는 이유에 대해 알아 보자. key는 map()을 통해 추가될 element들에 고유성을 부여하기 위해 지정한다. 이하와 같다.
const foo = [
{ id: 0, content: "Hello world!" },
{ id: 1, content: "React is awesome!" },
];
return foo.map((value) => <div key={value.id}>{value.content}</div>);
일반적으로, 이런 값들은 백엔드와 통신하여 DB에서 수신하기 때문에 각각의 데이터에 대하여 구분되는 값이 하나 이상 존재할 것이다. 그 중에서 적절한 값을 골라 key에 부여하면 된다. 위의 예시에서는 각각의 데이터의 id를 key로 부여했다.
그러나 개발을 하다 보면 항상 이런 경우만 있는 것은 아니다. 만약 key 값에 넣을 마땅한 고유 값이 없다면 어떻게 해야 할까? 가장 먼저 떠오르는 것은 해당 원소의 index를 집어 넣는 것이다. 그렇지만 이 방법은 그렇게 추천할 만한 방법이 아니다.
실제로, 이전에 있던 회사에서 실시간 코멘트 시스템을 개발할 때 개발 후 테스트 과정에서 key를 index로 지정했다가 버그가 생겨 쩔쩔맸던 경험이 있었다. 물론 실제로 적용할 때에는 해당 데이터도 데이터베이스에 저장될 때 고유한 값이 있었기 때문에 버그는 쉽게 해결됐지만, 버그가 발견되었을 시점에는 백엔드와 연결하기 전에 프론트엔드 내부의 로직으로 데이터를 추가했다가 지웠다가 테스트하는 과정이었다. 때문에 단순하게 index를 넣었을 뿐인데, 이것이 버그의 원인이었을지는 생각을 못하고 있었다.
map()의 key에 index를 넣었을 때 일어날 수 있는 현상
const [input, setInput] = useState("");
const [comments, setComments] = useState([]);
return (
<div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button
onClick={() => {
setComments((prev) => [input, ...comments]);
setInput("");
}}
>
Enter
</button>
{comments.map((value, index) => (
<div key={index}>
{value}
<input />
</div>
))}
</div>
);
위와 같은 코드로 하나의 컴포넌트를 작성했다고 생각해 보자.
해당 코드를 실행하면 위와 같은 페이지가 나타날 것이다. 위의 input은 댓글 입력 창, 아래의 text는 기존에 입력된 댓글. 그 옆의 input은 댓글에 추가할 답글을 입력하는 창이라고 생각해 보자.
별다른 문제가 없어 보이지만, 이 데이터가 "실시간"으로 바뀌는 경우 문제가 확연히 드러난다. 나는 답글을 달고 있는데, 누군가가 댓글을 추가적으로 작성한다.
그러면, 위와 같은 현상이 발생한다. 분명 나는 첫 번째 댓글에 답글을 달고 있었는데, 새로운 데이터가 추가되고 두 번째 댓글에 답글을 다는 것처럼 바뀌고 말았다. 것처럼이라고 말했지만, 사실 이대로 데이터를 송신하면 정말로 두 번째 댓글에 답글이 달리게 된다.
왜 이러한 일이 일어나게 될까?
문제는 key가 index이기 때문이다. 댓글을 최신 순으로 정렬하기에 가장 최근에 작성된 댓글이 배열의 앞에 오도록 설정했고, 이 때문에 key가 0이었던 요소가 "Hi :)" 댓글에서 "Nice to meet you!" 댓글로 변했기 때문이다. 내가 작성중인 답글은 key가 0인 요소에 고유하게 있어야 하기 때문에, 답글이 위로 옮겨간 것처럼 보이는 것이다.
React는 map()으로 만들어진 element들을 key를 통해 구분하기 때문에, "새로운 댓글이 위에 추가되었다"라고 생각하는 것이 아니라, "새로운 댓글이 아래에 추가되었고, 기존 댓글의 내용이 "Hi :)"에서 "Nice to meet you!"로 변경되었다"라고 받아들이는 것이다.
실무에서 했던 삽질
이를 해결하기 위해서 (key가 원인이라는 것을 모른 채) 새로 추가되는 댓글을 배열의 가장 마지막에 할당하고, map() 할 때 순서를 뒤집어 줬었다. 새로 추가되는 댓글의 key가 1, 2, 3, ...과 같이 늘어나고 원래 댓글의 key는 그대로 유지되기 때문에 버그가 고쳐진듯 하였으나...
문제는 댓글이 추가되는 게 아니라 댓글이 삭제될 때였다. 이번엔 오래된 댓글이 지워졌을 때 key가 하나씩 줄어들게 되므로 똑같은 버그가 발생했다.
이후에는 key를 index로 할당했다는 것이 문제였음을 (방치하고 1~2주가 지났을 무렵) 깨닫고, 완전히 해결했었다. 그때는 정말 묵은 체증이 내려가는 느낌이었다. 그러면서도 이런저런 로직의 변경이 아니라 단순히 한줄 변경하는 것으로 해결되는 버그였다는 사실에 멍해지기도 했었다.
바람직한 key의 선택
당연히 위에서 말했던 것처럼 데이터베이스에서 받은 데이터의 고유한 값을 key로 지정하는 것이 좋다. Auto Increment된 id값도 좋고, 데이터베이스에는 Primary Key가 항상 있기 때문에 이러한 값을 key로 넣자.
그런데, DOM에 렌더하고자 하는 데이터가 데이터베이스를 거쳐서 받은 값이 아니라 프론트엔드 로직으로만 다루어지는 값이라면? 혹은 백엔드에서 해당 데이터만이 가지는 고유한 값을 넘겨주지 않았더라면? 실제로 svg 내에 map()으로 텍스트를 띄우는 작업을 진행해 본 적이 있는데, 받은 데이터는 좌표와 텍스트 뿐이었다. 그때는 좌표와 텍스트를 적절히 섞어 랜덤해 보이는 값을 생성한 다음에 key 값에 할당하는 방식으로 해결했었다.
이런 방법조차 정 떠오르지 않는다면, Lint가 허용한다면 index를 넣어도 괜찮을 수도 있다.
해당 포스트에 따르면, 이러한 경우 key에 index를 넣어도 괜찮다고 소개하고 있다.
1. 리스트와 내부의 데이터는 static하다; 계산되지 않고, 변경되지 않는다.
2. 리스트 내부의 데이터에 id가 없다.
3. 리스트는 절대로 재배열되거나 필터링되지 않는다.
사실, 잘 생각해보면 "한 번 불러오고 이후에 건드릴 가능성이 아예 없는 리스트"에만 key에 index를 넣어라! 라고 하는 것과 똑같은 말같다.
'공부 > React' 카테고리의 다른 글
Django + React로 로그인 시스템 구현하기 / Pt. 2) React에 Django 연동하기 (0) | 2022.10.20 |
---|