---
title: "dnd-kit useSortable이 React.memo로 막히지 않는 이유와 렌더링 경계 분리"
date: "2026-07-26"
lastModified: "2026-07-26"
section: "기술 노트"
category: "React / Performance"
tags: ["React","dnd-kit","@hello-pangea/dnd","Render Props","React.memo","React Portal"]
cover: "/images/blog/dnd-kit-rendering-boundaries.png"
coverAlt: "dnd-kit Context 전파와 드래그 상태·카드 콘텐츠 분리를 표현한 구조도"
coverFit: "contain"
description: "dnd-kit의 useSortable이 Context를 구독하는 구조를 따라가며 React.memo가 실패한 이유를 확인하고, Render Props와 컴포넌트 분리로 렌더링 경계를 나누는 방법을 정리합니다."
---

카드 하나를 드래그했을 뿐인데 움직이지 않은 카드의 회사명·직무·마감일까지 다시 계산된다면, 먼저 `React.memo`가 붙어 있는지보다 컴포넌트가 어떤 상태를 구독하는지 확인해야 합니다.

JobSecretary 칸반 보드에서 이 현상을 확인했을 때 초기 구현은 `dnd-kit`의 `useSortable`을 카드 컴포넌트에서 직접 호출하고 있었습니다. 카드 데이터가 바뀌지 않아 `React.memo`가 Props 비교를 통과하더라도, `useSortable`이 읽는 Context 값이 바뀌면 해당 컴포넌트는 다시 실행될 수 있습니다.

이 글에서는 이 현상을 특정 라이브러리의 성능 문제로 단정하지 않고, 다음 순서로 구조를 살펴봅니다.

1. 드래그 중 실제로 무엇이 다시 실행되는지 구분합니다.
2. `useSortable`이 `SortableContext`의 값을 어떻게 읽는지 공식 소스에서 확인합니다.
3. Context를 읽는 컴포넌트에서 `React.memo`가 왜 충분하지 않은지 설명합니다.
4. `@hello-pangea/dnd`로 변경하면서 `Render Props`를 선택한 이유를 살펴봅니다.
5. 드래그 연결 정보와 카드 콘텐츠 Props를 나누고, 콘텐츠에 `memo`를 적용하는 경계를 코드로 확인합니다.
6. `React Portal`을 렌더링 최적화가 아닌 DOM 배치 문제의 해결책으로 구분합니다.

## 먼저 구분할 것: render와 commit

React에서 말하는 렌더링은 우선 컴포넌트 함수가 다시 실행되어 다음 UI 결과를 계산하는 단계입니다. 이 단계가 실행되었다고 브라우저의 DOM 전체가 즉시 바뀌는 것은 아닙니다.

React는 이전 결과와 새 결과를 비교한 뒤 필요한 변경만 실제 DOM에 반영합니다. 이 반영 단계가 `commit`입니다. 따라서 Profiler에서 카드가 여러 번 렌더링되었다는 말은 카드의 DOM이 같은 횟수만큼 교체되었다는 뜻이 아니라, 카드의 JSX를 다시 계산하는 작업이 반복되었다는 뜻입니다.

이 구분이 중요한 이유는 위치 계산을 위해 다시 실행되어야 하는 코드와, 다시 실행할 이유가 없는 콘텐츠 코드가 같은 컴포넌트 안에 있을 수 있기 때문입니다. 드래그 중에는 `transform`과 `transition`을 계속 계산해야 하지만, 회사명·직무·마감일이 포인터 위치에 따라 바뀌는 것은 아닙니다.

## 문제를 재현하고 측정하기

측정 시나리오는 한 목록에서 카드 하나를 드래그해 다른 위치에 놓는 동작으로 고정했습니다. React Developer Tools Profiler에서 드래그 시작부터 종료까지를 기록하고, 전체 커밋 수와 실제로 이동하지 않은 카드의 렌더링 횟수를 비교했습니다.

| 항목 | 측정 결과 |
| --- | ---: |
| 한 번의 드래그에서 발생한 전체 커밋 | 271회 |
| 실제로 이동하지 않은 카드의 렌더링 | 268회 |

이 수치는 특정 카드 목록과 특정 브라우저에서 기록한 한 시나리오의 결과입니다. 모든 환경의 절대적인 성능을 의미하지는 않지만, 드래그 상태와 카드 콘텐츠가 같은 렌더링 범위에 들어가 있다는 사실을 확인하기에는 충분했습니다.

개선 전후 동작은 [JobSecretary 프로젝트 상세](/projects/jobsecretary)의 칸반 보드 섹션에서 확인할 수 있습니다. 여기서는 영상보다 그 현상이 만들어지는 상태 흐름과 컴포넌트 구조에 집중합니다.

## 첫 번째 가설: React.memo를 붙이면 충분할까?

처음에는 카드 Props가 바뀌지 않으면 카드도 다시 실행될 필요가 없다고 생각했습니다. 그래서 구조를 단순화하면 다음과 같이 카드 전체를 `memo`로 감쌀 수 있습니다.

```tsx
const SortableCard = memo(function SortableCard({application}: Props) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
  } = useSortable({id: application.id});

  return (
    <article
      ref={setNodeRef}
      style={{transform: CSS.Transform.toString(transform), transition}}
      {...attributes}
      {...listeners}
    >
      <h3>{application.company}</h3>
      <p>{application.role}</p>
      <span>{application.deadline}</span>
    </article>
  );
});
```

`React.memo`는 부모가 전달한 Props를 이전 값과 비교해, Props가 같으면 일반적인 부모 리렌더링을 건너뛸 수 있게 합니다. 그러나 이 컴포넌트는 `application`만 읽는 것이 아니라 내부에서 `useSortable`을 호출합니다. 따라서 메모이제이션이 기대대로 동작하는지 확인하려면 `application`의 참조뿐 아니라 `useSortable`이 내부에서 읽는 값까지 추적해야 합니다.

## useSortable은 어떤 값을 읽는가

`dnd-kit`의 정렬 기능은 `DndContext`와 `SortableContext`를 겹쳐 놓는 구조로 동작합니다.

- `DndContext`는 센서 이벤트, 현재 활성 아이템, 충돌 대상, 측정된 드롭 영역처럼 전체 드래그에 필요한 상태를 관리합니다.
- `SortableContext`는 정렬할 `items`, 현재 아이템의 인덱스, 대상 아이템의 인덱스, 정렬 전략과 측정된 사각형 목록을 정렬 기능에 맞는 값으로 묶어 제공합니다.
- 각 카드의 `useSortable`은 이 정렬 Context를 읽고, `useDraggable`과 `useDroppable`을 조합해 카드가 사용할 ref·이벤트 핸들러·위치 스타일을 계산합니다.

공식 `useSortable.ts`에서 Context를 읽는 부분은 다음과 같습니다.

```tsx
const {
  items,
  activeIndex,
  sortedRects,
  overIndex,
  strategy: globalStrategy,
} = useContext(Context);
```

이 코드는 [dnd-kit 공식 `useSortable.ts`](https://github.com/clauderic/dnd-kit/blob/master/packages/sortable/src/hooks/useSortable.ts)의 실제 구현에서 Context 구독에 해당하는 부분만 발췌한 것입니다. `useSortable`은 여기서 읽은 값으로 현재 카드의 `index`를 구하고, 정렬 전략에 전달할 `finalTransform`을 계산합니다. 동시에 내부에서 `useDroppable`과 `useDraggable`을 호출해 드롭 영역 측정과 드래그 이벤트 연결도 처리합니다.

`SortableContext`는 `useDndContext`로 상위 드래그 상태를 읽은 뒤, 그 값을 정렬에 필요한 형태로 변환해 자신의 Context Provider에 넣습니다. 공식 구현의 핵심 흐름을 실제 변수명에 맞춰 줄이면 다음과 같습니다.

```tsx
const {active, droppableRects, over} = useDndContext();

const activeIndex = active ? items.indexOf(active.id) : -1;
const overIndex = over ? items.indexOf(over.id) : -1;

const contextValue = useMemo(
  () => ({
    activeIndex,
    items,
    overIndex,
    sortedRects: getSortedRects(items, droppableRects),
    strategy,
  }),
  [activeIndex, items, overIndex, droppableRects, strategy],
);
```

위 블록은 전체 파일을 옮긴 것이 아니라, [dnd-kit 공식 `SortableContext.tsx`](https://github.com/clauderic/dnd-kit/blob/master/packages/sortable/src/components/SortableContext.tsx)에서 상위 Context 값을 정렬 Context로 만드는 흐름만 남긴 축약입니다. 실제 코드에는 `containerId`, `disabled`, `disableTransforms`, `useDragOverlay`와 아이템 변경 시 측정 갱신 로직도 포함되어 있습니다.

구조를 단순하게 그리면 다음과 같습니다.

```text
DndContext
  └─ SortableContext
       ├─ 카드 A ─ useSortable() ─ 회사명·직무·마감일
       ├─ 카드 B ─ useSortable() ─ 회사명·직무·마감일
       └─ 카드 C ─ useSortable() ─ 회사명·직무·마감일
```

## 포인터가 움직일 때 값은 어떻게 전파되는가

카드 A를 드래그한다고 해서 카드 A의 Props만 바뀌는 것은 아닙니다. 센서가 포인터 위치를 전달하면 드래그 상태와 충돌 대상이 갱신되고, `SortableContext`는 그 상태를 정렬 계산에 필요한 값으로 다시 만듭니다.

```text
포인터 이동
  ↓
DndContext의 active·over·droppableRects 갱신
  ↓
SortableContext의 activeIndex·overIndex·sortedRects 계산
  ↓
SortableContext Context value 변경
  ↓
useSortable을 호출한 카드가 새 값을 읽음
  ↓
transform·transition·드래그 연결 정보 재계산
```

이때 `useSortable`을 호출한 카드가 Context 소비자가 됩니다. 카드가 `SortableContext`를 직접 JSX로 감싸고 있지 않더라도, 훅 내부의 `useContext`가 Provider 값을 읽기 때문에 Context 변경의 영향을 받습니다.

정렬에 참여하는 카드들이 위치를 다시 계산하는 것은 라이브러리의 역할입니다. 문제는 같은 함수 안에서 위치 계산과 회사명·직무·마감일을 그리는 일까지 함께 처리하고 있었다는 점입니다. 위치가 바뀌는 순간, 바뀌지 않은 콘텐츠도 같은 함수 실행 범위에 들어갔습니다.

## 왜 React.memo가 이 업데이트를 막지 못했는가

`React.memo`의 비교 대상은 기본적으로 부모가 전달하는 Props입니다. Context를 직접 읽는 컴포넌트는 이 비교와 별개로 Context Provider의 업데이트를 받습니다.

```tsx
const SortableCard = memo(function SortableCard({application}: Props) {
  // application이 같아도 useSortable 내부의 Context 값이 바뀌면 다시 실행될 수 있습니다.
  const sortable = useSortable({id: application.id});

  return <CardView application={application} sortable={sortable} />;
});
```

따라서 이 구조에서 `application`이 같은 객체라는 사실만으로는 충분하지 않습니다. `useSortable`이 읽는 `items`, `activeIndex`, `overIndex`, `sortedRects` 등의 값이 갱신되면 카드 컴포넌트는 새 `transform`과 이벤트 연결 정보를 계산해야 합니다.

여기서 “`memo`가 적용되지 않았다”고 표현하면 정확하지 않습니다. `memo`는 Props가 같은 부모 업데이트를 건너뛰는 기능으로 여전히 동작하지만, Context를 읽는 컴포넌트의 Context 업데이트까지 차단하는 기능은 아닙니다. 이번 문제는 메모이제이션 API의 실패라기보다, 빠르게 바뀌는 Context 구독과 안정적인 콘텐츠 렌더링이 한 컴포넌트에 섞여 있었던 문제에 가깝습니다.

## 라이브러리를 바꾸기 전에 비교한 선택지

원인을 확인한 뒤 선택지는 두 가지였습니다.

### dnd-kit을 유지하고 경계를 직접 만들기

`useSortable`을 별도의 외곽 컴포넌트에서 호출하고, 그 결과를 카드 콘텐츠 컴포넌트에 Props로 넘기는 방식입니다. dnd-kit의 `DragOverlay`를 함께 사용하면 드래그 중인 표시와 정렬 대상 표시를 분리할 수도 있습니다.

이 방식은 라이브러리 교체 비용이 없다는 장점이 있지만, 기존 코드의 드래그 상태·DOM 연결·콘텐츠 책임을 직접 재배치해야 했습니다. 어떤 값이 어느 경계에서 필요한지 팀원이 코드를 읽으며 추적해야 하는 부분도 남습니다.

### `@hello-pangea/dnd`의 Draggable로 변경하기

이번 프로젝트에서는 `@hello-pangea/dnd`로 칸반 구현을 옮겼습니다. `Draggable`이 Render Props를 통해 `provided`와 `snapshot`을 전달하기 때문에, 드래그 연결 정보가 들어오는 지점과 카드 데이터를 전달하는 지점을 호출부에서 바로 구분할 수 있었기 때문입니다.

다만 라이브러리 변경 자체가 렌더링을 줄여주는 것은 아닙니다. 새로운 라이브러리를 사용하면서도 드래그 상태와 카드 콘텐츠를 한 컴포넌트에 계속 넣으면 같은 종류의 비용이 다시 생길 수 있습니다. 핵심 선택은 “어떤 라이브러리가 더 빠른가”가 아니라 “빠르게 변하는 값을 어디에서 읽고, 안정적인 UI를 어느 컴포넌트에 둘 것인가”였습니다.

## Render Props란 무엇인가

Render Props는 컴포넌트가 계산한 값을 자식 함수의 인자로 전달하는 패턴입니다. 흔히 `children`을 함수로 받는 형태로 나타납니다.

```tsx
function Draggable({children}: Props) {
  const provided = getDraggableBindings();
  const snapshot = getDragSnapshot();

  return children(provided, snapshot);
}
```

위 코드는 Render Props의 개념을 설명하기 위한 축약 예시입니다. 부모 컴포넌트가 드래그 상태를 계산하고, 실제 화면을 그리는 함수에 필요한 값만 전달한다는 점이 핵심입니다.

`@hello-pangea/dnd`의 실제 사용부는 다음과 같습니다. 이 구조는 공개된 JobSecretary의 `features/document-kanban/ui/kanban-column.tsx`에서 사용하고 있습니다.

```tsx
{applications.map((application, index) => (
  <Draggable
    key={application.id}
    draggableId={application.id}
    index={index}
  >
    {(provided, snapshot) => (
      <KanbanCard
        application={application}
        onDelete={onDelete}
        provided={provided}
        isDragging={snapshot.isDragging}
      />
    )}
  </Draggable>
))}
```

Render Props를 사용한 이유는 이 패턴이 리렌더링을 자동으로 줄여주기 때문이 아닙니다. `Draggable`이 계산한 `provided`와 `snapshot`을 사용하는 컴포넌트를 호출부에서 분명하게 만들기 위해서입니다.

`useSortable` 같은 훅은 컴포넌트 내부에서 필요한 상태를 직접 읽는 형태입니다. 반면 Render Props는 부모가 계산한 값을 자식 함수의 인자로 밀어 넣는 형태라, “드래그 상태를 받는 코드”와 “카드 데이터를 받는 코드”를 한눈에 나눠 볼 수 있습니다. 이번 구조에서는 이 명시적인 경계가 다음 단계의 `React.memo` 적용을 위한 전제가 되었습니다.

Render Props, Hook, HOC를 같은 문제의 해결책으로 볼 필요는 없습니다.

| 방식 | 상태가 들어오는 경로 | 이번 구조에서의 역할 |
| --- | --- | --- |
| Hook | 컴포넌트가 훅을 호출해 Context·상태를 직접 읽음 | `useSortable`이 드래그 계산을 연결함 |
| Render Props | 부모가 계산한 값을 자식 함수 인자로 전달함 | `Draggable`의 `provided`·`snapshot` 경계를 드러냄 |
| HOC | 래퍼가 컴포넌트를 감싸 Props나 동작을 주입함 | 이번 분리에는 사용하지 않음 |

Render Props 함수 자체는 드래그 상태에 따라 다시 호출될 수 있습니다. 따라서 Render Props를 적용한 것만으로 성능이 좋아졌다고 말할 수 없습니다. 성능에 영향을 준 부분은 그 함수에서 받은 드래그 값이 카드 콘텐츠 컴포넌트의 Props로 내려가지 않도록 경계를 만든 뒤, 안정적인 콘텐츠에 `memo`를 적용한 것입니다.

## 드래그 연결과 카드 콘텐츠를 컴포넌트로 나누기

Render Props에서 받은 `provided`와 `snapshot`은 드래그 가능한 DOM에 연결하고 현재 드래그 상태를 표현하는 데 필요합니다. 반면 회사명·직무·마감일·상태 배지는 카드 데이터만 있으면 그릴 수 있습니다.

그래서 컴포넌트 책임을 다음처럼 나눴습니다.

| 컴포넌트 | 읽는 값 | 역할 |
| --- | --- | --- |
| `KanbanCard` | `provided`, `isDragging`, 카드 데이터 | DOM ref·드래그 속성·드래그 중 스타일 연결 |
| `KanbanCardContent` | `application`, `onDelete` | 카드 정보와 액션 UI 렌더링 |

공개 저장소의 실제 `kanban-card.tsx`에서 경계를 보여주는 부분만 발췌하면 다음과 같습니다. 카드 내부의 아이콘과 스타일 JSX는 줄였지만, `memo`의 위치와 Props의 종류, 드래그 DOM을 만드는 방식은 실제 코드와 같습니다.

```tsx
const KanbanCardContent = memo(function KanbanCardContent({
  application,
  onDelete,
}: {
  application: Document;
  onDelete: (id: string) => void;
}) {
  const router = useRouter();
  const dDay = getDDay(application.deadline);

  const handleClick = () => {
    router.push(`/document/${application.id}?from=dashboard`);
  };

  // 회사명·직무·마감일·상태 배지·삭제 버튼을 렌더링합니다.
  return (
    <div onClick={handleClick} className="cursor-pointer">
      {/* 카드 정보 JSX */}
    </div>
  );
});

export function KanbanCard({
  application,
  onDelete,
  provided,
  isDragging,
}: KanbanCardProps) {
  const card = (
    <div
      ref={provided.innerRef}
      {...provided.draggableProps}
      {...provided.dragHandleProps}
      style={getStyle(provided.draggableProps.style, isDragging)}
    >
      <KanbanCardContent
        application={application}
        onDelete={onDelete}
      />
    </div>
  );

  return isDragging && typeof document !== "undefined"
    ? createPortal(card, document.body)
    : card;
}
```

실제 코드에서는 `KanbanCardContent` 내부에서 `application.company`, `application.role`, `application.deadline`과 상태 배지를 그립니다. 중요한 부분은 `provided`와 `isDragging`이 콘텐츠 컴포넌트로 전달되지 않는다는 점입니다.

부모인 `KanbanCard`가 `provided`나 `isDragging` 변경으로 다시 실행되어도, `KanbanCardContent`가 받는 `application`과 `onDelete`의 참조가 같다면 `React.memo`가 콘텐츠 함수 실행을 건너뛸 수 있습니다. 반대로 다음과 같이 매번 새 객체나 함수를 만들면 메모이제이션 효과가 사라집니다.

```tsx
// 매 렌더링마다 새 객체와 새 함수가 만들어지는 예시
<KanbanCardContent
  application={{...application}}
  onDelete={(id) => handleDelete(id)}
/>
```

따라서 경계를 분리할 때는 컴포넌트만 나누는 것으로 끝나지 않습니다. 콘텐츠에 전달하는 Props도 실제로 필요한 최소 단위로 줄이고, 상위에서 참조가 불필요하게 바뀌지 않는지 함께 확인해야 합니다.

## Portal은 렌더링이 아니라 DOM 위치를 위한 선택

드래그 상태와 콘텐츠를 분리한 뒤에도 드래그 중인 카드가 스크롤 영역 안에서 잘리거나 다른 요소 뒤에 가려지는 문제가 남을 수 있습니다. 칸반 보드처럼 부모에 `overflow`가 있고 여러 부모가 stacking context를 만들 수 있는 화면에서는 `z-index`만 높이는 것으로 충분하지 않을 수 있습니다.

그래서 드래그 중인 카드만 `document.body` 아래로 옮겼습니다.

```tsx
if (isDragging && typeof document !== "undefined") {
  return createPortal(card, document.body);
}

return card;
```

`createPortal`은 React 트리에서 컴포넌트를 분리하는 기능이 아닙니다. React의 논리적인 부모·Context 관계는 유지하면서, 실제 DOM 노드만 다른 DOM 컨테이너에 배치합니다. 따라서 Portal만 적용한다고 `useSortable`이나 드래그 상태 Context의 업데이트가 사라지지는 않습니다.

이번 Portal의 목적은 다음과 같이 한정됩니다.

- 스크롤 컨테이너의 `overflow`에 드래그 카드가 잘리지 않게 하기
- 부모의 stacking context와 `z-index`에 카드가 가려지지 않게 하기
- 윈도우 스크롤 환경에서 드래그 중인 카드를 화면 위에 안정적으로 표시하기

`React.memo`는 콘텐츠 함수가 다시 실행되는 범위를 줄이고, Portal은 드래그 중인 DOM의 배치 위치를 바꿉니다. 서로 다른 문제를 각각 다른 도구로 다룬 것입니다.

## 변경 후 같은 조건에서 다시 측정하기

같은 카드 목록과 같은 드래그 시나리오를 다시 Profiler로 기록했습니다.

| 항목 | 변경 전 | 변경 후 | 변화 |
| --- | ---: | ---: | ---: |
| 전체 커밋 횟수 | 271회 | 98회 | **63.8% 감소** |
| 비활성 카드 렌더링 | 268회 | 0회 | **100% 감소** |

이 결과를 `@hello-pangea/dnd`로 바꾼 하나의 효과로 해석하면 안 됩니다. 라이브러리 변경과 함께 드래그 연결 정보와 카드 콘텐츠를 분리했고, 콘텐츠 컴포넌트에 `React.memo`를 적용했으며, 드래그 중 DOM을 Portal로 옮겼습니다. 또한 측정은 특정 카드 수·브라우저·드래그 시나리오에서 얻은 값이므로, 다른 환경에서 같은 비율을 보장하는 수치가 아닙니다.

## 적용할 때 확인할 기준

비슷한 렌더링 문제를 만났을 때는 `memo`를 먼저 붙이기보다 다음 순서로 확인하는 편이 안전합니다.

1. **구독 경로**: 해당 컴포넌트가 Props만 읽는지, `useContext`를 직접 또는 훅 내부에서 읽는지 확인합니다.
2. **변경 빈도**: 드래그 좌표·활성 아이템처럼 자주 바뀌는 값과 카드 정보처럼 안정적인 값을 구분합니다.
3. **컴포넌트 경계**: 빠르게 바뀌는 값을 읽는 바깥 컴포넌트와 콘텐츠를 그리는 안쪽 컴포넌트를 나눕니다.
4. **Props 참조**: `memo` 대상에 새 객체·배열·함수를 매번 전달하고 있지 않은지 확인합니다.
5. **DOM 배치**: 렌더링 횟수와 별개로 `overflow`·stacking context·`z-index` 때문에 드래그 UI가 잘리는지 확인합니다.
6. **측정 범위**: Profiler의 render·commit 수와 실제 프레임 드롭·입력 지연을 같은 것으로 취급하지 않습니다.

## 정리

이번 현상은 “dnd-kit이 느리다”는 한 문장으로 설명되지 않습니다. 초기 카드 컴포넌트가 `useSortable`을 통해 정렬 Context를 읽고 있었고, 같은 함수에서 드래그 위치 계산과 회사명·직무·마감일 UI까지 함께 렌더링하고 있었습니다.

`React.memo`는 Props가 같은 일반적인 부모 업데이트를 건너뛰지만, Context를 읽는 컴포넌트의 Context 업데이트까지 차단하지 않습니다. 이 차이를 이해하면 메모이제이션이 실패한 이유를 Props 값이 아니라 구독 경로에서 찾을 수 있습니다.

이번 구조에서 각 기술의 역할은 다음과 같습니다.

- `useSortable`: 정렬 Context를 읽고 드래그·드롭·위치 계산을 연결합니다.
- `@hello-pangea/dnd`의 `Draggable`: Render Props로 드래그 연결 정보를 호출부에 전달합니다.
- `Render Props`: 드래그 상태를 받는 코드와 카드 정보를 받는 코드를 명시적으로 나누는 경계를 제공합니다.
- `React.memo`: 드래그 상태를 받지 않는 카드 콘텐츠를 카드 데이터가 바뀔 때만 다시 계산하도록 합니다.
- `React Portal`: 드래그 중인 DOM을 `document.body` 아래에 배치해 `overflow`와 stacking context 문제를 줄입니다.

결국 성능 개선의 핵심은 특정 API를 추가하는 일이 아니라, 상태가 바뀌는 범위와 화면을 다시 계산하는 범위를 일치시키는 일이었습니다.

## 참고 자료

- [JobSecretary 실제 `kanban-column.tsx`](https://github.com/kimjunha1231/JobSecretary/blob/main/features/document-kanban/ui/kanban-column.tsx)
- [JobSecretary 실제 `kanban-card.tsx`](https://github.com/kimjunha1231/JobSecretary/blob/main/features/document-kanban/ui/kanban-card.tsx)
- [dnd-kit `SortableContext.tsx`](https://github.com/clauderic/dnd-kit/blob/master/packages/sortable/src/components/SortableContext.tsx)
- [dnd-kit `useSortable.ts`](https://github.com/clauderic/dnd-kit/blob/master/packages/sortable/src/hooks/useSortable.ts)
- [dnd-kit Sortable 공식 문서](https://docs.dndkit.com/presets/sortable)
- [React `memo` 공식 문서](https://react.dev/reference/react/memo)
- [React `createPortal` 공식 문서](https://react.dev/reference/react-dom/createPortal)
- [React Render Props 문서](https://legacy.reactjs.org/docs/render-props.html)
