Logan.

2026-05-15

Storybook을 디바이스 안으로 DebugView에 심은 on-device 카탈로그

RN-Web Storybook만으로는 닿지 못한 칸이 있었어요. @storybook/react-native를 DebugView 안에 토글로 끼우고, gesture · IME · Reanimated worklet 같은 회귀를 디바이스 QA 전에 한 번 더 거르는 그물을 추가한 이야기.

by Logan·14분 읽기·part 8 of 본격 Storybook 도입·#storybook #react-native #on-device #debugview

안녕하세요, 로건입니다 👋

여러분, 이 시리즈를 1편부터 따라오신 분들은 기억하실 거예요. RN-Web Storybook이 못 잡는 회귀 9가지를 표로 정리해두고, "이 영역은 디바이스 QA의 몫" 이라고 분명히 선을 그어뒀던 그 표 말이에요.

저는 그 선이 정답이라고 믿었고, 검증 단계 내내 그 선을 지키는 데 집중했어요. 그런데 도입을 막 시작하려는 지금 시점에 한 가지 결정을 하나 더 얹게 됐어요 — Storybook을 웹에만 두지 않고, 앱 자체에도 심기로 한 것입니다. 그것도 별도 빌드가 아니라, 이미 있던 DebugView 안에 한 줄을 더 얹는 형태로요.

1편의 그 표가 살짝 흔들리는 결정이에요. 오늘은 그 흔들림과 남은 선에 대한 이야기를 풀어보려고 합니다.

RN-Web만으로는 닿지 못한 칸

1편의 "못 잡는 회귀 9가지" 표를 다시 떠올려주세요. gesture, IME, SafeArea, Reanimated worklet, 네이티브 모듈, 스크롤 관성 — 이 영역은 RN-Web에서는 원리상 잡을 방법이 없어요. 그런데 검증 단계 막판에 한 가지 깨달은 게 있었어요.

이 영역의 절반쯤은 "디바이스 빌드 + 실제 화면 클릭" 이라는 비싼 과정 없이도, 진짜 RN 런타임 위에서 컴포넌트 한 개만 띄울 수 있으면 잡힐 수 있다는 것. 디바이스 QA가 본질을 잡는다면, in-app Storybook은 디바이스 QA 직전에 한 번 더 걸러주는 그물이 되어요.

회귀 종류RN-Webin-app SBDevice QA
pan / pinch / long-press 제스처
한국어 IME 조합 (composition event)
SafeAreaView의 실제 inset
Reanimated worklet 실행 결과
네이티브 모듈 (camera · biometrics · push)△ (mock 필요)
60fps 애니메이션 jank△ (체감 가능)
콜드 스타트 race condition
OS별 폰트 fallback
스크롤 관성 / overscroll

9개 칸 중 6개가 ✅로 바뀌어요. 이게 도입 결정의 출발점이었습니다.

DebugView 한 줄 차이

저희 팀에는 이미 DebugView라는 페이지가 있었어요. 환경 토글이나 사내 로그 뷰어 같은 dev tool이 모여 있던 곳인데, 여기에 "Storybook" 카테고리 하나만 더 끼웠습니다.

별도 화면을 만들지 않은 게 중요한 결정이었어요. 새 진입점을 만들면 동료들이 또 하나의 도구를 익혀야 하지만, DebugView 안에 끼우면 이미 들어와 본 길 위에 한 칸이 늘어난 형태가 되거든요. 신규 인지 비용이 거의 0이에요.

DebugView 안의 Storybook 카테고리 — openStorybook 항목

동작 방식 — 부팅 분기 + 런타임 토글

구조 자체는 두 갈래예요.

첫째, 빌드 시점 분기. 환경변수 STORYBOOK_ENABLED=true로 빌드하면 앱이 통째로 Storybook 전용 모드로 부팅돼요. 디자이너에게 별도 검수 빌드를 던질 때 쓰는 경로예요.

둘째, 런타임 토글. 일반 dev 빌드에서 __DEV__ 체크가 통과하면 AppWithStorybookToggle이 export돼서, DebugView에서 toggleStorybook(true)를 부르는 순간 같은 앱 인스턴스 안에서 앱 ↔ Storybook을 오갈 수 있어요. production 빌드에서는 이 분기가 dead code로 죽어서 사용자에게 절대 노출되지 않아요.

모듈 레벨 콜백 패턴(registerStorybookToggle)으로 state setter를 빼두는 게 핵심인데, navigation tree와 무관하게 어디서든 토글을 부를 수 있게 됩니다.

런타임 전환은 이렇게 동작해요.

코드로는 App.tsx가 이 정도 패턴이에요.

// src/App.tsx
let __toggle: ((show: boolean) => void) | null = null;
 
export function registerStorybookToggle(cb: typeof __toggle) {
  __toggle = cb;
}
 
export function toggleStorybook(show: boolean) {
  __toggle?.(show);
}
 
function AppWithStorybookToggle() {
  const [showStorybook, setShowStorybook] = useState(false);
  useEffect(() => {
    registerStorybookToggle(setShowStorybook);
    return () => registerStorybookToggle(null);
  }, []);
  if (showStorybook) {
    const StorybookUIRoot = require("../.storybook/ondevice").default;
    return <StorybookUIRoot />;
  }
  return <App />;
}
 
export default __DEV__ ? AppWithStorybookToggle : App;

빌드 정책 — 누가 어떤 번들을 받는가

production에 절대 들어가지 않는다는 점이 도입 결정의 큰 비중을 차지했어요. __DEV__ 가드와 require 동적 로드 덕분에 production 번들에서는 StorybookUIRoot 자체가 require 그래프에 없어요. 배포본 무게에도 영향이 없고, 디버그 메뉴 자체가 prod에는 안 들어가니 사용자가 우연히 마주칠 경로도 없어요.

디자이너에게 별도 검수 빌드를 줄 때는 STORYBOOK_ENABLED=true로 Storybook 단독 빌드를 따로 떨어뜨려서 — 카탈로그만 든 작은 앱처럼 사용해요.

빌드 종류env__DEV__결과배포 대상
Storybook 단독STORYBOOK_ENABLED=truetrueStorybookUIRoot only디자이너 검수용
Dev / Staging(unset)trueApp + 토글 + DebugView팀 내부
Production(unset)falseApp만 (토글 dead code)사용자

package.json의 scripts는 이렇게 가져갑니다.

{
  "scripts": {
    "storybook:web": "storybook dev -p 6006",
    "storybook:ios": "STORYBOOK_ENABLED=true react-native run-ios",
    "storybook:android": "STORYBOOK_ENABLED=true react-native run-android"
  }
}

진짜 RN에서만 잡힌 두 회귀

검증 단계 마지막에 in-app으로 한 번 띄워보니까 바로 두 종류 회귀가 튀어나왔어요.

첫째, 한국어 IME 조합 중 onChangeText 시그니처 mismatch. RN-Web에서는 웹 키보드라 조합 단위 이벤트가 안 와서 안 잡혔던 거예요. 사용자가 "안녕"을 입력하는 중 자모 합쳐지는 그 순간의 분기를 진짜 디바이스에서야 따라가게 됐어요.

둘째, BottomSheet의 Reanimated worklet이 첫 프레임을 건너뛰는 jank. 웹 shim에서는 worklet이 mock이라 형태만 비슷했지 타이밍은 다른 동작이었어요. 60fps 위에서 실제 UI 스레드가 worklet을 돌릴 때만 보이는 jank였습니다.

두 회귀 다 디바이스 QA로 가면 잡혔겠지만, 디바이스 빌드를 만들기 전에 컴포넌트 한 개만 띄워서 잡을 수 있다는 게 큰 차이예요. 디바이스 QA의 부담을 체감 가능한 분량 만큼 줄여줍니다.

디바이스에서 띄운 Storybook UI — BottomSheet 스토리

두 Storybook의 분담

이제 저희에게는 Storybook이 두 개가 있어요. 처음엔 중복 아닐까 싶었는데, 1주일 써보니까 역할이 완전히 다르더라고요.

Web Storybook은 PR마다 자동으로 돌아요 — Chromatic이 픽셀 diff를 잡고, play 함수가 JS 핸들러를 자동 검증해요. CI 친화적이고, 1분 안에 결과가 나와요.

in-app Storybook은 디바이스가 손에 있을 때 켜요 — 디자이너 검수, IME 손맛, 제스처 확인. 자동화가 안 되니까 수동 카탈로그에 가까운 도구예요.

Web Storybook (RN-Web)in-app Storybook (on-device)
어디서 도나브라우저실제 RN 앱 안
누가 자주 켜나CI · PR 리뷰어디자이너 · QA · 개발자 (디바이스)
잡는 회귀JS 레이어 + 픽셀 diffgesture · IME · worklet · 네이티브 동작
자동화✅ Chromatic + play❌ 수동
들어가는 빌드정적 빌드 (storybook-static)dev / staging / Storybook 단독
속도초 단위빌드 + 설치 시간
진입점localhost:6006DebugView → openStorybook

도입을 시작하면서 측정하고 싶은 것

검증 단계를 막 끝낸 지금, 두 가지 효과를 측정해보고 싶어요.

첫째, 디바이스 QA에서 컴포넌트 단위 회귀가 분기당 한 자릿수로 떨어지는지 — 1편 표의 9가지 중 절반 정도가 in-app 단계에서 걸러지면 QA 시간은 분명히 줄어들 거예요.

둘째, 디자이너의 검수 사이클이 줄어드는지 — Storybook 단독 빌드를 폰에 깔아드리면, 디자이너가 "그 BottomSheet 상태 좀 보여주세요" 라고 부탁할 필요 없이 사이드바에서 직접 띄울 수 있게 됩니다.

한 가지 약속도 해두려고 해요 — 1편의 "9가지 못 잡는 회귀" 표는 지우지 않을 거예요. in-app이 그 표의 한 칸을 살짝 줄여주긴 하지만, 콜드 스타트 race나 네이티브 모듈 실제 권한 흐름 같은 칸은 여전히 디바이스 QA의 몫이에요.

그물눈을 조밀하게 만든 게 아니라, 그물을 하나 더 겹친 것 — 그게 in-app Storybook 도입의 정확한 의미입니다.

여기까지가 출발선

여기까지가 검증 단계를 막 마치고 in-app Storybook을 도입하기로 결정한 이야기예요. 정착했다고 말씀드릴 수는 없어요 — 첫 디자이너 검수 빌드를 다음 주에 굽고, 첫 디바이스 QA 회귀 카운트를 한 분기 동안 모아볼 예정이거든요.

다만, 시리즈 내내 그어둔 선이 살짝 흔들렸다는 사실은 정직하게 적어두고 싶었어요. 1편의 표는 지우지 않지만, 9가지 옆에 "in-app으로 절반쯤은 더 일찍 잡힐 수 있다" 는 주석을 달게 됐어요.

안전망은 완성되는 것이 아니라 살아 움직이는 것이라는 게, 이번에 또 한 번 손에 남았습니다. 도입 이후 측정 결과는, 가설 검증 한 분기쯤 지난 다음에 별도 글로 인사드릴게요.

다음 편이 시리즈의 마지막입니다. 도입 과정에서 손에 남은 것들과 앞으로의 활용 기대를 정리해볼게요. Web · on-device 두 Storybook이 함께 짠 안전망의 전체 모양과, 아직 비워둔 칸들에 대해서요.

related — in series · 본격 Storybook 도입