본문으로 건너뛰기
5분 AI.
REPO DEEP DIVE · 2026-07-29 · ADOBE/REACT-SPECTRUM · 3층 설계로 만든 접근성 컴포넌트 왕국

adobe/react-spectrum 딥다이브
— "상태 · 동작 · 디자인"을 완전히 분리한 컴포넌트 모노레포

UI 라이브러리

빠른 판단

무엇
상태 관리·접근성 동작·Spectrum 스타일을 분리해 제공하는 React UI 패키지 모음
이럴 때
Adobe 스타일 완제품을 쓰거나, 자체 디자인에 필요한 선택 상태·키보드·포커스 로직만 가져올 때
react-spectrum은 Adobe가 만드는 단일 저장소, 4개 라이브러리다. "어떻게 상태가 바뀌는가(React Stately)" · "어떻게 접근 가능하게 동작하는가(React Aria)" · "어떻게 보이는가(React Spectrum / S2)"를 층층이 분리해, 같은 로직 위에 완전히 다른 디자인을 자유롭게 얹을 수 있게 만들었다. 그 자체가 하나의 UI 킷이 아니라, UI 킷을 만드는 재료 창고에 가깝다.

가장 큰 특징은 "컴포넌트 하나(예: Select)가 상태 훅 → ARIA 훅 → 스타일 컴포넌트, 3단계를 순서대로 통과하며 완성된다"는 점이다. 각 단계는 독립적으로 npm에 배포되고, 독립적으로 테스트되며, 독립적으로 재사용 가능하다. 그래서 Adobe 자체 제품(Photoshop 웹, Adobe Express 등)뿐 아니라 완전히 다른 디자인 시스템을 만들고 싶은 팀도 React Aria 훅만 가져다 쓸 수 있다.

(저장소 adobe/react-spectrum · 라이선스 Apache-2.0 · 모노레포 루트 버전 2.21.0-beta.0 · React 19.2 지원(16~19 전부 테스트) · @react-stately(33개) · @react-aria(55개) · @react-spectrum(66개) 스코프 패키지 + react-aria-components(선언형 레이어, 컴포넌트 69종) + @internationalized/* · 소스 TS/TSX 약 2,549개(테스트·스토리 제외), 전체 파일 1만여 개 · 빌드 yarn 4 + Lerna + Parcel + Make · CI는 CircleCI(잡 30여 개) · 최신 커밋 1259843b(2026-07-29))
목차
  1. 한 줄 정체
  2. 왜 지금 뜨는가
  3. 무엇을 해결하나
  4. 전체 구조
  5. 핵심 설계·동작 원리
  6. 기술 스택·의존성
  7. 설치·요구사항
  8. 실제 사용 흐름
  9. 함정·오해 방지
  10. 대안 비교
  11. 정리 — 누가 쓰면 좋은가
  12. 직접 해볼 수 있는 실습 과제
  13. 관련 기술 심화 학습 로드맵
  14. 핵심 키워드 사전

1한 줄 정체

한 문장으로: "상태 로직·접근성 동작·디자인 스타일을 3층으로 쌓아 만든, 컴포넌트계의 반도체 팹"

react-spectrum은 컴포넌트를 "완제품"이 아니라 "재료·중간재·완제품"의 3단 생산 라인으로 나눠 파는 저장소다. 예를 들어 드롭다운 하나를 만들 때, ① @react-stately/select가 "지금 뭐가 선택됐는지, 메뉴가 열렸는지"를 관리하고, ② @react-aria/select가 거기에 "키보드 화살표·포커스·스크린리더 안내"를 씌워 순수 DOM 속성(props)만 돌려주고, ③ @react-spectrum/picker 또는 react-aria-components의 <Select>가 그 위에 실제 화면을 그린다. 세 층 모두 따로 npm에 배포되고 따로 테스트된다.

이 구조 덕에 Adobe는 "같은 로직 위에 Spectrum 1(구 디자인)과 Spectrum 2(신 디자인)를 동시에" 얹을 수 있고, 외부 팀은 "Adobe 디자인은 필요 없고 접근성 로직만" 가져다 자기 디자인 시스템을 만들 수 있다. React Aria 자체가 "unstyled(스타일 없는) 컴포넌트" 생태계의 대표 주자로 꼽히는 이유다.

한 줄 비유

"뼈대(상태) · 신경(동작) · 피부(디자인)"를 따로 만들어 조립하는 인체 공장

보통 UI 라이브러리는 뼈대·신경·피부가 한 몸에 붙어 나온다. 마음에 안 드는 피부(디자인)를 바꾸려면 몸 전체를 다시 만들어야 한다.

react-spectrum은 뼈대(React Stately), 신경계(React Aria), 피부(React Spectrum/S2, 또는 당신의 디자인)를 따로 파는 조립 부품점이다. 뼈대와 신경은 그대로 둔 채 피부만 통째로 갈아 끼울 수 있다.

2왜 지금 뜨는가

"unstyled 컴포넌트" 트렌드의 원조 · AI 시대 접근성 회귀 · Spectrum 2 전환기

① "unstyled 컴포넌트" 붐의 뿌리

Radix Primitives·Headless UI·Base UI·Ark UI 같은 "스타일 없이 동작만 주는" 컴포넌트 라이브러리가 최근 프론트엔드의 표준 문법이 됐다. React Aria는 이 흐름을 2020년부터 밀어붙인 선구자 중 하나다. 다른 라이브러리들이 여기 합류하면서 오히려 "react-spectrum의 접근성 깊이"가 재조명받고 있다.

② AI 코드 생성 시대의 "접근성 부채" 문제

AI가 UI 코드를 순식간에 찍어내는 시대에, 키보드 내비게이션·스크린리더 라벨·포커스 관리 같은 디테일은 오히려 더 쉽게 누락된다. React Aria처럼 "동작을 훅으로 캡슐화"해두면 AI가 만든 커스텀 UI에도 검증된 접근성 로직을 그대로 꽂을 수 있어, 최근 AI 코딩 워크플로에서 재조명되고 있다. 실제로 이 저장소에는 packages/dev/mcp/react-aria·packages/dev/mcp/s2라는 공식 MCP(Model Context Protocol) 서버가 포함돼, AI 에이전트가 React Aria/S2 문서를 직접 조회하며 코드를 짤 수 있게 되어 있다.

③ Spectrum 2(S2) 전환과 스타일 매크로 실험

Adobe는 자체 디자인 시스템을 Spectrum 2로 새로 그리는 중이며, @react-spectrum/s2 패키지가 그 결과물이다. 여기서 쓰는 컴파일 타임 스타일 매크로(style macro)는 "런타임 CSS-in-JS 없이, 빌드 시점에 원자적 CSS로 컴파일"하는 실험적 접근이라 프론트엔드 성능 커뮤니티에서 자주 언급된다.

경쟁 지형
"완제품 UI 킷"과 "재료 창고"의 차이

MUI·Chakra·Mantine 같은 완제품 킷은 빠르게 시작하기 좋지만 로직과 디자인이 붙어있다. react-spectrum(정확히는 그 안의 React Aria)은 재료만 파는 쪽이다. Radix·Base UI·Ark UI도 같은 철학이지만, react-spectrum은 Adobe 실제 제품(수백 개 화면)에서 10년 가까이 검증된 접근성 세부사항과 완전한 국제화(30개 이상 언어, 9종 달력)까지 포함한다는 깊이가 다르다.

3무엇을 해결하나

"접근성은 어렵다" · "디자인 시스템 교체는 전면 재작성" · "국제화는 나중에" 세 가지 고질병
문제 ①

웹 접근성(a11y)은 혼자 구현하기 매우 어렵다

키보드로만 메뉴를 열고 닫고 화살표로 이동하고, 스크린리더가 "몇 개 중 몇 번째 항목"이라고 읽어주고, 포커스가 열린 팝업 안에 갇혔다가(focus trap) 닫히면 원래 자리로 돌아오는 것 — 이걸 매번 새로 짜면 버그투성이가 된다. React Aria는 WAI-ARIA 명세를 코드로 미리 구현해, useSelect() 한 번 호출로 이 모든 걸 해결한다.

문제 ②

디자인 시스템을 바꾸면 로직까지 다 새로 짜야 한다

보통 UI 킷은 컴포넌트를 바꾸면 상태 관리·키보드 로직까지 통째로 새로 짜야 한다. react-spectrum은 상태(Stately)·동작(Aria)을 디자인(Spectrum)과 분리해 둬서, Spectrum 1 → Spectrum 2 전환처럼 큰 리디자인도 아래 두 층은 그대로 두고 위 껍데기만 바꿔 끼울 수 있다.

문제 ③

국제화(i18n)는 항상 "나중에 붙이는 애물단지"가 된다

날짜·숫자 포맷, RTL(오른쪽에서 왼쪽 언어) 레이아웃, 지역별 달력 체계는 나중에 붙이려면 컴포넌트 구조 자체를 바꿔야 하는 경우가 많다. react-spectrum은 @internationalized/date·number를 맨 처음부터 상태 로직 안에 내장해, 그레고리력뿐 아니라 불교력·에티오피아력·헤브루력·인도력·이슬람력·일본력·페르시아력·타이완력까지 9종을 기본 지원한다.

4전체 구조

모노레포 하나, 4개 라이브러리, 220개 이상의 독립 패키지

packages/ 아래에 라이브러리별로 스코프가 나뉜다. 실제 클론해서 세어보면 @react-stately 33개, @react-aria 55개, @react-spectrum 66개 스코프 패키지가 있고, 여기에 react-aria-components(선언형 레이어, 컴포넌트 소스 파일 69개), @internationalized/*(date·number·message·string·string-compiler), @react-types/*(순수 타입), @spectrum-icons/*가 더해진다.

┌────────────────────────────────────────────────────────────────┐ │ REACT-SPECTRUM 모노레포 전체 지도 │ └────────────────────────────────────────────────────────────────┘ packages/ ├── react-stately/ # "번들 패키지" — 실제 훅 소스가 여기 있음 │ └── src/select/useSelectState.ts ... ├── react-aria/ # "번들 패키지" — 실제 훅 소스가 여기 있음 │ └── src/select/useSelect.ts ... ├── react-aria-components/ # 선언형 JSX 레이어(RAC) — <Select><Button>... │ ├── @react-stately/* (33개) # select, list, table, calendar, color, dnd ... │ └── select/src/index.ts → "export {useSelectState} from 'react-stately/useSelectState'" ├── @react-aria/* (55개) # select, listbox, menu, focus, i18n, virtualizer ... │ └── select/src/index.ts → "export {useSelect} from 'react-aria/useSelect'" ├── @react-spectrum/* (66개) # picker, table, combobox, s2(Spectrum 2), theme-* ... │ ├── @internationalized/ date · number · message · string · string-compiler ├── @react-types/* # 순수 타입 정의(.d.ts) — 모든 층이 공유 ├── @spectrum-icons/* # SVG 아이콘 → React 컴포넌트 ├── @adobe/react-spectrum # 완성형 "올인원" 패키지(v3.47.2, "S1") └── dev/mcp/{react-aria,s2} # AI 에이전트용 공식 MCP 문서 서버

핵심 포인트: @react-stately/select·@react-aria/select 같은 스코프 패키지들은 껍데기다. 진짜 훅 구현은 packages/react-stately/src/와 packages/react-aria/src/라는 "번들 패키지"에 있고, 스코프 패키지는 그 파일을 package.json의 exports 서브패스 매핑을 통해 재수출만 한다. (이 구조는 5장·9장에서 실제 코드로 추적한다.)

5핵심 설계·동작 원리

Select/Picker 하나를 3층 끝까지 추적 · Collections API · 가상화 · 국제화 · usePress

① 3층 아키텍처를 실제 소스로 추적하기 — "Select"

가장 좋은 증명은 실제로 하나의 컴포넌트를 끝까지 따라가는 것이다. 여기서는 드롭다운 선택 컴포넌트(Spectrum에서는 Picker라는 이름)를 추적한다.

1단계 — 상태만 아는 useSelectState (React Stately)

packages/react-stately/src/select/useSelectState.ts는 DOM도, ARIA도, 렌더링도 전혀 모른다. 하는 일은 딱 세 가지: useListState로 항목 컬렉션과 선택 상태를 관리하고, useOverlayTriggerState로 "메뉴가 열렸는지"를 관리하고, useFormValidationState로 폼 유효성 상태를 관리한 뒤, 이 셋을 하나의 SelectState 객체로 합쳐 돌려준다.

export function useSelectState<T, M>(props): SelectState<T, M> {
  let triggerState = useOverlayTriggerState(props);       // 열림/닫힘
  let listState = useListState({ ...props, selectionMode }); // 선택/컬렉션
  let validationState = useFormValidationState({ ...props });  // 폼 검증
  return { ...validationState, ...listState, ...triggerState, value, setValue, open, toggle };
}

이 함수만 떼어서 React Native나 서버 컴포넌트 상태 관리에 그대로 쓸 수 있을 만큼 플랫폼 독립적이다.

2단계 — 동작·접근성을 입히는 useSelect (React Aria)

packages/react-aria/src/select/useSelect.ts는 위 state와 DOM ref를 받아, 순수 DOM 속성 객체(props bag)만 돌려준다. 내부적으로 useMenuTrigger(버튼-메뉴 연결), useTypeSelect(문자 입력으로 항목 점프), useField(라벨·에러 연결), ListKeyboardDelegate(방향키 이동 규칙)를 조합하고, ArrowLeft/ArrowRight로 값을 순환시키는 단축키까지 직접 등록한다. 반환값은 triggerProps·labelProps·menuProps·valueProps·descriptionProps·errorMessageProps·hiddenSelectProps — 전부 <button {...triggerProps}>처럼 그대로 엘리먼트에 뿌릴 수 있는 순수 객체다. 여기엔 CSS 클래스도, 화면에 그려지는 모양도 전혀 없다.

3단계 — 디자인을 입히는 완제품 컴포넌트 (React Spectrum / RAC / S2)

같은 두 훅(useSelectState + useSelect) 위에 세 가지 다른 옷이 입혀진다.

완제품실제 소스 위치입히는 것
Spectrum 1 Picker@adobe/react-spectrum/src/picker/Picker.tsxSpectrum CSS(vars.css) 클래스, 데스크탑은 Popover·모바일은 Tray로 자동 전환, 가상화된 리스트박스
RAC <Select>react-aria-components/src/Select.tsx스타일 없음. 대신 className/style에 render prop 함수를 받고, Context로 하위 컴포넌트에 상태 전파
Spectrum 2 Picker@react-spectrum/s2/src/Picker.tsx컴파일 타임 style 매크로(style({...}))로 원자적 CSS 생성 — 런타임 CSS-in-JS 없음

세 완제품 모두 import {useSelect} from 'react-aria/useSelect'와 import {useSelectState} from 'react-stately/useSelectState'를 토씨 하나 다르지 않게 그대로 가져다 쓴다. 다른 것은 오직 "그 결과를 어떤 태그·어떤 클래스로 그리느냐"뿐이다.

비유

연극의 대본(Stately)·연출 지시(Aria)·무대 의상(Spectrum)을 떠올리면 된다. 대본은 "누가 언제 등장해 무슨 대사를 하는지"만 정한다. 연출 지시는 "이 대사를 할 때 몸을 어느 방향으로 돌리고, 조명이 어디를 비추는지"를 정한다. 의상은 완전히 갈아입혀도 대본과 연출은 그대로다 — Spectrum 1의 연극과 Spectrum 2의 연극은 배우(로직)가 똑같은데 의상만 다른 것이다.

용어
React Aria (동작 훅)
키보드·마우스·터치·스크린리더 상호작용을 처리해, DOM 엘리먼트에 그대로 뿌릴 수 있는 props 객체(onKeyDown, role, aria-* 등)만 돌려주는 훅 모음. 화면에 아무것도 그리지 않는다.
용어
React Stately (상태 훅)
선택값·열림 상태·컬렉션 등을 관리하는 순수 로직 훅 모음. DOM도 ARIA도 모른다. React Native 등 웹이 아닌 환경에서도 그대로 재사용 가능하다.

② Collections API — "리스트를 리스트답게 다루는" 공통 언어

Select·ListBox·Menu·Table·GridList·Tabs 등 "항목이 여러 개인" 모든 컴포넌트는 같은 컬렉션 모델을 공유한다. 사용자는 <Item>/<Section>(정적) 또는 배열 + 렌더 함수(동적, items={data} children={item => <Item>...</Item>})로 항목을 선언하고, 내부적으로 CollectionBuilder가 이를 Node<T> 트리로 변환한다. @react-types/shared/collections.d.ts에 ItemProps·AsyncLoadable(isLoading·onLoadMore) 같은 공통 타입이 정의돼, 모든 컴포넌트가 같은 방식으로 무한 스크롤·비동기 로딩을 지원한다.

③ 가상화(Virtualizer) — 수만 개 항목도 가볍게

@react-stately/virtualizer(레이아웃 계산: 어떤 항목이 어디 위치하는지 순수 계산)와 @react-aria/virtualizer(그 계산 결과를 실제 DOM에 붙이고 스크롤·포커스를 관리)로 나뉜다. Table·큰 ListBox·Calendar처럼 항목 수가 많은 컴포넌트가 이를 사용해, 화면에 보이는 행만 실제 DOM에 그리고 나머지는 가상 좌표로만 계산한다. 여기서도 "레이아웃 계산(상태)"과 "DOM 반영(동작)"이 각각 Stately/Aria로 나뉘는 동일한 철학이 반복된다.

④ 국제화 — 날짜·숫자·문자열·RTL을 프레임워크 독립적으로

@internationalized/date는 리액트에 의존하지 않는 순수 날짜 계산 라이브러리다. CalendarDate 클래스 하나로 그레고리력뿐 아니라 불교력·에티오피아력·헤브루력·인도력·이슬람력(여러 변형 포함)·일본력·페르시아력·타이완력까지 9종의 달력 시스템을 계산한다(packages/@internationalized/date/src/calendars/). @internationalized/number의 NumberParser는 latn·arab·hanidec(한자)·deva(데바나가리)·beng(벵골)·fullwide 6개 숫자 체계를 파싱한다. RTL(오른쪽에서 왼쪽 언어)은 I18nProvider가 isRTL(locale) 결과로 direction: 'rtl'|'ltr'을 context에 심어, 모든 하위 컴포넌트가 자동으로 좌우 반전된다.

용어
RTL (Right-to-Left)
아랍어·히브리어처럼 글이 오른쪽에서 왼쪽으로 흐르는 언어. 이 경우 화살표 키의 좌/우 의미, 아이콘 위치, 레이아웃 순서가 전부 반전돼야 한다. react-spectrum은 이를 컴포넌트마다 따로 처리하지 않고 공통 context로 자동 반영한다.

⑤ usePress — 마우스·터치·펜·키보드·스크린리더를 하나의 "누름" 이벤트로

packages/react-aria/src/interactions/usePress.ts는 이 저장소에서 가장 정교한 로우레벨 훅 중 하나다. 브라우저의 PointerEvent·TouchEvent·클릭 이벤트가 기기·브라우저마다 미묘하게 다르게 발생하는 문제를, 하나의 통일된 onPress 이벤트로 추상화한다. 스크린리더가 발생시키는 "가상 클릭"(isVirtualClick/isVirtualPointerEvent)을 실제 마우스 클릭과 구분하고, 터치로 드래그하다 원래 요소를 벗어나면 포인터 캡처를 해제해 다른 요소로 상호작용이 넘어가게 하며, iOS의 "포인터업 시 크기 0" 버그도 우회 처리한다. continuePropagation()이라는 탈출구로 상위 컴포넌트가 이벤트를 가로챌 수도 있다.

중요한 경계
"디자인은 하나도 없다" — React Aria/Stately만 읽으면 놓치는 것

React Aria·React Stately 소스만 보면 CSS·색상·간격에 대한 코드가 단 한 줄도 없다. 실제 "Adobe스러운 화면"이 궁금하다면 @react-spectrum/* 또는 @react-spectrum/s2/* 쪽 소스(styles, vars.css, style 매크로)를 따로 봐야 한다. 이 분리 자체가 설계 의도이므로 "왜 여기엔 스타일이 없지?"라고 헤매지 않아야 한다.

6기술 스택·의존성

yarn 4 + Lerna 독립 버전 배포 · Parcel 빌드 · CircleCI 30여 개 잡

① 패키지 매니저·버전 관리 — "버전의 진실원"

요소내용
패키지 매니저Yarn 4.13.0(Berry, workspaces) — package.json의 workspaces가 packages/react-stately·packages/react-aria·packages/@*/* 등을 지정
버전 배포Lerna 3.13.2의 lerna publish from-package — Changesets가 아니라, 각 패키지의 package.json 버전 필드 자체가 진실원이며 Lerna는 이미 올라간 버전을 그대로 배포만 한다
버전 독립성react-aria 3.50.0 · react-stately 3.48.0 · react-aria-components 1.19.0 · @react-aria/select 3.18.1 · @react-stately/select 3.10.1 · @adobe/react-spectrum 3.47.2 · @react-spectrum/s2 1.5.1 — 220여 개 패키지가 모두 각자 다른 버전으로 독립 배포된다
빌드Parcel 2.16.3(문서 MDX 컴파일, 스토리북 정적 빌드, S2 style 매크로용 Rust 트랜스포머) + Make(Makefile이 clean·build·test 전체를 오케스트레이션, CIRCLE_BRANCH 감지)
타입체크TypeScript(+ 실험적 tsgo) — check-types/check-types:tsc 스크립트로 이원화
함정
"nx 모노레포"가 아니다 — 실제로는 yarn workspaces + Lerna + Make

모노레포 빌드 도구로 Nx가 쓰일 거라 짐작하기 쉽지만, 저장소에 nx.json은 없다. 실제 태스크 오케스트레이션은 Makefile + 각 패키지의 package.json 스크립트 + CircleCI가 담당한다. "유명한 모노레포 도구 이름"만 보고 넘겨짚지 말고 package.json·Makefile을 직접 열어 확인해야 한다.

② 프론트엔드 의존성

영역핵심 의존성
React 지원 범위기본은 React 19.2. install-16/install-17/install-18/install-canary 스크립트로 16.8~canary까지 실제 CI 매트릭스에서 검증(test-canary 잡)
Spectrum 1 스타일@adobe/spectrum-css-temp(정적 CSS 클래스) — classNames() 유틸로 조건부 클래스 결합
Spectrum 2 스타일자체 style 매크로(@react-spectrum/s2/style/style-macro.ts) — Parcel의 with {type:'macro'} 매크로 임포트로 빌드 타임에 원자적 CSS를 생성, 런타임 CSS-in-JS 비용 없음. 구버전 @react-spectrum/style-macro-s1도 병존
선언형 레이어react-aria-components — Context + render props로 완전히 스타일 없는 조립형 컴포넌트(<Select><Button><ListBox> 등 69개 소스 파일)
테스트Jest 30(단위) + Vitest + Playwright(test:browser, 브라우저 실동작) + axe-playwright(접근성 자동 검사) + react-test-renderer + Storybook test-runner

③ CI 파이프라인 — CircleCI

.circleci/config.yml(3만 자 이상) 안에 30여 개 잡이 정의돼 있다: rsp/rsp-large/rsp-xlarge(리소스 등급별 실행기), test/test-ssr/test-canary/test-ssr-canary(SSR·React canary 호환성까지 검증), test-browser(Playwright 브라우저 테스트), lint, ts-diff/skills-diff/weekly-api-diff(공개 API 표면 변경 자동 감지), storybook, verdaccio(로컬 npm 레지스트리에 실제로 배포해보는 통합 테스트), v-nextjs/v-rac-rsp-tailwind/v-starters(실사용 스타터 앱 검증), weekly-chromatic(비주얼 회귀), publish-nightly, prod-docs/beta-docs.

7설치·요구사항

세 가지 진입점 — 완제품(Spectrum) · 동작만(Aria) · 선언형(RAC)
용도설치
Adobe 디자인 그대로 쓰기npm install @adobe/react-spectrum — Provider로 감싸고 바로 사용, 테마·다크모드 자동 대응
새 Spectrum 2 디자인npm install @react-spectrum/s2 + Parcel(매크로 지원 필요)
동작 훅만(직접 디자인)npm install react-aria react-stately(또는 @react-aria/select처럼 필요한 것만 낱개 설치)
선언형 조립형npm install react-aria-components — Tailwind와 함께 쓰는 예제가 문서에 풍부
레포 자체 개발yarn install(Node·Yarn 4 필요) → yarn start(Storybook, 9003 포트) / yarn test(Jest, STRICT_MODE=1 VIRT_ON=1) / yarn build:docs
주의
S2의 style 매크로는 번들러를 가린다

Spectrum 2의 style 매크로(with {type:'macro'})는 Parcel의 매크로 기능에 강하게 의존한다. Webpack·Vite 등 다른 번들러 프로젝트에 그대로 들고 가면 매크로가 해석되지 않을 수 있어, 공식 문서의 번들러별 통합 가이드를 확인해야 한다. React Aria/Stately 훅 자체는 번들러 독립적이다.

8실제 사용 흐름

완제품 → 선언형 조립 → 순수 훅, 세 가지 실전 경로
흐름 ①

Adobe 디자인을 그대로 — @adobe/react-spectrum

가장 빠른 길. <Provider theme={defaultTheme}>로 앱을 감싸고 <Picker><Item>...</Item></Picker>처럼 바로 쓴다. 접근성·다크모드·RTL이 기본 포함이라 별도 설정이 거의 없다. 대신 화면이 "Adobe스러운 모양"으로 고정된다.

흐름 ②

내 디자인 + 검증된 동작 — react-aria-components(RAC)

Tailwind 등으로 직접 스타일링하되 로직은 가져다 쓰고 싶을 때. <Select><Button>...</Button><Popover><ListBox>...</ListBox></Popover></Select>처럼 하위 컴포넌트를 직접 배치하고, className에 (renderProps) => ... 함수를 넘겨 isOpen·isPressed 같은 상태에 따라 클래스를 바꾼다. 내부적으로는 결국 1절에서 추적한 useSelectState+useSelect가 그대로 돈다.

흐름 ③

완전히 새로운 디자인 시스템 — react-aria + react-stately 훅만

자체 디자인 시스템을 밑바닥부터 만드는 팀은 useSelectState·useSelect 훅만 가져다 자신만의 JSX·CSS를 얹는다. 이 방식이 정확히 @react-spectrum/picker와 @react-spectrum/s2가 이미 사내에서 하고 있는 일이다 — 즉 "Adobe가 자체 디자인 시스템에 하는 것과 동일한 절차"를 외부 팀도 그대로 밟을 수 있다.

9함정·오해 방지

패키지 이름 = 소스 위치가 아니다 · 모듈 3개 이름이 헷갈린다 · 별점만 보지 말 것
함정 ①
"@react-aria/select 폴더를 열면 진짜 코드가 있겠지" — 아니다

packages/@react-aria/select/src/index.ts를 열어보면 20줄도 안 되는 재수출 파일뿐이다: export {useSelect, HiddenSelect} from 'react-aria/useSelect';. 진짜 구현은 packages/react-aria/src/select/useSelect.ts(번들 패키지 react-aria 안)에 있다. react-stately/@react-stately/select 관계도 똑같다. 220여 개 스코프 패키지 중 다수가 이런 "얇은 재수출 껍데기"이므로, 코드를 찾다가 스코프 패키지 폴더에서 멈추면 시간을 낭비한다.

왜 이렇게 만들었나: 소스는 한 곳(번들 패키지)에 모아 유지보수하되, npm에는 잘게 쪼갠 패키지로 배포해 "필요한 컴포넌트만 설치"가 가능하게 하기 위해서다. package.json의 exports 서브패스("./*": {"source": "./exports/*.ts", ...})가 이 트릭의 핵심이다.

함정 ②
"Select"라는 이름이 세 곳에서 다른 걸 가리킨다

① Spectrum 1에서는 완제품 컴포넌트 이름이 Picker다(Select가 아니다). ② react-aria-components에서는 <Select>라는 이름을 쓴다. ③ 상태 훅 이름은 useSelectState다. "Select를 검색했는데 안 나온다"면 Picker 쪽도 함께 찾아봐야 한다.

함정 ③
"nx", "Changesets" 같은 유행 도구를 쓸 거라 짐작하지 말 것

6장에서 확인했듯 이 저장소는 Nx도, Changesets도 쓰지 않는다. 버전 관리는 package.json 버전 필드 수동/스크립트 관리 + Lerna의 publish from-package다. 최신 유행 도구 이름으로 넘겨짚지 말고 실제 설정 파일(lerna.json, Makefile, .circleci/config.yml)을 확인하는 습관이 중요하다.

참고
별점·트렌딩 수치보다 실물 구조를 봐야 하는 이유

이 문서는 GitHub 별점 수를 인용하지 않는다. react-spectrum처럼 오래되고(수년 간 릴리스) 사내 실사용(수백 개 Adobe 제품 화면)이 검증 기준인 저장소는, 별점보다 실제 소스 구조·CI 매트릭스·릴리스 빈도가 신뢰도를 보여주는 더 정확한 지표다.

10대안 비교

"동작만 주는" unstyled 라이브러리 4파전
라이브러리철학·차별점
React Aria
(이 저장소)
가장 오래되고 깊은 접근성·국제화 커버리지(9종 달력, 30개+ 언어). 상태(Stately)·동작(Aria)·디자인(Spectrum) 3층 분리가 가장 명시적. 다만 API 표면이 넓고 학습 곡선이 있다.
Radix PrimitivesReact 생태계에서 가장 널리 쓰이는 unstyled 컴포넌트. shadcn/ui의 기반. 컴포넌트별 단일 패키지 설치가 쉽고 생태계(Tailwind 조합 문서)가 크지만, 국제화·달력 시스템 등 깊이는 React Aria에 못 미친다.
Headless UITailwind Labs 제작. 컴포넌트 수가 적고 가볍다. React Aria만큼 폭넓은 컴포넌트·상호작용 커버리지는 없지만 진입 장벽이 가장 낮다.
Base UIMUI 팀과 Radix 팀 일부가 합류해 만든 신흥 unstyled 라이브러리. React Aria·Radix의 장점을 절충하려는 최신 시도.
Ark UIZag.js(프레임워크 독립 상태 머신) 기반이라 React 외에 Vue·Solid·Svelte에도 같은 로직을 쓸 수 있다는 게 가장 큰 차별점. react-spectrum의 "Stately/Aria 분리"와 유사한 철학을 다중 프레임워크로 확장한 형태.
비유

Radix·Headless UI·Base UI가 "잘 정리된 부품 세트"라면, React Aria는 부품 하나하나를 국제 표준(ARIA 명세) 문서까지 대조해가며 만든 정밀 부품 공장에 가깝다. Ark UI는 그 부품을 "다른 자동차 브랜드(프레임워크)에도 끼울 수 있게" 규격화한 쪽이다.

11정리 — 누가 쓰면 좋은가

"디자인 시스템을 직접 만드는 팀"과 "접근성을 제대로 하고 싶은 팀"에게

react-spectrum(정확히는 그 안의 React Aria + React Stately)은 "우리 회사만의 디자인 시스템을 밑바닥부터 만들어야 하는 팀"에게 가장 큰 가치가 있다. 상태와 동작을 이미 검증된 훅으로 가져오고, 디자인만 온전히 자기 것으로 만들 수 있기 때문이다. @adobe/react-spectrum을 완제품으로 그대로 쓰고 싶은 팀에게도 물론 유효하지만, 이 경우엔 Adobe 톤앤매너를 그대로 받아들여야 한다.

접근성(a11y)을 진지하게 다뤄야 하는 프로덕트 — 공공기관·금융·의료·대기업 내부 툴 — 팀에게도 강력히 추천된다. 키보드·스크린리더·RTL·다국어 달력까지 이미 검증된 로직을 쓸 수 있다는 점이 가장 큰 무기다. 반대로 "빠르게 프로토타입만 찍고 싶다"면 Radix+shadcn/ui나 Headless UI 쪽이 진입 장벽이 낮다.

이 저장소를 공부하는 것 자체로도 배울 게 많다. "로직과 렌더링을 분리하는 설계", "하나의 npm 패키지를 재수출 계층으로 잘게 쪼개 배포하는 기법", "브라우저 입력 이벤트를 하나로 통합하는 정교한 이벤트 시스템(usePress)"은 이 저장소가 아니어도 어디서나 써먹을 수 있는 프론트엔드 아키텍처 지식이다.

12직접 해볼 수 있는 실습 과제

가져오기 경로 추적부터 키보드 조작·입력 이벤트·날짜 경계값 검증까지

모든 경로와 명령은 react-spectrum 레포 루트 기준이다. 개발 서버와 테스트를 실행하는 과제는 해당 작업본의 Node·Yarn 설정에 맞는 환경과 의존성이 이미 준비되어 있다고 가정한다. 소스를 바꾸지 않고 기존 예제와 테스트만으로 진행할 수 있다.

과제 ① · 입문

Select의 가져오기 경로 지도 만들기

목표: 설치하는 패키지 이름과 실제 구현 파일의 위치를 구분하고, 상태·동작·렌더링의 연결을 직접 찾는다.

  1. 다음 명령으로 스코프 패키지의 재수출 구문과 공개 진입 파일을 읽는다.
cat packages/@react-aria/select/src/index.ts
cat packages/@react-stately/select/src/index.ts
cat packages/react-aria/exports/useSelect.ts
cat packages/react-stately/exports/useSelectState.ts
  1. packages/react-aria/package.json의 exports에서 ./*과 source 매핑을 찾아, react-aria/useSelect가 개발 소스로 연결되는 과정을 설명한다.
  2. packages/react-stately/src/select/useSelectState.ts, packages/react-aria/src/select/useSelect.ts, packages/react-aria-components/src/Select.tsx를 순서대로 읽는다. 상태 객체·DOM 참조·반환된 속성이 다음 단계로 전달되는 위치를 표시한다.

완료 기준: 스코프 패키지 → 공개 진입 파일 → 실제 훅 구현 → RAC 컴포넌트의 경로 지도를 만들고, 선택값·키보드 처리·JSX 렌더링의 담당 파일을 각각 설명할 수 있다.

과제 ② · 초급

마우스 없이 Select의 열림·선택·닫힘 검증하기

목표: JSX 조립 구조가 키보드 동작과 접근성 속성으로 어떻게 이어지는지 관찰한다.

  1. packages/react-aria-components/stories/Select.stories.tsx의 SelectRenderProps를 읽는다. Label·Button·SelectValue·Popover·ListBox의 역할과 isOpen을 사용하는 부분을 찾는다.
  2. 개발 서버를 시작하고 로컬 9003 포트의 Storybook에서 React Aria Components/Select 아래 해당 예제를 연다.
yarn start
  1. 단일 선택 상태에서 Tab으로 버튼에 도달한 뒤 Space로 열고, 방향키로 일반 텍스트 항목인 Foo·Bar·Baz 사이를 이동해 Enter로 선택한다. 다시 열고 Escape로 닫는 경우도 확인한다.
  2. 브라우저 개발자 도구에서 버튼의 aria-expanded, 목록의 role, 닫힌 뒤 포커스 위치를 확인한다. 열림·닫힘에 따라 화살표 표시가 달라지는 이유를 예제의 렌더 함수와 연결한다.

완료 기준: 열기·이동·선택·취소 네 동작에 대해 입력 키, 선택값, 열림 상태, 포커스 위치를 표로 남긴다. Enter 선택 뒤 표시값이 바뀌고, Escape로 취소하면 직전 선택값이 유지되는지 확인한다.

과제 ③ · 중급

usePress의 정상 입력·반복 입력·비활성 상태 비교하기

목표: 브라우저 이벤트와 라이브러리가 제공하는 논리적인 누름 이벤트의 차이를 이해한다.

  1. packages/react-aria/test/interactions/usePress.test.js의 keyboard events 묶음과 비활성 요소 테스트를 읽는다. 각 사례에서 보내는 이벤트와 기대하는 onPress 발생 여부를 먼저 예측한다.
  2. 다음 명령으로 해당 사례를 실행한다. -t는 테스트 이름을 고르므로, 실행된 테스트가 있는지도 결과에서 확인한다.
yarn test packages/react-aria/test/interactions/usePress.test.js --runInBand -t 'keyboard events|disabled elements'
  1. packages/react-aria/src/interactions/usePress.ts에서 isDisabled와 반복 키 입력을 처리하는 분기를 찾는다. 테스트의 repeat: true와 일반 키 입력이 다른 경로를 타는 이유를 적는다.
  2. 일반 키보드 사례 하나를 골라 onPressStart·onPressUp·onPressEnd·onPress의 기대 순서를 정리한다. 다른 입력 방식에도 이 순서가 그대로 적용되는지는 별도 사례로 대조한다.

완료 기준: 정상 누름, 반복 키 이벤트, 비활성 요소의 세 행으로 예측·실행 결과·관련 소스 분기를 연결한 표를 만든다. 선택한 테스트가 통과했는지와 건너뛴 테스트가 무엇인지 구분해 기록한다.

과제 ④ · 심화

CalendarDate의 월말·윤년·불변성 검증하기

목표: 날짜를 단순한 밀리초 덧셈으로 다루지 않고, 달력 규칙에 맞춰 계산하는 설계를 익힌다.

  1. packages/@internationalized/date/src/CalendarDate.ts에서 add의 반환형과 toDate의 인자를 확인한다. 날짜만 가진 값과 시간대가 필요한 변환을 구분한다.
  2. 아래 계산에서 date, nextMonth, leapDay가 각각 어떤 날짜인지 먼저 예측한다. 코드는 CalendarDate를 사용하는 계산 예시다.
import {CalendarDate} from '@internationalized/date';

const date = new CalendarDate(2020, 8, 31);
const nextMonth = date.add({months: 1});
const leapDay = new CalendarDate(2020, 2, 28).add({days: 1});
  1. packages/@internationalized/date/tests/manipulation.test.js에서 월말 보정과 윤년 사례를 찾아 예측을 대조한 뒤 실행한다.
yarn test packages/@internationalized/date/tests/manipulation.test.js --runInBand -t 'CalendarDate manipulation'
  1. packages/@internationalized/date/src/manipulation.ts의 add가 원본을 복사하는 위치와 날짜 범위를 보정하는 부분을 찾는다. 윤년이 아닌 해의 2월 사례도 테스트에서 함께 비교한다.

완료 기준: 원본은 2020-08-31로 유지되고 다음 달은 2020-09-30, 윤년 계산은 2020-02-29가 되는 이유를 설명한다. 한 달 더하기를 일정한 일수 더하기로 바꾸면 안 되는 이유와, 원본을 바꾸지 않는 설계의 이점도 적는다.

13관련 기술 심화 학습 로드맵

6주 동안 패키지 경계 → 상태 → 입력 → 조립 → 국제화 → 검증으로 확장하기

한 주마다 읽은 내용을 작은 결과물 하나로 남긴다. 아래 경로는 모두 레포 루트 기준이며, 앞 주의 Select 경로 지도를 다음 주의 상태표와 동작표에 계속 덧붙이면 된다.

주차배울 개념이 레포에서 읽을 곳결과물
1주차 모노레포, 워크스페이스, 공개 진입점과 실제 구현의 구분 package.json, .yarnrc.yml, lerna.json, packages/@react-aria/select/src/index.ts, packages/react-aria/exports/useSelect.ts Select의 가져오기 경로 지도와 개발·테스트 명령 목록. 버전 번호는 패키지별로 어디에 적히는지도 표시한다.
2주차 제어·비제어 상태, 열림 상태, 선택 상태, 컬렉션 packages/react-stately/src/select/useSelectState.ts, packages/react-stately/src/overlays/useOverlayTriggerState.ts, packages/react-stately/src/utils/useControlledState.ts, packages/react-stately/src/list/useListState.ts 열기·닫기·선택의 상태 전이표. 부모가 상태를 관리할 때와 내부에서 관리할 때를 나눠 그린다.
3주차 ARIA 연결, 키보드 이동, 누름 이벤트 통합, 포커스 관리 packages/react-aria/src/select/useSelect.ts, packages/react-aria/src/interactions/usePress.ts, packages/react-aria/src/focus/FocusScope.tsx, packages/react-aria/test/interactions/usePress.test.js 입력 방식별 동작표와 라벨·버튼·목록의 연결도. 각 동작이 상태를 바꾸는지 포커스를 옮기는지 구분한다.
4주차 Context, 렌더 함수, 동작과 스타일의 분리, 스타일 매크로 packages/react-aria-components/src/Select.tsx, packages/@adobe/react-spectrum/src/picker/Picker.tsx, packages/@react-spectrum/s2/src/Picker.tsx, packages/@react-spectrum/s2/style/style-macro.ts RAC·Spectrum 1·S2의 조립 방식과 스타일 처리 비교표. 추가로 examples/rac-spectrum-tailwind/src/components/SelectBoxGroup.tsx에서 RAC의 RadioGroup에 자체 디자인을 입히는 예를 찾아 정리한다.
5주차 날짜 값의 불변성, 달력 규칙, 시간대 변환의 경계 packages/@internationalized/date/src/CalendarDate.ts, packages/@internationalized/date/src/manipulation.ts, packages/@internationalized/date/src/calendars/, packages/@internationalized/date/tests/manipulation.test.js 월말·윤년·연도 변경의 입력과 기대 결과 표. 날짜 계산과 표시 형식, 시간대 변환을 별개 단계로 설명한다.
6주차 단위 테스트, 서버 렌더링과 하이드레이션, 실제 브라우저 검증의 역할 jest.config.js, jest.ssr.config.js, vitest.browser.config.ts, packages/react-aria-components/test/Select.test.js, packages/react-aria-components/test/Select.ssr.test.js 같은 Select를 어떤 환경에서 무엇으로 검증할지 적은 계획서. 실행한 사례·실행하지 않은 사례·수동 키보드 확인 결과를 구분해 담는다.
학습 점검
동작 하나를 여러 층의 근거로 설명해 보기

마지막에는 “선택 목록을 열고 항목을 고른 뒤 닫는다”라는 동작을 골라 상태 변화 → 접근성 속성·이벤트 → JSX 조립 → 검증 사례로 이어서 설명해 보자. 파일 이름을 외우는 것보다 각 층이 주고받는 값과 책임을 짚는 것이 목표다.

14핵심 키워드 사전

소스를 읽다가 만나는 용어 15개를 이 저장소의 역할과 연결하기
모노레포와 워크스페이스
모노레포는 여러 패키지를 한 저장소에서 함께 관리하는 구조이고, 워크스페이스는 패키지 매니저가 그 패키지들을 연결하는 단위다. 이 레포에서는 루트 package.json의 workspaces가 packages/ 아래 개발 패키지를 묶는다.
재수출과 서브패스
재수출은 다른 파일의 기능을 다시 공개하는 것이고, 서브패스는 react-aria/useSelect처럼 패키지 이름 뒤에 붙는 공개 가져오기 경로다. 이 레포의 스코프 패키지는 재수출을 사용하며, exports 매핑과 진입 파일을 따라가야 실제 구현을 찾을 수 있다.
React Stately
선택값과 열림 여부처럼 화면의 상태가 어떻게 바뀌는지 관리하는 훅 모음이다. 이 레포의 useSelectState는 목록·열림·검증 상태를 조합해 Select가 사용할 상태 객체를 만든다.
React Aria
키보드·포인터·보조 기술로 조작할 수 있도록 동작과 접근성 연결을 제공하는 훅 모음이다. 이 레포의 useSelect는 상태와 DOM 참조를 받아 버튼·라벨·목록 등에 전달할 속성을 반환한다.
RAC · React Aria Components
접근성 훅을 JSX 컴포넌트로 조립해서 사용할 수 있게 만든 계층이다. 이 레포의 packages/react-aria-components/src/Select.tsx는 상태와 동작을 하위 컴포넌트에 연결하면서, 사용자가 구조와 스타일을 정할 수 있게 한다.
제어 상태와 비제어 상태
제어 상태는 부모가 현재 값을 전달하며 관리하고, 비제어 상태는 컴포넌트가 초기값을 받아 내부에서 관리한다. 이 레포의 열림 상태에서는 isOpen과 defaultOpen이 각각 그 역할을 하며, onOpenChange로 변경을 알린다.
ARIA
보조 기술이 요소의 역할·이름·상태·관계를 이해하도록 전달하는 접근성 정보다. 이 레포의 Select는 aria-labelledby 같은 속성으로 라벨과 선택값을 연결하며, 실제 키보드 동작은 별도의 이벤트 처리와 함께 구현한다.
속성 묶음 · props 객체
후속 훅이나 컴포넌트에 전달할 속성을 하나의 객체로 묶은 것이다. 이 레포의 useSelect가 반환하는 triggerProps·labelProps·menuProps는 각각 전달할 대상이 다르므로, 이름에 맞는 곳에 연결해야 한다.
Context
중간 컴포넌트마다 속성을 전달하지 않고도 하위 컴포넌트가 공통 값을 읽게 하는 React 기능이다. 이 레포의 RAC Select는 SelectStateContext 등으로 상태와 속성을 나누어, SelectValue 같은 하위 요소가 선택 상태를 읽게 한다.
렌더 함수 · render props
고정된 화면 대신 상태를 받는 함수를 전달해, 그 상태에 따라 그릴 내용이나 클래스를 정하는 방식이다. 이 레포의 SelectRenderProps 예제는 isOpen을 받아 열림·닫힘에 따라 화살표를 바꾼다.
컬렉션 · Collection
목록 항목을 단순한 화면 조각이 아니라 키와 순서 등을 가진 구조로 다루는 모델이다. 이 레포의 packages/react-stately/src/collections/CollectionBuilder.ts는 항목 선언을 컬렉션 노드로 만들며, 목록과 선택 로직이 이 구조를 사용한다.
포커스 범위 · FocusScope
키보드 입력을 받을 요소들의 범위를 관리하고, 필요하면 범위 안에 포커스를 유지하거나 닫힌 뒤 이전 위치로 돌리는 기능이다. 이 레포의 packages/react-aria/src/focus/FocusScope.tsx는 contain·restoreFocus·autoFocus로 이러한 정책을 설정한다.
누름 이벤트 · usePress
마우스·터치·키보드 등 서로 다른 입력을 논리적인 누름 동작으로 다루는 훅이다. 이 레포의 usePress는 onPress와 누름 시작·종료 이벤트를 제공하며, 비활성 요소나 반복 입력 같은 조건도 함께 처리한다.
CalendarDate와 불변성
CalendarDate는 특정 달력의 날짜를 시간 정보 없이 표현하며, add 같은 연산은 원본을 바꾸지 않고 새 날짜를 반환한다. 이 레포의 @internationalized/date에서는 날짜 계산과 시간대가 필요한 toDate 변환을 구분한다.
스타일 매크로 · style macro
스타일 선언을 빌드할 때 해석해 CSS와 클래스 이름으로 바꾸는 도구다. 이 레포의 packages/@react-spectrum/s2/style/style-macro.ts는 S2의 스타일 생성에 쓰이며, 실행 중 상태·입력 처리와는 다른 역할을 맡는다.