안녕하세요, 로건입니다 👋
어느 화면을 변환하는데, production 코드에 거의 똑같은 화면이 있더라고요. 운이 좋다고 생각했죠. 한참을 베껴서 빠르게 스토리를 짰고, 결과물을 본 심정은 당혹스러웠습니다.
"이건 figma 디자인과 다른데....?"
그날 알게 된 게 두 가지였어요. 첫째, figma URL을 받았다는 건 그 디자인이 source of truth라는 뜻. 둘째, 비슷한 production은 가장 그럴듯한 함정이라는 것. 이 글은 그 분노의 오후를 포함해서, RN-Web 검증 단계에서 마주친 함정 여섯 개에 대한 기록입니다.
함정 1 — production 베끼기
위에서 말한 그 사건이에요. production 컴포넌트가 figma와 80% 비슷해 보이면, 그걸 베껴서 빠르게 끝내고 싶은 충동이 듭니다. 그런데 그 20%가 보통 최근에 디자이너가 의도해서 바꾼 부분이에요.
| production (현재) | figma (요청된 새 디자인) | |
|---|---|---|
| 헤드라인 폰트 | 18px | 20px |
| 버튼 개수 | 2개 | 3개 |
| 카드 패딩 | 12px | 16px |
| CTA 라벨 | "확인" | "동의하고 진행" |
스토리 짤 때는 항상 figma를 source로 보세요. production은 참고로만. 디자이너가 figma URL을 던졌다는 건, "이 화면을 이대로 만들어 달라"는 명시적 요청이에요. 그걸 production 흉내로 우회하면 디자이너의 의도를 무시하는 셈이 됩니다.
비슷한 production은 가장 그럴듯한 함정입니다.
함정 2 — SVG loader가 color prop을 무시
RN에서 SVG 아이콘을 쓰는 패턴이 보통 이래요.
<IconCheck color={tokens.color.iconAccent} />그런데 RN-Web에서 SVG를 변환해주는 loader 중 일부가 이 color prop을 무시합니다. 처음 만났을 때, "내가 prop을 잘못 넣었나?" 하면서 한참 헤맸어요.
해결은 loader를 수정하지 않는 것입니다. 공유 인프라를 만지면 합격 받은 스토리들이 도미노로 깨질 수 있거든요. 대신 스토리 측에서 우회합니다.
// ❌ 처음 시도 — shim을 고치려고 함
// patches/svg-rn-loader+1.2.3.patch ... (도미노 위험)
// ✅ 우회 — 스토리에서 강제 주입
<IconCheck
style={{
...StyleSheet.flatten(styles.iconBase),
color: tokens.color.iconAccent,
}}
/>이 한 줄 규칙을 박아두고 나서 회귀 폭(blast radius)을 한 자릿수로 묶을 수 있었어요.
함정 3 — flex-wrap + gap의 trailing gap
여러 칩을 한 줄에 늘어놓다가 다음 줄로 넘기는 흔한 패턴이에요.
// ❌ trailing gap이 잡히는 경우
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{chips.map((c) => (
<Chip key={c.id}>{c.label}</Chip>
))}
</View>이게 RN-Web에서는 마지막 칩 옆에 빈 gap이 남는 회귀를 만들 때가 있어요. Chromatic diff에서 매번 빨갛게 떠서, 한참을 false positive로 의심했죠. 그런데 실제 회귀였습니다.
해결은 wrap을 포기하고 명시적으로 row를 나누는 거였어요.
// ✅ chunk로 명시
<View>
{chunk(chips, 3).map((row, i) => (
<View key={i} style={{ flexDirection: "row", gap: 8, marginBottom: 8 }}>
{row.map((c) => (
<View key={c.id} style={{ flex: 1 }}>
<Chip>{c.label}</Chip>
</View>
))}
</View>
))}
</View>코드가 조금 더 길어졌지만 결과가 결정적이고, Chromatic이 정직해졌어요.
함정 4 — device frame 폭(390) ≠ figma frame 폭(360)
이건 디버깅에 한 시간을 날린 함정이에요. Storybook decorator는 iPhone 14/15 기준 390px wide인데, figma는 종종 360px 기준으로 그려져 있어요. 그대로 두면 카드 폭이 어긋나고 우측이 잘립니다.
해결은 outer container에 figma frame 폭을 명시하고 가운데 정렬하는 것.
const ScreenLayout = ({ children }: { children: ReactNode }) => (
<View style={{ width: 360, alignSelf: "center", flex: 1 }}>{children}</View>
);이걸 화면 스토리의 공통 wrapper로 박아두면, inner padding 기준 layout이라는 한 줄 규칙이 자연스럽게 강제됩니다. 다음 변환부터는 이 함정을 다시 만나지 않아요.
함정 5 — <View> JSX prop과 style 병합 충돌
<View width={100} style={styles.box}> 같이 width를 두 군데에서 지정하면, RN-Web에서는 어느 쪽이 이기는지 비결정적이에요. 어떤 빌드에서는 prop이, 어떤 빌드에서는 style이.
// ❌ 두 source of truth
<View width={100} style={{ width: 120, padding: 8 }}>
// ✅ 단일 source
<View style={{ width: 100, padding: 8 }}>규칙은 단순해요. layout 값은 항상 style로만 표현하고, JSX prop은 layout이 아닌 props만. <View>에서 width, height, flex 같은 layout prop을 절대 쓰지 않는다는 한 줄 정책이 회귀를 한 묶음 정리해줬어요.
함정 6 — 회사 네트워크 환경의 proxy가 localhost를 가로채기
이건 회사마다 다를 텐데, 만나면 정말 당황스러운 함정이에요. 사내 proxy가 들어있는 환경에서, headless chrome으로 localhost:6006을 캡처하려는데 chrome이 proxy를 거쳐서 외부로 요청을 보내버려요. 그러면 proxy가 "localhost는 내부망에 없어!"라며 503을 줍니다. Storybook은 멀쩡히 떠 있는데도요.
해결은 두 가지를 같이 박아두는 것.
# 1. 환경변수로 localhost를 proxy 우회
export no_proxy=127.0.0.1,localhost
export NO_PROXY=127.0.0.1,localhost
# 2. chrome에 --no-proxy-server 직접 전달
chromium --no-proxy-server --headless --screenshot=out.png http://127.0.0.1:6006이 둘을 같이 두지 않으면 환경별로 동작이 갈리더라고요. 시각 회귀 캡처 스크립트에 박아둘 만한 디테일입니다.
함정 여섯 개에 대한 공통 결론
다 풀어놓고 보면, 여섯 함정 모두 공통점이 있어요.
인프라를 고치지 마라. 변경의 범위(blast radius)를 좁혀라.
shim도, loader도, 공유 wrapper도, decorator도 — 만지는 순간 합격 받은 스토리들이 도미노로 깨질 위험이 있어요. 회귀 폭을 한 자릿수로 묶으려면, 변경을 내 스토리 안에서 우회하는 게 답입니다. 처음엔 답답해요. "왜 한 줄이면 될 걸 다섯 줄로 우회하지?" 싶죠. 그런데 두세 달 운영해보면, 그 다섯 줄의 우회가 합격 받은 50개 스토리를 안 깨뜨리는 보험이라는 게 보입니다.
다음 편에서는 Chromatic으로 시각 회귀를 어떻게 자동화했는지를 깊이 풀어볼게요. pilot 단계에서 시작해서 PR 머지 블로킹까지 어떻게 단계적으로 풀어갔는지, false positive를 어떻게 줄였는지, 그리고 운영하면서 박아둔 두 줄짜리 정책들에 대해서요.