WebView 뒤로가기와 Native 내비게이션 연결

WebView 뒤로가기와 Native 내비게이션 연결

한눈에 보기

Hybrid 화면에는 Web history와 Native route stack이 동시에 존재한다. 뒤로가기를 누른 순간 canGoBack()을 한 번 조회하는 것만으로는 SPA modal, page reload, 중복 입력, Android predictive back을 안정적으로 처리하기 어렵다. Web이 navigation state를 event로 알려 Native가 현재 pop 가능성을 미리 알고 있게 만들고, Native modal → Web overlay → Web history → Native route → 앱 종료 순서를 하나의 coordinator가 결정하게 한다.

목차

하나의 화면 안에 Back Stack이 두 개 있다

Flutter 화면 하나가 WebView를 포함하면 사용자에게는 한 화면처럼 보여도 navigation 기록은 최소 두 곳에 생긴다.

Flutter Navigator
Home → OrdersHybridScreen → NativeImagePreview

Web history inside OrdersHybridScreen
/orders → /orders/42 → /orders/42/edit

NativeImagePreview가 닫힌 뒤 사용자가 Android system back을 누르면 /orders/42/edit에서 /orders/42로 가기를 기대한다. 하지만 Flutter Navigator만 pop하면 OrdersHybridScreen 자체가 사라져 Home으로 돌아간다.

반대로 Web history가 root인데 무조건 webView.goBack()을 호출하면 아무 변화가 없고 back 입력이 먹히지 않은 것처럼 보인다.

실제로는 stack이 더 많을 수 있다.

Back은 명령이 아니라 정책이다

“어느 stack을 pop할지”를 한곳에서 결정하지 않으면 각 layer가 같은 back event를 처리하거나 아무도 처리하지 않는 상황이 생긴다.

이 글의 route와 코드는 가상 주문 화면을 기준으로 재구성했으며 실제 프로젝트 구현을 사용하지 않는다.

단순 canGoBack 구현이 놓치는 경우

가장 흔한 구현은 다음과 같다.

Future<void> handleBack() async {
  if (await controller.canGoBack()) {
    await controller.goBack();
    return;
  }

  if (context.mounted) {
    Navigator.of(context).maybePop();
  }
}

기본적인 multi-page Web에는 동작한다. 하지만 다음 문제를 모두 해결하지는 않는다.

SPA overlay가 history에 없는 경우

Web modal이 열렸지만 history.pushState()를 하지 않았다면 canGoBack()은 modal을 모른다. browser history를 pop하면 modal 뒤의 page까지 이동할 수 있다.

뒤로가기 시점의 비동기 조회

canGoBack()은 WebView와 비동기 통신한다. 조회하는 동안 page navigation이 완료되거나 두 번째 back이 들어올 수 있다.

redirect와 인증 bootstrap

about:blank → session bootstrap → /orders가 history에 남으면 사용자가 root에서 back을 눌렀을 때 빈 화면이나 login redirect로 갈 수 있다.

Native modal

Flutter dialog가 떠 있는데 WebView부터 뒤로 보내면 사용자가 보고 있는 layer와 다른 곳이 움직인다.

Predictive Back

Android predictive back은 gesture가 시작되기 전에 route가 pop 가능한지 알아야 한다. back event를 받은 뒤 비동기 canGoBack()으로 취소 여부를 결정하는 방식과 맞지 않는다.

따라서 canGoBack()은 유용한 신호지만 전체 navigation policy는 아니다.

뒤로가기 우선순위를 먼저 정하기

제품의 interaction layer 순서에 맞춰 표를 만든다.

우선순위 현재 상태 Back 동작 다음 상태
1 Native dialog·sheet 열림 해당 overlay 닫기 Hybrid 화면 유지
2 Web blocking modal 열림 Web modal 닫기 Web route 유지
3 Web history 존재 Web router back 이전 Web route
4 Hybrid Native route pop 가능 Flutter route pop 이전 Native 화면
5 앱 root OS 기본 정책 background 또는 종료

예외도 명시한다.

이 우선순위는 Web과 Native 각각의 handler에 복사하지 않는다. Native coordinator가 최종 순서를 소유하고 Web은 현재 상태와 “내 layer를 한 단계 뒤로 처리한 결과”를 제공한다.

flowchart TD
    A[Back attempt] --> B{Native overlay?}
    B -- Yes --> C[Close Native overlay]
    B -- No --> D{Web overlay?}
    D -- Yes --> E[Request Web dismiss]
    D -- No --> F{Web can navigate back?}
    F -- Yes --> G[Request Web back]
    F -- No --> H{Native route can pop?}
    H -- Yes --> I[Pop Flutter route]
    H -- No --> J[Allow system root behavior]

Web이 Navigation State를 알려 주게 만들기

Native가 back 순간마다 JavaScript를 조회하기보다 Web router가 변할 때 navigation state event를 보낸다.

type WebNavigationState = {
  revision: number;
  routeKey: string;
  canGoBack: boolean;
  hasBlockingOverlay: boolean;
  hasUnsavedChanges: boolean;
};

function publishNavigationState(
  state: WebNavigationState,
): void {
  bridge.emit({
    protocolVersion: 1,
    kind: "event",
    type: "WEB_NAVIGATION_STATE_CHANGED",
    eventId: crypto.randomUUID(),
    sessionId: bridge.sessionId,
    payload: state,
  });
}

SPA router와 overlay store의 변경을 하나의 selector로 합친다.

router.subscribe(() => {
  publishNavigationState({
    revision: navigationRevision.next(),
    routeKey: router.currentRouteKey,
    canGoBack: router.canNavigateBack(),
    hasBlockingOverlay: overlayStore.hasDismissibleOverlay(),
    hasUnsavedChanges: formRegistry.hasUnsavedChanges(),
  });
});

routeKey에는 전체 URL query를 보내지 않는다. 사용자 검색어와 식별자가 Native log로 새어 나올 수 있다. /orders/:id/edit 같은 정규화된 route 이름을 사용한다.

Native는 현재 bridge session의 최신 revision만 반영한다.

void onWebNavigationState(WebNavigationState next) {
  if (next.sessionId != currentWebSessionId) {
    return;
  }
  if (next.revision <= state.webRevision) {
    return;
  }

  state = state.copyWith(
    webRevision: next.revision,
    webCanGoBack: next.canGoBack,
    webHasOverlay: next.hasBlockingOverlay,
    webHasUnsavedChanges: next.hasUnsavedChanges,
  );
  notifyListeners();
}

event는 놓칠 수 있으므로 page handshake 뒤 WEB_NAVIGATION_STATE_GET request로 현재 값을 다시 읽을 수 있게 한다.

Native에 Back Coordinator 만들기

coordinator는 현재 snapshot을 바탕으로 back 의도를 계산한다.

enum BackTarget {
  nativeOverlay,
  webOverlay,
  webHistory,
  nativeRoute,
  system,
  blocked,
}

final class HybridBackState {
  const HybridBackState({
    required this.hasNativeOverlay,
    required this.webHasOverlay,
    required this.webCanGoBack,
    required this.webHasUnsavedChanges,
    required this.nativeCanPop,
  });

  final bool hasNativeOverlay;
  final bool webHasOverlay;
  final bool webCanGoBack;
  final bool webHasUnsavedChanges;
  final bool nativeCanPop;
}

결정 함수는 I/O 없는 순수 함수로 둔다.

BackTarget resolveBackTarget(HybridBackState state) {
  if (state.hasNativeOverlay) {
    return BackTarget.nativeOverlay;
  }
  if (state.webHasOverlay) {
    return BackTarget.webOverlay;
  }
  if (state.webHasUnsavedChanges) {
    return BackTarget.blocked;
  }
  if (state.webCanGoBack) {
    return BackTarget.webHistory;
  }
  if (state.nativeCanPop) {
    return BackTarget.nativeRoute;
  }
  return BackTarget.system;
}

이 함수는 입력 조합을 표 기반 unit test로 검증할 수 있다. 실제 pop과 bridge 호출은 별도 executor가 맡는다.

PopScope에서 미리 Pop 가능성을 계산하기

Flutter의 PopScope는 Android predictive back에 맞춰 canPop을 미리 제공한다. Web이 먼저 처리할 상태라면 Flutter route의 직접 pop을 막는다.

class HybridScreen extends StatelessWidget {
  const HybridScreen({
    required this.coordinator,
    super.key,
  });

  final HybridBackCoordinator coordinator;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: coordinator,
      builder: (context, child) {
        final target = coordinator.currentTarget;
        final nativeMayPop =
            target == BackTarget.nativeRoute ||
            target == BackTarget.system;

        return PopScope<void>(
          canPop: nativeMayPop,
          onPopInvokedWithResult: (didPop, result) async {
            if (didPop) {
              return;
            }
            await coordinator.handleBlockedNativePop(context);
          },
          child: child!,
        );
      },
      child: const HybridWebView(),
    );
  }
}

onPopInvokedWithResultdidPop이 true면 이미 Native route가 pop된 것이므로 다시 Web back을 실행하지 않는다. false일 때만 현재 target을 확인한다.

canPop을 정하려면 최신 webCanGoBack이 동기 상태로 있어야 한다. 이 때문에 Web navigation state event를 미리 받는 구조가 필요하다.

최신 Flutter API 확인

back API는 predictive back 지원 과정에서 변경돼 왔다. 발행 시 사용하는 Flutter 버전의 PopScope와 callback signature를 공식 API 문서에서 다시 확인한다.

Web의 뒤로가기 응답을 명시적으로 받기

Native가 goBack()을 호출했다고 성공으로 가정하지 않는다. Web router에게 의도를 보내고 처리 결과와 새 state를 받는다.

{
  "protocolVersion": 1,
  "kind": "request",
  "type": "WEB_BACK_REQUEST",
  "requestId": "req-back-demo-4",
  "sessionId": "page-demo-7",
  "payload": {
    "expectedRevision": 18,
    "reason": "system-back"
  }
}

Web은 expected revision이 현재와 다르면 stale request로 거절한다.

async function handleBackRequest(
  request: WebBackRequest,
): Promise<WebBackResult> {
  const current = navigationStore.snapshot();

  if (request.expectedRevision !== current.revision) {
    return {
      handled: false,
      reason: "STALE_NAVIGATION_STATE",
      state: current,
    };
  }

  if (current.hasBlockingOverlay) {
    overlayStore.dismissTop();
    return handledWithCurrentState();
  }

  if (current.hasUnsavedChanges) {
    confirmLeaveStore.open();
    return handledWithCurrentState();
  }

  if (current.canGoBack) {
    router.back();
    return handledAfterRouterSettles();
  }

  return {
    handled: false,
    reason: "WEB_ROOT",
    state: current,
  };
}

Native는 WEB_ROOT일 때만 자기 route pop을 재평가한다. stale state라면 새 state를 저장하고 사용자의 다음 back을 기다리거나, 정책상 한 번만 다시 결정한다. 무한 재귀 호출은 피한다.

SPA Modal과 Browser History를 일치시키기

Web modal도 사용자가 back으로 닫기를 기대한다면 history에 상태를 반영하는 방법이 있다.

function openFilterModal(): void {
  history.pushState(
    { overlay: "filter" },
    "",
    location.href,
  );
  overlayStore.open("filter");
}

window.addEventListener("popstate", () => {
  if (overlayStore.isOpen("filter")) {
    overlayStore.close("filter");
    return;
  }
  router.synchronizeWithLocation();
});

다만 router가 이미 history를 관리한다면 직접 pushState를 섞지 말고 router의 modal route 기능을 사용한다. 동일한 back event를 overlay listener와 router가 둘 다 소비하지 않게 한다.

모든 ephemeral tooltip까지 history에 넣을 필요는 없다. back으로 닫혀야 하는 blocking layer만 navigation state에 포함한다.

앱이 deep link로 바로 /orders/42에 들어오면 Web history에는 이전 /orders가 없을 수 있다. 사용자는 back으로 앱 Home에 돌아가기를 기대할 수도 있고, 주문 목록으로 가기를 기대할 수도 있다.

두 전략을 구분한다.

실제 history만 존중

Web root이면 Native Hybrid route를 pop한다. deep link 이전의 앱 화면으로 돌아간다.

논리적 parent route 생성

Web router가 /orders/42의 parent를 /orders로 정의한다.

const routeParents: Record<string, string> = {
  "order-detail": "/orders",
  "order-edit": "/orders/:id",
};

이때 browser history가 아니라 app navigation graph의 정책임을 명시한다. 인증 실패 redirect나 bootstrap URL을 parent로 사용하지 않는다.

deep link를 push로 넣을지 replace로 넣을지도 중요하다. session bootstrap 페이지는 최종 업무 route로 replace해 back stack에 남지 않게 한다.

Native 화면을 Web에서 열었을 때의 복귀

Web 주문 화면에서 Native 이미지 preview를 열면 Native stack이 한 단계 추가된다.

sequenceDiagram
    participant Web
    participant Bridge
    participant Nav as Flutter Navigator

    Web->>Bridge: ATTACHMENT_PREVIEW_OPEN
    Bridge->>Nav: Native preview push
    Nav-->>Bridge: preview result
    Bridge-->>Web: request response

preview가 열린 동안 system back은 Native preview를 닫아야 한다. Hybrid coordinator는 아래 route이므로 back을 가로채지 않는다.

Native 화면 결과를 기다리는 bridge Promise는 Web page reload 시 유효하지 않을 수 있다. 결과에 bridge session ID를 포함하고, old session이면 폐기한다. 파일 삭제 같은 중요한 결과는 Web callback 하나에만 의존하지 않고 서버나 Native repository의 상태로 다시 조회 가능하게 만든다.

연속 입력과 비동기 경쟁 막기

사용자가 back을 빠르게 두 번 누르면 첫 번째 Web back이 완료되기 전에 두 번째 요청이 갈 수 있다.

Future<void> handleBlockedNativePop(
  BuildContext context,
) async {
  if (_backInFlight) {
    return;
  }

  _backInFlight = true;
  try {
    await _executeCurrentTarget(context);
  } finally {
    _backInFlight = false;
  }
}

단순 lock만으로 모든 문제가 끝나지는 않는다. Web response가 오기 전에 page가 이동하면 expected revision이 달라진다. request에 revision을 넣고 Web이 검증한다.

back 실행 timeout도 정한다.

final result = await bridge
    .requestWebBack(expectedRevision: state.webRevision)
    .timeout(const Duration(seconds: 2));

timeout이 났다고 즉시 Native route를 pop하면 Web에서 늦게 back이 실행돼 양쪽 stack이 모두 움직일 수 있다. timeout은 “처리되지 않았다”가 아니라 “결과를 모른다”다. 안전하게 현재 navigation state를 다시 handshake하거나 오류 상태를 보여 준다.

Page Reload와 Process 복구 처리하기

page reload 시 이전 Web navigation state를 그대로 사용하면 안 된다.

void onBridgeSessionStarted(String sessionId) {
  state = state.copyWith(
    webSessionId: sessionId,
    webRevision: 0,
    webCanGoBack: false,
    webHasOverlay: false,
    webHasUnsavedChanges: false,
    webStateReady: false,
  );
}

새 Web이 handshake와 current state를 보내기 전에는 Native route pop을 허용할지 정책을 정한다. 초기 loading 중 back으로 화면을 닫을 수 있게 할 수도 있고, session bootstrap transaction 중 잠깐 막을 수도 있다. 영구 block이 되지 않도록 initialization timeout과 error UI가 필요하다.

앱 process 복구 후 Web history를 완전히 복원하기 어렵다면 저장된 business route만 새 session의 initial route로 전달한다. browser history 객체 전체를 serialize하려고 하지 않는다.

Android Predictive Back과 iOS Gesture 확인하기

Android predictive back은 gesture를 시작할 때 destination preview를 보여 준다. Flutter 공식 migration 문서는 back event 뒤 비동기로 취소하는 WillPopScope 방식보다 PopScope.canPop처럼 미리 계산된 상태를 사용하도록 안내한다.

Web history를 Native route보다 먼저 처리하면 canPop: false로 Native route pop을 막고 Web back을 실행할 수 있다. 그러나 system이 Web 내부 destination을 Native route animation처럼 자동 preview해 주는 것은 아니다. 제품에서 predictive animation 품질이 중요하면 WebView 통합 방식과 대상 Android API에서 별도 검증이 필요하다.

iOS의 edge swipe는 Flutter의 Cupertino route transition과 WebView 자체 gesture가 충돌할 수 있다. 다음을 실제 기기에서 확인한다.

Android와 iOS가 gesture 인식 방식이 같다고 가정하지 않는다. 화면 상단의 explicit back button도 같은 coordinator를 호출하게 만들어 정책은 공유하되, 플랫폼 gesture behavior는 별도로 시험한다.

Native overlay Web overlay Web history Native pop 기대 target
O O O O Native overlay
X O O O Web overlay
X X O O Web history
X X X O Native route
X X X X system

추가 시나리오:

순수 resolveBackTarget은 모든 조합을 unit test하고 실제 WebView integration test에서는 state event와 route 변화를 검증한다.

test('Web overlay가 history보다 먼저 처리된다', () {
  final target = resolveBackTarget(
    const HybridBackState(
      hasNativeOverlay: false,
      webHasOverlay: true,
      webCanGoBack: true,
      webHasUnsavedChanges: false,
      nativeCanPop: true,
    ),
  );

  expect(target, BackTarget.webOverlay);
});

로그로 두 Stack을 함께 추적하기

navigation bug는 사용자가 “뒤로 갔더니 앱이 꺼졌다”고만 보고하기 쉽다. 개인정보 없는 route key와 revision을 남긴다.

back_attempt target=web_history web_route=order-edit web_rev=18
web_back_result handled=true next_route=order-detail web_rev=19
back_attempt target=native_route web_route=orders-root web_rev=20
native_pop from=hybrid-orders to=home

전체 URL, query, 주문 ID, 사용자 입력은 로그에 남기지 않는다. Native route도 object의 debug string보다 안정적인 route key를 사용한다.

back attempt ID를 Web bridge request ID와 연결하면 중복 처리와 timeout을 찾을 수 있다.

구현 체크리스트

정책

상태 동기화

동시성과 Lifecycle

플랫폼 검증

마무리

Hybrid 화면의 뒤로가기는 WebView.canGoBack()Navigator.maybePop() 중 하나를 고르는 조건문이 아니다. 여러 overlay와 두 개의 history가 같은 사용자 입력을 어떤 순서로 소비할지 정하는 navigation 정책이다.

Web router는 현재 route, dismiss 가능한 overlay, dirty form, Web back 가능 여부를 version 있는 state로 알린다. Native coordinator는 그 최신 상태와 Native stack을 함께 보고 target을 결정한다. Web back 요청에는 expected revision을 넣어 오래된 결정을 거절하고, page reload와 연속 입력에서는 session과 in-flight 상태를 정리한다.

Android predictive back을 지원하려면 gesture 순간에 비동기로 물어보기보다 PopScope.canPop에 미리 계산된 상태를 제공해야 한다. iOS gesture는 별도의 충돌 조건을 실제 기기에서 확인한다.

결국 안정적인 back 동작은 stack을 하나로 합치는 데서 나오지 않는다. 서로 다른 stack의 소유권을 인정하고, 그 사이의 우선순위와 상태 전달을 하나의 coordinator가 책임질 때 만들어진다.

관련 노트

참고 자료