나쁜 링크가 Production에 들어왔을 때

Vibe-coded 앱에서 deep link bug는 대체로 지루한 input 문제에서 시작합니다. malformed user ID, 오래된 campaign URL, push payload에서 빠진 enum 값 같은 것들입니다. 그다음 앱은 그 input을 신뢰해 버리고, happy-path logic 깊숙한 곳에서 screen이 crash합니다.

모든 URL parameter는 user input입니다. deep link, push notification payload, 공유 URL, QR code까지 전부 신뢰할 수 없습니다. Cursor로 Vibe coding을 하면 Claude Code는 happy path를 아주 잘 생성합니다. 하지만 paranoid path는 직접 만들라고 하지 않으면 나오지 않습니다. Claude는 가정합니다. Production은 그런 가정을 벌합니다.

Route Boundary에서 Zod로 검증하기

parameter는 앱 안으로 들어오는 지점에서 바로 validate해야 합니다. 검증이 끝나면, 나머지 코드는 typed되고 안전한 값만 다루게 됩니다.

// app/(app)/profile.tsx
// Validate at the gate. Everything past this point is typed.

import { useLocalSearchParams } from 'expo-router'
import { z } from 'zod'

const profileParamsSchema = z.object({
  section: z
    .enum(['overview', 'security', 'preferences'])
    .default('overview'),
  userId: z.string().uuid().optional(),
})

type ProfileParams = z.infer<typeof profileParamsSchema>

export default function ProfileScreen() {
  const raw = useLocalSearchParams()
  const result = profileParamsSchema.safeParse(raw)

  if (!result.success) {
    return <ErrorScreen message='Invalid profile parameters' />
  }

  const { section, userId } = result.data
  return <ProfileContent section={section} userId={userId} />
}

ProfileContent는 이제 sectionuserId를 올바르게 typed된 값으로 받습니다. useLocalSearchParams가 뭔지 몰라도 됩니다. validate도 하지 않습니다. 그저 정상 데이터만 받습니다. 현실 세계가 garbage를 보내 와도 AI가 생성한 feature가 망가지지 않는 Vibe-coded 앱은 이런 식으로 만듭니다.

+native-intent: 앱에 들어오기 전에 악성 URL을 거절하라

Expo Router는 malformed, stale, malicious할 수 있는 incoming native path를 처리하도록 +native-intent.tsx를 제공합니다. 이 파일은 provider가 mount되기 전에 실행됩니다. 여기서는 path를 rewrite하고 나쁜 입력을 거절하는 일만 해야 합니다.

// app/+native-intent.tsx
// This is your firewall, not your business logic.

export function redirectSystemPath({
  path,
}: {
  path: string
}) {
  // Reject paths with suspicious patterns
  if (path.includes('..') || path.includes('//')) {
    return '/'
  }

  // Migrate old URL format to new format
  if (path.startsWith('/user/')) {
    const userId = path.replace('/user/', '')
    if (z.string().uuid().safeParse(userId).success) {
      return `/profile?userId=${userId}`
    }
    return '/'
  }

  return path
}

핵심은 이겁니다. +native-intent는 사용자가 로그인 상태인지 확인할 수 없습니다. 여기서 해야 할 일은 path sanitization뿐입니다. business authorization은 여기서가 아니라 route guard에 있어야 합니다.

Typed Route: 컴파일 타임에 오류 잡기

config에서 typedRoutes를 켜면, route name 오타를 사용자가 보기 전에 잡을 수 있습니다.

// app.config.ts
{
  experiments: {
    typedRoutes: true,
  }
}

// Now the router validates at compile time:
router.push('/profile')         // OK
router.push('/profle')          // TypeScript error
router.push({
  pathname: '/profile',
  params: { section: 'overview' },
})  // OK

typed route는 route 이름 오타를 잡고, Zod validation은 잘못된 parameter를 잡습니다. 둘을 합치면 대부분의 웹 라우터가 제공하지 못하는 안전망이 생깁니다.

Autotomy Expo Starter Pack은 Zod validation pattern, typed route 설정, +native-intent handler를 모두 미리 갖추고 있습니다. deep link는 제대로 동작하고, 사용자는 크래시를 겪지 않습니다.