데이터는 대부분 배열로 온다. 게시글 목록, 댓글, 장바구니 상품. 이 배열을 화면의 목록으로 바꾸는 게 리스트 렌더링이다. 순수 자바스크립트라면 반복문을 돌며 문자열을 이어 붙였겠지만, React에서는 배열의 map으로 JSX 조각들의 배열을 만들어 그대로 화면에 꽂는다. 익숙해지면 목록 만드는 일이 한 줄로 끝난다.
그런데 여기엔 key라는, 안 넣으면 경고가 뜨고 잘못 넣으면 조용히 버그가 나는 물건이 딸려 온다. 오늘의 진짜 주제는 사실 이 key다.
목록은 map으로 그린다. 배열을 받아 각 항목을 JSX로 바꾸는 게 시작이다. 이름 배열을 li 목록으로 만들어 보자.
function NameList() {
const names = ["김", "이", "박"];
return (
<ul>
{names.map(name => (
<li>{name}</li>
))}
</ul>
);
}
map이 배열의 각 이름을 <li>김</li> 같은 JSX로 바꿔, 결과적으로 li 세 개짜리 배열이 나온다. React는 JSX 배열을 받으면 그걸 차례로 화면에 그린다. 그래서 화면에는 김, 이, 박이 목록으로 뜬다. 배열이 늘거나 줄면 화면의 항목 수도 그대로 따라간다.
이대로 두면 key 경고가 뜬다. 위 코드를 실제로 돌리면 화면은 나오지만, 개발자 도구 콘솔에 빨간 경고가 하나 찍힌다.
Warning: Each child in a list should have a unique "key" prop.
목록의 각 항목에는 key라는 값을 붙이라는 뜻이다. key는 React가 "이 항목은 저번의 그 항목과 같은 놈"이라고 알아보게 해주는 이름표다. 이게 없으면 항목이 추가되거나 순서가 바뀌었을 때, React가 어느 것이 그대로 남았고 어느 것이 새로 생겼는지 헷갈린다. 그래서 목록을 그릴 땐 반드시 key를 붙여야 한다.
key에는 고유하고 안정적인 값을 넣는다. 대부분의 데이터에는 id가 있다. 그게 바로 key에 넣기 딱 좋은 값이다.
const users = [
{ id: 101, name: "김" },
{ id: 102, name: "이" },
];
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
key={u.id}처럼 각 항목마다 겹치지 않는 값을 넣으면 경고가 사라진다. 좋은 key의 조건은 두 가지다. 형제 항목들 사이에서 겹치지 않을 것(고유), 그리고 그 항목을 가리키는 값이 계속 같을 것(안정). 데이터베이스의 id가 보통 둘 다 만족한다. key는 화면에 보이는 속성이 아니라 React 내부에서만 쓰는 이름표라, DOM에는 나타나지 않는다.
인덱스를 key로 쓰면 언젠가 문제가 터진다. 마땅한 id가 없다고 map의 두 번째 인자인 인덱스를 key에 넣는 사람이 많다. 항목이 절대 안 바뀌는 목록이면 괜찮지만, 순서가 바뀌거나 중간이 지워지는 목록이면 이게 조용한 버그의 근원이 된다.
{items.map((item, index) => (
<li key={index}>{item.text}</li>
))}
왜 위험한지는 입력창이 딸린 목록에서 극명하게 드러난다. 세 개의 입력 행이 있고 각 행에 글자를 쳐 넣었다고 하자. 이때 맨 앞 행을 삭제하면, 두 번째 행이 인덱스 0으로 당겨진다. React는 key가 0인 자리는 그대로 있다고 판단해, 삭제된 첫 행의 입력값을 새로 0이 된 행에 그대로 남겨둔다. 결과적으로 지운 건 첫 행인데 화면에는 엉뚱하게 마지막 행이 사라진 것처럼 보이고, 입력값은 한 칸씩 밀려 딴 행에 붙는다. 나도 체크박스 목록에서 이걸 겪고 "체크한 항목이 왜 옆줄로 옮겨가지" 하며 한나절을 날렸다.
인덱스가 밀려도 id는 안 밀린다. 같은 상황을 id로 key를 주면 문제가 사라진다.
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
맨 앞 항목을 지워도 남은 항목들의 id는 그대로다. React는 id를 보고 "이건 아까 그 항목"이라고 정확히 짚어내, 입력값과 화면을 제자리에 붙여둔다. 그래서 항목을 추가하고 지우고 순서를 바꾸는 목록이라면 key는 반드시 데이터 고유의 id여야 한다. 인덱스는 "이 목록은 절대 순서가 안 바뀐다"고 확신할 때만 쓰는, 예외적인 선택이다.
걸러서 그릴 땐 filter를 먼저 건다. 목록 전체가 아니라 조건에 맞는 것만 보여줄 땐, filter로 추린 다음 map을 잇는다.
{todos
.filter(t => !t.done)
.map(t => (
<li key={t.id}>{t.text}</li>
))}
완료되지 않은 할 일만 골라 화면에 그린다. filter가 배열을 먼저 추리고, 그 결과에 map이 붙어 JSX로 바꾼다. 여기서도 key는 걸러진 뒤의 인덱스가 아니라 각 항목의 id를 써야, 필터 조건이 바뀌어 목록이 재구성돼도 항목들이 제자리를 지킨다.
렌더할 때 key를 즉석에서 만들지 마라. 마땅한 id가 없다고 map 안에서 key 값을 그때그때 새로 만들어 내는 사람도 있다. 이건 인덱스보다 더 나쁘다.
// 나쁜 예
{items.map(item => (
<li key={Math.random()}>{item.text}</li>
))}
Math.random()은 렌더할 때마다 다른 값을 낸다. 그러면 React는 매번 모든 key가 바뀌었다고 보고, 멀쩡히 있던 항목을 전부 지웠다가 새로 그린다. 입력창 포커스가 날아가고, 화면이 미세하게 깜빡이고, 성능도 나빠진다. key로 쓸 고유값이 정말 없으면, 화면을 그릴 때가 아니라 데이터를 처음 만들 때 crypto.randomUUID() 같은 걸로 한 번 붙여서 데이터에 저장해 둔다. key는 렌더마다 같아야 제 역할을 한다.
항목 하나가 여러 요소면 Fragment에 key를 준다. 항목마다 요소를 두 개 이상 나란히 그려야 할 때가 있다. 이럴 때 key는 map이 돌려주는 가장 바깥 요소에 붙어야 한다.
{terms.map(item => (
<React.Fragment key={item.id}>
<dt>{item.word}</dt>
<dd>{item.desc}</dd>
</React.Fragment>
))}
한 항목이 dt와 dd 두 개로 이뤄져 있어서, 둘을 React.Fragment로 묶고 key는 그 묶음에 준다. 참고로 흔히 쓰는 짧은 빈 태그 <></>는 key를 받지 못한다. 그래서 목록 안에서 여러 요소를 묶으면서 key도 붙여야 하면, 축약형 대신 React.Fragment를 풀어서 쓴다.
목록의 뼈대는 map, 안전장치는 key다. 정리하면 리스트 렌더링은 map으로 배열을 JSX 배열로 바꾸는 게 전부고, 각 항목에는 key를 반드시 붙인다. key는 고유하고 안정적인 값, 현실적으로는 데이터의 id가 정답이다. 인덱스를 key로 쓰는 건 순서가 절대 안 바뀌는 목록에서만 허용되는 예외지, 기본값이 아니다. 이 하나만 지키면 목록에 항목을 넣고 빼도 화면이 엉키지 않고, 콘솔의 빨간 경고도 조용해진다.