94C73F07-2622-45A5-A821-29F7FEE5B9D2.png

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

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. history 쿼리와 함께 열리는 훅

이 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라는 boolean을 이용합니다. 그러나 우리 코드에서는 이 값이 파편화되어 여러 곳에서 사용되고 있었습니다.

특히 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 훅
      2. 페이지 level block 훅
      3. 같은 priority 내에서는 나중에 등록된 핸들러가 먼저 실행됩니다.
  • 페이지 활성화 인식isActive=false(다른 페이지로 이동) 시 핸들러가 자동 해제되고, 복귀 시 재등록됩니다.
  • Promise 직렬화activate()/deactivate() 비동기 호출의 레이스 컨디션을 pendingOp 체인으로 방지했습니다.
  • 브릿지를 통한 직렬화 — LIFO에 close 함수가 쌓이면 브릿지를 통해 백버튼을 deactivate()합니다. 그리고 close 함수가 모두 소비되면 activate()를 통해 다시 기본 동작으로 돌아가게 했습니다.

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

사용하는 컴포넌트에서 해당 훅을 선언하고 isOpenclose() 함수만 넘겨주면 됩니다.

심지어 사용하는 측의 변경사항도 없습니다.

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

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

작업을 하면서 여러 버그를 수정하게 되었습니다.

수정한 버그 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개의 버그가 모두 이 지점에서 발생했습니다.

사례를 통한 설계

리포 분석, 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%”라는 숫자의 Value

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

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

특히 0.02%의 사용자가 의도하지 않은 동작을 겪는 것이 아니라, 현재 구현 동작과 동일하게 동작한다는 점에서 99.8%의 개선이라고 생각했습니다.

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

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

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

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

앞으로 해당 코드는 부동산팀이 서비스를 종료하는 시점까지 함께 가지 않을까 생각합니다. 비록 인턴이었지만, 스스로 문제를 정의하고 해결할 수 있는 시간을 주신 팀원분들께 매우 감사합니다.