ywc.life

React Native Skills - Vercel이 공개한 React Native·Expo 베스트 프랙티스 (전문 번역)

· react-native, expo, react, vercel, best-practices

목차

참고: 이 문서는 Vercel Labs의 react-native-skills 스킬 전문을 번역한 것입니다. 원문은 36개 규칙 파일을 하나로 컴파일한 통합 문서(AGENTS.md) 기준으로 옮겼고, 컴파일 과정에서 누락된 코드 블록 7개는 개별 규칙 파일에서 복원했습니다. 원문 목차는 블로그 목차와 중복되어 생략했어요. 이전에 번역한 React Best Practices, Next.js Best Practices의 React Native 버전으로, 리스트 성능·애니메이션·네이티브 UI 같은 모바일 특화 규칙 중심입니다.


Version 1.0.0 · Engineering · 2026년 1월

노트: 이 문서는 주로 에이전트와 LLM이 React Native 코드베이스를 유지보수·생성·리팩터링할 때 따르도록 만들어졌습니다. 사람에게도 유용할 수 있지만, 여기의 가이드는 AI 기반 워크플로우의 자동화와 일관성에 최적화되어 있어요.

초록 (Abstract)

AI 에이전트와 LLM을 위해 설계된 React Native 애플리케이션 종합 성능 최적화 가이드입니다. 13개 이상의 카테고리에 걸친 35개 이상의 규칙을 담고 있으며, 크리티컬(코어 렌더링, 리스트 성능)부터 점진적 개선(폰트, 임포트)까지 임팩트 순으로 우선순위가 매겨져 있어요. 각 규칙은 상세한 설명, 잘못된 구현과 올바른 구현을 비교하는 실전 예제, 그리고 자동화된 리팩터링과 코드 생성을 안내하는 구체적인 임팩트 지표를 포함합니다.


1. 코어 렌더링 (Core Rendering)

임팩트: CRITICAL

React Native 렌더링의 근본 규칙입니다. 위반하면 런타임 크래시나 깨진 UI로 이어져요.

1.1 falsy일 수 있는 값에 && 를 쓰지 마세요

임팩트: CRITICAL (프로덕션 크래시 방지)

value가 빈 문자열이나 0일 수 있다면 {value && <Component />}를 절대 쓰지 마세요. 이 값들은 falsy이지만 JSX로 렌더링 가능해서, React Native가 <Text> 컴포넌트 밖에서 텍스트로 렌더링하려다가 프로덕션에서 하드 크래시를 일으킵니다.

잘못된 예: count가 0이거나 name이 ""이면 크래시

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {name && <Text>{name}</Text>}
      {count && <Text>{count} items</Text>}
    </View>
  )
}
// name="" 또는 count=0이면 falsy 값이 렌더링됨 → 크래시

올바른 예: 삼항 연산자와 null

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {name ? <Text>{name}</Text> : null}
      {count ? <Text>{count} items</Text> : null}
    </View>
  )
}

올바른 예: 명시적 불리언 변환

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {!!name && <Text>{name}</Text>}
      {!!count && <Text>{count} items</Text>}
    </View>
  )
}

가장 좋은 예: 조기 반환

function Profile({ name, count }: { name: string; count: number }) {
  if (!name) return null

  return (
    <View>
      <Text>{name}</Text>
      {count > 0 ? <Text>{count} items</Text> : null}
    </View>
  )
}

조기 반환이 가장 명확합니다. 인라인 조건을 쓸 때는 삼항 연산자나 명시적 불리언 검사를 우선하세요.

린트 규칙: eslint-plugin-reactreact/jsx-no-leaked-render를 활성화하면 자동으로 잡아낼 수 있어요.

1.2 문자열은 Text 컴포넌트로 감싸세요

임팩트: CRITICAL (런타임 크래시 방지)

문자열은 반드시 <Text> 안에서 렌더링해야 합니다. 문자열이 <View>의 직접 자식이면 React Native는 크래시합니다.

잘못된 예: 크래시

import { View } from 'react-native'

function Greeting({ name }: { name: string }) {
  return <View>Hello, {name}!</View>
}
// Error: Text strings must be rendered within a <Text> component.

올바른 예:

import { View, Text } from 'react-native'

function Greeting({ name }: { name: string }) {
  return (
    <View>
      <Text>Hello, {name}!</Text>
    </View>
  )
}

2. 리스트 성능 (List Performance)

임팩트: HIGH

부드러운 스크롤과 빠른 업데이트를 위한 가상화 리스트(FlatList, LegendList, FlashList) 최적화입니다.

2.1 renderItem 안에서 인라인 객체를 만들지 마세요

임팩트: HIGH (메모이즈된 리스트 아이템의 불필요한 재렌더링 방지)

renderItem 안에서 새 객체를 만들어 props로 넘기지 마세요. 인라인 객체는 렌더링마다 새 참조를 만들어 메모이제이션을 깨뜨립니다. 대신 item에서 원시 값을 직접 전달하세요.

잘못된 예: 인라인 객체가 메모이제이션을 깨뜨림

function UserList({ users }: { users: User[] }) {
  return (
    <LegendList
      data={users}
      renderItem={({ item }) => (
        <UserRow
          // 나쁨: 렌더링마다 새 객체
          user={{ id: item.id, name: item.name, avatar: item.avatar }}
        />
      )}
    />
  )
}

잘못된 예: 인라인 스타일 객체

renderItem={({ item }) => (
  <UserRow
    name={item.name}
    // 나쁨: 렌더링마다 새 스타일 객체
    style={{ backgroundColor: item.isActive ? 'green' : 'gray' }}
  />
)}

올바른 예: item을 직접 전달하거나 원시 값 전달

function UserList({ users }: { users: User[] }) {
  return (
    <LegendList
      data={users}
      renderItem={({ item }) => (
        // 좋음: item을 직접 전달
        <UserRow user={item} />
      )}
    />
  )
}

올바른 예: 원시 값을 전달하고, 자식 안에서 파생

renderItem={({ item }) => (
  <UserRow
    id={item.id}
    name={item.name}
    isActive={item.isActive}
  />
)}

const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
  // 좋음: 메모이즈된 컴포넌트 안에서 스타일을 파생
  const backgroundColor = isActive ? 'green' : 'gray'
  return <View style={[styles.row, { backgroundColor }]}>{/* ... */}</View>
})

올바른 예: 정적 스타일은 모듈 스코프로 호이스팅

const activeStyle = { backgroundColor: 'green' }
const inactiveStyle = { backgroundColor: 'gray' }

renderItem={({ item }) => (
  <UserRow
    name={item.name}
    // 좋음: 안정적인 참조
    style={item.isActive ? activeStyle : inactiveStyle}
  />
)}

원시 값이나 안정적인 참조를 전달하면 실제 값이 바뀌지 않았을 때 memo()가 재렌더링을 건너뛸 수 있어요.

노트: React Compiler를 켰다면 메모이제이션이 자동으로 처리되므로 이런 수동 최적화의 중요도는 낮아집니다.

2.2 콜백은 리스트의 루트로 호이스팅하세요

임팩트: MEDIUM (더 적은 재렌더링, 더 빠른 리스트)

리스트 아이템에 콜백 함수를 전달할 때는 리스트 루트에서 콜백 인스턴스를 하나만 만드세요. 아이템은 고유 식별자를 넘기며 그 콜백을 호출하면 됩니다.

잘못된 예: 렌더링마다 새 콜백을 생성

return (
  <LegendList
    renderItem={({ item }) => {
      // 나쁨: 렌더링마다 새 콜백을 생성
      const onPress = () => handlePress(item.id)
      return <Item key={item.id} item={item} onPress={onPress} />
    }}
  />
)

올바른 예: 단일 함수 인스턴스를 각 아이템에 전달

const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id])

return (
  <LegendList
    renderItem={({ item }) => (
      <Item key={item.id} item={item} onPress={onPress} />
    )}
  />
)

2.3 리스트 아이템은 가볍게 유지하세요

임팩트: HIGH (스크롤 중 보이는 아이템의 렌더링 시간 감소)

리스트 아이템은 가능한 한 렌더링 비용이 싸야 합니다. 훅을 최소화하고, 쿼리를 피하고, React Context 접근을 제한하세요. 가상화 리스트는 스크롤 중에 많은 아이템을 렌더링하는데, 비싼 아이템은 버벅임(jank)을 일으킵니다.

잘못된 예: 무거운 리스트 아이템

function ProductRow({ id }: { id: string }) {
  // 나쁨: 리스트 아이템 안의 쿼리
  const { data: product } = useQuery(['product', id], () => fetchProduct(id))
  // 나쁨: 여러 번의 컨텍스트 접근
  const theme = useContext(ThemeContext)
  const user = useContext(UserContext)
  const cart = useContext(CartContext)
  // 나쁨: 비싼 계산
  const recommendations = useMemo(
    () => computeRecommendations(product),
    [product]
  )

  return <View>{/* ... */}</View>
}

올바른 예: 가벼운 리스트 아이템

function ProductRow({ name, price, imageUrl }: Props) {
  // 좋음: 원시 값만 받고, 훅은 최소한으로
  return (
    <View>
      <Image source={{ uri: imageUrl }} />
      <Text>{name}</Text>
      <Text>{price}</Text>
    </View>
  )
}

데이터 페칭은 부모로 옮기세요:

// 부모가 모든 데이터를 한 번에 페칭
function ProductList() {
  const { data: products } = useQuery(['products'], fetchProducts)

  return (
    <LegendList
      data={products}
      renderItem={({ item }) => (
        <ProductRow name={item.name} price={item.price} imageUrl={item.image} />
      )}
    />
  )
}

공유 값은 Context 대신 Zustand 셀렉터를 쓰세요:

// 잘못된 예: Context는 장바구니의 아무 값이나 바뀌어도 재렌더링을 일으킴
function ProductRow({ id, name }: Props) {
  const { items } = useContext(CartContext)
  const inCart = items.includes(id)
  // ...
}

// 올바른 예: Zustand 셀렉터는 이 특정 값이 바뀔 때만 재렌더링
function ProductRow({ id, name }: Props) {
  // Array.includes() 대신 (루트에서 한 번 생성한) Set.has를 사용
  const inCart = useCartStore((s) => s.items.has(id))
  // ...
}

리스트 아이템 가이드라인:

  • 쿼리나 데이터 페칭 금지
  • 비싼 계산 금지 (부모로 옮기거나 부모 레벨에서 메모이즈)
  • React Context보다 Zustand 셀렉터 우선
  • useState/useEffect 훅 최소화
  • 미리 계산된 값을 props로 전달

목표: 리스트 아이템은 props를 받아 JSX를 반환하는 단순한 렌더링 함수여야 합니다.

2.4 안정적인 객체 참조로 리스트 성능을 최적화하세요

임팩트: CRITICAL (가상화는 참조 안정성에 의존)

가상화 리스트에 데이터를 넘기기 전에 map이나 filter를 하지 마세요. 가상화는 무엇이 바뀌었는지 알기 위해 객체 참조 안정성에 의존하는데, 새 참조는 보이는 모든 아이템의 전체 재렌더링을 유발합니다. 리스트 부모 레벨에서 잦은 렌더링을 막으려고 노력하세요.

필요한 곳에서는 리스트 아이템 안에서 컨텍스트 셀렉터를 사용하세요.

잘못된 예: 키 입력마다 새 객체 참조를 생성

function DomainSearch() {
  const { keyword, setKeyword } = useKeywordZustandState()
  const { data: tlds } = useTlds()

  // 나쁨: 렌더링마다 새 객체를 만들어, 키 입력마다 전체 리스트를 리페어런팅
  const domains = tlds.map((tld) => ({
    domain: `${keyword}.${tld.name}`,
    tld: tld.name,
    price: tld.price,
  }))

  return (
    <>
      <TextInput value={keyword} onChangeText={setKeyword} />
      <LegendList
        data={domains}
        renderItem={({ item }) => <DomainItem item={item} keyword={keyword} />}
      />
    </>
  )
}

올바른 예: 안정적인 참조, 변환은 아이템 안에서

const renderItem = ({ item }) => <DomainItem tld={item} />

function DomainSearch() {
  const { data: tlds } = useTlds()

  return (
    <LegendList
      // 좋음: 데이터가 안정적인 한 LegendList는 전체 리스트를 재렌더링하지 않음
      data={tlds}
      renderItem={renderItem}
    />
  )
}

function DomainItem({ tld }: { tld: Tld }) {
  // 좋음: 변환은 아이템 안에서 하고, 동적 데이터는 prop으로 넘기지 않음
  // 좋음: zustand 셀렉터 함수를 사용해 안정적인 문자열을 돌려받음
  const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
  return <Text>{domain}</Text>
}

부모 배열 참조 업데이트:

내부 객체 참조가 안정적이라면 새 배열 인스턴스를 만드는 것은 괜찮을 수 있습니다. 예를 들어 객체 리스트를 정렬하는 경우:

// 좋음: 내부 객체를 변경하지 않고 새 배열 인스턴스를 생성
// 좋음: 부모 배열 참조는 타이핑과 "keyword" 업데이트에 영향받지 않음
const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))

return <LegendList data={sortedTlds} renderItem={renderItem} />

sortedTlds라는 새 배열 인스턴스가 생기더라도 내부 객체 참조는 안정적입니다.

동적 데이터에 zustand 사용: 부모 재렌더링 회피

const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' }))

function DomainSearch() {
  const { data: tlds } = useTlds()

  return (
    <>
      <SearchInput />
      <LegendList
        data={tlds}
        // React Compiler를 쓰지 않는다면 renderItem을 useCallback으로 감싸세요
        renderItem={({ item }) => <DomainItem tld={item} />}
      />
    </>
  )
}

function DomainItem({ tld }: { tld: Tld }) {
  // 필요한 것만 선택 — keyword가 바뀔 때만 이 컴포넌트가 재렌더링됨
  const keyword = useSearchStore((s) => s.keyword)
  const domain = `${keyword}.${tld.name}`
  return <Text>{domain}</Text>
}

이제 타이핑할 때 가상화가 바뀌지 않은 아이템을 건너뛸 수 있어요. 키 입력마다 부모 대신 보이는 아이템(~20개)만 재렌더링됩니다.

부모 데이터 기반 상태를 리스트 아이템 안에서 파생하기 (부모 재렌더링 회피):

부모 상태에 따라 데이터가 조건부인 컴포넌트에서는 이 패턴이 더욱 중요합니다. 예를 들어 아이템이 즐겨찾기됐는지 검사할 때, 부모가 아니라 아이템 자신이 상태 접근을 담당하면 즐겨찾기 토글 시 컴포넌트 하나만 재렌더링됩니다:

function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
  const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
  return <TldFavoriteButton isFavorited={isFavorited} />
}

노트: React Compiler를 쓴다면 리스트 아이템 안에서 React Context 값을 직접 읽어도 됩니다. 대부분의 경우 Zustand 셀렉터보다 약간 느리지만, 그 차이는 무시할 만한 수준일 수 있어요.

2.5 메모이제이션을 위해 리스트 아이템에는 원시 값을 전달하세요

임팩트: HIGH (효과적인 memo() 비교 가능)

가능하면 리스트 아이템 컴포넌트에는 원시 값(문자열, 숫자, 불리언)만 props로 전달하세요. 원시 값은 memo()의 얕은 비교가 올바르게 동작하게 해서, 값이 바뀌지 않았을 때 재렌더링을 건너뛰게 합니다.

잘못된 예: 객체 prop은 깊은 비교가 필요

type User = { id: string; name: string; email: string; avatar: string }

const UserRow = memo(function UserRow({ user }: { user: User }) {
  // memo()는 user를 값이 아닌 참조로 비교
  // 부모가 새 user 객체를 만들면 데이터가 같아도 재렌더링됨
  return <Text>{user.name}</Text>
})

renderItem={({ item }) => <UserRow user={item} />}

이것도 최적화할 수는 있지만, 제대로 메모이즈하기가 더 어렵습니다.

올바른 예: 원시 값 props는 얕은 비교를 가능하게 함

const UserRow = memo(function UserRow({
  id,
  name,
  email,
}: {
  id: string
  name: string
  email: string
}) {
  // memo()는 각 원시 값을 직접 비교
  // id, name, email이 실제로 바뀌었을 때만 재렌더링
  return <Text>{name}</Text>
})

renderItem={({ item }) => (
  <UserRow id={item.id} name={item.name} email={item.email} />
)}

필요한 것만 전달하세요:

// 잘못된 예: name만 필요한데 아이템 전체를 전달
<UserRow user={item} />

// 올바른 예: 컴포넌트가 쓰는 필드만 전달
<UserRow name={item.name} avatarUrl={item.avatar} />

콜백은 호이스팅하거나 아이템 ID를 사용하세요:

// 잘못된 예: 인라인 함수가 새 참조를 생성
<UserRow name={item.name} onPress={() => handlePress(item.id)} />

// 올바른 예: ID를 전달하고 자식에서 처리
<UserRow id={item.id} name={item.name} />

const UserRow = memo(function UserRow({ id, name }: Props) {
  const handlePress = useCallback(() => {
    // 여기서 id 사용
  }, [id])
  return <Pressable onPress={handlePress}><Text>{name}</Text></Pressable>
})

원시 값 props는 메모이제이션을 예측 가능하고 효과적으로 만듭니다.

노트: React Compiler를 켰다면 memo()useCallback()을 쓸 필요는 없지만, 객체 참조 원칙은 여전히 적용됩니다.

2.6 어떤 리스트든 리스트 가상화 도구를 쓰세요

임팩트: HIGH (메모리 감소, 더 빠른 마운트)

짧은 리스트라도 ScrollView에 자식을 map으로 펼치는 대신 LegendList나 FlashList 같은 리스트 가상화 도구를 쓰세요. 가상화 도구는 보이는 아이템만 렌더링해서 메모리 사용량과 마운트 시간을 줄입니다. ScrollView는 모든 자식을 한꺼번에 렌더링해서 금방 비싸집니다.

잘못된 예: ScrollView는 모든 아이템을 한 번에 렌더링

function Feed({ items }: { items: Item[] }) {
  return (
    <ScrollView>
      {items.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </ScrollView>
  )
}
// 50개 아이템 = 10개만 보여도 50개 컴포넌트가 마운트됨

올바른 예: 가상화 도구는 보이는 아이템만 렌더링

import { LegendList } from '@legendapp/list'

function Feed({ items }: { items: Item[] }) {
  return (
    <LegendList
      data={items}
      // React Compiler를 쓰지 않는다면 useCallback으로 감싸세요
      renderItem={({ item }) => <ItemCard item={item} />}
      keyExtractor={(item) => item.id}
      estimatedItemSize={80}
    />
  )
}
// 한 번에 보이는 ~10-15개 아이템만 마운트됨

대안: FlashList

import { FlashList } from '@shopify/flash-list'

function Feed({ items }: { items: Item[] }) {
  return (
    <FlashList
      data={items}
      // React Compiler를 쓰지 않는다면 useCallback으로 감싸세요
      renderItem={({ item }) => <ItemCard item={item} />}
      keyExtractor={(item) => item.id}
    />
  )
}

이 이점은 스크롤 가능한 콘텐츠가 있는 모든 화면에 적용됩니다 — 프로필, 설정, 피드, 검색 결과. 기본값으로 가상화를 선택하세요.

2.7 리스트에는 압축된 이미지를 쓰세요

임팩트: HIGH (더 빠른 로드 시간, 더 적은 메모리)

리스트에서는 항상 압축되고 적절한 크기의 이미지를 로드하세요. 원본 해상도 이미지는 과도한 메모리를 소비하고 스크롤 버벅임을 일으킵니다. 서버에 썸네일을 요청하거나 리사이즈 파라미터를 지원하는 이미지 CDN을 쓰세요.

잘못된 예: 원본 해상도 이미지

function ProductItem({ product }: { product: Product }) {
  return (
    <View>
      {/* 100x100 썸네일에 4000x3000 이미지를 로드 */}
      <Image
        source={{ uri: product.imageUrl }}
        style={{ width: 100, height: 100 }}
      />
      <Text>{product.name}</Text>
    </View>
  )
}

올바른 예: 적절한 크기의 이미지를 요청

function ProductItem({ product }: { product: Product }) {
  // 200x200 이미지 요청 (레티나 대응 2배)
  const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`

  return (
    <View>
      <Image
        source={{ uri: thumbnailUrl }}
        style={{ width: 100, height: 100 }}
        contentFit='cover'
      />
      <Text>{product.name}</Text>
    </View>
  )
}

expo-image나 (내부적으로 expo-image를 쓰는) SolitoImage처럼 캐싱과 플레이스홀더를 내장한 최적화된 이미지 컴포넌트를 쓰세요. 레티나 화면을 위해 표시 크기의 2배로 이미지를 요청하세요.

2.8 이종 리스트에는 아이템 타입을 쓰세요

임팩트: HIGH (효율적인 재활용, 레이아웃 스래싱 감소)

리스트에 서로 다른 아이템 레이아웃(메시지, 이미지, 헤더 등)이 있다면 각 아이템에 type 필드를 두고 리스트에 getItemType을 제공하세요. 이렇게 하면 아이템이 별도의 재활용 풀에 들어가서, 메시지 컴포넌트가 이미지 컴포넌트로 재활용되는 일이 없어집니다.

LegendList getItemType

잘못된 예: 조건문이 있는 단일 컴포넌트

type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }

function ListItem({ item }: { item: Item }) {
  if (item.isHeader) {
    return <HeaderItem title={item.text} />
  }
  if (item.imageUrl) {
    return <ImageItem url={item.imageUrl} />
  }
  return <MessageItem text={item.text} />
}

function Feed({ items }: { items: Item[] }) {
  return (
    <LegendList
      data={items}
      renderItem={({ item }) => <ListItem item={item} />}
      recycleItems
    />
  )
}

올바른 예: 타입이 지정된 아이템과 분리된 컴포넌트

type HeaderItem = { id: string; type: 'header'; title: string }
type MessageItem = { id: string; type: 'message'; text: string }
type ImageItem = { id: string; type: 'image'; url: string }
type FeedItem = HeaderItem | MessageItem | ImageItem

function Feed({ items }: { items: FeedItem[] }) {
  return (
    <LegendList
      data={items}
      keyExtractor={(item) => item.id}
      getItemType={(item) => item.type}
      renderItem={({ item }) => {
        switch (item.type) {
          case 'header':
            return <SectionHeader title={item.title} />
          case 'message':
            return <MessageRow text={item.text} />
          case 'image':
            return <ImageRow url={item.url} />
        }
      }}
      recycleItems
    />
  )
}

이것이 중요한 이유:

<LegendList
  data={items}
  keyExtractor={(item) => item.id}
  getItemType={(item) => item.type}
  getEstimatedItemSize={(index, item, itemType) => {
    switch (itemType) {
      case 'header':
        return 48
      case 'message':
        return 72
      case 'image':
        return 300
      default:
        return 72
    }
  }}
  renderItem={({ item }) => {
    /* ... */
  }}
  recycleItems
/>
  • 재활용 효율: 같은 타입의 아이템은 재활용 풀을 공유
  • 레이아웃 스래싱 없음: 헤더가 이미지 셀로 재활용되지 않음
  • 타입 안전성: TypeScript가 각 분기에서 아이템 타입을 좁힐 수 있음
  • 더 나은 크기 추정: itemType과 함께 getEstimatedItemSize를 써서 타입별로 정확한 추정 가능

3. 애니메이션 (Animation)

임팩트: HIGH

GPU 가속 애니메이션, Reanimated 패턴, 그리고 제스처 중 렌더 스래싱 회피입니다.

3.1 레이아웃 속성 대신 transform과 opacity를 애니메이션하세요

임팩트: HIGH (GPU 가속 애니메이션, 레이아웃 재계산 없음)

width, height, top, left, margin, padding 애니메이션을 피하세요. 이 속성들은 매 프레임 레이아웃 재계산을 유발합니다. 대신 레이아웃을 건드리지 않고 GPU에서 실행되는 transform(scale, translate)과 opacity를 쓰세요.

잘못된 예: height 애니메이션, 매 프레임 레이아웃 유발

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function CollapsiblePanel({ expanded }: { expanded: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    height: withTiming(expanded ? 200 : 0), // 매 프레임 레이아웃을 유발
    overflow: 'hidden',
  }))

  return <Animated.View style={animatedStyle}>{children}</Animated.View>
}

올바른 예: scaleY 애니메이션, GPU 가속

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function CollapsiblePanel({ expanded }: { expanded: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { scaleY: withTiming(expanded ? 1 : 0) },
    ],
    opacity: withTiming(expanded ? 1 : 0),
  }))

  return (
    <Animated.View style={[{ height: 200, transformOrigin: 'top' }, animatedStyle]}>
      {children}
    </Animated.View>
  )
}

올바른 예: 슬라이드 애니메이션은 translateY

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function SlideIn({ visible }: { visible: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: withTiming(visible ? 0 : 100) },
    ],
    opacity: withTiming(visible ? 1 : 0),
  }))

  return <Animated.View style={animatedStyle}>{children}</Animated.View>
}

GPU 가속 속성: transform(translate, scale, rotate), opacity. 그 외 모든 속성은 레이아웃을 유발합니다.

3.2 useAnimatedReaction보다 useDerivedValue를 우선하세요

임팩트: MEDIUM (더 깔끔한 코드, 자동 의존성 추적)

공유 값(shared value)에서 다른 값을 파생할 때는 useAnimatedReaction 대신 useDerivedValue를 쓰세요. 파생 값은 선언적이고, 의존성을 자동으로 추적하며, 바로 쓸 수 있는 값을 반환합니다. 애니메이션 리액션은 파생이 아니라 사이드 이펙트를 위한 것이에요.

Reanimated useDerivedValue

잘못된 예: 파생에 useAnimatedReaction 사용

import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'

function MyComponent() {
  const progress = useSharedValue(0)
  const opacity = useSharedValue(1)

  useAnimatedReaction(
    () => progress.value,
    (current) => {
      opacity.value = 1 - current
    }
  )

  // ...
}

올바른 예: useDerivedValue

import { useSharedValue, useDerivedValue } from 'react-native-reanimated'

function MyComponent() {
  const progress = useSharedValue(0)

  const opacity = useDerivedValue(() => 1 - progress.get())

  // ...
}

useAnimatedReaction은 값을 만들지 않는 사이드 이펙트(햅틱 트리거, 로깅, runOnJS 호출 등)에만 쓰세요.

3.3 애니메이션 프레스 상태에는 GestureDetector를 쓰세요

임팩트: MEDIUM (UI 스레드 애니메이션, 더 부드러운 프레스 피드백)

애니메이션이 있는 프레스 상태(누를 때 scale, opacity 변화)에는 Pressable의 onPressIn/onPressOut 대신 GestureDetectorGesture.Tap(), 공유 값을 쓰세요. 제스처 콜백은 워크릿(worklet)으로 UI 스레드에서 실행되어, 프레스 애니메이션에 JS 스레드 왕복이 없습니다.

Gesture Handler Tap Gesture

잘못된 예: JS 스레드 콜백을 쓰는 Pressable

import { Pressable } from 'react-native'
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated'

function AnimatedButton({ onPress }: { onPress: () => void }) {
  const scale = useSharedValue(1)

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  return (
    <Pressable
      onPress={onPress}
      onPressIn={() => (scale.value = withTiming(0.95))}
      onPressOut={() => (scale.value = withTiming(1))}
    >
      <Animated.View style={animatedStyle}>
        <Text>Press me</Text>
      </Animated.View>
    </Pressable>
  )
}

올바른 예: UI 스레드 워크릿을 쓰는 GestureDetector

import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  interpolate,
  runOnJS,
} from 'react-native-reanimated'

function AnimatedButton({ onPress }: { onPress: () => void }) {
  // 프레스 "상태"를 저장 (0 = 안 눌림, 1 = 눌림)
  const pressed = useSharedValue(0)

  const tap = Gesture.Tap()
    .onBegin(() => {
      pressed.set(withTiming(1))
    })
    .onFinalize(() => {
      pressed.set(withTiming(0))
    })
    .onEnd(() => {
      runOnJS(onPress)()
    })

  // 상태에서 시각적 값을 파생
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
    ],
  }))

  return (
    <GestureDetector gesture={tap}>
      <Animated.View style={animatedStyle}>
        <Text>Press me</Text>
      </Animated.View>
    </GestureDetector>
  )
}

프레스 상태(0 또는 1)를 저장하고, scale은 interpolate로 파생하세요. 이렇게 하면 공유 값이 근본 진실(ground truth)로 유지됩니다. 워크릿에서 JS 함수를 호출할 때는 runOnJS를 쓰세요. React Compiler 호환성을 위해 .set().get()을 쓰세요.


4. 스크롤 성능 (Scroll Performance)

임팩트: HIGH

렌더 스래싱 없이 스크롤 위치를 추적하는 방법입니다.

4.1 스크롤 위치를 useState로 추적하지 마세요

임팩트: HIGH (스크롤 중 렌더 스래싱 방지)

스크롤 위치를 useState에 저장하지 마세요. 스크롤 이벤트는 매우 빠르게 발생하는데, 상태 업데이트는 렌더 스래싱과 프레임 드랍을 일으킵니다. 애니메이션에는 Reanimated 공유 값을, 비반응형 추적에는 ref를 쓰세요.

잘못된 예: useState가 버벅임을 유발

import { useState } from 'react'
import {
  ScrollView,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native'

function Feed() {
  const [scrollY, setScrollY] = useState(0)

  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    setScrollY(e.nativeEvent.contentOffset.y) // 매 프레임 재렌더링
  }

  return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
}

올바른 예: 애니메이션에는 Reanimated

import Animated, {
  useSharedValue,
  useAnimatedScrollHandler,
} from 'react-native-reanimated'

function Feed() {
  const scrollY = useSharedValue(0)

  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      scrollY.value = e.contentOffset.y // UI 스레드에서 실행, 재렌더링 없음
    },
  })

  return (
    <Animated.ScrollView
      onScroll={onScroll}
      // 높은 숫자일수록 성능이 좋지만 이벤트가 덜 자주 발생합니다.
      // 성능보다 높은 정밀도가 필요하면 이 값을 설정하지 마세요.
      scrollEventThrottle={16}
    />
  )
}

올바른 예: 비반응형 추적에는 ref

import { useRef } from 'react'
import {
  ScrollView,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native'

function Feed() {
  const scrollY = useRef(0)

  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    scrollY.current = e.nativeEvent.contentOffset.y // 재렌더링 없음
  }

  return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
}

5. 내비게이션 (Navigation)

임팩트: HIGH

JS 기반 대안 대신 네이티브 내비게이터로 스택·탭 내비게이션을 구현합니다.

5.1 내비게이션에는 네이티브 내비게이터를 쓰세요

임팩트: HIGH (네이티브 성능, 플랫폼에 맞는 UI)

항상 JS 기반이 아닌 네이티브 내비게이터를 쓰세요. 네이티브 내비게이터는 플랫폼 API(iOS의 UINavigationController, Android의 Fragment)를 사용해 더 나은 성능과 네이티브 동작을 제공합니다.

스택: @react-navigation/native-stack이나 expo-router의 기본 스택(native-stack 사용)을 쓰세요. @react-navigation/stack은 피하세요.

탭: react-native-bottom-tabs(네이티브)나 expo-router의 네이티브 탭을 쓰세요. 네이티브 느낌이 중요하다면 @react-navigation/bottom-tabs는 피하세요.

잘못된 예: JS 스택 내비게이터

import { createStackNavigator } from '@react-navigation/stack'

const Stack = createStackNavigator()

function App() {
  return (
    <Stack.Navigator>
      <Stack.Screen name='Home' component={HomeScreen} />
      <Stack.Screen name='Details' component={DetailsScreen} />
    </Stack.Navigator>
  )
}

올바른 예: react-navigation의 네이티브 스택

import { createNativeStackNavigator } from '@react-navigation/native-stack'

const Stack = createNativeStackNavigator()

function App() {
  return (
    <Stack.Navigator>
      <Stack.Screen name='Home' component={HomeScreen} />
      <Stack.Screen name='Details' component={DetailsScreen} />
    </Stack.Navigator>
  )
}

올바른 예: expo-router는 기본으로 네이티브 스택을 사용

// app/_layout.tsx
import { Stack } from 'expo-router'

export default function Layout() {
  return <Stack />
}

잘못된 예: JS 바텀 탭

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'

const Tab = createBottomTabNavigator()

function App() {
  return (
    <Tab.Navigator>
      <Tab.Screen name='Home' component={HomeScreen} />
      <Tab.Screen name='Settings' component={SettingsScreen} />
    </Tab.Navigator>
  )
}

올바른 예: react-navigation의 네이티브 바텀 탭

import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'

const Tab = createNativeBottomTabNavigator()

function App() {
  return (
    <Tab.Navigator>
      <Tab.Screen
        name='Home'
        component={HomeScreen}
        options={{
          tabBarIcon: () => ({ sfSymbol: 'house' }),
        }}
      />
      <Tab.Screen
        name='Settings'
        component={SettingsScreen}
        options={{
          tabBarIcon: () => ({ sfSymbol: 'gear' }),
        }}
      />
    </Tab.Navigator>
  )
}

올바른 예: expo-router 네이티브 탭

// app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name='index'>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon sf='house.fill' md='home' />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name='settings'>
        <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon sf='gear' md='settings' />
      </NativeTabs.Trigger>
    </NativeTabs>
  )
}

iOS에서 네이티브 탭은 각 탭 화면 루트의 첫 ScrollViewcontentInsetAdjustmentBehavior를 자동으로 활성화해서, 반투명 탭 바 뒤로 콘텐츠가 올바르게 스크롤됩니다. 이를 비활성화하려면 트리거에 disableAutomaticContentInsets를 쓰세요.

잘못된 예: 커스텀 헤더 컴포넌트

<Stack.Screen
  name='Profile'
  component={ProfileScreen}
  options={{
    header: () => <CustomHeader title='Profile' />,
  }}
/>

올바른 예: 네이티브 헤더 옵션

<Stack.Screen
  name='Profile'
  component={ProfileScreen}
  options={{
    title: 'Profile',
    headerLargeTitleEnabled: true,
    headerSearchBarOptions: {
      placeholder: 'Search',
    },
  }}
/>

네이티브 헤더는 iOS 라지 타이틀, 검색 바, 블러 효과, 올바른 세이프 에어리어 처리를 자동으로 지원합니다.

  • 성능: 네이티브 전환과 제스처가 UI 스레드에서 실행
  • 플랫폼 동작: iOS 라지 타이틀, Android 머티리얼 디자인 자동 적용
  • 시스템 통합: 탭 탭(tap) 시 최상단 스크롤, PiP 회피, 올바른 세이프 에어리어
  • 접근성: 플랫폼 접근성 기능이 자동으로 동작

6. React 상태 (React State)

임팩트: MEDIUM

오래된 클로저(stale closure)와 불필요한 재렌더링을 피하는 React 상태 관리 패턴입니다.

6.1 상태 변수를 최소화하고 값을 파생하세요

임팩트: MEDIUM (더 적은 재렌더링, 상태 불일치 감소)

가능한 한 적은 상태 변수를 쓰세요. 기존 상태나 props에서 계산할 수 있는 값이라면 상태에 저장하지 말고 렌더링 중에 파생하세요. 중복 상태는 불필요한 재렌더링을 일으키고 동기화가 어긋날 수 있습니다.

잘못된 예: 중복 상태

function Cart({ items }: { items: Item[] }) {
  const [total, setTotal] = useState(0)
  const [itemCount, setItemCount] = useState(0)

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0))
    setItemCount(items.length)
  }, [items])

  return (
    <View>
      <Text>{itemCount} items</Text>
      <Text>Total: ${total}</Text>
    </View>
  )
}

올바른 예: 파생 값

function Cart({ items }: { items: Item[] }) {
  const total = items.reduce((sum, item) => sum + item.price, 0)
  const itemCount = items.length

  return (
    <View>
      <Text>{itemCount} items</Text>
      <Text>Total: ${total}</Text>
    </View>
  )
}

또 다른 예:

// 잘못된 예: firstName, lastName에 더해 fullName까지 저장
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')

// 올바른 예: fullName을 파생
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const fullName = `${firstName} ${lastName}`

상태는 최소한의 진실 원천(source of truth)이어야 합니다. 나머지는 모두 파생입니다.

참고: https://react.dev/learn/choosing-the-state-structure

6.2 initialState 대신 폴백 상태를 쓰세요

임팩트: MEDIUM (동기화 없이 반응형 폴백)

초기 상태로 undefined를 쓰고, 널 병합 연산자(??)로 부모나 서버 값으로 폴백하세요. 상태는 오직 사용자 의도만 나타냅니다 — undefined는 "사용자가 아직 선택하지 않았다"는 뜻이에요. 이렇게 하면 최초 렌더링 시점만이 아니라 소스가 바뀔 때마다 갱신되는 반응형 폴백이 가능합니다.

잘못된 예: 상태를 동기화, 반응성 상실

type Props = { fallbackEnabled: boolean }

function Toggle({ fallbackEnabled }: Props) {
  const [enabled, setEnabled] = useState(defaultEnabled)
  // fallbackEnabled가 바뀌면 상태가 낡음
  // 상태에 사용자 의도와 기본값이 뒤섞임

  return <Switch value={enabled} onValueChange={setEnabled} />
}

올바른 예: 상태는 사용자 의도, 반응형 폴백

type Props = { fallbackEnabled: boolean }

function Toggle({ fallbackEnabled }: Props) {
  const [_enabled, setEnabled] = useState<boolean | undefined>(undefined)
  const enabled = _enabled ?? defaultEnabled
  // undefined = 사용자가 아직 건드리지 않음, prop으로 폴백
  // defaultEnabled가 바뀌면 컴포넌트에 반영됨
  // 사용자가 한번 조작하면 그 선택이 유지됨

  return <Switch value={enabled} onValueChange={setEnabled} />
}

서버 데이터와 함께:

function ProfileForm({ data }: { data: User }) {
  const [_theme, setTheme] = useState<string | undefined>(undefined)
  const theme = _theme ?? data.theme
  // 사용자가 덮어쓰기 전까지 서버 값을 표시
  // 서버 리페치가 폴백을 자동으로 갱신

  return <ThemePicker value={theme} onChange={setTheme} />
}

6.3 현재 값에 의존하는 상태에는 useState 디스패치 업데이터를 쓰세요

임팩트: MEDIUM (오래된 클로저 회피, 불필요한 재렌더링 방지)

다음 상태가 현재 상태에 의존한다면, 콜백에서 상태 변수를 직접 읽는 대신 디스패치 업데이터(setState(prev => ...))를 쓰세요. 오래된 클로저를 피하고 항상 최신 값과 비교할 수 있습니다.

잘못된 예: 상태를 직접 읽음

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  // 이 클로저 안에서 size는 낡은 값일 수 있음
  if (size?.width !== width || size?.height !== height) {
    setSize({ width, height })
  }
}

올바른 예: 디스패치 업데이터

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize((prev) => {
    if (prev?.width === width && prev?.height === height) return prev
    return { width, height }
  })
}

업데이터에서 이전 값을 반환하면 재렌더링을 건너뜁니다.

원시 값 상태는 재렌더링 전에 값을 비교할 필요가 없어요.

잘못된 예: 원시 값 상태에 불필요한 비교

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize((prev) => (prev === width ? prev : width))
}

올바른 예: 원시 값 상태는 직접 설정

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize(width)
}

다만 다음 상태가 현재 상태에 의존한다면 여전히 디스패치 업데이터를 써야 합니다.

잘못된 예: 콜백에서 상태를 직접 읽음

const [count, setCount] = useState(0)

const onTap = () => {
  setCount(count + 1)
}

올바른 예: 디스패치 업데이터

const [count, setCount] = useState(0)

const onTap = () => {
  setCount((prev) => prev + 1)
}

7. 상태 아키텍처 (State Architecture)

임팩트: MEDIUM

상태 변수와 파생 값에 대한 근본 진실(ground truth) 원칙입니다.

7.1 상태는 근본 진실을 나타내야 합니다

임팩트: HIGH (더 깔끔한 로직, 더 쉬운 디버깅, 단일 진실 원천)

상태 변수는 — React useState와 Reanimated 공유 값 모두 — 파생된 시각적 값(scale, opacity, translateY 등)이 아니라 무언가의 실제 상태(pressed, progress, isOpen 등)를 나타내야 합니다. 시각적 값은 계산이나 보간(interpolation)으로 상태에서 파생하세요.

잘못된 예: 시각적 출력을 저장

const scale = useSharedValue(1)

const tap = Gesture.Tap()
  .onBegin(() => {
    scale.set(withTiming(0.95))
  })
  .onFinalize(() => {
    scale.set(withTiming(1))
  })

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: scale.get() }],
}))

올바른 예: 상태를 저장하고 시각적 값을 파생

const pressed = useSharedValue(0) // 0 = 안 눌림, 1 = 눌림

const tap = Gesture.Tap()
  .onBegin(() => {
    pressed.set(withTiming(1))
  })
  .onFinalize(() => {
    pressed.set(withTiming(0))
  })

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
}))

이것이 중요한 이유:

상태 변수는 원하는 최종 결과가 아니라 실제 "상태"를 나타내야 합니다.

  1. 단일 진실 원천 — 상태(pressed)가 무슨 일이 일어나는지 설명하고, 시각적 값은 파생됨
  2. 확장 용이 — opacity, rotation 등 다른 효과 추가가 같은 상태에서의 보간 추가로 끝남
  3. 디버깅pressed = 1을 확인하는 게 scale = 0.95보다 명확함
  4. 로직 재사용 — 같은 pressed 값이 여러 시각적 속성을 구동할 수 있음

React 상태에도 같은 원칙:

// 잘못된 예: 파생 값을 저장
const [isExpanded, setIsExpanded] = useState(false)
const [height, setHeight] = useState(0)

useEffect(() => {
  setHeight(isExpanded ? 200 : 0)
}, [isExpanded])

// 올바른 예: 상태에서 파생
const [isExpanded, setIsExpanded] = useState(false)
const height = isExpanded ? 200 : 0

상태는 최소한의 진실입니다. 나머지는 모두 파생입니다.


8. React Compiler

임팩트: MEDIUM

React Native·Reanimated와 React Compiler의 호환성 패턴입니다.

8.1 렌더링 초반에 함수를 구조 분해하세요 (React Compiler)

임팩트: HIGH (안정적인 참조, 더 적은 재렌더링)

이 규칙은 React Compiler를 쓸 때만 해당됩니다.

훅에서 얻은 함수는 렌더 스코프 최상단에서 구조 분해하세요. 객체에 점(.)으로 접근해 함수를 호출하지 마세요. 구조 분해된 함수는 안정적인 참조지만, 점 접근은 새 참조를 만들어 메모이제이션을 깨뜨립니다.

잘못된 예: 객체에 점으로 접근

import { useRouter } from 'expo-router'

function SaveButton(props) {
  const router = useRouter()

  // 나쁨: react-compiler가 캐시 키를 "props"와 "router"로 잡는데, 이들은 렌더링마다 바뀌는 객체
  const handlePress = () => {
    props.onSave()
    router.push('/success') // 불안정한 참조
  }

  return <Button onPress={handlePress}>Save</Button>
}

올바른 예: 초반에 구조 분해

import { useRouter } from 'expo-router'

function SaveButton({ onSave }) {
  const { push } = useRouter()

  // 좋음: react-compiler가 push와 onSave를 키로 잡음
  const handlePress = () => {
    onSave()
    push('/success') // 안정적인 참조
  }

  return <Button onPress={handlePress}>Save</Button>
}

8.2 Reanimated 공유 값에는 .value가 아니라 .get()과 .set()을 쓰세요

임팩트: LOW (React Compiler 호환에 필수)

React Compiler를 켰다면 Reanimated 공유 값을 읽고 쓸 때 .value 직접 접근 대신 .get().set()을 쓰세요. 컴파일러는 프로퍼티 접근을 추적할 수 없습니다 — 명시적 메서드가 올바른 동작을 보장합니다.

잘못된 예: React Compiler에서 깨짐

import { useSharedValue } from 'react-native-reanimated'

function Counter() {
  const count = useSharedValue(0)

  const increment = () => {
    count.value = count.value + 1 // react compiler 최적화에서 제외됨
  }

  return <Button onPress={increment} title={`Count: ${count.value}`} />
}

올바른 예: React Compiler 호환

import { useSharedValue } from 'react-native-reanimated'

function Counter() {
  const count = useSharedValue(0)

  const increment = () => {
    count.set(count.get() + 1)
  }

  return <Button onPress={increment} title={`Count: ${count.get()}`} />
}

자세한 내용은 Reanimated 문서를 참고하세요.


9. 사용자 인터페이스 (User Interface)

임팩트: MEDIUM

이미지, 메뉴, 모달, 스타일링, 그리고 플랫폼과 일관된 인터페이스를 위한 네이티브 UI 패턴입니다.

9.1 뷰 크기 측정하기

임팩트: MEDIUM (동기 측정, 불필요한 재렌더링 회피)

useLayoutEffect(동기)와 onLayout(업데이트용)을 함께 쓰세요. 동기 측정은 초기 크기를 즉시 제공하고, onLayout은 뷰가 바뀔 때 최신 상태로 유지합니다. 원시 값이 아닌 상태에는 디스패치 업데이터로 값을 비교해 불필요한 재렌더링을 피하세요.

높이만 측정:

import { useLayoutEffect, useRef, useState } from 'react'
import { View, LayoutChangeEvent } from 'react-native'

function MeasuredBox({ children }: { children: React.ReactNode }) {
  const ref = useRef<View>(null)
  const [height, setHeight] = useState<number | undefined>(undefined)

  useLayoutEffect(() => {
    // 마운트 시 동기 측정 (RN 0.82+)
    const rect = ref.current?.getBoundingClientRect()
    if (rect) setHeight(rect.height)
    // 0.82 이전: ref.current?.measure((x, y, w, h) => setHeight(h))
  }, [])

  const onLayout = (e: LayoutChangeEvent) => {
    setHeight(e.nativeEvent.layout.height)
  }

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  )
}

양쪽 치수 모두:

import { useLayoutEffect, useRef, useState } from 'react'
import { View, LayoutChangeEvent } from 'react-native'

type Size = { width: number; height: number }

function MeasuredBox({ children }: { children: React.ReactNode }) {
  const ref = useRef<View>(null)
  const [size, setSize] = useState<Size | undefined>(undefined)

  useLayoutEffect(() => {
    const rect = ref.current?.getBoundingClientRect()
    if (rect) setSize({ width: rect.width, height: rect.height })
  }, [])

  const onLayout = (e: LayoutChangeEvent) => {
    const { width, height } = e.nativeEvent.layout
    setSize((prev) => {
      // 원시 값이 아닌 상태는 재렌더링 전에 값을 비교
      if (prev?.width === width && prev?.height === height) return prev
      return { width, height }
    })
  }

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  )
}

함수형 setState로 비교하세요 — 콜백에서 상태를 직접 읽지 마세요.

9.2 모던 React Native 스타일링 패턴

임팩트: MEDIUM (일관된 디자인, 더 부드러운 모서리, 더 깔끔한 레이아웃)

더 깔끔하고 일관된 React Native 코드를 위해 이 스타일링 패턴을 따르세요.

borderRadius에는 항상 borderCurve: 'continuous'를 함께 쓰세요:

// 잘못된 예
{ borderRadius: 12 }

// 올바른 예 – 더 부드러운 iOS 스타일 모서리
{ borderRadius: 12, borderCurve: 'continuous' }

요소 사이 간격은 margin 대신 gap을 쓰세요:

// 잘못된 예 – 자식에 margin
<View>
  <Text style={{ marginBottom: 8 }}>Title</Text>
  <Text style={{ marginBottom: 8 }}>Subtitle</Text>
</View>

// 올바른 예 – 부모에 gap
<View style={{ gap: 8 }}>
  <Text>Title</Text>
  <Text>Subtitle</Text>
</View>

내부 공간은 padding, 사이 공간은 gap:

<View style={{ padding: 16, gap: 12 }}>
  <Text>First</Text>
  <Text>Second</Text>
</View>

선형 그라데이션은 experimental_backgroundImage를 쓰세요:

// 잘못된 예 – 서드파티 그라데이션 라이브러리
<LinearGradient colors={['#000', '#fff']} />

// 올바른 예 – 네이티브 CSS 그라데이션 문법
<View
  style={{
    experimental_backgroundImage: 'linear-gradient(to bottom, #000, #fff)',
  }}
/>

그림자는 CSS boxShadow 문자열 문법을 쓰세요:

// 잘못된 예 – 레거시 그림자 객체나 elevation
{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
{ elevation: 4 }

// 올바른 예 – CSS box-shadow 문법
{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }

여러 폰트 크기를 피하세요 – 강조에는 굵기와 색상을 쓰세요:

// 잘못된 예 – 위계를 위해 다양한 폰트 크기 사용
<Text style={{ fontSize: 18 }}>Title</Text>
<Text style={{ fontSize: 14 }}>Subtitle</Text>
<Text style={{ fontSize: 12 }}>Caption</Text>

// 올바른 예 – 일관된 크기, 굵기와 색상으로 변화
<Text style={{ fontWeight: '600' }}>Title</Text>
<Text style={{ color: '#666' }}>Subtitle</Text>
<Text style={{ color: '#999' }}>Caption</Text>

폰트 크기를 제한하면 시각적 일관성이 생깁니다. 위계는 fontWeight(bold/semibold)와 그레이스케일 색상으로 표현하세요.

9.3 동적인 ScrollView 여백에는 contentInset을 쓰세요

임팩트: LOW (더 부드러운 업데이트, 레이아웃 재계산 없음)

바뀔 수 있는 여백(키보드, 툴바, 동적 콘텐츠)을 ScrollView의 위/아래에 추가할 때는 padding 대신 contentInset을 쓰세요. contentInset 변경은 레이아웃 재계산을 유발하지 않고, 콘텐츠 재렌더링 없이 스크롤 영역만 조정합니다.

잘못된 예: padding은 레이아웃 재계산을 유발

function Feed({ bottomOffset }: { bottomOffset: number }) {
  return (
    <ScrollView contentContainerStyle={{ paddingBottom: bottomOffset }}>
      {children}
    </ScrollView>
  )
}
// bottomOffset 변경이 전체 레이아웃 재계산을 유발

올바른 예: 동적 여백에는 contentInset

function Feed({ bottomOffset }: { bottomOffset: number }) {
  return (
    <ScrollView
      contentInset={{ bottom: bottomOffset }}
      scrollIndicatorInsets={{ bottom: bottomOffset }}
    >
      {children}
    </ScrollView>
  )
}
// bottomOffset 변경은 스크롤 경계만 조정

스크롤 인디케이터를 정렬 상태로 유지하려면 contentInset과 함께 scrollIndicatorInsets를 쓰세요. 절대 바뀌지 않는 정적 여백이라면 padding도 괜찮습니다.

9.4 세이프 에어리어에는 contentInsetAdjustmentBehavior를 쓰세요

임팩트: MEDIUM (네이티브 세이프 에어리어 처리, 레이아웃 시프트 없음)

콘텐츠를 SafeAreaView로 감싸거나 수동 padding을 넣는 대신, 루트 ScrollView에 contentInsetAdjustmentBehavior="automatic"을 쓰세요. iOS가 올바른 스크롤 동작과 함께 세이프 에어리어 인셋을 네이티브로 처리하게 합니다.

잘못된 예: SafeAreaView 래퍼

import { SafeAreaView, ScrollView, View, Text } from 'react-native'

function MyScreen() {
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <ScrollView>
        <View>
          <Text>Content</Text>
        </View>
      </ScrollView>
    </SafeAreaView>
  )
}

잘못된 예: 수동 세이프 에어리어 padding

import { ScrollView, View, Text } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

function MyScreen() {
  const insets = useSafeAreaInsets()

  return (
    <ScrollView contentContainerStyle={{ paddingTop: insets.top }}>
      <View>
        <Text>Content</Text>
      </View>
    </ScrollView>
  )
}

올바른 예: 네이티브 콘텐츠 인셋 조정

import { ScrollView, View, Text } from 'react-native'

function MyScreen() {
  return (
    <ScrollView contentInsetAdjustmentBehavior='automatic'>
      <View>
        <Text>Content</Text>
      </View>
    </ScrollView>
  )
}

네이티브 접근은 동적 세이프 에어리어(키보드, 툴바)를 처리하고, 콘텐츠가 상태 바 뒤로 자연스럽게 스크롤되게 합니다.

9.5 최적화된 이미지에는 expo-image를 쓰세요

임팩트: HIGH (메모리 효율, 캐싱, blurhash 플레이스홀더, 점진적 로딩)

React Native의 Image 대신 expo-image를 쓰세요. 메모리 효율적인 캐싱, blurhash 플레이스홀더, 점진적 로딩, 그리고 리스트에서 더 나은 성능을 제공합니다.

잘못된 예: React Native Image

import { Image } from 'react-native'

function Avatar({ url }: { url: string }) {
  return <Image source={{ uri: url }} style={styles.avatar} />
}

올바른 예: expo-image

import { Image } from 'expo-image'

function Avatar({ url }: { url: string }) {
  return <Image source={{ uri: url }} style={styles.avatar} />
}

blurhash 플레이스홀더와 함께:

<Image
  source={{ uri: url }}
  placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
  contentFit="cover"
  transition={200}
  style={styles.image}
/>

우선순위와 캐싱과 함께:

<Image
  source={{ uri: url }}
  priority="high"
  cachePolicy="memory-disk"
  style={styles.hero}
/>

주요 props:

  • placeholder — 로딩 중 blurhash 또는 썸네일
  • contentFitcover, contain, fill, scale-down
  • transition — 페이드인 시간 (ms)
  • prioritylow, normal, high
  • cachePolicymemory, disk, memory-disk, none
  • recyclingKey — 리스트 재활용을 위한 고유 키

크로스 플랫폼(웹 + 네이티브)에는 내부적으로 expo-image를 쓰는 solito/imageSolitoImage를 쓰세요.

참고: https://docs.expo.dev/versions/latest/sdk/image/

9.6 이미지 갤러리와 라이트박스에는 Galeria를 쓰세요

임팩트: MEDIUM

라이트박스(탭하면 전체 화면)가 있는 이미지 갤러리에는 @nandorojo/galeria를 쓰세요. 핀치 줌, 더블 탭 줌, 팬으로 닫기가 되는 네이티브 셰어드 엘리먼트 전환을 제공합니다. expo-image를 포함한 어떤 이미지 컴포넌트와도 함께 동작해요.

잘못된 예: 커스텀 모달 구현

function ImageGallery({ urls }: { urls: string[] }) {
  const [selected, setSelected] = useState<string | null>(null)

  return (
    <>
      {urls.map((url) => (
        <Pressable key={url} onPress={() => setSelected(url)}>
          <Image source={{ uri: url }} style={styles.thumbnail} />
        </Pressable>
      ))}
      <Modal visible={!!selected} onRequestClose={() => setSelected(null)}>
        <Image source={{ uri: selected! }} style={styles.fullscreen} />
      </Modal>
    </>
  )
}

올바른 예: Galeria와 expo-image

import { Galeria } from '@nandorojo/galeria'
import { Image } from 'expo-image'

function ImageGallery({ urls }: { urls: string[] }) {
  return (
    <Galeria urls={urls}>
      {urls.map((url, index) => (
        <Galeria.Image index={index} key={url}>
          <Image source={{ uri: url }} style={styles.thumbnail} />
        </Galeria.Image>
      ))}
    </Galeria>
  )
}

단일 이미지:

import { Galeria } from '@nandorojo/galeria'
import { Image } from 'expo-image'

function Avatar({ url }: { url: string }) {
  return (
    <Galeria urls={[url]}>
      <Galeria.Image>
        <Image source={{ uri: url }} style={styles.avatar} />
      </Galeria.Image>
    </Galeria>
  )
}

저해상도 썸네일과 고해상도 전체 화면:

<Galeria urls={highResUrls}>
  {lowResUrls.map((url, index) => (
    <Galeria.Image index={index} key={url}>
      <Image source={{ uri: url }} style={styles.thumbnail} />
    </Galeria.Image>
  ))}
</Galeria>

FlashList와 함께:

<Galeria urls={urls}>
  <FlashList
    data={urls}
    renderItem={({ item, index }) => (
      <Galeria.Image index={index}>
        <Image source={{ uri: item }} style={styles.thumbnail} />
      </Galeria.Image>
    )}
    numColumns={3}
    estimatedItemSize={100}
  />
</Galeria>

expo-image, SolitoImage, react-native Image 등 어떤 이미지 컴포넌트와도 동작합니다.

참고: https://github.com/nandorojo/galeria

9.7 드롭다운과 컨텍스트 메뉴에는 네이티브 메뉴를 쓰세요

임팩트: HIGH (네이티브 접근성, 플랫폼과 일관된 UX)

커스텀 JS 구현 대신 네이티브 플랫폼 메뉴를 쓰세요. 네이티브 메뉴는 내장 접근성, 일관된 플랫폼 UX, 더 나은 성능을 제공합니다. 크로스 플랫폼 네이티브 메뉴에는 zeego를 쓰세요.

잘못된 예: 커스텀 JS 메뉴

import { useState } from 'react'
import { View, Pressable, Text } from 'react-native'

function MyMenu() {
  const [open, setOpen] = useState(false)

  return (
    <View>
      <Pressable onPress={() => setOpen(!open)}>
        <Text>Open Menu</Text>
      </Pressable>
      {open && (
        <View style={{ position: 'absolute', top: 40 }}>
          <Pressable onPress={() => console.log('edit')}>
            <Text>Edit</Text>
          </Pressable>
          <Pressable onPress={() => console.log('delete')}>
            <Text>Delete</Text>
          </Pressable>
        </View>
      )}
    </View>
  )
}

올바른 예: zeego 네이티브 메뉴

import * as DropdownMenu from 'zeego/dropdown-menu'

function MyMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Open Menu</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.Item key='edit' onSelect={() => console.log('edit')}>
          <DropdownMenu.ItemTitle>Edit</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>

        <DropdownMenu.Item
          key='delete'
          destructive
          onSelect={() => console.log('delete')}
        >
          <DropdownMenu.ItemTitle>Delete</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

컨텍스트 메뉴: 롱 프레스

import * as ContextMenu from 'zeego/context-menu'

function MyContextMenu() {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger>
        <View style={{ padding: 20 }}>
          <Text>Long press me</Text>
        </View>
      </ContextMenu.Trigger>

      <ContextMenu.Content>
        <ContextMenu.Item key='copy' onSelect={() => console.log('copy')}>
          <ContextMenu.ItemTitle>Copy</ContextMenu.ItemTitle>
        </ContextMenu.Item>

        <ContextMenu.Item key='paste' onSelect={() => console.log('paste')}>
          <ContextMenu.ItemTitle>Paste</ContextMenu.ItemTitle>
        </ContextMenu.Item>
      </ContextMenu.Content>
    </ContextMenu.Root>
  )
}

체크박스 아이템:

import * as DropdownMenu from 'zeego/dropdown-menu'

function SettingsMenu() {
  const [notifications, setNotifications] = useState(true)

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Settings</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.CheckboxItem
          key='notifications'
          value={notifications}
          onValueChange={() => setNotifications((prev) => !prev)}
        >
          <DropdownMenu.ItemIndicator />
          <DropdownMenu.ItemTitle>Notifications</DropdownMenu.ItemTitle>
        </DropdownMenu.CheckboxItem>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

서브메뉴:

import * as DropdownMenu from 'zeego/dropdown-menu'

function MenuWithSubmenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Options</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.Item key='home' onSelect={() => console.log('home')}>
          <DropdownMenu.ItemTitle>Home</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>

        <DropdownMenu.Sub>
          <DropdownMenu.SubTrigger key='more'>
            <DropdownMenu.ItemTitle>More Options</DropdownMenu.ItemTitle>
          </DropdownMenu.SubTrigger>

          <DropdownMenu.SubContent>
            <DropdownMenu.Item key='settings'>
              <DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
            </DropdownMenu.Item>

            <DropdownMenu.Item key='help'>
              <DropdownMenu.ItemTitle>Help</DropdownMenu.ItemTitle>
            </DropdownMenu.Item>
          </DropdownMenu.SubContent>
        </DropdownMenu.Sub>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

참고: https://zeego.dev/components/dropdown-menu

9.8 JS 기반 바텀 시트보다 네이티브 모달을 쓰세요

임팩트: HIGH (네이티브 성능, 제스처, 접근성)

JS 기반 바텀 시트 라이브러리 대신 presentationStyle="formSheet"를 쓴 네이티브 <Modal>이나 React Navigation v7의 네이티브 폼 시트를 쓰세요. 네이티브 모달은 제스처, 접근성, 더 나은 성능을 내장합니다. 저수준 프리미티브는 네이티브 UI에 의존하세요.

잘못된 예: JS 기반 바텀 시트

import BottomSheet from 'custom-js-bottom-sheet'

function MyScreen() {
  const sheetRef = useRef<BottomSheet>(null)

  return (
    <View style={{ flex: 1 }}>
      <Button onPress={() => sheetRef.current?.expand()} title='Open' />
      <BottomSheet ref={sheetRef} snapPoints={['50%', '90%']}>
        <View>
          <Text>Sheet content</Text>
        </View>
      </BottomSheet>
    </View>
  )
}

올바른 예: formSheet를 쓴 네이티브 Modal

import { Modal, View, Text, Button } from 'react-native'

function MyScreen() {
  const [visible, setVisible] = useState(false)

  return (
    <View style={{ flex: 1 }}>
      <Button onPress={() => setVisible(true)} title='Open' />
      <Modal
        visible={visible}
        presentationStyle='formSheet'
        animationType='slide'
        onRequestClose={() => setVisible(false)}
      >
        <View>
          <Text>Sheet content</Text>
        </View>
      </Modal>
    </View>
  )
}

올바른 예: React Navigation v7 네이티브 폼 시트

// 내비게이터에서
<Stack.Screen
  name='Details'
  component={DetailsScreen}
  options={{
    presentation: 'formSheet',
    sheetAllowedDetents: 'fitToContents',
  }}
/>

네이티브 모달은 스와이프로 닫기, 올바른 키보드 회피, 접근성을 기본으로 제공합니다.

9.9 Touchable 컴포넌트 대신 Pressable을 쓰세요

임팩트: LOW (모던 API, 더 유연함)

TouchableOpacityTouchableHighlight를 쓰지 마세요. react-native 또는 react-native-gesture-handlerPressable을 쓰세요.

잘못된 예: 레거시 Touchable 컴포넌트

import { TouchableOpacity } from 'react-native'

function MyButton({ onPress }: { onPress: () => void }) {
  return (
    <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
      <Text>Press me</Text>
    </TouchableOpacity>
  )
}

올바른 예: Pressable

import { Pressable } from 'react-native'

function MyButton({ onPress }: { onPress: () => void }) {
  return (
    <Pressable onPress={onPress}>
      <Text>Press me</Text>
    </Pressable>
  )
}

올바른 예: 리스트에서는 gesture handler의 Pressable

import { Pressable } from 'react-native-gesture-handler'

function ListItem({ onPress }: { onPress: () => void }) {
  return (
    <Pressable onPress={onPress}>
      <Text>Item</Text>
    </Pressable>
  )
}

스크롤 가능한 리스트 안에서는 제스처 조율이 더 잘 되는 react-native-gesture-handler의 Pressable을 쓰세요 — 단, ScrollView도 react-native-gesture-handler의 것을 쓰고 있어야 합니다.

애니메이션 프레스 상태(scale, opacity 변화): Pressable의 스타일 콜백 대신 GestureDetector와 Reanimated 공유 값을 쓰세요. animation-gesture-detector-press 규칙을 참고하세요.


10. 디자인 시스템 (Design System)

임팩트: MEDIUM

유지보수 가능한 컴포넌트 라이브러리를 만들기 위한 아키텍처 패턴입니다.

10.1 다형성 children보다 컴파운드 컴포넌트를 쓰세요

임팩트: MEDIUM (유연한 조합, 더 명확한 API)

텍스트 노드가 아닌 컴포넌트가 문자열을 받게 만들지 마세요. 문자열 자식을 받을 수 있는 컴포넌트라면 전용 *Text 컴포넌트여야 합니다. 버튼처럼 View(또는 Pressable)와 텍스트를 함께 가질 수 있는 컴포넌트에는 Button, ButtonText, ButtonIcon 같은 컴파운드 컴포넌트를 쓰세요.

잘못된 예: 다형성 children

import { Pressable, Text } from 'react-native'

type ButtonProps = {
  children: string | React.ReactNode
  icon?: React.ReactNode
}

function Button({ children, icon }: ButtonProps) {
  return (
    <Pressable>
      {icon}
      {typeof children === 'string' ? <Text>{children}</Text> : children}
    </Pressable>
  )
}

// 사용법이 모호함
<Button icon={<Icon />}>Save</Button>
<Button><CustomText>Save</CustomText></Button>

올바른 예: 컴파운드 컴포넌트

import { Pressable, Text } from 'react-native'

function Button({ children }: { children: React.ReactNode }) {
  return <Pressable>{children}</Pressable>
}

function ButtonText({ children }: { children: React.ReactNode }) {
  return <Text>{children}</Text>
}

function ButtonIcon({ children }: { children: React.ReactNode }) {
  return <>{children}</>
}

// 사용법이 명시적이고 조합 가능함
<Button>
  <ButtonIcon><SaveIcon /></ButtonIcon>
  <ButtonText>Save</ButtonText>
</Button>

<Button>
  <ButtonText>Cancel</ButtonText>
</Button>

11. 모노레포 (Monorepo)

임팩트: LOW

모노레포의 의존성 관리와 네이티브 모듈 설정입니다.

11.1 네이티브 의존성은 앱 디렉터리에 설치하세요

임팩트: CRITICAL (오토링킹이 동작하기 위한 필수 조건)

모노레포에서 네이티브 코드가 있는 패키지는 네이티브 앱 디렉터리에 직접 설치해야 합니다. 오토링킹은 앱의 node_modules만 스캔합니다 — 다른 패키지에 설치된 네이티브 의존성은 찾지 못해요.

잘못된 예: 네이티브 의존성이 공유 패키지에만 있음

packages/
  ui/
    package.json  # react-native-reanimated 있음
  app/
    package.json  # react-native-reanimated 없음

오토링킹 실패 — 네이티브 코드가 링크되지 않습니다.

올바른 예: 네이티브 의존성이 앱 디렉터리에 있음

packages/
  ui/
    package.json  # react-native-reanimated 있음
  app/
    package.json  # react-native-reanimated도 있음
// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

공유 패키지가 네이티브 의존성을 쓰더라도, 오토링킹이 네이티브 코드를 감지하고 링크하려면 앱에도 반드시 명시해야 합니다.

11.2 모노레포 전체에서 단일 의존성 버전을 쓰세요

임팩트: MEDIUM (중복 번들, 버전 충돌 회피)

모노레포의 모든 패키지에서 각 의존성의 버전을 하나로 통일하세요. 범위(range)보다 정확한(exact) 버전을 우선하세요. 여러 버전이 존재하면 번들에 중복 코드가 생기고, 런타임 충돌이 나고, 패키지 간 동작이 일관되지 않게 됩니다.

syncpack 같은 도구로 강제하세요. 최후의 수단으로 yarn resolutions나 npm overrides를 쓰세요.

잘못된 예: 버전 범위, 여러 버전

// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "^3.0.0"
  }
}

// packages/ui/package.json
{
  "dependencies": {
    "react-native-reanimated": "^3.5.0"
  }
}

올바른 예: 정확한 버전, 단일 진실 원천

// package.json (루트)
{
  "pnpm": {
    "overrides": {
      "react-native-reanimated": "3.16.1"
    }
  }
}

// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

// packages/ui/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

패키지 매니저의 override/resolution 기능으로 루트에서 버전을 강제하세요. 의존성을 추가할 때는 ^~ 없이 정확한 버전을 명시하세요.


12. 서드파티 의존성 (Third-Party Dependencies)

임팩트: LOW

유지보수를 위해 서드파티 의존성을 감싸고 재내보내기(re-export)합니다.

12.1 디자인 시스템 폴더에서 임포트하세요

임팩트: LOW (전역 변경과 쉬운 리팩터링 가능)

의존성을 디자인 시스템 폴더에서 재내보내세요. 앱 코드는 패키지가 아니라 거기서 임포트합니다. 이렇게 하면 전역 변경과 쉬운 리팩터링이 가능해져요.

잘못된 예: 패키지에서 직접 임포트

import { View, Text } from 'react-native'
import { Button } from '@ui/button'

function Profile() {
  return (
    <View>
      <Text>Hello</Text>
      <Button>Save</Button>
    </View>
  )
}

올바른 예: 디자인 시스템에서 임포트

// components/view.tsx
import { View as RNView } from 'react-native'

// 이상적: 실제로 쓸 props만 골라서 구현을 통제
export function View(
  props: Pick<React.ComponentProps<typeof RNView>, 'style' | 'children'>
) {
  return <RNView {...props} />
}
// components/text.tsx
export { Text } from 'react-native'
// components/button.tsx
export { Button } from '@ui/button'
import { View } from '@/components/view'
import { Text } from '@/components/text'
import { Button } from '@/components/button'

function Profile() {
  return (
    <View>
      <Text>Hello</Text>
      <Button>Save</Button>
    </View>
  )
}

처음에는 단순히 재내보내기만 하세요. 나중에 앱 코드를 바꾸지 않고 커스터마이즈할 수 있습니다.


13. JavaScript

임팩트: LOW

비싼 객체 생성을 호이스팅하는 것 같은 마이크로 최적화입니다.

13.1 Intl 포매터 생성을 호이스팅하세요

임팩트: LOW-MEDIUM (비싼 객체 재생성 회피)

Intl.DateTimeFormat, Intl.NumberFormat, Intl.RelativeTimeFormat을 렌더링이나 루프 안에서 생성하지 마세요. 인스턴스화 비용이 비쌉니다. 로케일/옵션이 정적이라면 모듈 스코프로 호이스팅하세요.

잘못된 예: 렌더링마다 새 포매터

function Price({ amount }: { amount: number }) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  })
  return <Text>{formatter.format(amount)}</Text>
}

올바른 예: 모듈 스코프로 호이스팅

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
})

function Price({ amount }: { amount: number }) {
  return <Text>{currencyFormatter.format(amount)}</Text>
}

동적 로케일은 메모이즈하세요:

const dateFormatter = useMemo(
  () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
  [locale]
)

호이스팅할 만한 흔한 포매터:

// 모듈 레벨 포매터
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
  numeric: 'auto',
})

Intl 객체 생성은 RegExp나 일반 객체보다 훨씬 비쌉니다 — 인스턴스화할 때마다 로케일 데이터를 파싱하고 내부 룩업 테이블을 만듭니다.


14. 폰트 (Fonts)

임팩트: LOW

성능 향상을 위한 네이티브 폰트 로딩입니다.

14.1 폰트는 빌드 타임에 네이티브로 로드하세요

임팩트: LOW (앱 시작 시 폰트 사용 가능, 비동기 로딩 없음)

useFontsFont.loadAsync 대신 expo-font 설정 플러그인(config plugin)으로 빌드 타임에 폰트를 임베드하세요. 임베드된 폰트가 더 효율적입니다.

Expo Font 문서

잘못된 예: 비동기 폰트 로딩

import { useFonts } from 'expo-font'
import { Text, View } from 'react-native'

function App() {
  const [fontsLoaded] = useFonts({
    'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
  })

  if (!fontsLoaded) {
    return null
  }

  return (
    <View>
      <Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
    </View>
  )
}

올바른 예: 설정 플러그인, 빌드 시 폰트 임베드

// app.json
{
  "expo": {
    "plugins": [
      [
        "expo-font",
        {
          "fonts": ["./assets/fonts/Geist-Bold.otf"]
        }
      ]
    ]
  }
}
import { Text, View } from 'react-native'

function App() {
  // 로딩 상태가 필요 없음 — 폰트가 이미 사용 가능
  return (
    <View>
      <Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
    </View>
  )
}

설정 플러그인에 폰트를 추가한 뒤 npx expo prebuild를 실행하고 네이티브 앱을 다시 빌드하세요.


참고 자료 (References)

  1. https://react.dev
  2. https://reactnative.dev
  3. https://docs.swmansion.com/react-native-reanimated
  4. https://docs.swmansion.com/react-native-gesture-handler
  5. https://docs.expo.dev
  6. https://legendapp.com/open-source/legend-list
  7. https://github.com/nandorojo/galeria
  8. https://zeego.dev

댓글