useEffect Subscription 함정

예전의 저는 대부분의 Vibe coder처럼 network detection hook을 작성했습니다. useEffect에서 NetInfo를 subscribe하고, connectivity가 바뀌면 state를 갱신하고, 잘 되길 바랐습니다. 개발 환경에서는 괜찮았습니다. production에서는 실패했습니다.

race condition, memory leak, stale closure. Cursor 미리보기에서는 보이지 않지만 crash log에서는 바로 보이는, 늘 그 문제들이었습니다.

network detection을 요청했을 때 Claude Code가 생성하는 코드는 보통 이런 식입니다. 맞아 보입니다. 실제로는 production liability입니다.

// What Cursor gives you. Works in dev. Breaks in prod.

function useNetwork() {
  const [isConnected, setIsConnected] = useState(true)

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setIsConnected(!!state.isConnected)
    })
    return unsubscribe
  }, [])

  return isConnected
}

문제는 이것입니다. effect가 실행되기 전에 callback이 먼저 발화할 때 생기는 tearing. setup 중 unmount되면서 생기는 memory leak. callback이 prop을 캡처하고 dependency array가 틀렸을 때 생기는 stale closure. 모든 component가 같은 boilerplate를 반복하면서 생기는 duplication.

이런 버그가 바로 사용자를 떠나게 만드는 별점 1개짜리 리뷰를 만듭니다.

useSyncExternalStore 등장

React 18은 바로 이 문제를 위해 useSyncExternalStore를 도입했습니다. 외부 store, 즉 subscribe 함수와 getSnapshot 함수를 가진 모든 대상을 읽기 위해 설계된 hook입니다. NetInfo는 external store입니다. auth state도 그렇습니다. event를 내보내는 모든 native module도 마찬가지입니다.

깨지기 쉬운 useEffect subscription을 직접 쓰는 대신, 어려운 부분은 React에 맡기면 됩니다.

// src/services/network/network.service.ts
// The service owns ONE native subscription.

import NetInfo from '@react-native-community/netinfo'

export type NetworkSnapshot = {
  isConnected: boolean
  isInternetReachable: boolean | null
  connectionType: 'wifi' | 'cellular' | 'none' | 'unknown'
}

export interface NetworkService {
  getSnapshot(): NetworkSnapshot
  subscribe(listener: () => void): () => void
}

adapter는 native subscription을 정확히 하나만 소유합니다. NetInfo가 event를 내보내면 internal snapshot을 갱신하고 모든 listener에게 알립니다. hook은 React를 이 store에 연결하기만 하면 됩니다.

// src/hooks/use-network.ts
// Three lines. No useEffect. No cleanup. No stale closures.

import { useSyncExternalStore } from 'react'

export function useNetwork() {
  const { network } = useServices()
  return useSyncExternalStore(
    network.subscribe,       // React handles subscribe/unsubscribe
    network.getSnapshot,     // Read current value
    network.getSnapshot      // SSR snapshot
  )
}

이게 전부입니다. subscription lifecycle은 React가 소유합니다. native subscription은 service가 소유합니다. hook은 둘을 이어 주기만 합니다. component가 mount되면 React가 subscribe합니다. unmount되면 React가 unsubscribe합니다. store가 바뀌면 React가 re-render합니다. 그 어떤 로직도 직접 쓸 필요가 없습니다. 틀릴 여지도 줄어듭니다.

모든 External Store에 적용되는 하나의 패턴

이 패턴은 아주 잘 확장됩니다. auth, network, permission, lifecycle처럼 React 밖에서 오는 모든 state는 같은 모양을 쓸 수 있습니다.

// Every external store follows the same contract:
interface ExternalStore<T> {
  getSnapshot(): T
  subscribe(listener: () => void): () => void
}

// Every hook looks the same:
function useStore<T>(store: ExternalStore<T>): T {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot
  )
}

useSyncExternalStore 이전에는 외부 데이터 소스마다 제각각의 useEffect hook을 썼습니다. 각각이 조금씩 달랐고, 각각 나름의 버그가 있었습니다. 이제는 패턴 하나를 정하고, 모든 곳에 적용하고, subscription lifecycle은 React에 맡깁니다.

아직도 Vibe-coded React Native 앱에서 external subscription을 위해 useEffect를 쓰고 있다면, 필요 이상으로 힘들게 일하면서 필요 이상으로 버그 많은 코드를 만들고 있는 것입니다. hook은 이미 있습니다. 쓰면 됩니다.

Autotomy Expo Starter Pack은 network state, auth state, app lifecycle에 이 패턴을 적용합니다. 패턴은 하나, subscription bug는 0개, 마음은 훨씬 편해집니다.