웹뷰에서 안드로이드 백버튼으로 오버레이(바텀시트, 메뉴시트, 다이얼로그)를 닫을 수 있도록 대응한 전체 과정이에요.

3가지 접근 방식을 비교·구현·폐기하며 팀원들과 함께 최적 아키텍처를 찾아간 기록이에요.


1. 문제 인식

안드로이드 웹뷰에서 백버튼을 누르면 브라우저 히스토리를 pop하는 것이 기본 동작이에요. 바텀시트나 메뉴시트가 열려 있는 상태에서 백버튼을 누르면 시트가 닫히지 않고 이전 페이지로 이동하는 문제가 있었어요.

팀 내에서도 이 문제가 반복적으로 제기되었고, 여러 팀이 각자의 방식으로 해결하고 있어 표준이 부재한 상태였어요.


2. 전사 현황 분석

가장 먼저 SPA 프레임워크를 사용하는 여러 클라이언트 리포의 백버튼 대응 방식을 전수 분석했어요.

핵심은 아래와 같았어요

  1. 컴포넌트 자체에는 백버튼 처리 없음 — 상위 레이어가 해키하게 담당
  2. URL step = 모달 상태 패턴이 사실상 표준stepPush → 열기, stepPop → 닫기
  3. 컴포넌트 자체를 페이지화 - 사내 디자인 시스템 표준
  4. 내부 SPA 프레임워크 백버튼 플러그인 실사용률 낮음 — 실제 활성 사용은 1개 리포가 유일해요 , 실제로 레거시 패턴으로 백버튼 대응을 해결해주지 못했어요
  5. 외부 오버레이 라이브러리 혼용 시 주의 — 외부 라이브러리로 연 모달은 URL step과 무관하여 백버튼 대응이 없어요

3. 접근 방식 검토

방식을 검토하던중 고민했던 가장 큰 요소는 아래와 같아요.

점진적으로 마이그레이션 하지 않을 것

디자이너 팀원들과도 많이 이야기 나눈 내용이었어요. 일부만 개선이 된다면 사용자 입장에서는 혼동이 올 수 있고 일관성이 부족하다는 내용이에요.

한번에 마이그레이션을 하는데 필요한 걸림돌이 하나 더 있었어요!

현재 일부는 백버튼을 지원한다는 내용이에요

오버레이를 포함하는 컴포넌트들의 open 이 전부 제각각 이었어요.

  1. useDiscourse
  2. useState
  3. use-overlay (선언적)
  4. hisotry 쿼리와 함께 열리는 훅

4개를 모두 대응시키면서 한번에 적용하는게 목표였어요!

특히 가장큰 문제가 팀원들의 니즈였던것 같아요

하나로 통일하고 싶었지만 , 선언적으로 오버레이 요소를 열고 싶은 팀원과 controlled 패턴 즉 state를 오버레이 요소와 공유하고 싶은 팀원등등 모두의 니즈를 만족했어야 했어요

가장큰 중요점은 빠른개발이었어요

function Example() {
  const [value, setValue] = useState("기본값");
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <div>{value}</div>

      <button onClick={() => setIsOpen(true)}>바텀시트 열기</button>

      {isOpen && (
        <BottomSheet onClose={() => setIsOpen(false)}>
          <div>{value}</div>

          <button onClick={() => setValue("변경된 값")}>
 변경
          </button>

          <button onClick={() => setIsOpen(false)}>
            닫기
          </button>
        </BottomSheet>
      )}
    </div>
  );

페이지 (Activity) 전환 → 채택하지 않았어요

시트를 열 때 새로운 페이지를 push하는 방식이에요. 해당 변경은 가장 큰 사이드 이펙트를 발생시키기 좋았어요 현재 페이지가 활성화 상태인지 확인하기 위한 훅이 있어요 해당 훅에 대한 값은 isActive라는 bollean을 이용해요. 허나 우리 코드에서는 파편화 되어서 해당 값을 많이 사용하곤 했어요. 특히 isActive 변경으로 인해 백스와이프 재설정, 타이틀 변경, 앱바의 로직변경 등 다수의 사이드 이펙트가 발생했어요.

Step 기반 + use-overlay로 통일 → 0 번째 구현

use-overlay 기반의 훅으로 모두 선언적으로 열도록 하고 isOpen에 따라 히스토리를 useEffect 레벨에서 추가해주는 방식이에요! 공통훅을 사용하기 때문에 마이그레이션 난이도가 낮아질것이라 판단했었어요. 팀의 니즈중 하나였던 선언적 open이란 장점이 있어서 시도했었어요 이전에 말한 팀원의 니즈로 인해 실패했어요 특히 이미 짜여진 코드에서 controlled 패턴이 많았는데 이것을 모두 async화 해서 처리하게 패턴을 수정하기는 쉽지 않았어요

Step 기반 → 첫 번째 구현

SPA 프레임워크의 stepPush/stepPop으로 동일 Activity 내에서 URL 쿼리파람만 변경하는 방식이에요. isActive에 영향 없고, 기존 히스토리 이용 패턴을 재사용할 수 있어 먼저 구현했어요.

네이티브 브릿지 가로채기 → 최종 구현

네이티브 앱 브릿지를 통해 백버튼 이벤트 자체를 가로채는 방식이에요. 히스토리를 건드리지 않아 URL 오염이 없고, AlertDialog까지 동일 패턴으로 적용 가능했어요.

세 가지 방식을 비교 분석했어요.

항목페이지로 전환Step (쿼리스트링) 기반네이티브 브릿지 가로채기
브라우저 히스토리 조작❌ Activity push⚠️ step push (쿼리파람)✅ 없음
isActive 변경❌ 발생✅ 없음✅ 없음
URL 변화❌ 페이지 변경⚠️ 쿼리파람 추가✅ 없음
iOS 지원❌ Android 전용
AlertDialog 적용⚠️ 의미론적 문제✅ 동일 패턴
중첩 오버레이 순서⚠️ key 분리 필요✅ priority + LIFO

4. 첫 번째 구현: Step 기반

useSheetBack 훅 설계

useDisclosure 기반 시트에 백버튼 처리만 추가한 thin wrapper를 만들었어요.

시트 열림 → stepPush({ _sheet_N: 'open', modal: 'true' })
백버튼    ← stepPop 자동 → params._sheet_N 소거 → onClose()
시트 닫힘  → stepPop 명시 호출 → 히스토리 엔트리 제거

발견한 버그

  1. ghost step 버그 : overlay 요소를 close하지 않고 페이지 이동하는경우 복귀시 ghost step이 남게 되서 히스토리 상에는 오버레이 요소의 정보가 남아있지만 컴포넌트는 재 오픈되지 않는 버그에요
  2. 시트 닫기 + 페이지 이동시 : ghost step이 남아있는버그
  • useSheetBack 훅이 useEffect 레벨에서 false 트리거 이후 popStep 을 진행하기 때문에 push 가 먼제 일어나게 되고 popStep은 stale한 메소드가 되며 호출 시점은 페이지 이동 후가 된다

    setIsOpen(false)
    push("A-page")
    

버그 수정

  • 모듈 레벨 전역 변수 → nanoid(6) — 인스턴스별 독립 key로 교체했어요 - 히스토리가 replace가 아닌 쌓이도록
  • flushSync 레이스 컨디션 해결 — 시트 닫기 + 페이지 이동 시 히스토리 순서가 뒤바뀌는 문제를 flushSync로 Commit Phase를 강제 실행하여 해결했어요

명확했던 단점

히스토리는 엄밀히 말하면 비동기로 동작합니다. 자바스크립트 코드에서 동기적으로 작성하더라도 브라우저에 일임되는 코드기 때문에 호출시점을 정확하게 제어하기 쉽지 않으며 모든 패턴을 flushSync로 맞추는 패턴은 추후에도 유지보수하는 관점에서 매우 힘든 작업이라고 생각이 들었습니다.

따라서 팀원 대부분이 히스토리에 대한 부정적인 생각이 들었고 저는 이에따라 다른 방안도 고안해야만 했습니다.


5. 최종 구현: 네이티브 브릿지 가로채기

Step 기반의 구조적 한계(URL 오염, stale params)를 해결하기 위해 싱글톤 매니저 방식으로 전환했어요.

아키텍처

BottomSheet / AlertDialogRoot / MenuSheetRoot

    useOverlayBackGuard (OVERLAY_PRIORITY=1)

    useBlockAndroidBack (PAGE_BLOCK_PRIORITY=0)

    backButtonManager (싱글톤)

    네이티브 브릿지 (단일 구독)

사내시스템에는 브릿지가 있어요.

  1. 안드로이드 기본 백버튼을 차단
  2. 안드로이드 기본 백버튼을 싱크

이 브릿지를 적절히 활용한다면 우리가 수동으로 백버튼을 제어할 수 있을것이라 판단했어요

우리팀의 코드에는 이미 백버튼을 제어하는 부분이 존재했어요 따라서 해당 구현도 참고하고 백버튼 관리하는 파편화된 브릿지 설정도 통합이 가능할것이라고 판단했어요

기존의 코드는 두가지였어요

  1. 물리적 백버튼 차단 훅
  2. 페이지 이탈 방지 훅 (물리적 백버튼 차단 훅을 이용)

따라서 해당 훅들을 사용하는 부분에서 사이드 이펙트가 없도록 구성했어요

핵심 설계 결정

  • 우선순위 기반 LIFO — 오버레이 요소와 페이지레벨에서의 블록의 우선순위를 결정해요!
    1. 우선순위가 겹치는경우 페이지 블록이 되고 오버레이는 안닫히는등 버그가 발생 가능해요
    2. 오버레이(priority 1)가 페이지 블록(priority 0)보다 먼저 소비되도록 훅을 분리해요
      1. (오버레이용 block훅 , 페이지 level block 훅)
      2. 같은 priority 내에서는 나중에 등록된 핸들러가 먼저 실행돼요
  • 페이지 활성화 인식isActive=false(다른 페이지로 이동) 시 핸들러가 자동 해제되고, 복귀 시 재등록돼요
  • promise 직렬화activate()/deactivate() 비동기 호출의 레이스 컨디션을 pendingOp 체인으로 방지해요
  • 브릿지 통한 직렬화 - LIFO에 close 함수가 쌓이면 브릿지를 통해서 백버튼을 deactivate()해요 그리고 close함수가 모두 소비되면 activate() 를 통해서 다시 기본동작으로 돌아가게 되요!

컴포넌트 또는 개발에서의 변경사항

사용하는 컴포넌트에서 해당 훅을 선언하고 isOpen과 close()함수만 넘겨주면 끝이에요!

심지어 사용하는 측에서 변경사항도 없어요

close()함수는 stale 해지지 않도록 항상 ref로 관리되며 전역 인스턴스에 등록되는 방식이에요

가장 중요했던 것은 리액트 상태를 js로 관리한다는것이었어요 따라서 비동기로 동작하는 리액트 흐름을 전역 싱글톤에서 구현해 내는것이 주요과제였어요

작업을 하면서 여러 버그를 수정하게 되었어요

수정한 버그 8개

#버그원인해결
1연속 백버튼 중복 실행핸들러 실행 중 다음 이벤트 수신실행 중 플래그로 중복 방지했어요
2activate() 영구 고착Promise reject 시 체인 끊김catch로 항상 pendingOp을 갱신했어요
3비활성 activity 핸들러 누출isActive=false 전환 시 해제 누락useEffect deps에 isActive를 추가했어요
4포그라운드 복귀 시 블로커 미재등록앱 백그라운드 → 포그라운드 시 상태 초기화visibility change 이벤트 감지 후 재등록했어요
5defaultOpen=true 백버튼 미등록초기 렌더 시 isOpen=true인데 핸들러 미등록mount 시점 isOpen 체크를 추가했어요
6promise chain 중간 끊김연쇄 activate/deactivate 중 중간 rejectpendingOp 체인을 항상 이어가도록 했어요
7deactivate 시 불필요한 feature 재체크deactivate 후에도 브릿지 기능 확인 호출deactivate 경로에서 feature 체크를 스킵했어요
8useActivity() null 크래시SPA 프레임워크 컨텍스트 밖에서 호출optional chaining + isActiveOverride 파라미터를 적용했어요

버전 호환성 검증

현재 브릿지가 지원되는 앱버전은 정해져 있었어요

BigQuery로 Android 앱 버전 분포를 조회한 결과, 네이티브 브릿지 API를 지원하지 않는 구버전 사용자는 **0.02% (4,570명)**에 불과하여 버전 가드 없이 사용해도 무방하다는 결론을 내렸어요.

테스트 — 11개 시나리오

기본 BottomSheet, AlertDialog, MenuSheet 닫기부터 다중 스텝, 중첩 오버레이 LIFO, 페이지 Push 중 오버레이, 외부 scheme URL, GlobalDialog(root-level) 등 포괄적인 시나리오를 검증했어요.


6. 오버레이 라이브러리 마이그레이션

백버튼 대응과 병행하여 기존 오버레이 라이브러리에서 새로운 라이브러리로의 마이그레이션을 진행했어요.

이벤트 버스 충돌 해결

새 라이브러리는 모듈 전역 이벤트 버스(mitt) 기반이라, SPA 프레임워크가 activity 2개를 DOM에 유지하면 overlay.open() 1회 호출에 오버레이가 2개 열리는 버그가 발생했어요. activity별 독립 overlay context를 생성하여 해결했어요.

  • iOS 키보드가 있으면 100ms 딜레이를 줘요

7. 인사이트

”히스토리를 건드리지 마라”

Step 기반에서 네이티브 브릿지 기반으로 전환한 가장 큰 이유는 브라우저 히스토리 조작의 부작용이었어요. URL 쿼리파람을 오버레이 상태로 사용하면 stale params, ghost step, 충돌 등 예측하기 어려운 버그가 발생해요.

특히 디버깅관점에서 중요했던것 같아요 . 동기적으로 작성된 코드를 보고 비동기로 동작할것이라고 생각하지는 못하니까요!

결국 **“히스토리는 페이지 네비게이션만 담당하고, 오버레이는 in-memory로 관리한다”**는 원칙이 가장 안정적이었어요.

특히 안드로이드 대응을 안드로이드로만 대응한다는점에서 복잡한 문제를 간단하게 풀어 나가는 케이스였다고 생각해요.

🏗️ 싱글톤 패턴의 힘과 한계

싱글톤 매니저는 우선순위 기반 LIFO로 중첩 오버레이, AlertDialog, 페이지 블록까지 하나의 패턴으로 통합할 수 있게 해줬어요. 하지만 페이지 생명주기와의 동기화가 가장 어려운 문제였어요. isActive 변화에 따른 핸들러 등록/해제, promise 직렬화, 포그라운드 복귀 시 재등록 등 8개의 버그가 모두 이 지점에서 발생했어요.

교훈: 싱글톤은 일관된 인터페이스를 제공하지만, 멀티 컨텍스트(동시 activity 유지) 환경에서는 생명주기 관리 비용이 커요.

사례를 통한 설계

리포 분석, 100건+ 오버레이 감사, 56개 바텀시트 전수조사 — 이 세 번의 전수조사가 없었다면 설계 결정에 확신을 가질 수 없었을 거예요.

  • 리포 분석 → “Step 기반이 사실상 표준”이라는 현황 파악 → 네이티브 브릿지 방식의 차별점 명확화
  • 100건+ 오버레이 감사 → 마이그레이션 범위와 리스크를 정량화 → “변환 실패 2건”이라는 수용 가능한 수준 확인
  • 56개 바텀시트 조사onClose() 누락 버그 2건 발견 → 네비게이션 규칙 정립

전수조사는 시간이 오래 걸리지만, “모르는 게 없다”는 상태에서 내리는 결정은 흔들리지 않아요.

🧩 레이어드 추상화의 중요성

최종 구조는 명확한 레이어를 가져요:

  1. Bridge 레이어 — 네이티브 브릿지 API (플랫폼)
  2. Manager 레이어 — 싱글톤 매니저 (우선순위 + 생명주기)
  3. Hook 레이어useOverlayBackGuard, useBlockAndroidBack (용도별 인터페이스)
  4. Component 레이어 — BottomSheet, AlertDialog, MenuSheet (한 줄 통합)

각 레이어가 하나의 책임만 지기 때문에, 새로운 오버레이 타입을 추가할 때 Component 레이어에서 useOverlayBackGuard 한 줄만 추가하면 돼요.

⚡ “0.02%“라는 숫자의 가치

BigQuery로 앱 버전 분포를 조회하여 하위 호환성 비용을 정량화한 것이 핵심이었어요. “구버전 사용자가 있을 수 있다”는 막연한 우려를 “0.02% (4,570명)“이라는 구체적 숫자로 바꾸자, 버전 가드 없이 배포하겠다는 결정이 자연스럽게 도출됐어요.

데이터 기반 의사결정은 불필요한 복잡성을 제거해요.

특히 0.02 퍼센트가 의도하지 않은 동작이 아닌 현재 구현동작과 동일하게 동작한다는 점에서 99.8프로의 개선이라고 생각해요

📐 결론: 좋은 시스템은 사용자가 실수하기 어렵게 만들어요

이 전체 과정의 궁극적인 목표는 **“개발자가 백버튼을 의식하지 않아도 되는 시스템”**을 만드는 것이었어요. useOverlayBackGuard 한 줄이면 모든 오버레이가 백버튼에 대응하고, 우선순위 기반 LIFO가 중첩 순서를 자동으로 관리하며, activity 생명주기에 맞춰 핸들러가 자동으로 등록/해제돼요.

사용하는 쪽의 인터페이스가 단순할수록, 시스템 내부의 복잡성을 잘 감춘 거예요.

해당 내용을 스킬로 만들면서 ai가 올바르게 참고하는 모습을 보면 올바른 레이어 설계였다고 생각이 들어요.

앞으로 해당코드는 부동산팀이 서비스 종료하는 시점까지 같이 가지 않을까 싶어요 비록 인턴이었지만 스스로 문제를 정의하고 해결할 수 있는 시간을 주어주신 팀원분들께 너무 감사해요!