React 공통 UI 컴포넌트, 어디까지 설계해봤나요?

React 공통 UI 컴포넌트, 어디까지 설계해봤나요?

바텀시트 Portal부터 싱글톤 패턴, CSS-in-JS, 폴더 구조까지—실무 컴포넌트 설계의 진짜 깊이를 짚습니다.

React Portal 바텀시트 useSyncExternalStore Emotion CSS-in-JS 싱글톤 패턴 Feature Colocation 공통 컴포넌트 설계 Next.js 폴더 구조
광고

공통 컴포넌트를 '그냥 만드는' 것과 '설계해서 만드는' 것 사이에는 생각보다 훨씬 큰 간격이 있습니다. 바텀시트 하나를 제대로 구현하려고 앉았다가 SSR 이슈, hydration 타이밍, 애니메이션 프레임 제어, 스크롤 락 동기화까지 건드리게 되는 경험—겪어본 분이라면 고개를 끄덕일 겁니다. 오늘은 실무에서 자주 마주치는 네 가지 설계 지점을 엮어서, '공통 컴포넌트를 어디까지 고민해야 하는가'를 이야기해보려 합니다.


Portal 바텀시트: 1px이 아니라 1프레임이 문제입니다

velog의 「우아하게 완성하는 바텀시트 2편」은 바텀시트를 createPortal로 구현하는 과정을 상세하게 다룹니다. Portal을 쓰는 이유는 명확합니다. 부모 컴포넌트에 overflow: hidden이나 transform이 걸려 있으면 바텀시트가 그냥 잘려버리거든요. Figma에서 볼 때는 멀쩡했는데 실제 구현하면 사라지는 그 현상—Portal로 body 혹은 #portal-root에 직접 꽂아버리면 말끔하게 해결됩니다. position: fixed도 진짜 뷰포트 기준으로 동작하고, Safe Area 처리도 훨씬 수월해집니다.

그런데 문제는 SSR입니다. Next.js 앱 라우터 환경에서 document.getElementById를 서버에서 호출하면 터집니다. 이 글에서 흥미로운 선택을 하는데, useEffect 기반의 isMounted 패턴 대신 React 18의 useSyncExternalStore를 활용합니다. getServerSnapshot에서 false를, getSnapshot에서 true를 반환하도록 구성해서 hydration 과정 자체에서 클라이언트 전환을 처리하는 방식입니다. useEffect 방식이 hydration 이후 state 업데이트를 한 번 더 유발하는 것과 달리, useSyncExternalStore는 hydration 단계 안에서 처리가 완료됩니다. 리렌더링 타이밍이 타이트한 컴포넌트라면 이 차이가 의미 있습니다.

애니메이션 부분은 더 까다롭습니다. 이 글이 setTimeout(..., 0)requestAnimationFrame을 이중으로 쓰는 이유가 여기 있습니다. 브라우저는 같은 Call Stack 안에서 스타일이 여러 번 바뀌면 중간 과정을 전부 건너뛰고 최종 상태만 Paint합니다. 즉, DOM에 translateY(100%) 상태로 삽입하고 같은 틱 안에서 translateY(0)으로 바꾸면 애니메이션은 존재하지 않는 겁니다. setTimeout으로 다음 매크로태스크까지 밀어서 DOM 삽입을 확정하고, 첫 번째 rAF에서 초기 스타일을 확정한 뒤, 두 번째 rAF에서 비로소 transform을 트리거하는 구조—이 세 단계가 '슬라이드 업' 애니메이션의 전부입니다. 닫힐 때는 React state 대신 sheetRef.current.style.transform을 직접 수정하고, transitionend 이벤트를 리스닝해서 언마운트 타이밍을 잡습니다. 이벤트가 씹히는 엣지 케이스에 대비한 setTimeout 폴백까지 붙여두고요.

복수의 바텀시트가 동시에 열릴 때 스크롤 락을 어떻게 처리할지도 흥미롭습니다. 컴포넌트 외부에 Set<object> 형태의 전역 scrollLockOwners를 두고, 각 바텀시트 인스턴스가 고유한 빈 객체 토큰을 Set에 등록합니다. Set의 size가 1이 될 때만 스크롤 락을 적용하고, 0이 될 때 해제합니다. Redux나 Zustand 없이 모듈 스코프 변수 하나로 해결한 거라, 어떻게 보면 아래에서 다룰 싱글톤 패턴과 맥이 닿아 있습니다.


싱글톤 패턴: '나쁘다'고 들었는데 사실은요

dev.to의 「React: Singletons aren't as evil as you think」는 React 생태계에서 유독 기피받는 싱글톤 패턴을 정면으로 재평가합니다. 기존의 비판은 타당한 면이 있었습니다. 싱글톤 데이터를 React 트리와 동기화하려면 polling이나 수동 refresh 버튼 같은 어색한 우회로를 써야 했거든요. 그런데 핵심은 useSyncExternalStoreEventTarget의 조합입니다.

이 글은 토스트 매니저를 예시로 듭니다. EventTarget을 확장한 ToastManager 클래스를 만들고, 내부 _toasts 배열에 setter를 붙여서 값이 바뀔 때마다 changed 이벤트를 dispatch합니다. React 컴포넌트에서는 useSyncExternalStoresubscribe에 이 이벤트 리스너를 연결하고, getSnapshot에서 toastManager.toasts를 반환합니다. 결과적으로 토스트 매니저는 완전히 React 외부에 존재하면서도, 데이터가 바뀌는 순간 컴포넌트가 자연스럽게 리렌더링됩니다. 무거운 상태관리 라이브러리 없이, 추가 번들 사이즈 없이. 사용자 입장에서는 차이가 없지만 번들 사이즈 입장에서는 꽤 다른 이야기입니다.

바텀시트의 scrollLockOwners와 이 패턴을 연결해서 보면 흥미롭습니다. 둘 다 '컴포넌트 트리 외부에서 상태를 관리하되, React와의 동기화는 useSyncExternalStore로 깔끔하게 처리한다'는 철학을 공유합니다. 공통 컴포넌트를 설계할 때 전역 상태를 어디에 두어야 하는가—이 질문에 대한 실용적인 답변이 여기 있습니다.


CSS-in-JS: 스타일도 컴포넌트 단위로 설계해야 합니다

velog의 「[React] Emotion CSS 사용법 정리」는 상대적으로 입문 친화적인 글이지만, 공통 컴포넌트 설계 맥락에서 읽으면 시사하는 바가 있습니다. Button/Button.tsxButton/styles.ts를 폴더 단위로 코로케이션하는 방식—이건 단순한 파일 정리가 아니라 '스타일의 소유권을 컴포넌트 단위로 명확히 한다'는 설계 철학입니다.

Emotion의 css prop 방식과 styled 방식은 각각 장단점이 다릅니다. css prop은 조건부 스타일 조합이 직관적이고 기존 HTML 구조를 유지할 수 있지만, /** @jsxImportSource @emotion/react */ pragma를 매번 붙여야 하는 번거로움이 있습니다. styled 방식은 <S.Btn> 같은 캡슐화된 컴포넌트로 로직과 스타일을 완전히 분리할 수 있어서 재사용과 variant 확장에 강합니다. 바텀시트처럼 오버레이 성격의 공통 컴포넌트라면 styled로 래핑하되, 애니메이션 상태처럼 동적으로 바뀌는 속성은 ref를 통한 직접 DOM 조작으로 처리하는 혼합 전략이 현실적입니다—React state 변경이 불필요한 리렌더링을 유발할 수 있는 구간이니까요.


폴더 구조: 컴포넌트 설계의 마지막 퍼즐

dev.to의 「Solved: How do you handle feature-driven folder isolation in large Next.js apps?」는 새벽 3시 PagerDuty 알림으로 글을 시작합니다. 마케팅 팀이 공유 Button 컴포넌트의 CSS를 수정했다가 결제 플로우를 통째로 내려버린 사건—이게 단순한 실수가 아니라 구조적 문제라는 걸 지적합니다. 공유 /components 폴더가 커질수록 Card.tsx, CardV2.tsx, NewCardFinal.tsx가 생기고, 어느 것 하나 건드리기 무서운 상태가 됩니다.

이 글이 제안하는 Feature Colocation은 공통 컴포넌트 설계와 직결됩니다. /features/auth, /features/billing 각각의 폴더 안에 컴포넌트, 훅, 유틸을 함께 두고, index.ts를 퍼블릭 API로 삼아 외부에 노출할 것만 명시적으로 export하는 방식입니다. 바텀시트라면 어떻게 배치될까요? 진짜 전역적으로 쓰이는 오버레이라면 /components 하위에 두되, Portal, useBottomSheet, 애니메이션 로직을 모두 같은 폴더 안에 코로케이션하는 게 맞습니다. 특정 피처에만 쓰이는 바텀시트라면 그 피처 폴더 안으로 들어가야 하고요.

ESLint의 no-restricted-paths 룰로 피처 간 직접 import를 막는 Quick Fix부터, Turborepo 기반 모노레포까지—규모와 팀 구성에 따라 선택지가 다릅니다. 이 글의 결론처럼, 대부분의 프로젝트에서는 Feature Colocation이 모노레포 복잡도의 10%로 90%의 효과를 냅니다.


시사점: 공통 컴포넌트 설계는 '기술의 조합'입니다

네 가지 기사를 함께 읽으면 공통 컴포넌트 설계가 얼마나 많은 레이어를 건드리는지 보입니다. Portal로 DOM 계층을 탈출하고, useSyncExternalStore로 외부 상태와 React를 동기화하고, requestAnimationFrame 이중 중첩으로 애니메이션 타이밍을 제어하고, CSS-in-JS로 스타일 소유권을 컴포넌트 단위로 고정하고, Feature Colocation으로 코드 경계를 명확히 합니다. 이 중 하나라도 빠지면 어딘가에서 버그가 납니다. Figma 시안에서는 보이지 않는 버그가요.

앞으로의 방향은 이 레이어들이 더 명시적으로 연결되는 쪽입니다. Figma Dev Mode의 Design Token이 CSS Variable로 자동 변환되고, 컴포넌트 경계가 코드 레벨에서 lint로 강제되고, hydration 타이밍은 프레임워크가 추상화해주는 방향으로 가고 있습니다. 그렇더라도 transitionend가 씹히는 엣지 케이스, 복수 인스턴스의 스크롤 락 동기화 같은 디테일은 여전히 사람이 설계해야 합니다. 공통 컴포넌트를 '그냥 만드는' 것과 '설계해서 만드는' 것의 간격이 좁혀지지 않는 이유가 바로 여기 있습니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요