iOS·안드로이드 웹뷰 컴포넌트 라이브러리
웹뷰 컴포넌트 197개를 iOS·안드로이드 두 팔레트로 굴린 디자인 시스템
화면마다 다르게 생긴 버튼
네이티브 앱 안에서 웹뷰로 그리는 화면이 늘어나던 때였습니다. 같은 '확인' 버튼인데 라운드가 6px인 화면과 8px인 화면이 섞였고, 회색도 저마다 달랐어요.
디자인 시스템 자체는 있었습니다. Figma에 102_Button/Normal/Large, 205_ListItem/Child/Left/Line2IconMedium4 같은 번호 체계로 정리되어 있었죠. 없는 건 코드였습니다.
요구사항은 셋이었습니다. 코드의 디렉터리가 Figma의 번호 체계를 그대로 따를 것. 같은 컴포넌트가 iOS에서는 iOS 색과 폰트로, 안드로이드에서는 안드로이드 색과 폰트로 나올 것. 그리고 웹뷰가 네이티브 셸 안에 있다는 사실을 컴포넌트가 알 것. 노치 아래로 내려가야 하고, 바텀시트를 올릴 때 네이티브가 깔아주는 딤과 타이밍이 맞아야 했습니다.
제약도 분명했습니다. 이 패키지를 쓰는 웹뷰 서비스가 여럿이라, 한 번 잘못 배포하면 여러 화면이 동시에 깨집니다. 릴리즈는 디자인 QA를 거쳤습니다. PR을 올리면 그 PR의 결과물이 확인돼야 머지가 됐어요.

CSS 변수 한 벌로 두 OS 팔레트
색 토큰을 CSS custom property로 두고 클래스 하나로 팔레트를 갈아끼운다
colors.js가 색 이름을 rgba(var(--color-green-100), var(--tw-bg-opacity, 1)) 형태로 뱉고, tailwind.config.css의 :root에 iOS 값이, .and 스코프에 안드로이드 값이 들어 있습니다. body에 and 클래스 하나만 붙으면 스타일시트를 다시 받지 않고 팔레트 전체가 바뀝니다. rgb 세 값만 변수로 빼둔 덕에 bg-opacity-30 같은 Tailwind 불투명도 유틸리티도 그대로 살아 있어요. 웹뷰가 자기 OS를 아는 통로가 네이티브가 붙여주는 클래스명뿐이었습니다. JS로 분기하면 첫 페인트가 iOS 색으로 한 번 깜빡입니다.
Storybook을 staging과 production 두 S3 버킷에 정적 배포
PR을 올리면 staging 버킷에 스토리북이 올라가고, 그 주소에서 QA를 거쳐 승인됩니다. 스토리마다 storybook-addon-designs로 Figma 노드 URL을 붙여둬서 원본과 구현을 같은 화면에서 볼 수 있어요. 코드를 읽지 않는 사람에게 결과물을 보여주는 가장 싼 방법이었습니다. 배포가 aws s3 cp --recursive라 원자적이지 않습니다. 미는 몇 초 동안은 옛 파일과 새 파일이 섞인 상태가 보일 수 있어요.

패키지 셋과 스토리북 S3
레포는 셋인데 역할이 겹치지 않습니다. bpl-web이 컴포넌트와 색 토큰을 갖습니다. rollup -c가 lib/에 번들을 만들고, 따로 postcss tailwind.config.css -o lib/styles.css가 색 변수와 유틸리티 클래스를 담은 스타일시트 한 장을 만듭니다. 아이콘과 이미지는 패키지 안에 없고 https://cdn.banksalad.com/bpl/101-icon/<name>.svg에서 런타임에 받아옵니다. web-native-ui-interface는 웹과 네이티브 사이의 UI 프로토콜만 담은 아주 작은 패키지입니다. 여기서는 색 토큰과 아이콘 전달을 중심에 둡니다. 번들 진입점 분리, semantic-release 릴리즈 채널, styleguide 규약은 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접고, 네이티브 딤 핸드셰이크는 뒤에서 요약만 하겠습니다.

아이콘을 번들에 넣지 않고 CDN에서 글자로 받아오기
아이콘 SVG를 전부 React 컴포넌트로 만들어 패키지에 넣습니다. 아니면 <img src='.../icon.svg'>로 겁니다. 앞쪽은 아이콘 하나가 추가될 때마다 npm 릴리즈가 필요하고, 개수가 늘수록 타입 정의만으로도 빌드가 느려집니다. 뒤쪽은 배포는 편한데 색을 못 바꿔요. <img> 안의 SVG에는 바깥 CSS가 닿지 않으니 text-green-100 같은 토큰을 얹을 방법이 없습니다. 디자인 시스템에서 아이콘 색을 못 바꾸는 건 치명적입니다.
fetch로 SVG를 텍스트로 받아 dangerouslySetInnerHTML로 심었습니다. 그러면 SVG가 문서 트리 안으로 들어오니 CSS가 닿습니다. 래퍼 <span>에 text-${color} 클래스를 주고 & > svg { fill: currentColor }와 & * { fill: unset }을 걸어, 아이콘 색이 색 토큰을 그대로 상속하게 했어요. 색이 여러 개인 일러스트형 아이콘은 isColoredIcon으로 이 규칙을 건너뜁니다.
받아온 문자열은 그냥 심지 않고 손을 봅니다. width와 height를 요청한 크기로 정규식 치환하고, 원본에 viewBox가 없으면 원래 크기로 viewBox를 만들어 붙입니다. viewBox 없는 SVG는 크기를 바꾸면 잘리거든요.
중복 요청은 모듈 스코프 맵 두 개로 접었습니다. iconFetchingRequest는 진행 중인 Promise를, iconCache는 완료된 문자열을 담습니다. 실패하면 같은 URL을 <img>로 보여주는 FallbackImage로 넘어가고, isMounted 플래그로 언마운트 뒤 setState를 막습니다.
핵심은 두 맵의 역할이 다르다는 점입니다. 캐시 하나만 두면 같은 리스트에 같은 아이콘이 스무 번 나올 때 fetch 스무 개가 동시에 날아갑니다. 그 시점엔 아직 캐시에 아무것도 없으니까요. 진행 중인 Promise를 따로 붙들어 두면 두 번째부터는 같은 Promise를 await하게 되어 요청이 한 번으로 줄어듭니다.
그리고 아이콘 추가라는 작업이 npm 릴리즈에서 CDN 업로드로 내려왔습니다. 아이콘이 하나 더 늘었다고 해서 패키지 버전이 올라가고 여러 서비스가 그 버전을 따라가야 할 이유는 없으니까요.
네이티브가 딤을 다 깔 때까지 기다리는 Promise
showDim()을 부르고 바로 다음 줄에서 바텀시트를 올립니다. 그런데 네이티브가 딤 뷰를 얹는 데 걸리는 시간은 기기마다 다릅니다.
브릿지 호출을 fireEventToNative(type, data?) 하나로 모으고 두 채널을 ||로 흡수했습니다. 그 위에 showDim()을 Promise로 감쌌습니다.
호출부는 await showDim() 한 줄이면 됩니다. 타임아웃을 reject로 만들되 밖으로 던지지 않은 게 의도한 지점입니다.
유저가 할 수 있는 일
CI가 못 돌린 스냅샷 테스트
시각 회귀 테스트가 CI에서 돌지 않습니다. _templates/bpl/test/index.test.t가 찍어내는 테스트는 puppeteer로 http://localhost:3030/iframe.html?id=...에 접속해 iPhone X로 에뮬레이트한 스크린샷을 toMatchImageSnapshot으로 비교하는데, 크롬 경로가 /Applications/Google Chrome.app/...으로 하드코딩되어 있고 로컬 스토리북이 떠 있어야 합니다. 그래서 CI는 SKIP_SNAPSHOT=true로 유닛 테스트만 돌리고, 시각 회귀는 사람이 dev 스토리북을 눈으로 보는 걸로 대신했어요. 도구는 만들어 뒀는데 자동으로 돌지는 않는 상태였습니다. 다시 만든다면 컨테이너 안의 Chromium으로 옮기고, 스토리 URL도 손으로 적는 대신 스토리 목록에서 뽑아 채웠을 겁니다.












