
최근 대규모 데이터 환경에서도 매끄럽게 동작하는 서비스를 만들기 위해 토이 프로젝트로 칸반 보드를 개발하며 1,000개 이상의 카드 컴포넌트를 목업으로 생성해 테스트를 진행했다.
데이터가 적을 때는 끊김 없이 동작하던 기능들이 카드 개수가 늘어나자 렌더링 지연이 발생하며 스크롤이 끊기기 시작했다. 여러 원인이 있었지만, 가장 먼저 눈에 띈 것은 전역 상태 관리 도구인 Zustand와 컴포넌트가 연결되는 지점에서의 불필요한 리렌더링이었다. 이번 글에서는 Zustand의 상태 구독 방식을 개선하여 렌더링 성능을 일차적으로 최적화한 과정을 정리한다.
Zustand는 기본적으로 스토어에서 객체나 배열을 반환할 때 내용이 같더라도 새로운 참조값이 생성되면 구독 중인 컴포넌트는 상태가 변경되었다고 판단하여 리렌더링을 발생시킨다.
칸반 보드에서 각 Column 컴포넌트가 전체 cards 배열을 구독하여 자신의 컬럼에 맞는 카드만 필터링하도록 다음과 같이 초기 코드를 구현했다.
const cards = useBoardStore((state) =>
state.cards.filter(item => item.columnId === column.id)
);
이 방식은 성능 문제를 일으켰다. 전역 상태인 state.cards에 변화가 생길 때마다 filter 메서드가 실행되어 매번 새로운 배열을 반환하기 때문이다. "할 일" 컬럼의 카드 하나만 수정되어도 "진행 중", "완료" 등 상태가 변하지 않은 다른 모든 컬럼까지 같이 리렌더링되는 현상이 발생했다.
useShallow를 통한 얕은 비교 적용이러한 불필요한 리렌더링을 막기 위해 Zustand에서 제공하는 useShallow를 적용했다. useShallow는 반환값을 얕은 비교를 통해 참조값이 다르더라도 내부의 실제 데이터가 동일하면 리렌더링을 발생시키지 않는다.
// Column.tsx
import { useShallow } from "zustand/react/shallow";
const cardsFromStore = useBoardStore(
useShallow((state) =>
state.cards.filter((item) => item.columnId === column.id),
),
);
useShallow의 효과useShallow는 반환된 배열이나 객체의 각 요소를 얕게 비교하여 실제 데이터 변경이 있을 때만 구독 컴포넌트에 변경을 알린다.

React DevTools에서
useShallow적용 시 hooks에Shallow:항목이 나타난다. 위 캡쳐본의 "완료" 컬럼에서Shallow: Ref배열에는 해당 컬럼의 카드들(columnId: "done")만 포함되어 있어 선택적 구독이 이루어지고 있음을 확인할 수 있다.
이를 통해 각 Column 컴포넌트 내부에서 구독하는 cardsFromStore 값이 실제로 변경되지 않으면, 해당 컬럼의 내부 로직(가상화 계산, 필터링 등)이 불필요하게 재실행되는 것을 방지할 수 있다.
예를 들어, "완료" 컬럼의 카드가 전혀 변경되지 않았는데도 다른 컬럼의 카드가 변경되면, useShallow 없이는 매번 새로운 배열 참조가 생성되어 내부 로직이 재실행된다. useShallow를 적용하면 실제 데이터가 동일한지 비교하여 불필요한 재계산을 막을 수 있다.
useShallow는 중첩된 객체의 내부 프로퍼티 변경은 감지하지 못한다얕은 비교만 하기 때문에 내부 프로퍼티 변경은 감지하지 못한다.
const card = useBoardStore(
useShallow((state) => state.cards.find(c => c.id === cardId))
);
// ❌ card.description이 변경되어도 감지하지 않음!
const card = useBoardStore(
useShallow((state) => {
const found = state.cards.find(c => c.id === cardId);
return found ? { ...found } : null;
// ✅ 새 객체로 감싸서 반환
})
);
useShallow를 사용해도 매 렌더링마다 전체 배열을 순회하며 필터링하는 연산은 계속 발생한다. 데이터가 많아지면 전역 스토어 설계 단계에서 컬럼별로 데이터를 분리하는 구조를 고려해야 한다.
useShallow는 스토어 구독 레벨에서의 최적화이므로, 컴포넌트 자체의 리렌더링을 막으려면 React.memo와 함께 사용해야 한다. useShallow만으로는 부모 컴포넌트가 리렌더링될 때 자식 컴포넌트의 리렌더링을 막을 수 없다.
배열이나 객체가 아닌 숫자, 문자열, 불리언과 같은 단순 원시값을 반환할 때는 Zustand가 기본적으로 값을 정확히 비교하므로 useShallow를 사용할 필요가 없다.
// useShallow 불필요한 경우
const cardCount = useBoardStore(
(state) => state.cards.filter(c => c.columnId === columnId).length
);
현재 구현에서는
Column컴포넌트가React.memo로 감싸져 있지 않아서Board컴포넌트가 리렌더링될 때 모든Column이 함께 리렌더링된다. 따라서useShallow의 효과가 완전히 발휘되지는 않는다. 하지만useShallow를 적용함으로써 각Column내부에서 구독하는cardsFromStore값이 실제로 변경되지 않으면 해당 컬럼의 내부 로직(가상화 계산 등)이 불필요하게 재실행되는 것을 방지할 수 있다.
최종적으로 칸반 보드의 Column 컴포넌트 구조는 다음과 같이 개선되었다.
function ColumnInner({ column, ... }: ColumnProps) {
// 해당 컬럼의 카드만 구독
const cardsFromStore = useBoardStore(
useShallow((state) =>
state.cards.filter((item) => item.columnId === column.id),
),
);
// props로 전달된 cards가 있으면 우선 사용 (낙관적 업데이트용)
const cards = cardsProp ?? cardsFromStore;
// ...
}
useShallow를 통해 Zustand 스토어 구독 시 발생하는 불필요한 리렌더링을 효과적으로 제어할 수 있었다. 대량의 데이터를 다루거나 여러 컴포넌트가 하나의 상태를 파편적으로 구독해야 하는 환경에서 유용한 접근 방식이었다.
하지만 스토어 차원의 렌더링을 처리한 이후에도 컴포넌트 내부의 props 변화나 함수 재생성으로 인한 리렌더링 이슈는 여전히 남아있었다. 이는 다음 포스트에서는 메모이제이션을 활용해 개선한 방식을 통해 보여줄 예정이다.