← 글 목록

개발 기록

하나의 React 앱에서 WebView와 일반 Web 진입점 분리하기

처음에는 네이티브 앱 내부에서 실행되는 WebView만 고려해 화면 구조를 만들었습니다.

서비스를 일반 브라우저에서도 제공하게 되면서 같은 화면을 사용하되 진입점과 라우팅 방식은 다르게 처리해야 했습니다.

WebView
→ 네이티브 인증 정보 사용
→ 앱 전용 화면에서 시작
→ 네이티브 뒤로가기 사용

일반 Web
→ 웹 소개 화면에서 시작
→ URL로 화면 이동
→ 약관과 고객지원 페이지 직접 접근

화면은 그대로 쓰고 진입 방식만 다르게 처리하면 됐기 때문에, 하나의 React 앱을 유지하기로 했습니다.

먼저 나눠야 했던 부분

공통 화면과 비즈니스 로직은 그대로 두고, 각 환경에서 처음 보여줄 화면을 나눴습니다. 일반 웹은 URL로 직접 들어올 수 있어야 했지만 WebView에서는 URL 동기화가 필요하지 않았습니다.

잘못된 주소로 들어오면 기본 화면을 보여주도록 했습니다. 서버 렌더링 중에는 window에 접근하지 않도록 확인하는 코드도 필요했습니다.

전체 구조는 다음과 같습니다.

React App
    ↓
실행 환경 확인
    ↓
 ┌──────────────┬──────────────┐
 │ App WebView  │ 일반 Browser │
 ├──────────────┼──────────────┤
 │ 앱 진입 화면 │ 웹 소개 화면 │
 │ Stack 이동   │ URL 동기화   │
 │ Native Bridge│ Web History  │
 └──────────────┴──────────────┘

1. 애플리케이션을 분리하지 않은 이유

WebView와 일반 웹의 진입 방식은 달랐지만 실제 기능은 대부분 같았습니다.

  • 메인 화면
  • 상세 화면
  • 미션 화면
  • 공지사항
  • 이용 안내
  • 데이터 요청
  • 상태 관리

별도의 애플리케이션으로 나누면 같은 기능을 두 곳에서 관리해야 합니다.

webview-app
web-app

화면 수정이나 API 변경이 발생할 때 두 애플리케이션을 함께 수정해야 하고, 시간이 지나면 동작이 달라질 가능성도 있었습니다.

공통 기능은 하나의 애플리케이션에서 유지하고 다음 항목만 런타임에 따라 분리했습니다.

  • 초기 Activity
  • History Sync 사용 여부
  • 웹 전용 Activity
  • 네이티브 기능 호출 여부

2. 실행 환경 확인

WebView에서는 네이티브 앱이 JavaScript 브리지를 제공합니다.

브리지 사용 가능 여부를 기준으로 현재 환경을 판별했습니다.

type Runtime = 'app' | 'web'

function resolveRuntime(): Runtime {
  if (typeof window === 'undefined') {
    return 'web'
  }

  return hasNativeBridge(window)
    ? 'app'
    : 'web'
}

서버 렌더링과 정적 페이지 생성 과정에서는 window가 존재하지 않을 수 있으므로 먼저 확인했습니다.

if (typeof window === 'undefined') {
  return 'web'
}

브리지 프로퍼티를 여러 곳에서 직접 확인하지 않도록 환경을 판별하는 함수를 하나로 모았습니다.

const runtime = resolveRuntime()
const isWebRuntime = runtime === 'web'

테스트가 필요한 경우에만 쿼리 매개변수로 환경을 명시적으로 변경할 수 있도록 했습니다.

운영 동작은 브리지 판별을 기준으로 유지하고, 테스트용 분기는 별도로 구분했습니다.

3. 런타임별 초기 화면 분리

WebView에서는 앱에서 필요한 정보가 전달된 뒤 기능 화면으로 진입합니다.

일반 웹에서는 서비스 설명과 진입 버튼이 있는 소개 화면이 먼저 필요했습니다.

const initialActivity = () => {
  return isWebRuntime
    ? 'Intro'
    : 'Tutorial'
}

Stackflow 설정에서는 같은 Activity 목록을 사용하면서 초기 화면만 변경했습니다.

const { Stack } = stackflow({
  activities: {
    Intro: IntroActivity,
    Tutorial: TutorialActivity,
    Home: HomeActivity,
    Detail: DetailActivity,
    Mission: MissionActivity,
  },
  initialActivity,
})

공통 기능 화면은 그대로 재사용하고 일반 웹에 필요한 화면만 추가했습니다.

공통 Activity
- Home
- Detail
- Mission
- Notice

Web 전용 Activity
- Intro
- Terms
- Privacy
- Support

4. 일반 웹에서만 URL 동기화

WebView에서는 Stackflow의 Activity 상태만으로 화면을 관리했습니다.

URL이 사용자에게 노출되지 않고 네이티브 뒤로가기와 Stackflow의 pop이 이동을 담당하기 때문입니다.

일반 웹에서는 다음 기능이 필요했습니다.

  • 새로고침 후 현재 화면 유지
  • 브라우저 뒤로가기
  • 특정 페이지 링크 공유
  • 약관 페이지 직접 접근
  • 검색엔진과 Sitemap에 경로 제공

일반 웹에서만 History Sync Plugin을 추가했습니다.

const plugins = [
  rendererPlugin(),
  preloadPlugin({
    loaders,
  }),
  ...(isWebRuntime
    ? [
        historySyncPlugin({
          useHash: false,
          routes: webRoutes,
          fallbackActivity: () => 'Intro',
        }),
      ]
    : []),
]

WebView에는 History Sync Plugin이 포함되지 않습니다.

WebView
Stackflow 상태만 사용

일반 Web
Stackflow 상태 + Browser URL 동기화

같은 Activity를 사용하면서도 불필요한 브라우저 히스토리가 WebView에 쌓이지 않도록 했습니다.

5. Route 설정 분리

Activity 이름과 URL 경로의 관계는 한 곳에서 관리했습니다.

const webRoutes = {
  Intro: '/',
  Tutorial: '/tutorial',
  Home: '/home',
  Mission: '/mission',
  Detail: '/detail',
  Terms: '/terms',
  Privacy: '/privacy',
  Support: '/support',
}

Activity 내부에서는 URL 문자열을 직접 사용하지 않고 Stackflow의 이동 동작을 사용합니다.

push('Mission', {})

일반 웹에서는 History Sync Plugin이 이를 URL로 변환합니다.

push('Mission')
        ↓
/mission

WebView에서는 같은 호출이 URL 변경 없이 Activity만 추가합니다.

화면 컴포넌트가 자신이 WebView에서 실행되는지 일반 웹에서 실행되는지 알 필요가 없어졌습니다.

6. 존재하지 않는 경로 처리

일반 웹에서는 사용자가 주소를 직접 입력할 수 있습니다.

잘못된 경로나 더 이상 사용하지 않는 링크로 접근해도 빈 화면이 나타나지 않도록 기본 Activity를 지정했습니다.

historySyncPlugin({
  routes: webRoutes,
  fallbackActivity: () => 'Intro',
})

다음과 같은 경로로 진입하면 소개 화면을 표시합니다.

/unknown-page
        ↓
Intro Activity

지원하지 않는 경로를 각 화면에서 검사하지 않고 라우팅 설정에서 한 번에 처리했습니다.

7. Hash Routing으로 시작했던 이유

처음에는 Hash 기반 라우팅을 적용했습니다.

/#/home
/#/terms
/#/support

Hash Routing은 서버가 모든 경로를 처리하도록 설정하지 않아도 정적 파일 하나로 라우팅할 수 있다는 장점이 있습니다.

historySyncPlugin({
  useHash: true,
})

하지만 일반 웹을 구성하면서 다음 문제가 있었습니다.

  • URL에 #이 포함됨
  • Sitemap 경로와 실제 링크 형태가 달라짐
  • 약관과 고객지원 페이지 URL이 자연스럽지 않음
  • 정적 페이지 생성 경로와 일치시키기 어려움

웹 전용 링크도 Hash를 포함하고 있었습니다.

<a href="#/terms">이용약관</a>

화면 이동은 정상적으로 동작했지만 공개 URL과 정적 페이지 생성까지 고려하면 Path 기반 라우팅이 더 적합했습니다.

8. Hash Routing 제거

History Sync 설정을 일반 Path 방식으로 변경했습니다.

historySyncPlugin({
  useHash: false,
})

웹 전용 링크도 실제 경로를 사용하도록 수정했습니다.

<a href="/terms">이용약관</a>
<a href="/privacy">개인정보 처리방침</a>
<a href="/support">고객센터</a>

기존 Hash 경로를 보정하기 위해 작성했던 코드도 제거할 수 있었습니다.

normalizeHashRoutePath()

Hash 주소를 직접 수정하는 로직이 없어지고, Route 설정과 실제 URL이 같은 형태를 사용하게 됐습니다.

변경 전
/#/terms

변경 후
/terms

이후 경로별 HTML을 생성할 때도 같은 경로를 사용할 수 있게 됐습니다.

9. 링크 기본 동작과 Stack 이동

웹 전용 화면의 링크는 실제 href를 유지했습니다.

<a href="/terms">이용약관</a>

JavaScript가 정상적으로 실행되는 경우에는 기본 이동을 막고 Stackflow로 전환할 수 있습니다.

function handleNavigate(
  event: React.MouseEvent<HTMLAnchorElement>,
) {
  event.preventDefault()
  push('Terms', {})
}

이를 통해 두 가지 접근 방식을 모두 지원할 수 있습니다.

JavaScript 실행 가능
→ Stackflow로 화면 전환

직접 URL 접근 또는 새로고침
→ 해당 경로의 문서 요청

링크에 href가 존재하므로 사용자가 새 탭에서 열거나 링크 주소를 복사하는 동작도 유지됩니다.

10. 실행 환경은 앱 시작 지점에서 확인하기

런타임 분기를 각 화면에 반복해서 작성하지 않았습니다.

// 반복하지 않음
if (isWebRuntime) {
  ...
}

애플리케이션 시작 지점에서 다음 항목만 결정했습니다.

const runtimeConfig = {
  initialActivity: isWebRuntime
    ? 'Intro'
    : 'Tutorial',
  historySync: isWebRuntime,
}

화면 컴포넌트는 공통 이동 인터페이스만 사용합니다.

push('Home', {})
pop()
replace('Intro', {})

네이티브 기능을 사용할 수 없는 웹 환경의 대체 동작은 브리지 모듈에서 처리했습니다. 화면 컴포넌트마다 실행 환경을 확인할 필요가 없도록 하기 위해서였습니다.

정리

두 환경을 지원한다고 해서 모든 화면을 따로 만들 필요는 없었습니다. 이번에는 공통 화면을 유지하고, 진입 방식과 히스토리 동작을 나누는 것으로 충분했습니다.

공통으로 유지
- Activity
- UI
- API
- 상태 관리
- 화면 전환 코드

런타임별로 분리
- 초기 화면
- URL 동기화
- 웹 전용 화면
- 네이티브 브리지

처음 적용했던 Hash Routing은 단순한 정적 배포에는 편리했지만 공개 URL과 정적 페이지 생성을 고려해 Path 기반 라우팅으로 변경했습니다.

화면 이동 코드는 그대로 두고, WebView에서는 Stack을, 일반 브라우저에서는 URL까지 함께 관리할 수 있게 됐습니다.