새로운 Feature가 오래된 시스템에 손을 넣을 때

Vibe-coded React Native 앱에서 반복해서 보이는 failure mode 중 하나는, 새 feature가 기존 service를 직접 import하고 자기 소유가 아닌 state를 건드린 뒤 전혀 관계없는 flow를 깨뜨리는 것입니다. auth가 특히 자주 희생되는 이유는 모두의 shared dependency가 되기 쉽기 때문입니다.

Cursor와 Claude Code로 빠르게 조립한 앱에서는 각 feature가 로컬 context 안에서 생성됩니다. 코드는 compile됩니다. happy path는 통과할 수도 있습니다. 하지만 service boundary가 없으면, 독립적으로 보이는 file들이 함께 무너집니다.

이게 scale 단계의 Vibe coding이 안고 있는 근본적인 문제입니다. AI coding assistant는 context-blind architect입니다. 내가 요청한 것을 생성할 뿐, 코드베이스가 실제로 필요로 하는 것을 생성하지는 않습니다. 명시적인 service boundary가 없으면, 새 feature 하나가 언제든 breaking change가 될 수 있습니다.

Interface Rule

규칙은 간단합니다. 앱의 모든 service는 interface를 노출해야 합니다. interface는 당신의 코드베이스 안에 있습니다. implementation이 Supabase든 Firebase든 RevenueCat이든 mock이든, 모두 그 interface 뒤에 숨어야 합니다. 앱의 나머지 부분은 어느 쪽인지 몰라야 합니다.

// src/services/auth/auth.interface.ts
// Your app owns this. Not Supabase. Not Firebase. You.

export interface User {
  id: string
  email: string
  displayName: string | null
  avatarUrl: string | null
}

export interface Session {
  accessToken: string
  refreshToken: string
  expiresAt: number
  user: User
}

export interface AuthService {
  signIn(credentials: EmailCredentials): Promise<Session>
  signOut(): Promise<void>
  getSession(): Promise<Session | null>
  onAuthStateChange(
    callback: (session: Session | null) => void
  ): () => void
}

여기가 seam입니다. 앱 쪽의 모든 코드는 AuthService와 대화합니다. SDK 쪽의 모든 코드는 그 interface를 구현합니다. Supabase를 Firebase로 바꾸고 싶다면 implementation class 하나만 새로 쓰면 됩니다. login screen도, route guard도, hook도 바뀌지 않습니다. Claude Code는 swap이 일어났다는 사실조차 알 필요가 없습니다.

Composition Root

dependency graph 전체를 생성하는 파일은 하나뿐이어야 합니다. 나머지 곳에서는 소비만 해야 합니다.

// src/services/create-services.ts
// ONE file. Cursor never touches this. You own it.

import { config } from '@/constants/config'
import { SupabaseAuthService } from './auth/supabase'
import { RevenueCatService } from './payments/revenuecat'

export type Services = ReturnType<typeof createServices>

export function createServices() {
  const storage = createStorageServices(config.storageMode)

  return {
    storage,
    auth: new SupabaseAuthService(storage.secureStorage),
    payments: new RevenueCatService(),
    analytics: new SegmentAnalyticsService(),
    crashReporting: new SentryService(),
    network: createNetworkService(),
  }
}

Supabase를 Firebase로 바꾸고 싶습니까? 한 줄만 바꾸면 됩니다. Segment를 PostHog로 바꾸고 싶습니까? 역시 한 줄입니다. AI assistance를 받으며 Vibe coding을 해도 확장 가능한 React Native architecture는 이런 모습이어야 합니다.

왜 AI-Coded 앱에서는 이게 더 중요한가

사람이 코드를 쓸 때는 아키텍처를 머릿속에 품고 있습니다. screen component에서 supabase를 직접 import하면 안 된다는 걸 압니다. AI coding assistant는 아키텍처를 머릿속에 품지 않습니다. 대신 context window를 가집니다. 그리고 context window는 잊어버립니다.

// src/context/auth-provider.tsx
// This provider doesn't know what auth SDK you use.
// Claude Code generates against the interface, never the implementation.

export function AuthProvider({ children }: PropsWithChildren) {
  const { auth } = useServices()
  const [session, setSession] = useState<Session | null>(null)

  useEffect(() => {
    auth.getSession().then(setSession)
    return auth.onAuthStateChange(setSession)
  }, [auth])

  const signIn = useCallback(
    async (creds: AuthCredentials) => {
      const s = await auth.signIn(creds)
      setSession(s)
    },
    [auth]
  )

  return (
    <AuthContext.Provider
      value={{ session, user: session?.user ?? null, signIn }}>
      {children}
    </AuthContext.Provider>
  )
}

이 파일은 세 번의 backend migration을 거치는 동안 한 번도 바뀌지 않았습니다. supabase.auth.signInWithPassword가 어떻게 생겼는지 전혀 모릅니다. auth.signIn()만 압니다. 그게 핵심입니다.

Autotomy Expo Starter Pack은 service interface, composition root, barrel export를 처음부터 넣어 둡니다. 그래서 Cursor와 Claude Code가 첫날부터 boundary를 존중하는 코드를 생성하게 됩니다.