
리액트에서 리스트를 렌더링하다 보면 손이 먼저 움직이는 순간이 있다. 한 아이템에서 여러 요소를 반환해야 하니 일단 <>...</>로 감싼다.
items.map((item) => (
<>
<span>{item.title}</span>
<span>{item.description}</span>
</>
));그러면 리액트가 당연히 key를 달라고 한다. 여기서 처음에는 조금 난감해진다. 축약형 Fragment인 <>...</>에는 key를 붙일 자리가 없기 때문이다. 그래서 눈앞에 가장 쉬운 해결책이 보인다. div로 감싸고 거기에 key를 붙이는 것이다.
items.map((item) => (
<div key={item.id}>
<span>{item.title}</span>
<span>{item.description}</span>
</div>
));화면은 잘 나온다. 경고도 사라진다. DOM도 대충 기대한 모양처럼 보인다. 그래서 그냥 넘어가기 쉽다. 나도 그랬다. 하지만 이 div는 제목과 설명을 묶기 위해 필요한 의미 있는 요소가 아니라, 오직 key를 붙이기 위해 끼워 넣은 래퍼에 가깝다. JSX가 너무 친절하면 가끔 내가 정확히 무엇을 반환하고 있는지 잊게 된다.
문제는 key가 필요한 대상이 “눈에 보이는 DOM 노드”가 아니라 “반복에서 반환되는 React element”라는 데 있다. map 안에서 여러 요소를 하나의 반복 단위로 반환하고 싶다면, 그 반복 단위는 div가 아니라 Fragment여도 된다. 다만 축약형 Fragment는 props를 받을 수 없으니, key가 필요한 순간에는 명시적인 <Fragment>를 써야 한다.
이걸 알고 나면 조금 민망해진다. 나는 Fragment를 쓰면서도 Fragment를 반복 단위로 대하지 않았고, 리액트가 보라고 한 곳이 아니라 내가 보기 편한 DOM 노드에 이름표를 붙이고 있었다. 뭐랄까, 택배 상자에 송장을 붙여야 하는데 안에 든 물건 하나에만 붙인 셈이다. 물건은 멀쩡하지만 배송 시스템은 그걸 보고 싶어 하지 않는다.
Fragment에 key를 줄 수 있다
리액트 공식 문서의 <Fragment> 레퍼런스를 보면 이 부분이 아주 노골적으로 적혀 있다. <>...</>는 대부분의 경우 <Fragment>...</Fragment>의 축약 문법이지만, 모든 일을 대신해주지는 않는다. 특히 key가 필요하면 축약 문법을 사용할 수 없다. Fragment를 명시적으로 import해서 써야 한다. 그러면 위의 div는 이렇게 바꿀 수 있다.
import { Fragment } from 'react';
items.map((item) => (
<Fragment key={item.id}>
<span>{item.title}</span>
<span>{item.description}</span>
</Fragment>
));이렇게 쓰면 불필요한 div가 사라진다. Fragment는 DOM에 래퍼를 만들지 않기 때문에 span들은 별도의 부모 요소 없이 렌더링된다. 레이아웃이나 스타일을 위해 필요한 div라면 당연히 남겨야 한다. 하지만 오직 key를 붙이기 위해 만든 div라면 그 책임은 Fragment가 가져갈 수 있다.
이 차이는 작아 보이지만 글자 그대로 DOM 구조를 바꾼다. div를 넣는 순간 CSS selector, flex/grid 레이아웃, 접근성 트리, 테스트 쿼리에서 새로운 부모 요소가 생긴다. 물론 대부분의 경우 큰 문제 없이 지나가지만, “경고를 없애려고 DOM을 바꿨다”는 사실은 남는다. 나는 이 지점이 찝찝했다.
여기서 작지만 중요한 구분이 생긴다. <>...</>는 “아무것도 안 하는 빈 태그”가 아니다. Fragment라는 React element를 짧게 쓰는 문법이다. 다만 짧게 쓰는 대신 props를 붙일 수 있는 자리를 잃는다. key가 필요한 순간에는 축약형이 아니라 정식 이름을 불러야 한다. 공식 문서는 가끔 이런 사소한 습관을 아주 조용히 고쳐준다. 억울하게도 대체로 문서가 맞고, 내가 손에 익은 코드를 너무 믿은 쪽이다.
안정적인 Fragment가 하는 일
안정적으로 사용할 수 있는 Fragment의 역할은 분명하다. 여러 요소를 하나의 React element처럼 묶되, 실제 DOM에는 래퍼 노드를 만들지 않는다. 컴포넌트는 하나의 값을 반환해야 하지만, 화면 구조상 불필요한 부모 DOM을 추가하고 싶지 않을 때 Fragment가 그 경계를 맡는다.
function Post() {
return (
<>
<PostTitle />
<PostBody />
</>
);
}이 코드는 PostTitle과 PostBody를 함께 반환하지만, 브라우저 DOM에 div 같은 추가 노드를 만들지 않는다. 그래서 Fragment는 단순히 “여러 태그를 반환하려고 쓰는 문법”이 아니라, React tree와 DOM tree 사이의 비용을 분리하는 도구에 가깝다. 리액트에게는 “이 둘은 함께 반환되는 묶음”이라고 말하지만, 브라우저에게는 “새로운 박스를 만들 필요는 없다”고 말한다.
리스트에서도 같은 원리가 이어진다. 반복 단위가 여러 DOM 노드로 이루어져 있으면 Fragment 자체가 반복 단위가 된다. 그 반복 단위를 식별하려면 key가 Fragment에 있어야 한다.
import { Fragment } from 'react';
posts.map((post) => (
<Fragment key={post.id}>
<PostTitle title={post.title} />
<PostBody body={post.body} />
</Fragment>
));공식 문서가 보여주는 예시도 이 형태다. 이 코드를 보고 나면, 안쪽에 key용 div를 하나 더 만드는 방식이 조금 이상해 보인다. DOM 구조를 바꾸지 않으려고 Fragment를 썼는데, 다시 key 때문에 DOM 구조를 바꾸고 있었던 셈이다. 도구를 가져다 놓고 도구가 하려던 일을 옆에서 수작업으로 하고 있었다.
다만 여기서도 기준은 분명해야 한다. Fragment는 div를 없애는 자동 청소기가 아니다. 스타일링, 레이아웃, 접근성, 이벤트 위임, CSS selector처럼 실제 DOM 노드가 의미를 갖는 경우에는 div가 필요하다. 문제는 필요한 div와 습관적으로 생긴 div를 구분하지 않는 데 있다. Fragment는 후자를 줄이는 도구지, 전자를 부정하는 도구는 아니다.
Canary에서 Fragment는 더 많은 일을 한다
재미있는 부분은 여기서 끝나지 않는다. 같은 공식 문서에는 Canary 표시가 붙은 Fragment ref API도 함께 나온다. 안정 API처럼 당연히 운영 코드에 넣을 기능은 아니지만, 리액트가 Fragment를 어떤 방향으로 확장하고 있는지 보여준다.
Canary에서는 Fragment가 ref를 받을 수 있다. 이때도 <>...</> 축약 문법은 사용할 수 없고, 명시적인 <Fragment ref={...}>를 써야 한다. ref에 들어오는 값은 DOM element가 아니라 FragmentInstance다.
import { Fragment, useEffect, useRef } from 'react';
function ClickableGroup({ children, onClick }) {
const fragmentRef = useRef(null);
useEffect(() => {
const fragment = fragmentRef.current;
if (fragment === null) {
return;
}
fragment.addEventListener('click', onClick);
return () => {
fragment.removeEventListener('click', onClick);
};
}, [onClick]);
return <Fragment ref={fragmentRef}>{children}</Fragment>;
}공식 문서의 예시는 이 API로 래퍼 DOM 없이 여러 자식에게 이벤트 리스너를 붙인다. 기존에는 이런 일을 하려면 부모 div를 하나 만들거나, 자식 컴포넌트들이 각자 ref를 노출해야 했다. Canary Fragment ref는 “DOM 래퍼는 만들고 싶지 않지만, 이 그룹을 하나의 조작 단위로 다루고 싶다”는 요구를 직접 겨냥한다.
FragmentInstance가 제공하는 메서드는 생각보다 많다.
- 이벤트:
addEventListener,removeEventListener,dispatchEvent - 포커스:
focus,focusLast,blur - 관찰:
observeUsing,unobserveUsing - 위치와 스크롤:
getClientRects,getRootNode,compareDocumentPosition,scrollIntoView
이 목록만 보면 Fragment가 갑자기 DOM element처럼 행동하는 것처럼 보인다. 하지만 정확히는 DOM 노드가 생기는 것이 아니라, Fragment가 감싼 DOM 자식들을 대상으로 작업을 분배하는 객체가 생기는 쪽에 가깝다. 그래서 제한도 있다. 이벤트, observer, rect 관련 메서드는 Fragment의 first-level DOM children을 대상으로 한다. 반면 focus와 focusLast는 중첩된 자식까지 depth-first로 탐색해서 focus 가능한 요소를 찾는다.
이 차이는 꽤 중요하다. Fragment ref가 있다고 해서 Fragment가 보이지 않는 div가 되는 것은 아니다. 래퍼 노드가 없다는 사실은 그대로 유지된다. 대신 리액트가 “이 Fragment가 소유한 첫 번째 레벨의 DOM 자식들”을 추적하고, 그 범위 안에서 이벤트와 observer와 layout 작업을 대신 걸어준다. 없는 DOM을 있다고 속이는 API가 아니라, 없는 DOM 때문에 흩어지던 작업을 리액트 쪽에서 모아주는 API에 가깝다.
공식 문서에 있지만 아직 안정 API는 아니다
Canary API를 읽을 때 가장 조심해야 하는 지점도 여기에 있다. 리액트 공식 문서에 나온다고 해서 모두 같은 안정성을 가진 것은 아니다. Fragment의 key 사용과 래퍼 없는 그룹화는 안정적으로 사용할 수 있는 이야기다. 반면 Fragment의 ref와 FragmentInstance는 문서에서 Canary로 표시되어 있다.
그래서 이 기능은 “지금 당장 모든 프로젝트에서 쓰자”보다 “리액트가 Fragment를 단순한 JSX 묶음 이상으로 보고 있다”는 신호로 읽는 편이 좋다. 특히 래퍼 DOM을 추가하지 않고도 이벤트, focus, observer, scroll을 그룹 단위로 다루려는 방향은 흥미롭다. 지금은 div 하나 추가하는 비용이 작아 보여도, 스타일과 접근성, 레이아웃, selector, 테스트가 엮이면 그 div는 생각보다 오래 남는다.
observeUsing이 text node에는 동작하지 않는다거나, 이벤트와 observer 관련 메서드가 first-level DOM children을 대상으로 한다는 caveat도 문서에 함께 적혀 있다. 이런 세부사항은 API가 어떤 추상화를 제공하고, 어디까지는 DOM의 실제 구조를 따라가는지 보여준다. Fragment가 많은 일을 하더라도, DOM을 완전히 지워버리는 마법은 아니다.
문서는 가끔 습관보다 정확하다
내가 처음 놓친 것은 거대한 개념이 아니었다. <>...</>에 익숙해졌고, key 경고를 없애야 했고, 눈앞에 보이는 div에 key를 붙였다. 작은 습관이었다. 하지만 그 작은 습관은 DOM 구조를 불필요하게 바꾸고 있었다.
공식 문서를 다시 읽으면 이런 균열이 잘 보인다. Fragment는 그저 빈 태그처럼 보이지만, 안정 API에서는 래퍼 없는 반복 단위를 만들고 key를 받을 수 있다. React tree에서는 상태 보존 경계와도 관련이 있고, Canary에서는 더 나아가 래퍼 없는 DOM 그룹 조작의 실험장처럼 쓰인다. 같은 <Fragment>라는 이름 아래에 “지금 믿고 써도 되는 기능”과 “앞으로 이런 방향을 보고 있는 기능”이 같이 놓여 있는 셈이다.
결국 이 글의 결론은 조금 민망하지만 단순하다. map 안에서 여러 요소를 반환해야 하고 그 반복 단위에 key가 필요하다면, 불필요한 div를 만들지 말고 명시적인 <Fragment key={...}>를 쓰면 된다. 그리고 익숙한 문법이 손에 붙었다고 해서 그 문법이 API의 전부라고 믿지는 말자. 공식 문서는 대체로 재미없게 생겼지만, 가끔은 내가 만든 불필요한 div 하나를 조용히 지워주기도 한다.
댓글
댓글을 불러오는 중...