안녕하세요, 로건입니다 👋
여러분, 이 시리즈를 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-Web | in-app SB | Device 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이에요.

동작 방식 — 부팅 분기 + 런타임 토글
구조 자체는 두 갈래예요.
첫째, 빌드 시점 분기. 환경변수 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=true | true | StorybookUIRoot only | 디자이너 검수용 |
| Dev / Staging | (unset) | true | App + 토글 + DebugView | 팀 내부 |
| Production | (unset) | false | App만 (토글 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의 분담
이제 저희에게는 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 레이어 + 픽셀 diff | gesture · IME · worklet · 네이티브 동작 |
| 자동화 | ✅ Chromatic + play | ❌ 수동 |
| 들어가는 빌드 | 정적 빌드 (storybook-static) | dev / staging / Storybook 단독 |
| 속도 | 초 단위 | 빌드 + 설치 시간 |
| 진입점 | localhost:6006 | DebugView → 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이 함께 짠 안전망의 전체 모양과, 아직 비워둔 칸들에 대해서요.