← 글 목록

개발 기록

WebView에서 Stackflow로 화면 전환과 뒤로가기 관리하기

여러 화면을 가진 WebView 서비스를 구현하면서 화면 전환 상태를 어떻게 관리할지 고민하게 됐습니다.

URL에 맞는 화면을 보여주는 것 외에도 처리할 일이 있었습니다.

  • 화면 전환 애니메이션
  • 이전 화면으로 돌아가기
  • 모달과 바텀시트 닫기
  • 네이티브 뒤로가기 버튼
  • 첫 화면에서 WebView 종료
  • 다음 화면에 필요한 데이터 미리 불러오기

이번 글에서는 Stackflow를 이용해 화면을 Activity 단위로 관리하고, WebView의 뒤로가기 동작을 연결한 과정을 정리해 보려 합니다.

뒤로가기를 눌렀을 때 무엇부터 닫을까

화면을 Activity로 나누고 push, replace, pop으로 이동하면서, 전환 상태는 하나의 Stack에서 관리했습니다. 필요한 데이터는 화면에 들어가기 전에 요청하도록 했습니다.

뒤로가기는 웹 버튼에서 시작하든 네이티브에서 시작하든 같은 순서로 처리해야 했습니다. 특히 모달이 열린 상태에서는 화면을 이동하기 전에 모달부터 닫아야 했습니다.

우선 뒤로가기를 다음 순서로 처리하도록 했습니다.

모달 또는 바텀시트
        ↓
Stackflow Activity
        ↓
루트 화면의 별도 처리
        ↓
네이티브 WebView 종료

1. 화면 상태를 직접 관리할 때의 문제

간단한 화면 전환은 현재 화면을 상태로 관리할 수 있습니다.

const [currentScreen, setCurrentScreen] = useState('home')

하지만 화면이 늘어나면 이전 화면 정보를 별도로 관리해야 합니다.

Home
→ List
→ Detail
→ Modal

이 상태에서 뒤로가기를 누르면 다음 순서로 동작해야 합니다.

Modal 닫기
→ Detail
→ List
→ Home
→ WebView 종료

현재 화면만 저장하면 사용자가 어떤 경로로 진입했는지 알기 어렵습니다.

이전 화면 배열을 별도로 구현할 수도 있지만 전환 애니메이션, 화면 제거 시점, 중복 입력 방지까지 직접 관리해야 했습니다.

화면 이동 자체를 Stack으로 관리하기 위해 Stackflow를 적용했습니다.

2. Activity 구성

Stackflow에서는 각 화면을 Activity로 등록합니다.

const { Stack, useFlow } = stackflow({
  activities: {
    Home: HomeActivity,
    List: ListActivity,
    Detail: DetailActivity,
    Settings: SettingsActivity,
  },
  initialActivity: () => 'Home',
  transitionDuration: 300,
  plugins: [webRendererPlugin()],
})

애플리케이션에서는 생성된 Stack을 렌더링합니다.

function App() {
  return <Stack />
}

화면 이동은 push, replace, pop으로 구분했습니다.

const { push, replace, pop } = useFlow()

push('Detail', { id: 'item-id' })
replace('Home', {})
pop()

각 동작의 역할은 다음과 같습니다.

  • push: 현재 화면 위에 새 화면 추가
  • replace: 현재 화면을 다른 화면으로 교체
  • pop: 현재 화면을 제거하고 이전 화면으로 이동

첫 진입 화면에서 메인 화면으로 이동할 때는 replace를 사용했습니다.

Entry → Home

push를 사용하면 메인 화면에서 뒤로가기를 눌렀을 때 다시 진입 화면이 나타날 수 있기 때문입니다.

상세 화면으로 이동할 때는 push를 사용해 이전 화면으로 돌아갈 수 있도록 했습니다.

3. 전환이 끝난 Activity 구분

Stackflow의 Activity는 pop을 호출했다고 즉시 배열에서 사라지지 않습니다.

종료 애니메이션이 진행되는 동안 Stack에 남아 있으며 전환 상태가 변경됩니다.

enter-active
exit-active
exit-done

처음에는 Activity 배열의 길이만으로 현재 화면 깊이를 판단했습니다.

const depth = stack.activities.length

하지만 종료가 완료된 Activity까지 포함되면 실제 화면보다 Stack이 깊다고 판단할 수 있습니다.

활성 상태를 확인할 때는 전환이 끝난 Activity를 제외했습니다.

const activeActivities = stack.activities.filter(
  activity => activity.transitionState !== 'exit-done',
)

뒤로가기 처리도 같은 기준을 사용했습니다.

if (activeActivities.length > 1) {
  pop()
  return
}

배열 길이가 곧 현재 화면 깊이라고 생각했던 부분이 문제였습니다. 종료된 Activity를 제외한 뒤에 깊이를 판단해야 했습니다.

4. WebView 뒤로가기 연결

웹 화면의 뒤로가기 버튼은 pop을 호출하면 됩니다.

하지만 Android 하드웨어 뒤로가기처럼 네이티브에서 시작되는 이벤트는 React 컴포넌트 밖에서 전달됩니다.

네이티브에서 호출할 수 있는 함수를 window에 등록하고, Stackflow 동작과 연결했습니다.

type NativeWindow = Window & {
  onNativeBack?: () => void
}

뒤로가기 요청이 들어오면 현재 Stack을 확인합니다.

const requestBack = () => {
  const activeActivities = getStack().activities.filter(
    activity => activity.transitionState !== 'exit-done',
  )

  if (activeActivities.length > 1) {
    actions.pop()
    return
  }

  requestNativeExit()
}

하위 화면이 있으면 Stackflow에서 pop을 실행하고, 첫 화면이라면 네이티브에 WebView 종료를 요청합니다.

Stack 깊이 2 이상
→ Stackflow pop

Stack 깊이 1
→ 네이티브 종료 요청

웹 헤더의 뒤로가기와 네이티브 뒤로가기가 서로 다른 기준으로 동작하지 않도록 같은 처리 함수를 사용했습니다.

5. 모달이 화면보다 먼저 닫혀야 하는 문제

화면 위에 모달이나 바텀시트가 열린 상태에서 뒤로가기를 누르면 Activity가 먼저 제거되는 문제가 있었습니다.

이 경우에는 화면을 그대로 두고 모달만 닫히도록 해야 했습니다.

잘못된 순서

뒤로가기
→ Activity 제거
→ 모달도 함께 사라짐

뒤로가기 대상이 되는 Overlay를 별도 Stack으로 관리했습니다.

type OverlayEntry = {
  id: symbol
  close: () => void
}

const overlayStack: OverlayEntry[] = []

모달이 열릴 때 닫기 함수를 등록합니다.

function registerOverlay(close: () => void) {
  const id = Symbol('overlay')

  overlayStack.push({ id, close })

  return () => {
    const index = overlayStack.findIndex(item => item.id === id)

    if (index >= 0) {
      overlayStack.splice(index, 1)
    }
  }
}

뒤로가기 요청이 들어오면 가장 위에 있는 Overlay부터 확인합니다.

function closeTopOverlay() {
  const overlay = overlayStack.at(-1)

  if (!overlay) {
    return false
  }

  overlay.close()
  return true
}

뒤로가기를 처리하는 순서는 다음과 같습니다.

const requestBack = () => {
  if (closeTopOverlay()) {
    return
  }

  if (getActiveActivityCount() > 1) {
    actions.pop()
    return
  }

  requestNativeExit()
}

모달이 열려 있으면 화면 이동을 취소하고 모달만 닫습니다.

6. 기존 네이티브 콜백과 충돌한 문제

초기에는 네이티브 콜백이 없을 때만 뒤로가기 함수를 등록했습니다.

if (typeof window.onNativeBack !== 'function') {
  window.onNativeBack = requestBack
}

하지만 WebView가 먼저 콜백을 등록한 환경에서는 Stackflow용 함수가 연결되지 않았습니다.

같은 화면이라도 실행 환경에 따라 뒤로가기 우선순위가 달라질 수 있었습니다.

문제를 확인하기 위해 네이티브 이벤트 수신 여부와 Stack 깊이를 각각 기록했습니다.

네이티브 이벤트는 수신됨
Stackflow pop은 호출되지 않음
기존 Callback이 그대로 실행됨

뒤로가기 콜백은 항상 동일한 함수로 연결하도록 변경했습니다.

const previousHandler = window.dispatchNativeEvent

window.onNativeBack = requestBack

window.dispatchNativeEvent = (eventName, payload) => {
  if (eventName === 'back') {
    requestBack()
    return
  }

  previousHandler?.(eventName, payload)
}

뒤로가기 이벤트는 새로운 우선순위로 처리하고, 관계없는 네이티브 이벤트는 기존 콜백으로 전달했습니다.

기존 이벤트 처리는 유지하고, 뒤로가기 동작만 Stackflow에 연결할 수 있었습니다.

7. 루트 화면의 뒤로가기 처리

Stack 깊이가 1이라고 항상 WebView를 종료할 수 있는 것은 아니었습니다.

루트 화면 내부에 별도의 상태가 있을 수 있기 때문입니다.

예를 들어 다음과 같은 상황입니다.

  • 펼쳐진 패널
  • 선택 모드
  • 진행 중인 튜토리얼
  • 화면 내부에서 열린 추가 영역

루트 화면이 뒤로가기를 처리할 수 있는지 먼저 확인했습니다.

if (dispatchRootBackRequest()) {
  return
}

requestNativeExit()

최종 동작은 다음 순서가 됐습니다.

const requestBack = () => {
  if (closeTopOverlay()) {
    return
  }

  if (getActiveActivityCount() > 1) {
    actions.pop()
    return
  }

  if (dispatchRootBackRequest()) {
    return
  }

  requestNativeExit()
}

이 확인을 먼저 거치도록 하니, 화면 안에서 처리할 뒤로가기가 남아 있는데 WebView부터 닫히는 일을 막을 수 있었습니다.

8. Activity Preload 적용

일부 화면은 진입 후 API를 호출하면 로딩 화면이 길게 노출됐습니다.

Stackflow의 Preload Plugin을 이용해 화면 이동 전에 필요한 데이터를 요청했습니다.

preloadPlugin({
  loaders: {
    Home: preloadHome,
    List: preloadList,
    Detail: preloadDetail,
  },
})

화면에서는 Preload 결과를 참조합니다.

const preloadRef = useActivityPreloadRef()

const data = preloadRef.current

모든 화면을 미리 불러오지는 않았습니다.

다음 조건에 해당하는 화면만 Preload를 적용했습니다.

  • 화면 진입 직후 반드시 필요한 데이터
  • 사용자 행동을 통해 다음 이동이 예상되는 화면
  • 중복 요청을 방지할 수 있는 데이터
  • 요청 실패 시 화면에서 다시 불러올 수 있는 데이터

Preload는 화면 전환을 빠르게 보이게 할 수 있지만 무조건 적용하면 사용하지 않는 API 요청이 늘어날 수 있습니다.

정리

WebView의 뒤로가기는 단순히 브라우저 히스토리만 이동시키는 문제가 아니었습니다.

모달을 닫아야 하는지, 이전 화면으로 가야 하는지, 루트 화면 안에서 처리할 동작이 있는지 먼저 확인해야 했습니다. WebView 종료는 그 뒤에 요청하도록 했습니다.

1. 열려 있는 모달 또는 바텀시트 닫기
2. 이전 Stackflow Activity로 이동
3. 루트 화면 내부 상태 처리
4. 네이티브 WebView 종료 요청

Stackflow가 화면 이동 이력을 관리해 주더라도, 뒤로가기의 우선순위까지 정해 주는 것은 아니었습니다. 이 부분을 같은 함수에서 처리하면서 웹 버튼과 네이티브 뒤로가기가 동일하게 동작하도록 맞췄습니다.

이후 화면을 추가할 때도 Activity를 등록하고 push, replace, pop 중 필요한 동작을 선택하면 기존 구조를 사용할 수 있게 됐습니다.