본문 바로가기

React

React Native와 웹 React 코드 재사용 전략

모바일과 웹을 동시에 개발할 때 핵심은 공통 코드를 최대화하고, 플랫폼 차이를 얇은 경계로 격리하는 것입니다. 이 글은 실무에서 바로 적용할 수 있는 구조, 설정, 컴포넌트/로직 분리, 빌드/테스트 전략을 정리합니다.

1. 원칙과 목표

- UI는 플랫폼 차이를 흡수하는 공통 프리미티브 위에 올립니다.

- 비즈니스 로직, 상태, 데이터 접근은 100% 공유를 목표로 합니다.

- 플랫폼별 차이는 파일 분기(.native/.web)나 얇은 어댑터로 처리합니다.

- 모노레포로 단일 소스 오브 트루스와 일관된 버전 관리를 유지합니다.

2. 모노레포와 폴더 구조

Yarn Workspaces 또는 PNPM으로 앱과 공통 패키지를 한 저장소에 둡니다.

// repo 구조 예시
.
├─ apps/
│  ├─ mobile/        // React Native (Expo 권장)
│  └─ web/           // Next.js 또는 Vite + RN Web
├─ packages/
│  ├─ ui/            // 디자인 시스템, RN 프리미티브 기반
│  ├─ hooks/         // 비즈니스 로직 훅
│  ├─ utils/         // 공통 유틸
│  └─ api/           // 클라이언트, 모델, 타입
└─ tsconfig.base.json
// package.json (루트, JS 표기로 개념만 표현)
{
  private: true,
  workspaces: ['apps/*', 'packages/*']
}

TypeScript 경로 별칭으로 공통 패키지를 짧게 import 합니다.

// tsconfig.base.json (JS 표기)
{
  compilerOptions: {
    baseUrl: '.',
    paths: {
      '@ui/*': ['packages/ui/src/*'],
      '@hooks/*': ['packages/hooks/src/*'],
      '@utils/*': ['packages/utils/src/*'],
      '@api/*': ['packages/api/src/*']
    }
  }
}

3. 공통 모듈 분리: UI, 로직, 유틸

- ui: React Native 프리미티브(View, Text 등)와 react-native-web을 기반으로 합니다.

- hooks: 비즈니스 규칙, 서버 상태, 폼 로직을 담습니다.

- utils: 순수 함수로 환경에 독립적이어야 합니다.

// packages/hooks/src/useAuth.ts
import { useState, useCallback } from 'react'
import { login, logout } from '@api/auth'

export function useAuth() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(false)

  const signIn = useCallback(async (email, password) => {
    setLoading(true)
    try {
      const u = await login(email, password)
      setUser(u)
      return u
    } finally {
      setLoading(false)
    }
  }, [])

  const signOut = useCallback(async () => {
    await logout()
    setUser(null)
  }, [])

  return { user, loading, signIn, signOut }
}

4. 플랫폼별 파일 전략

같은 인터페이스, 다른 구현을 .native.tsx와 .web.tsx로 분리합니다. 가능하면 내부에서만 분기하고, 외부 사용자는 동일한 API를 사용하게 만듭니다.

// packages/ui/src/LinkButton.native.tsx
import { TouchableOpacity, Text, Linking } from 'react-native'
export function LinkButton({ href, children }) {
  return (
    <TouchableOpacity onPress={() => Linking.openURL(href)}>
      <Text>{children}</Text>
    </TouchableOpacity>
  )
}

// packages/ui/src/LinkButton.web.tsx
export function LinkButton({ href, children }) {
  return <a href={href}>{children}</a>
}

간단한 분기는 Platform.select로도 처리합니다.

import { Platform } from 'react-native'
const storageKey = Platform.select({ web: 'web-token', default: 'native-token' })

5. React Native Web 연결

웹 앱에서 react-native를 react-native-web으로 별칭 처리합니다. Next.js 기준입니다.

// apps/web/next.config.js
const nextConfig = {
  transpilePackages: ['@ui', '@hooks', '@utils', '@api'],
  webpack: (config) => {
    config.resolve.alias = {
      ...(config.resolve.alias || {}),
      'react-native$': 'react-native-web'
    }
    return config
  }
}
module.exports = nextConfig

모바일(Expo)에서 모노레포 패키지를 읽도록 Metro 설정을 조정합니다.

// apps/mobile/metro.config.js
const path = require('path')
const { getDefaultConfig } = require('expo/metro-config')

const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')

const config = getDefaultConfig(projectRoot)
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules')
]
module.exports = config

6. 라우팅 매핑 전략

화면 정의는 공유하고, 네비게이션 러너만 플랫폼별로 둡니다.

// packages/ui/src/screens/index.tsx
export const routes = [
  { name: 'Home', path: '/', component: require('./Home').Home },
  { name: 'Profile', path: '/profile', component: require('./Profile').Profile }
]

// apps/mobile/App.tsx (React Navigation)
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { routes } from '@ui/screens'
const Stack = createNativeStackNavigator()
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        {routes.map(r => (
          <Stack.Screen key={r.name} name={r.name} component={r.component} />
        ))}
      </Stack.Navigator>
    </NavigationContainer>
  )
}

// apps/web/app.tsx (React Router)
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { routes } from '@ui/screens'
const router = createBrowserRouter(routes.map(r => ({ path: r.path, element: <r.component /> })))
export default function App() { return <RouterProvider router={router} /> }

7. 스타일과 디자인 시스템

React Native 프리미티브 기반으로 컴포넌트를 만들면 RNW로 웹에 그대로 랜더링됩니다. 토큰 기반 디자인 시스템을 패키지로 제공합니다.

// packages/ui/src/theme.ts
export const theme = {
  colors: { primary: '#4f46e5', text: '#111827', bg: '#ffffff' },
  radius: { sm: 6, md: 10 }
}

// packages/ui/src/Button.tsx
import { Pressable, Text, StyleSheet } from 'react-native'
import { theme } from './theme'
export function Button({ title, onPress, disabled }) {
  return (
    <Pressable onPress={onPress} disabled={disabled} style={[s.base, disabled && s.disabled]}>
      <Text style={s.label}>{title}</Text>
    </Pressable>
  )
}
const s = StyleSheet.create({
  base: { backgroundColor: theme.colors.primary, padding: 12, borderRadius: theme.radius.md },
  disabled: { opacity: 0.5 },
  label: { color: '#fff', textAlign: 'center', fontWeight: '600' }
})

styled-components를 선호한다면 styled-components/native를 사용하면 웹에서도 동작합니다.

8. 데이터, API, 스토리지 레이어 공유

API 클라이언트, 모델, 유효성 검증은 100% 공유 가능합니다. 스토리지만 얇은 어댑터로 분리합니다.

// packages/api/src/http.ts
export async function http(url, options) {
  const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) } })
  if (!res.ok) throw new Error('HTTP ' + res.status)
  return res.json()
}

// packages/utils/src/storage.ts
import { Platform } from 'react-native'
let storage
if (Platform.OS === 'web') {
  storage = {
    get: (k) => Promise.resolve(localStorage.getItem(k)),
    set: (k, v) => Promise.resolve(localStorage.setItem(k, v)),
    del: (k) => Promise.resolve(localStorage.removeItem(k))
  }
} else {
  const AsyncStorage = require('@react-native-async-storage/async-storage').default
  storage = {
    get: (k) => AsyncStorage.getItem(k),
    set: (k, v) => AsyncStorage.setItem(k, v),
    del: (k) => AsyncStorage.removeItem(k)
  }
}
export const Storage = storage

9. 빌드/번들 전략

- 웹: Next.js를 권장합니다. SSR가 필요 없으면 Vite도 간단합니다. react-native-web alias와 transpilePackages가 핵심입니다.

- 모바일: Expo로 시작하면 네이티브 설정 비용을 크게 줄일 수 있습니다.

- 공통 패키지는 ESM 빌드를 기본으로 하고, 웹/네이티브 모두에서 트리 셰이킹이 가능한 구성을 유지합니다.

- Node 코어 모듈(fs, path 등) 의존은 피합니다. 웹 번들러가 폴리필을 추가해 번들 크기가 커집니다.

10. 테스트와 스토리북

- 단위/컴포넌트 테스트: react-native-testing-library로 UI를 테스트하고, 웹은 @testing-library/react로 추가 렌더러만 바꿉니다.

// packages/ui/src/__tests__/Button.test.tsx
import { render, fireEvent } from '@testing-library/react-native'
import { Button } from '../Button'

test('click', () => {
  const onPress = jest.fn()
  const { getByText } = render(<Button title='Save' onPress={onPress} />)
  fireEvent.press(getByText('Save'))
  expect(onPress).toBeCalled()
})

- 스토리북: 웹은 @storybook/react, 모바일은 @storybook/react-native를 사용합니다. 스토리는 공통 코드에서 재사용합니다.

- E2E: 모바일 Detox, 웹 Playwright를 각각 사용합니다.

11. 자주 맞닥뜨리는 함정과 대응

- 제스처: react-native-gesture-handler는 웹 지원 범위가 제한적입니다. 웹 전용 드래그/휠 로직이 필요할 수 있습니다.

- 파일 업로드: RN은 react-native-document-picker, 웹은 input type='file'을 사용합니다. 공통 인터페이스로 감싼 어댑터를 두세요.

- SVG/아이콘: react-native-svg와 웹의 <svg> 사용 차이를 RNW가 대부분 흡수하지만, 고급 기능은 분기 파일로 처리합니다.

- 폰트: 웹은 @font-face, RN은 expo-font 또는 react-native.config.js로 로드합니다. 디자인 시스템에서 폰트 토큰으로 추상화합니다.

- 딥링크/라우팅: 모바일은 Linking, 웹은 URL 라우터를 사용합니다. route map을 공유하고, 실행기는 플랫폼별로 둡니다.

- 스크롤/키보드: 모바일 키보드 회피(View)와 웹 스크롤 동작이 다르므로 화면 컨테이너에 플랫폼별 보정 레이어를 둡니다.

12. 점진적 적용 플랜

- 1단계: utils, api, hooks를 공유 패키지로 분리하고 앱에서 소비합니다.

- 2단계: 디자인 토큰과 단순 UI(Button, Text, Input)를 RN 프리미티브 기반으로 통합합니다.

- 3단계: 리스트, 카드, 모달 등 복합 컴포넌트를 공통화합니다.

- 4단계: 네비게이션/라우팅 추상화와 딥링크 매핑을 정리합니다.

- 5단계: 빌드 최적화, 번들 크기 모니터링, 성능 개선을 진행합니다.

13. 최종 체크리스트

- 모노레포와 TS 경로 별칭이 동작하는가

- react-native -> react-native-web alias, transpilePackages 설정이 되어 있는가

- 공통 패키지가 Node 코어 모듈을 참조하지 않는가

- .native/.web 파일 분기가 필요한 모듈을 식별했는가

- 디자인 토큰과 프리미티브가 정의되어 있는가

- 스토리지, 파일, 네비게이션 어댑터가 준비되어 있는가

- 테스트 러너와 스토리북이 두 플랫폼 모두에서 돈다고 확인했는가

핵심은 비즈니스 로직과 디자인 시스템을 중심으로 아키텍처를 구성하는 것입니다. 플랫폼 특수성은 어댑터로 격리하고, 공통 코드는 꾸준히 패키지로 끌어올리면 유지보수성이 크게 좋아집니다.