상태 머신을 사용하여 예측 가능하고 강력한 UI 구성 요소 구축
Olivia Novak
Dev Intern · Leapcell

소개
끊임없이 진화하는 프론트엔드 개발 환경에서 시각적으로 매력적일 뿐만 아니라 예측 가능하고, 견고하며, 유지 관리 가능한 사용자 인터페이스를 만드는 것은 끊임없는 과제입니다. 애플리케이션이 복잡해짐에 따라 개별 UI 구성 요소 내의 상호 작용도 마찬가지입니다. 일반적인 드롭다운 메뉴를 생각해 보세요. 열림, 닫힘, 포커스됨, 포커스 안 됨, 비활성화됨 또는 로딩 상태일 수 있습니다. 이제 이러한 상태 간의 전환, 이를 트리거하는 이벤트, 발생할 수 있는 부작용을 고려해 보세요. 구조화된 접근 방식 없이는 상태와 전환의 복잡한 춤을 관리하는 것이 빠르게 조건부 로직의 얽히고설킨 그물로 이어져 디버깅을 악몽으로 만들고 향후 개선을 위험하게 합니다. 이것이 바로 상태 머신과 상태 차트의 강력한 기능이 등장하는 지점입니다. UI 구성 요소의 동작을 형식화함으로써 탁월한 예측 가능성과 견고함을 얻을 수 있습니다. 이 글에서는 XState 및 Zag.js와 같은 라이브러리가 이러한 개념을 활용하여 드롭다운 및 모달과 같은 복잡한 UI 구성 요소를 구축하여 개발자에게 권한을 부여하고 혼란스러운 스파게티 코드를 우아하고 테스트 가능한 상태 기반 로직으로 변환하는 방법을 자세히 알아봅니다.
상태 머신 기반 UI의 핵심 개념
실용적인 적용 사례를 자세히 살펴보기 전에 XState와 Zag.js의 기초가 되는 핵심 개념에 대한 기본적인 이해를 확립해 봅시다.
상태 기계 및 상태 차트
상태 머신은 계산의 수학적 모델입니다. 특정 시점에 정확히 유한한 수의 상태 중 하나에 있을 수 있는 추상 기계입니다. 기계는 이벤트 또는 작업에 의해 트리거되어 한 상태에서 다른 상태로 변경될 수 있습니다. 이를 전환이라고 합니다.
상태 차트는 특히 복잡한 시스템을 처리할 때 상태 머신의 한계를 해결하는 상태 머신의 확장입니다. 상태 차트의 주요 기능은 다음과 같습니다.
- 계층 구조(중첩 상태): 상태는 다른 상태를 포함할 수 있어 더 체계적이고 모듈화된 상태 정의가 가능합니다. 예를 들어, 드롭다운의 "열림" 상태는 "항목에 포커스됨" 및 "항목에서 포커스 안 됨" 하위 상태를 포함할 수 있습니다.
- 직교 영역(동시 상태): 상태는 여러 하위 상태에 동시에 있을 수 있으며 동작의 독립적인 측면을 나타냅니다. 간단한 UI 구성 요소의 경우 덜 일반적이지만 고급 시나리오에서는 강력합니다.
- 기록 상태: 상위 상태에 다시 들어갈 때 마지막 활성 하위 상태를 기억합니다.
- 가드: 전환이 발생하기 위해 충족되어야 하는 조건입니다.
- 작업/효과: 상태에 들어가거나 나갈 때 또는 전환이 발생할 때 수행되는 작업입니다.
XState
XState는 상태 머신 및 상태 차트를 생성, 해석 및 실행하기 위한 JavaScript 라이브러리입니다. 매우 예측 가능하고 테스트 가능한 방식으로 복잡한 상태 로직을 정의하기 위한 강력하고 개발자 친화적인 API를 제공합니다. XState의 핵심 철학은 애플리케이션 로직을 유한 상태 머신으로 취급하여 암묵적인 동작을 명시적으로 만드는 것입니다.
Zag.js
Zag.js는 XState로 구동되는 상태 머신으로 전적으로 구축된 프레임워크에 독립적인 UI 구성 요소 모음입니다. "헤드리스 UI" 구성 요소를 제공합니다. 즉, 모든 상호 작용 로직, 상태 관리 및 접근성 속성을 처리하지만 UI 요소의 실제 렌더링은 전적으로 개발자에게 맡깁니다. 이를 통해 스타일링 및 모든 프론트엔드 프레임워크(React, Vue, Svelte 등)와의 통합에 대한 최대 유연성을 얻을 수 있습니다. Zag.js는 일반적인 UI 패턴에 대한 사전 구축된 강력한 상태 차트 모음 역할을 효과적으로 합니다.
XState 및 Zag.js를 사용한 예측 가능한 UI 구축
UI 구성 요소에 XState 또는 Zag.js를 사용하는 본질은 구성 요소의 수명 주기와 상호 작용을 공식 상태 차트로 정의하는 것입니다. 예를 들어 이를 살펴보겠습니다.
기존 UI 구성 요소 로직의 문제점
간단한 모달 구성 요소를 생각해 보세요. 동작에는 다음이 포함될 수 있습니다.
- 열려 있거나 닫힌 상태.
- 버튼을 클릭하면 열립니다.
- Escape 키를 누르면 닫힙니다.
- 모달 콘텐츠 외부를 클릭하면 닫힙니다.
- 열려 있을 때 모달 내에서 포커스를 가둡니다.
- 닫을 때 모달을 연 요소로 포커스를 복원합니다.
- 애니메이션 상태가 있을 수 있습니다.
상태 머신 없이 이를 구현하면 다음과 같은 결과가 나오는 경우가 많습니다.
// 가상 React 구성 요소(단순화) function Modal() { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); // 모달을 연 요소를 저장하기 위한 참조 useEffect(() => { const handleKeyDown = (event) => { if (isOpen && event.key === 'Escape') { setIsOpen(false); } }; const handleClickOutside = (event) => { // 클릭이 모달 콘텐츠 외부인지 확인하는 복잡한 로직 if (isOpen && !modalContentRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('mousedown', handleClickOutside); // 또는 click return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); // 포커스 관리, ARIA 속성 등은 이를 더욱 복잡하게 만들 것입니다. // ... 나머지 구성 요소 로직 및 JSX }
이 접근 방식은 다루기 어려워집니다. useEffect
훅이 방대해지고, 다른 로직 조각이 흩어져 있으며, 모든 가능한 상태와 전환을 이해하기 어렵습니다.
XState: 모달 동작 형식화
XState를 사용하여 모달의 상태 차트를 정의해 봅시다.
import { createMachine, assign } from 'xstate'; const modalMachine = createMachine({ id: 'modal', initial: 'closed', context: { // 포커스 복원을 위해 모달을 트리거한 요소를 저장합니다. triggerElement: null, }, states: { closed: { on: { OPEN: { target: 'opening', actions: assign({ triggerElement: (context, event) => event.trigger, }), }, }, }, opening: { // 애니메이션 지연 또는 비동기 작업 시뮬레이션 after: { 200: { target: 'open', actions: 'focusModalContent', // 포커스 트랩 로직 수행 }, }, on: { CLOSE: 'closing', // 애니메이션 중에 닫는 상태로 직접 전환 가능 }, }, open: { entry: 'trapFocus', // 포커스 트랩 보장 on: { CLOSE: 'closing', ESCAPE: 'closing', CLICK_OUTSIDE: 'closing', }, }, closing: { entry: 'restoreFocus', // 트리거로 포커스 복원 after: { 200: 'closed', // 애니메이션 지연 시뮬레이션 }, }, }, }, { actions: { focusModalContent: (context) => { // 모달 내의 첫 번째 포커스 가능한 요소에 포커스를 맞추는 로직 console.log('모달 콘텐츠에 포커스 중'); }, trapFocus: () => { // 포커스 트랩 핸들러 설정을 위한 로직 console.log('포커스 트랩 설정 중'); }, restoreFocus: (context) => { // context.triggerElement로 포커스를 반환하는 로직 console.log('포커스를 다음으로 복원 중:', context.triggerElement); context.triggerElement?.focus(); }, }, });
이제 React 구성 요소에서 이를 사용합니다.
import React, { useRef, useEffect } from 'react'; import { useMachine } from '@xstate/react'; // 또는 프레임워크의 XState 훅 function MyModalComponent({ children }) { const [current, send] = useMachine(modalMachine); const modalRef = useRef(null); // 모달 콘텐츠에 대한 참조 const isOpen = current.matches('open') || current.matches('opening'); useEffect(() => { const handleKeyDown = (event) => { if (isOpen && event.key === 'Escape') { send('ESCAPE'); } }; const handleClickOutside = (event) => { if (isOpen && modalRef.current && !modalRef.current.contains(event.target)) { send('CLICK_OUTSIDE'); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, send]); const handleOpen = (event) => send({ type: 'OPEN', trigger: event.currentTarget }); const handleClose = () => send('CLOSE'); return ( <div> <button onClick={handleOpen}>모달 열기</button> {isOpen && ( <div role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal-overlay" > <div ref={modalRef} className="modal-content"> <h2 id="modal-title">모달 제목</h2> {children} <button onClick={handleClose}>닫기</button> </div> </div> )} </div> ); }
이 접근 방식은 모든 모달 로직을 modalMachine
내에 중앙 집중화합니다. 구성 요소는 얇은 렌더링 계층이 되어 상태에 반응하고 이벤트를 보냅니다.
- 예측 가능성: 모든 가능한 상태와 전환이 명시적으로 정의됩니다. 숨겨진 상호 작용은 없습니다.
- 견고성: 설계상 불가능한 상태는 방지됩니다(예: 이미 닫힌 모달 닫기).
- 테스트 용이성: 상태 머신은 UI 프레임워크와 독립적으로 테스트할 수 있어 단위 테스트가 매우 효과적입니다.
- 유지 관리성: 동작 변경은 한 곳, 즉 상태 차트 정의에서 이루어집니다.
Zag.js: 일반적인 패턴을 위한 헤드리스 구성 요소
XState를 사용하면 처음부터 상태 머신을 구축할 수 있지만 Zag.js는 일반적인 UI 패턴에 대한 사전 빌드된 프로덕션 준비 상태 머신을 제공합니다. 이렇게 하면 유연성을 희생하지 않고도 개발 속도를 크게 높일 수 있습니다.
Zag.js를 사용한 드롭다운 메뉴로 이를 설명해 보겠습니다. 드롭다운에는 다음과 같은 상태가 있습니다.
open
/closed
focused.item
/focused.trigger
(그리고 어떤 항목이 포커스되었는지)disabled
Zag.js는 useMachine
훅(XState와 유사)을 노출하여 state
및 send
와 일반 UI 구성 요소용 api
속성을 제공합니다. api
개체에는 ARIA 속성, 이벤트 리스너 및 포커스 관리를 자동으로 처리하는 HTML 요소에 전파해야 하는 모든 필요한 속성이 포함됩니다.
// React 또는 동급 프레임워크 구성 요소에서 import { useMachine } from '@zag-js/react'; import * as dropdown from '@zag-js/dropdown'; import { useId } from 'react'; // 고유 ID용 function MyDropdown() { const [state, send] = useMachine(dropdown.machine({ id: useId() })); const api = dropdown.connect(state, send); return ( <div {...api.getRootProps()}> <button {...api.getTriggerProps()}> Actions <span aria-hidden>▼</span> </button> {api.isOpen && ( <ul {...api.getContentProps()}> <li {...api.getItemProps({ value: 'edit' })}>Edit</li> <li {...api.getItemProps({ value: 'duplicate' })}>Duplicate</li> <li {...api.getItemProps({ value: 'archive' })}>Archive</li> <li {...api.getItemProps({ value: 'delete' })}>Delete</li> </ul> )} </div > ); }
여기서 Zag.js는 이 간단한 드롭다운에 대해 다음을 기본 제공합니다.
- ARIA 속성:
role
,aria-haspopup
,aria-expanded
,aria-controls
,aria-labelledby
,aria-activedescendant
가 모두 관리됩니다. - 키보드 탐색: (
Home
,End
),Escape
(닫기),Enter
/Space
(선택)을 사용하여 항목을 탐색합니다. - 포커스 관리: 자동 포커스 트랩 및 복원.
- 클릭 외부: 외부 클릭 시 닫기 처리.
개발자의 책임은 순전히 JSX를 렌더링하고 api
속성을 적용하는 것입니다. 복잡한 상호 작용 로직은 모두 Zag.js의 기본 상태 머신에 의해 처리됩니다. 이를 통해 누락되고 잠재적인 버그가 크게 줄어들어 개발자는 최소한의 노력으로 접근성이 높고 강력한 구성 요소를 구축할 수 있습니다.
결론
기존 명령형 접근 방식을 사용하여 복잡한 UI 구성 요소를 구축하는 것은 빠르게 관리할 수 없는 코드베이스로 이어질 수 있습니다. XState 및 Zag.js에서 제공하는 상태 머신 및 상태 차트를 채택함으로써 프론트엔드 개발자는 예측 가능성, 견고성 및 유지 관리성을 최우선으로 가져올 수 있습니다. XState는 사용자 지정 상태 로직을 설계하기 위한 강력한 도구 키트를 제공하며, Zag.js는 일반적인 구성 요소의 검증된 헤드리스 UI 해석을 제공하여 접근성 및 상호 작용 복잡성을 추상화합니다. 이러한 도구를 채택하면 대기 상태 조건 문의 일련에서 잘 정의되고 테스트 가능하며 안정적인 시스템으로 대화형 UI 개발을 전환하여 복잡한 UI 구성 요소를 구축하고 유지 관리하는 즐거움을 얻을 수 있습니다.