Published on

shadcn/ui Carousel의 상태 관리 구조를 외부 상태 구독으로 전환하기

Authors
  • avatar
    Name
    GW

개요

shadcn/ui는 일반적인 UI 라이브러리처럼 패키지 내부의 컴포넌트를 가져와 사용하는 방식과 조금 다릅니다.

CLI를 통해 컴포넌트의 소스 코드를 프로젝트에 직접 추가하기 때문에, 설치한 컴포넌트의 코드를 직접 확인하고 요구사항에 맞게 수정할 수 있습니다.

프로젝트에 추가한 Carousel 컴포넌트를 살펴보던 중 ESLint에서 react-hooks/set-state-in-effect 오류가 발생하는 것을 발견했습니다.

처음에는 Effect 내부에서 상태를 변경하는 단순한 린트 문제처럼 보였습니다.

하지만 코드를 따라가 보니 React가 직접 관리하는 상태를 변경하는 문제가 아니라, Embla Carousel이 이미 가지고 있는 값을 React State에 다시 저장해 동기화하는 구조와 관련되어 있었습니다.

Embla가 Carousel 상태 관리
↓
Effect에서 Embla 상태 조회
↓
React State에 같은 값 저장
↓
React State를 기준으로 버튼 렌더링

그렇다면 Embla가 이미 상태를 관리하고 있다면 React가 같은 값을 별도의 State로 가지고 있어야 할까요?

이 질문에서 시작해 상태의 소유권을 다시 살펴봤고, Embla의 값을 React State로 복사하는 대신 useSyncExternalStore를 이용해 외부 상태를 직접 구독하는 구조로 변경했습니다.

이후 shadcn/ui Registry의 Carousel 소스에도 같은 변경을 적용하고 검증한 뒤 Issue와 PR을 제출했습니다.

주요 내용은 다음과 같습니다.

  • set-state-in-effect 오류가 발생한 원인 분석
  • Carousel의 실제 상태를 관리하는 Embla의 역할 확인
  • React State로 값을 복사하던 구조의 문제 확인
  • useSyncExternalStore를 이용한 외부 상태 구독
  • Embla 이벤트의 구독과 Cleanup 처리
  • shadcn/ui Registry 소스 수정 및 동작 검증

Carousel에서 발견한 ESLint 오류

오류가 발생한 파일은 프로젝트에 추가되어 있던 components/ui/carousel.tsx였습니다.

해당 파일을 대상으로 ESLint를 실행하면 react-hooks/set-state-in-effect 오류가 발생했습니다.

npx eslint components/ui/carousel.tsx
Carousel ESLint 오류

ESLint가 지적한 Effect에서는 setState를 직접 호출하고 있지 않았습니다.

대신 onSelect(api)를 호출하고 있었습니다.

const onSelect = React.useCallback((api: CarouselApi) => {
  if (!api) return;

  setCanScrollPrev(api.canScrollPrev());
  setCanScrollNext(api.canScrollNext());
}, []);

호출되는 함수 내부를 확인해 보면 두 개의 React State를 변경하고 있습니다.

Effect에서는 Embla API가 준비되면 이 함수를 바로 실행하고 있었습니다.

React.useEffect(() => {
  if (!api) return;

  onSelect(api);

  api.on('reInit', onSelect);
  api.on('select', onSelect);

  return () => {
    api.off('select', onSelect);
  };
}, [api, onSelect]);

호출 관계만 보면 다음과 같습니다.

useEffect 실행
↓
onSelect(api) 호출
↓
api.canScrollPrev() 조회
api.canScrollNext() 조회
↓
setCanScrollPrev()
setCanScrollNext()
↓
React State 변경

React의 set-state-in-effect 규칙은 Effect 안에서 동기적으로 상태를 변경하면 불필요한 추가 렌더링이 발생할 수 있다고 설명합니다.

여기서 주의할 점은 Effect 안에서 setState를 사용하는 것 자체가 항상 잘못됐다는 의미는 아니라는 것입니다.

Effect는 React 외부 시스템과 동기화하기 위한 기능이고, 외부 시스템의 변경을 구독한 뒤 이벤트 콜백에서 React State를 변경하는 패턴 자체는 사용할 수 있습니다.

이번에 문제가 된 부분은 Embla API가 준비된 뒤 Effect 본문에서 onSelect(api)를 바로 호출하고, 그 과정에서 현재 값을 React State로 동기화하는 구조였습니다.

Carousel의 실제 상태는 누가 관리하고 있을까?

린트 오류를 우회하기 전에 먼저 확인한 것은 이 상태의 실제 소유자가 누구인지입니다.

shadcn/ui Carousel은 내부적으로 embla-carousel-react를 사용합니다.

const [carouselRef, api] = useEmblaCarousel(
  {
    ...opts,
    axis: orientation === 'horizontal' ? 'x' : 'y',
  },
  plugins
);

여기서 carouselRef는 Carousel의 DOM과 연결하는 Ref이고, api는 초기화된 Embla Carousel을 제어하고 현재 상태를 조회할 수 있는 API입니다.

이번 문제와 직접 관련된 메서드는 다음 네 가지입니다.

api.scrollPrev();
api.scrollNext();

api.canScrollPrev();
api.canScrollNext();

Embla의 scrollPrev()와 scrollNext()는 이전 또는 다음 Scroll Snap으로 이동하고, canScrollPrev()와 canScrollNext()는 현재 위치에서 해당 방향으로 이동할 수 있는지를 반환합니다.

예를 들어 loop가 비활성화되어 있고 각 슬라이드가 하나의 Scroll Snap에 대응한다고 단순화하면 다음과 같습니다.

여기서 Scroll Snap이란 Carousel을 스크롤했을 때 슬라이드가 딱 맞춰서 멈추는 위치라고 이해할 수 있습니다.

현재 위치이전 이동 가능다음 이동 가능
첫 슬라이드falsetrue
중간 슬라이드truetrue
마지막 슬라이드truefalse

Carousel의 이전·다음 버튼은 이 값을 이용해 비활성화 여부를 결정합니다.

<button disabled={!canScrollPrev} onClick={scrollPrev}>
  이전
</button>

<button disabled={!canScrollNext} onClick={scrollNext}>
  다음
</button>

여기서 중요한 점이 하나 있습니다.

버튼 활성화 여부를 결정하기 위한 정보는 이미 Embla가 가지고 있다는 점입니다.

React가 이 값을 직접 계산하는 것이 아닙니다.

Carousel 위치 변경
↓
Embla가 현재 Scroll Snap 관리
↓
canScrollPrev()
canScrollNext()
↓
현재 이동 가능 여부 반환

그렇다면 기존 React State는 어떤 역할을 하고 있었을까요?

기존 구현은 Embla의 값을 React State로 복사하고 있었다

기존 구현에서 상태와 관련된 부분만 모아 보면 다음과 같습니다.

const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);

const onSelect = React.useCallback((api: CarouselApi) => {
  if (!api) return;

  setCanScrollPrev(api.canScrollPrev());
  setCanScrollNext(api.canScrollNext());
}, []);

React.useEffect(() => {
  if (!api) return;

  onSelect(api);

  api.on('reInit', onSelect);
  api.on('select', onSelect);

  return () => {
    api.off('select', onSelect);
  };
}, [api, onSelect]);

React State의 초기값은 모두 false입니다.

Embla API가 준비되면 Effect에서 현재 값을 읽고 React State에 저장합니다.

이후 사용자가 Carousel을 이동하면 Embla의 select 이벤트가 발생하고, 다시 Embla의 값을 읽어 React State를 변경합니다.

즉 React State가 새로운 정보를 만들어내는 것이 아닙니다.

Embla가 이미 가지고 있는 값 중 버튼에 필요한 두 개의 boolean 값을 React State에 다시 저장하고 있었습니다.

예를 들어 Embla가 다음 이동이 가능하다고 판단하면,

api.canScrollNext(); // true

그 값을 다시 React State에 저장합니다.

setCanScrollNext(true);

이 구조에서는 Embla가 원본 상태를 가지고 있고 React State는 그 값을 화면에 반영하기 위한 복사본 역할을 합니다.

그리고 초기 복사 과정이 Effect 안에서 동기적인 State Update를 발생시키면서 set-state-in-effect 규칙에 걸리고 있었습니다.

단순히 Effect를 없애면 해결될까?

가장 먼저 생각할 수 있는 방법은 렌더링할 때 Embla의 값을 바로 읽는 것입니다.

const canScrollPrev = api?.canScrollPrev() ?? false;
const canScrollNext = api?.canScrollNext() ?? false;

이렇게 하면 React State를 별도로 만들 필요가 없습니다.

하지만 한 가지 문제가 있습니다.

Embla의 상태가 바뀌었다고 해서 React가 자동으로 다시 렌더링되는 것은 아닙니다.

사용자가 Carousel Drag
↓
Embla 내부 상태 변경
↓
React는 이 변경을 모름
↓
다시 렌더링되지 않을 수 있음
↓
버튼 상태가 갱신되지 않음

React의 렌더링은 일반적으로 Props, State, Context 등의 변경을 통해 다시 발생합니다.

Embla는 React 외부에서 자신의 상태를 관리하고 있기 때문에 Embla의 값이 바뀌었다는 사실을 React에 알려주는 연결이 필요했습니다.

따라서 이번 문제에서 필요한 것은 두 가지였습니다.

1. 현재 상태는 어디에서 읽을 것인가?
→ Embla

2. 상태가 변경됐다는 사실을 React에 어떻게 알릴 것인가?
→ Embla Event

이 구조를 살펴보면 React의 useSyncExternalStore가 해결하려는 문제와 매우 비슷하다는 것을 알 수 있습니다.

useSyncExternalStore는 어떤 문제를 해결할까?

React의 useSyncExternalStore는 React 외부에 존재하는 Store의 값을 구독하기 위한 Hook입니다.

React 공식 문서에서는 외부 Store의 예로 React 외부에서 상태를 관리하는 서드파티 상태 관리 라이브러리뿐만 아니라, 변경 가능한 값을 가지고 이벤트를 통해 값의 변경을 알리는 Browser API도 설명합니다.

또한 React의 You Might Not Need an Effect에서도 외부 Store를 Effect와 State로 직접 동기화하기보다 useSyncExternalStore를 사용하는 방식을 소개합니다.

기본적인 구조는 다음과 같습니다.

const value = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

각 함수의 역할은 명확합니다.

subscribe
→ 값이 변경될 수 있는 시점을 React에 알림

getSnapshot
→ 현재 값을 읽음

getServerSnapshot
→ Server Rendering과 Hydration에서 사용할 초기값 제공

이 구조를 Embla에 대응시키면 다음과 같습니다.

External Store
→ Embla Carousel

subscribe
→ Embla의 reInit / select 이벤트

getSnapshot
→ api.canScrollPrev()
→ api.canScrollNext()

Embla는 현재 값을 읽을 수 있는 API와 상태 변경을 알리는 이벤트를 모두 제공하고 있습니다.

따라서 React State에 값을 복사해 두기보다 Embla를 상태의 원본으로 유지하면서 React가 필요한 값을 구독하도록 만드는 구조가 가능합니다.

Embla 상태를 useSyncExternalStore로 구독하기

기존에 사용하던 두 개의 useState와 onSelect, 초기 상태를 동기화하던 Effect를 제거하고 다음과 같이 변경했습니다.

const subscribe = React.useCallback(
  (onStoreChange: () => void) => {
    if (!api) {
      return () => {};
    }

    api.on('reInit', onStoreChange);
    api.on('select', onStoreChange);

    return () => {
      api.off('reInit', onStoreChange);
      api.off('select', onStoreChange);
    };
  },
  [api]
);

const canScrollPrev = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollPrev() ?? false,
  () => false
);

const canScrollNext = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollNext() ?? false,
  () => false
);

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

Embla에서 reInit / select 이벤트 발생
↓
onStoreChange() 호출 (React에 Store 변경 알림)
↓
getSnapshot()으로 현재 상태 확인
↓
canScrollPrev() / canScrollNext() 호출
↓
이전 Snapshot과 비교
↓
값이 변경되었다면 다시 렌더링
↓
이전 / 다음 버튼 상태 갱신

기존 구조와 비교하면 차이가 더 명확합니다.

기존

Embla 상태 변경
↓
Event 발생
↓
setState() 호출
↓
Embla의 상태를 React State에 복사
↓
React State가 변경되었기 때문에 다시 렌더링
변경 후

Embla 상태 변경
↓
Event 발생
↓
React에 상태 변경 알림
↓
Embla의 현재 Snapshot을 조회한 후, 이전 Snapshot과 비교
↓
변경되었다면 다시 렌더링

React State를 Embla와 계속 맞춰 두는 것이 아니라, Embla를 상태의 원본으로 두고 React는 필요한 값을 읽는 구조로 바뀐 것입니다.

subscribe는 Embla와 React를 어떻게 연결할까?

useSyncExternalStore의 첫 번째 인자는 subscribe입니다.

const subscribe = React.useCallback(
  (onStoreChange: () => void) => {
    if (!api) {
      return () => {};
    }

    api.on('reInit', onStoreChange);
    api.on('select', onStoreChange);

    return () => {
      api.off('reInit', onStoreChange);
      api.off('select', onStoreChange);
    };
  },
  [api]
);

React는 subscribe에 Store가 변경되었을 때 호출할 Callback을 전달합니다.

여기서는 그 Callback을 Embla의 reInit와 select 이벤트에 등록합니다.

이후 Embla 이벤트가 발생하면 onStoreChange가 실행되고 React는 Snapshot을 다시 확인합니다.

subscribe를 useCallback으로 감싼 이유도 여기에 있습니다.

React의 useSyncExternalStore 문서에서는 렌더링마다 다른 subscribe 함수가 전달되면 React가 Store를 다시 구독한다고 설명합니다.

따라서 같은 Embla API를 사용하는 동안에는 subscribe의 참조를 유지하고, API 인스턴스가 변경되었을 때 새로운 Embla 인스턴스를 구독하도록 합니다.

같은 api
→ 같은 subscribe
→ 기존 구독 유지

api 변경
→ 새로운 subscribe
→ 새로운 Embla 인스턴스 구독

getSnapshot은 현재 값을 직접 읽는다

두 번째 인자는 현재 Store의 Snapshot을 반환하는 함수입니다.

이전 버튼에서는 다음 값을 읽습니다.

() => api?.canScrollPrev() ?? false;

다음 버튼에서는 다음 값을 읽습니다.

() => api?.canScrollNext() ?? false;

React는 Store 변경 알림을 받으면 getSnapshot을 다시 호출하고 이전 Snapshot과 새로운 Snapshot을 Object.is로 비교합니다.

값이 달라졌다면 컴포넌트를 다시 렌더링합니다.

이번 Snapshot은 boolean이기 때문에 구조도 단순합니다.

이전 Snapshot
false
↓
Embla 상태 변경
↓
Store 변경 알림
↓
새 Snapshot
true
↓
Object.is(false, true)
↓
false
↓
Snapshot 변경됨
↓
React Re-render

반대로 Embla 이벤트가 발생했더라도 실제 이동 가능 여부가 그대로라면 다시 렌더링할 필요가 없습니다.

이전 Snapshot
true
↓
Embla select 이벤트 발생
↓
Store 변경 알림
↓
새 Snapshot
true
↓
Object.is(true, true)
↓
true
↓
Snapshot 변경 없음
↓
Re-render 하지 않음

예를 들어 양쪽으로 이동 가능한 중간 지점에서 다른 중간 지점으로 이동한다면 select 이벤트가 발생하더라도 canScrollPrev()와 canScrollNext()는 계속 true일 수 있습니다.

즉 이벤트가 발생했다는 사실과 실제 UI에 필요한 값이 변경되었다는 사실을 구분할 수 있습니다.

getServerSnapshot에서 false를 반환한 이유

세 번째 인자는 getServerSnapshot입니다.

() => false;

React 공식 문서에 따르면 getServerSnapshot은 Server Rendering과 서버에서 생성한 HTML을 Hydration하는 과정에서 초기 Snapshot을 제공하는 데 사용됩니다.

Embla API는 브라우저에서 Carousel이 초기화된 뒤 사용할 수 있으므로 Server에서는 실제 canScrollPrev()나 canScrollNext() 값을 계산할 수 없습니다.

따라서 초기에는 두 버튼 모두 이동할 수 없는 상태(false)로 두고,

Server Rendering
↓
canScrollPrev = false
canScrollNext = false

클라이언트에서 Embla API가 준비된 뒤 실제 Snapshot을 읽도록 했습니다.

Client
↓
Embla 초기화
↓
api 사용 가능
↓
실제 Snapshot 확인
↓
버튼 상태 반영

여기서 false는 Embla의 실제 초기 상태를 Server에서 계산했다는 의미가 아닙니다.

Server와 Hydration 과정에서 사용할 수 있는 안정적인 초기 Snapshot을 제공하고, Embla가 준비된 뒤 실제 상태를 구독하는 구조입니다.

구독한 이벤트는 Cleanup에서 모두 해제하기

useSyncExternalStore의 구독 함수에서는 Embla의 reInit와 select 이벤트를 사용했습니다.

reInit은 Carousel이 재초기화될 때 발생하고, select는 선택된 Scroll Snap이 변경될 때 발생합니다.

따라서 사용자가 Carousel을 이동하거나 크기와 설정 등의 변화로 Embla가 재초기화되면 React가 이동 가능 여부를 다시 확인할 수 있습니다.

Carousel 상태 변경
├─ reInit → Carousel 재초기화
└─ select → 선택된 Scroll Snap 변경
↓
onStoreChange()
↓
canScrollPrev() / canScrollNext() 다시 확인

이 과정에서 기존 구현의 이벤트 구독과 해제 코드도 함께 확인했습니다.

기존에는 reInit과 select 두 이벤트를 등록하고 있었지만 Cleanup에서는 select만 해제하고 있었습니다.

api.on('reInit', onSelect);
api.on('select', onSelect);

return () => {
  api.off('select', onSelect);
};

Embla의 이벤트 문서에 따르면 등록된 Event Listener는 reInit 이후에도 유지되며, off()로 제거하려면 on()에 전달했던 것과 동일한 Callback 참조를 전달해야 합니다.

수정 후에는 구독할 때 등록한 두 이벤트를 Cleanup에서 모두 해제하도록 변경했습니다.

api.on('reInit', onStoreChange);
api.on('select', onStoreChange);

return () => {
  api.off('reInit', onStoreChange);
  api.off('select', onStoreChange);
};
Subscribe
├─ reInit → onStoreChange 등록
└─ select → onStoreChange 등록

Cleanup
├─ reInit → onStoreChange 해제
└─ select → onStoreChange 해제

이를 통해 구독 과정에서 등록한 이벤트와 Cleanup에서 해제하는 이벤트가 서로 대응하도록 정리하고, 구독의 생성과 정리 책임을 하나의 함수 안에서 명확하게 맞출 수 있었습니다.

useSyncExternalStore를 두 번 사용하면 구독도 두 번 생긴다

이번 구현에서는 useSyncExternalStore를 두 번 사용합니다.

const canScrollPrev = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollPrev() ?? false,
  () => false
);

const canScrollNext = React.useSyncExternalStore(
  subscribe,
  () => api?.canScrollNext() ?? false,
  () => false
);

같은 subscribe 함수를 전달하기 때문에 하나의 구독을 공유한다고 생각할 수도 있습니다.

하지만 각각의 useSyncExternalStore 호출은 독립적인 구독입니다.

개념적으로는 다음과 같습니다.

useSyncExternalStore #1
├─ reInit 구독
└─ select 구독

useSyncExternalStore #2
├─ reInit 구독
└─ select 구독

하나의 객체 Snapshot으로 합치는 방법도 생각할 수 있습니다.

{
  canScrollPrev: api?.canScrollPrev() ?? false,
  canScrollNext: api?.canScrollNext() ?? false,
}

하지만 getSnapshot에서 렌더링할 때마다 새로운 객체를 생성하면 객체 참조가 계속 달라질 수 있습니다.

React 공식 문서에서도 getSnapshot의 결과는 안정적이어야 하며, 변경 가능한 Store를 사용하는 경우 변경되지 않았다면 캐시된 Snapshot을 반환해야 한다고 설명합니다.

이번에는 필요한 값이 boolean 두 개뿐이었기 때문에 각각 독립적인 Snapshot으로 두는 단순한 방식을 선택했습니다.

처음 오류를 발견한 곳은 개인 프로젝트의 components/ui/carousel.tsx였습니다.

shadcn/ui는 컴포넌트 코드를 프로젝트에 직접 추가하기 때문에 이 파일만 수정해도 해당 프로젝트의 문제는 해결할 수 있습니다.

하지만 shadcn/ui의 Carousel 코드 자체를 개선하려면, CLI를 통해 배포되는 코드의 원본인 Registry를 수정해야 했습니다.

shadcn/ui의 Registry 공식 문서에서는 Registry를 컴포넌트, Hook, Page, 설정 등의 코드를 다른 프로젝트에 배포할 수 있는 시스템으로 설명합니다.

shadcn/ui Registry 구조

공식 문서의 그림처럼 Registry에 정의된 코드는 shadcn CLI를 통해 다양한 프로젝트에 배포할 수 있으며, 특정 프레임워크에만 한정되지 않습니다.

Registry는 여러 Registry Item으로 구성되며, 각 Item에는 실제 소스 파일뿐 아니라 필요한 npm 패키지와 다른 Registry Item 의존성 등을 정의할 수 있습니다.

이러한 Registry 구조에서 Carousel 역시 하나의 Registry Item으로 관리됩니다.

예를 들어 new-york-v4의 UI Registry는 다음 파일에서 Carousel을 포함한 Registry Item 정보를 관리합니다.

apps/v4/registry/new-york-v4/ui/_registry.ts

이 파일에는 Carousel의 Registry Item이 다음과 같이 정의되어 있습니다.

{
  name: 'carousel',
  type: 'registry:ui',
  files: [
    {
      path: 'ui/carousel.tsx',
      type: 'registry:ui',
    },
  ],
  registryDependencies: ['button'],
  dependencies: ['embla-carousel-react'],
}

여기서 carousel.tsx 자체의 구현 코드가 들어 있는 것이 아니라, Carousel을 Registry에서 구성하고 배포하기 위해 필요한 정보가 정의되어 있습니다.

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

_registry.ts
↓
Carousel Registry Item
├─ 이름: carousel
├─ 소스 파일: ui/carousel.tsx
├─ Registry 의존성: button
└─ npm 의존성: embla-carousel-react

즉 Registry Item은 어떤 소스 파일을 사용하고 어떤 의존성이 필요한지를 정의하고, 실제 Carousel의 구현은 ui/carousel.tsx에 존재합니다.

수정 당시 저장소에서는 Carousel 소스가 여러 Registry 구성에 존재하고 있었기 때문에 동일한 구조를 사용하는 원본을 함께 수정했습니다.

apps/v4/registry/
├─ bases/
│  ├─ base/ui/carousel.tsx
│  ├─ aria/ui/carousel.tsx
│  └─ radix/ui/carousel.tsx
└─ new-york-v4/ui/carousel.tsx

이 구조가 shadcn/ui의 모든 컴포넌트에 항상 동일하게 적용된다는 의미는 아닙니다.

이번 변경 당시 Carousel의 수정 대상이었던 Registry 소스의 범위입니다.

린트 오류 해결과 기존 동작 검증

코드를 변경한 뒤에는 두 가지를 나누어 확인했습니다.

문제가 해결됐는가?
+
기존 기능이 그대로 동작하는가?

먼저 처음 문제를 발견했던 Carousel 파일에 ESLint를 다시 실행했습니다.

npx eslint components/ui/carousel.tsx

react-hooks/set-state-in-effect 오류가 사라진 것을 확인했습니다.

그다음 테스트 페이지에서 Carousel의 실제 동작을 확인했습니다.

확인한 상황확인 결과
첫 슬라이드이전 버튼 비활성화
중간 슬라이드양쪽 버튼 활성화
마지막 슬라이드다음 버튼 비활성화
버튼으로 이동이동 후 버튼 상태 갱신
Drag로 이동이동 후 버튼 상태 갱신
loop: true 설정양쪽 버튼 활성화 유지

shadcn/ui 저장소에서는 Registry Build와 정적 검사도 실행했습니다.

pnpm registry:build
pnpm check

작업 당시 check 스크립트에서 실행하던 lint, typecheck, format 검사까지 통과하는 것을 확인했습니다.

마지막으로 CLI 통합 테스트가 포함된 tests 패키지도 실행했습니다.

pnpm --filter=tests exec vitest run \
  --maxWorkers=1 \
  --no-file-parallelism \
  --silent=passed-only
shadcn/ui CLI 통합 테스트 결과

실행한 테스트 파일이 모두 통과했으며, 실패한 테스트가 없는 것을 확인했습니다.

다만 이 결과는 Carousel만을 대상으로 한 테스트가 아니라 shadcn CLI의 기존 동작을 확인하는 통합 테스트 전체의 실행 결과입니다.

Carousel의 버튼 상태와 Drag 동작은 테스트 페이지에서 별도로 확인했습니다.

따라서 이번 변경의 검증 범위는 다음과 같이 구분할 수 있습니다.

ESLint
→ 기존 린트 오류 해결 확인

Registry Build / Check
→ Registry 생성과 정적 검사 확인

CLI 통합 테스트
→ 기존 CLI 동작의 회귀 여부 확인

수동 Carousel 테스트
→ 버튼 상태와 Drag 동작 확인

Issue 및 PR 작성

수정과 검증을 마친 뒤에는 문제를 발견한 과정과 변경 내용을 정리해 shadcn/ui 저장소에 Issue와 PR을 작성했습니다.

Issue에는 ESLint 오류가 발생하는 환경과 재현 방법, 영향을 받는 Carousel 소스의 범위를 정리했습니다.

PR에는 기존 상태 관리 방식에서 어떤 부분을 변경했는지와 함께 검증한 내용을 담았습니다.

  • Embla 값을 복사하던 React State와 초기 동기화 Effect 제거
  • useSyncExternalStore를 이용한 Embla 상태 구독
  • reInit과 select 이벤트를 이용한 Snapshot 갱신
  • 등록한 Embla Event Listener의 Cleanup 처리
  • Registry의 Carousel 원본에 동일한 변경 적용
  • ESLint, Registry Build, 정적 검사, 통합 테스트 및 수동 동작 검증

관련 내용은 아래 Issue와 PR에서 확인할 수 있습니다.

마치며

이번 작업은 shadcn/ui를 통해 추가한 Carousel에서 발생한 set-state-in-effect 오류를 확인하는 것에서 시작했습니다.

처음에는 Effect 내부의 상태 업데이트가 문제처럼 보였지만, 코드를 따라가면서 살펴보니 핵심은 Embla가 이미 관리하고 있는 상태를 React State에서도 별도로 관리하고 있다는 점에 있었습니다.

Embla는 현재 상태를 읽을 수 있는 API와 상태 변경을 알리는 Event를 제공하고 있었고, 이를 useSyncExternalStore와 연결하면서 별도의 React State 없이도 필요한 값을 구독할 수 있었습니다.

하나의 린트 오류를 해결하는 과정이었지만, useSyncExternalStore를 실제 사례에 적용해보면서 외부에서 관리되는 상태를 React가 어떻게 구독하고 렌더링에 반영하는지를 구체적으로 이해할 수 있었습니다.

참고 자료