Vibe Coding의 숙취

Cursor를 열고 prompt를 입력하자, Claude Code가 순식간에 완전한 React Native UI를 만들어 냈습니다. flex layout, 둥근 버튼, color theme까지 첫 시도에 모두 동작했습니다. TestFlight에 올렸고, 사용자가 다운로드하기 시작했습니다. 그게 첫 주였습니다.

6주 차가 되자 brand color 하나 바꾸는 데 47개 파일을 find-and-replace 하느라 3시간이 걸렸습니다.

이게 styling contract 없이 Vibe coding을 했을 때 치르는 숨은 비용입니다. 모든 component가 Tailwind utility class를 하드코딩하면, styling engine이 모든 screen, 모든 hook, 모든 route guard에 새어 들어가는 dependency가 됩니다. A를 바꾸면 B, C, D가 깨집니다. 로직이 틀려서가 아니라, styling에 boundary가 없기 때문입니다.

왜 NativeWind가 함정이 되는가

button을 만들어 달라고 했을 때 Cursor가 생성하는 코드는 대체로 이렇습니다. 깔끔해 보입니다. 동시에 maintenance nightmare이기도 합니다.

// This is what Claude Code gives you. It works until it doesn't.
<View className='p-4 flex-row justify-between'>
  <Text className='text-lg font-bold text-slate-800'>
    Title
  </Text>
  <Button className='bg-blue-500 px-4 py-2 rounded-lg' />
</View>

문제는 코드 자체가 아닙니다. 패턴입니다. 모든 파일이 raw styling logic를 품고 있으면, design system을 바꾸기 위해 모든 파일을 건드려야 합니다. AI는 빠르게 배포하지만 coupling도 함께 배포합니다. 지금은 “동작하는” 코드베이스처럼 보여도 실제로는 카드로 쌓은 집입니다.

해결책: Theme Contract

앱은 styling engine과 직접 대화하면 안 됩니다. theme contract와 대화해야 합니다. contract는 한 파일에 있습니다. primitive는 한 폴더에 있습니다. 나머지 모든 코드는 primitive만 씁니다.

// src/constants/theme.ts
// The ONLY file that defines your app's visual identity.
// Claude Code reads this. Cursor references it.

export interface ThemeContract {
  colors: {
    background: string
    foreground: string
    surface: string
    primary: string
    danger: string
  }
  spacing: { xs: number; sm: number; md: number; lg: number; xl: number }
  radius: { sm: number; md: number; lg: number; full: number }
  typography: { title: number; body: number; caption: number }
}

export const theme = {
  colors: {
    background: '#0B0F19',
    foreground: '#F8FAFC',
    surface: '#111827',
    primary: '#2563EB',
    danger: '#DC2626',
  },
  spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
  radius: { sm: 8, md: 12, lg: 20, full: 999 },
  typography: { title: 28, body: 16, caption: 13 },
} as const satisfies ThemeContract

satisfies 키워드가 핵심 무기입니다. TypeScript는 contract를 검사하면서도 literal type을 보존합니다. theme.spacing.md에 autocomplete가 붙습니다. Claude Code는 따라야 할 schema를 얻습니다. 모두에게 이득입니다.

Semantic Primitive: UI Vocabulary

StyleSheet를 직접 만지는 파일은 semantic primitive뿐이어야 합니다. Button은 variant와 size를 알고, Stack은 gap과 alignment를 압니다. 어떤 screen도 className을 직접 쓰지 않습니다.

// src/components/ui/button.tsx
// Public API: semantic props only.

import { Pressable, Text } from 'react-native'
import { theme } from '@/constants/theme'

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  onPress: () => void
  children: string
}

export function Button({
  variant = 'primary',
  size = 'md',
  loading,
  onPress,
  children,
}: ButtonProps) {
  const bgMap = {
    primary: theme.colors.primary,
    secondary: theme.colors.surface,
    danger: theme.colors.danger,
  }
  const heightMap = { sm: 36, md: 44, lg: 52 }

  return (
    <Pressable
      onPress={onPress}
      disabled={loading}
      accessibilityRole='button'
      accessibilityState={{ busy: loading }}
      style={{
        backgroundColor: bgMap[variant],
        height: heightMap[size],
        paddingHorizontal: theme.spacing.lg,
        borderRadius: theme.radius.md,
        justifyContent: 'center',
        alignItems: 'center',
        opacity: loading ? 0.6 : 1,
      }}>
      <Text style={{ color: '#FFF', fontSize: theme.typography.body }}>
        {children}
      </Text>
    </Pressable>
  )
}

파일 하나를 바꾸면 모든 button이 업데이트됩니다. 새로운 screen도 contract에 맞춰 생성하면, 아키텍처는 무너지지 않습니다.

ESLint Gate

강제되지 않는 contract는 그냥 제안일 뿐입니다. primitive seam 바깥에서 className을 거부하는 rule을 추가하세요.

// eslint.config.js
{
  files: ['app/**/*', 'src/**/*'],
  ignores: ['src/components/ui/**/*', 'src/components/layout/**/*'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'JSXAttribute[name.name="className"]',
      message: 'Use semantic UI primitives instead of className.'
    }]
  }
}

Autotomy Expo Starter Pack은 이 guardrail을 미리 설정해 둡니다. Cursor의 속도는 그대로 가져가고, 아키텍처는 첫 1,000명의 사용자 이후에도 버틸 수 있게 만듭니다.