grep

Engineering

항공 프론트엔드 구축기 (5/10): 뒤로가기가 가장 어려웠다

에릭Eric_jeong(정용욱)여기어때

2026년 8월 7일

원문에서 보기 ↗

글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: 뒤로가기가 가장 어려웠다

안녕하세요. 서비스웹개발팀의 에릭입니다.

이번 편은 이 구성에서 가장 어려웠던 문제, 뒤로가기입니다. 웹의 브라우저 히스토리와 안드로이드 하드웨어 백키는 원리가 아예 다릅니다. 그 둘을 코드 하나가 같이 만족해야 했습니다.

백키를 누르면 무엇이 닫혀야 할까요

예약 취소 화면입니다. 취소 사유를 고르는 바텀시트를 열고, 사유를 고르면 “정말 취소하시겠습니까?” 다이얼로그가 그 위에 뜹니다.

이 상태에서 안드로이드 백키를 누릅니다. 무엇이 닫혀야 할까요.

세 가지 답이 다 말이 됩니다. 다이얼로그만 닫히고 시트는 남거나, 둘 다 닫히거나, 아무것도 닫히지 않거나. 어느 쪽이 맞는지는 위에 떠 있는 게 무엇이냐에 달렸습니다. 확인 다이얼로그라면 그것만 닫혀야 하고, 로딩이라면 아무것도 닫히면 안 됩니다.

설계 단계에서 제가 문서에 적어 둔 안은 이랬습니다.

// 설계 문서 원안
const back = () => {
  if (isWebview) {
    // 앱에게 뒤로가기를 넘긴다
  } else {
    router.back();
  }
};

환경만 분기하면 될 거라고 봤습니다. 그런데 이 코드에는 방금 질문의 답이 들어갈 자리가 아예 없습니다.

웹과 웹뷰는 원리가 다릅니다

웹의 뒤로가기는 브라우저 히스토리입니다. 엔트리 하나를 빼는 동작이고, 그러니 우리가 할 일은 모달이 열릴 때 엔트리를 하나 만들어 두는 것입니다.

웹뷰는 다릅니다. 안드로이드 하드웨어 백키가 있고, 그건 브라우저 히스토리와 아무 관계가 없습니다.

여기서 이 회사 앱의 특징이 하나 나옵니다. 백키 제어권을 웹에 통째로 넘깁니다.

화면이 뜰 때 브릿지로 한 줄 켜 둡니다.

// 이 화면의 백키는 앱이 아니라 웹이 처리한다
appBridge.enableWebBackHandling();

그 뒤로 하드웨어 백키는 네이티브가 처리하지 않고, 웹이 준비해 둔 전역 함수를 부릅니다.

그래서 웹뷰에서는 브라우저 히스토리를 아예 쓰지 않습니다. 웹뷰에서 히스토리를 흉내 내는 코드를 종종 보는데, 제어권을 받은 이상 흉내 낼 이유가 없습니다. “지금 화면에서 가장 나중에 열린 것”만 알면 됩니다.

라우터는 모달이 몇 겹인지 모릅니다

원안이 안 되는 이유는 환경 분기가 틀려서가 아닙니다. 분기 자체는 맞습니다.

문제는 router.back()이 하는 일입니다. 페이지를 나가버립니다. 다이얼로그만 닫혀야 하는데 화면 전체가 뒤로 갑니다.

모달 스택은 React 트리 안에 있습니다. 무엇이 몇 번째로 열렸는지는 컴포넌트들이 알고, 라우터는 그걸 볼 수 없습니다. 라우터에 물어봐서는 답이 나오지 않는 질문이었습니다.

그래서 추상화 지점을 옮겼습니다. 라우터가 아니라 모달 자신이 뒤로가기를 처리합니다.

하나로 합치지 않은 것이 답이었습니다

여기까지는 사실 새로운 이야기가 아닙니다. 웹만 만든다면 히스토리로 풀면 되고, 웹뷰만 만든다면 트리거 하나면 됩니다. 각자 풀면 어느 쪽도 어렵지 않습니다.

어려워진 건 한 컴포넌트가 두 규약을 동시에 만족해야 했기 때문입니다. 바텀시트 코드는 하나인데, 그 하나가 브라우저 뒤로가기에도 맞고 하드웨어 백키에도 맞아야 합니다.

두 환경의 방식을 하나로 통일하려고 했다면 둘 중 하나를 흉내 내야 했을 겁니다. 그렇게 하지 않았습니다.

원리는 각자 두고, 같은 콜백에 도달하게 했습니다.

// 바텀시트 안. 한 컴포넌트가 둘 다 렌더한다
<ModalOverlay
  isShow={isShow}
  onHistoryBack={handleHistoryBack}   // 웹: 히스토리 매니저에 등록
  onClickDimmed={handleClickDimmed}
>
  <NativeBackTrigger onNativeBack={handleHistoryBack} />   {/* 웹뷰: DOM에 등록 */}
  {/* ... */}
</ModalOverlay>
const handleHistoryBack = useCallback(() => {
  onHide?.(true);
}, [onHide]);

두 줄이 서로를 모릅니다. 웹에서는 아래 트리거가 렌더만 되고 아무 일도 하지 않고, 웹뷰에서는 히스토리 쪽 코드가 통째로 실행되지 않습니다. 만나는 지점은 handleHistoryBack 하나뿐입니다.

뒤로가기 한 번이 웹과 웹뷰에서 서로 다른 경로를 지나 같은 콜백에 도달하는 흐름도

왼쪽은 브라우저 히스토리를, 오른쪽은 DOM에 렌더된 순서를 읽는다. 두 갈래에 겹치는 코드가 없고, 파란 선이 만나는 지점 아래로만 공통 경로가 이어진다.

이 그림의 왼쪽과 오른쪽을 차례로 보겠습니다.

웹뷰: 화면에 보이지 않는 버튼

웹뷰 쪽부터 보겠습니다. 훨씬 단순합니다.

let createdOrder = 0;
const NativeBackTrigger = ({ onNativeBack }) => {
  const [creationOrder, setCreationOrder] = useState(null);

  useEffect(() => {
    setCreationOrder(createdOrder++);
  }, []);

  return (
    <button
      data-component="NativeBackTrigger"
      className="yf-hidden"
      data-order={creationOrder}
      onClick={() => onNativeBack?.(true)}
    />
  );
};

보이지 않는 버튼입니다. 마운트될 때 자기 순번을 data-order에 새겨 둡니다.

앱이 부르는 전역 함수는 그 버튼들 중 하나를 고릅니다.

window.handleBackKey = () => {
  const buttons = document.querySelectorAll('[data-component="NativeBackTrigger"]');
  // data-order가 가장 큰 것 = 가장 나중에 마운트된 것 = 화면 최상단
  lastOrderButton?.click();
};

React 상태로 스택을 관리하지 않고 DOM을 뒤지는 게 이상해 보일 수 있습니다. 앱의 호출 진입점이 React 트리 바깥이기 때문입니다. 네이티브는 전역 함수 하나만 부를 수 있고, 그 함수는 어느 컴포넌트에도 속해 있지 않습니다. 전역 함수가 React 상태를 구독하게 만드는 것보다, DOM에 렌더된 사실을 그대로 읽는 쪽이 단순하고 덜 깨진다고 판단했습니다.

덤으로 얻은 게 둘 있습니다.

페이지와 모달이 같은 줄에 섭니다. 페이지 컨테이너도 트리거를 렌더하고 바텀시트도 렌더하니, 모달이 떠 있으면 모달이 이기고 없으면 페이지가 받습니다. 둘을 조율하는 코드가 따로 필요 없습니다.

중첩도 자동입니다. 마운트 순서가 곧 스택이라서, 시트 위에 다이얼로그를 띄우면 다이얼로그의 순번이 더 큽니다. 맨 앞의 질문에 답이 생깁니다.

웹: 모달마다 리스너를 달았더니

웹 쪽은 세 번 고쳐 썼습니다.

첫 번째가 앞에서 본 라우터 안이고, 두 번째는 이렇게 만들었습니다. 모달이 열릴 때 URL에 #modal 해시를 하나 밀어 넣고, 자기 popstate 리스너를 붙이고, 닫힐 때 history.back()으로 자기 해시를 걷어낸다. 모달이 스스로 히스토리에 등록하는 방식이라 앞의 문제는 풀립니다.

한동안 잘 돌았습니다. 그러다 이상한 걸 봤습니다. 모달을 닫은 적이 없는데 다음 페이지에서 뒤로가기가 한 번 먹히지 않는 현상이었습니다.

원인은 해시가 하나라는 데 있었습니다. #modal은 URL에 하나뿐인데, 그걸 밀어 넣고 걷어내는 주체는 여럿이었습니다. 각 모달이 있고, "모달을 닫고 다른 페이지로 이동" 같은 동작을 하는 훅이 따로 있었습니다. 서로가 서로의 상태를 모릅니다.

그래서 뒤로가기를 흡수하는 모달(잠시 뒤에 나옵니다)이 열린 채로 화면을 이동하면, 걷히지 못한 #modal 하나가 다음 페이지까지 따라갔습니다. 코드 주석에 그때 상황이 남아 있습니다.

두 주체가 activeModalCount/타이머를 공유하지 않아, 방어형(재푸시) 모달이 열린 채 네비게이션하면 유령 #modal 엔트리가 다음 페이지로 따라가는 desync가 있었다.

세 번째 판이 지금 코드입니다. 방식을 바꾼 게 아니라 소유권을 바꿨습니다. #modal 해시와 popstate 리스너를 모듈 싱글톤 하나가 단독으로 소유하고, 모달들은 자기를 등록만 합니다.

registerModal(entry)         // 열림. 첫 모달일 때만 #modal을 푸시한다
unregisterModal(id)          // 정리. 엔트리만 빼고 back은 하지 않는다
requestBackOnClose(pathname) // 닫힘. 마지막 모달이면 back을 예약한다
closeAllThenRun(callback)    // 모달을 정리한 뒤 이동한다

리스너도 하나입니다. 모달 열 개가 떠 있어도 popstate를 듣는 곳은 한 군데입니다.

한 곳에서 관리하니 그전에는 손댈 수 없던 것들이 처리 가능해졌습니다.

닫히자마자 다시 열리는 경우 가 그렇습니다. 시트를 닫으면서 바로 다른 시트를 여는 화면이 있는데, 예전에는 앞 모달이 자기 해시를 걷어내고 뒤 모달이 새로 밀어 넣었습니다. 지금은 걷어내는 일이 예약으로 걸리고, 그사이에 새 모달이 등록되면 예약을 취소하고 있던 해시를 그대로 물려받습니다. 히스토리에는 아무 변화가 없습니다.

엔트리가 어느 페이지 것인지도 매니저가 기억합니다. 화면을 옮겨도 닫히지 않는 전역 로딩 같은 게 있는데, 그 엔트리가 남아 있으면 다음 페이지의 뒤로가기를 엉뚱하게 가로챕니다. 그래서 등록할 때 열린 경로를 같이 적어 두고, 다른 경로에서 온 뒤로가기는 무시합니다.

돌아보면 두 번째 방식이 틀렸다기보다, 조율해야 할 상태를 URL 해시 하나에 맡긴 것이 문제였습니다. 해시는 있거나 없거나 둘 중 하나라, 몇 개가 열려 있고 어디서 열렸는지를 담을 자리가 없었습니다.

모달이 없으면 누가 받나

여기까지가 무언가 떠 있을 때의 이야기입니다. 아무것도 안 떠 있으면 어떻게 되는지도 짚고 가겠습니다.

앱은 앞에서 본 대로 페이지가 백키를 받습니다. 남은 트리거가 그것뿐이니 화면이 넘긴 onNativeBack이 실행됩니다.

웹은 아무도 가로채지 않습니다. 밀어 넣어 둔 해시가 없으니 매니저가 잡을 게 없고, 뒤로가기는 브라우저가 히스토리대로 처리합니다.

여기서 두 환경의 성격이 갈립니다. 앱은 백키를 통째로 넘겨받아서 항상 우리가 처리합니다. 웹은 그렇지 않습니다. 히스토리에 미리 얹어 둔 것이 있을 때만 끼어들 수 있고, 우리가 얹는 건 모달용 해시 하나뿐입니다.

모달은 두 종류였습니다

만들면서 늦게 알아챈 것이 있습니다. 저는 모달을 한 종류로 생각하고 있었는데, 뒤로가기 앞에서는 두 종류였습니다.

닫힐 것. 대부분의 모달입니다. 백키를 누르면 닫힙니다.

버틸 것. 확인 다이얼로그와 로딩입니다. 여기서 뒤로가기가 먹히면 사용자가 답하지 않은 채로 뒤의 화면이 닫힙니다. 매니저가 back을 흡수하고 #modal을 다시 밀어 넣어 상태를 유지합니다.

이 구분은 타입으로 강제했습니다.

type ModalOverlayHistoryProps =
  | { interceptHistoryBack: true;   onHistoryBack?: () => void }  // 버틸 것: 선택
  | { interceptHistoryBack?: false; onHistoryBack: () => void };  // 닫힐 것: 필수

일반 모달에서 onHistoryBack을 빼면 컴파일이 되지 않습니다. 백키로 닫히는 처리를 깜빡하는 건 리뷰에서 잘 안 보이는 종류의 누락이라, 타입으로 막아 두는 편이 낫다고 판단했습니다.

“버틸 것”은 탈출구를 두 개 다 막습니다. 뒤로가기만 막으면 딤드를 눌러서 나갈 수 있으니까요.

<ModalOverlay
  isShow={isOpen}
  interceptHistoryBack={true}   // 뒤로가기 흡수
  onClickDimmed={() => {}}      // 딤드 클릭도 무시
>

onClickDimmed는 안 넘겨도 타입이 통과합니다. 그래도 빈 함수를 적어 두는 쪽을 택했습니다. 빠뜨린 게 아니라 의도한 것이라는 표시입니다.

한 가지 덧붙이면, 이 “버티기”는 웹에서만 히스토리로 동작합니다. 웹뷰에는 히스토리가 없으니 같은 방어를 하려면 순번이 가장 큰 자리를 선점해야 합니다.

<NativeBackTrigger onNativeBack={() => {}} />   // 아무것도 하지 않는 트리거

한 벌로 만들어도 방어 수단까지 같아지지는 않습니다. 그래서 두 환경을 각각 눌러 보는 검증이 끝까지 필요했습니다.

부딪힌 것들

정리해 놓고 보면 단순한데, 오는 길이 단순하지는 않았습니다. 넷만 적습니다.

모달이 열리자마자 닫혔습니다. 처음 여는 순간에만 그랬습니다. 코드를 나눠 담아 두면 처음 열 때 파일을 받아오면서 컴포넌트가 한 번 정리되고 다시 세워지는데, 그 정리 과정이 “닫혔다”로 읽혔습니다. 그래서 정리 시점이 아니라 열림에서 닫힘으로 넘어가는 순간에만 뒤로가기를 예약하도록 바꿨습니다.

되돌아갈 곳이 없으면 아무 일도 일어나지 않습니다. history.back()을 불렀는데 뒤가 없으면 popstate가 오지 않습니다. 그런데 코드는 그 신호를 기다리고 있었습니다. 안 오면 "정리 중" 표시가 영원히 켜진 채로 남고, 그러면 그 뒤로 열리는 모든 방어형 모달이 버티지 못합니다. 화면 하나의 버그가 아니라 앱 전체가 조용히 망가지는 종류였습니다. 100밀리초를 기다려 보고 안 오면 직접 정리하도록 했습니다.

뒤로가기 버튼을 빠르게 두 번 누르면 두 칸이 뒤로 갔습니다. 화면 안의 뒤로가기 컨트롤을 연타했을 때입니다. 모달을 정리하려고 history.back()을 부른 상태에서 한 번 더 눌리면, 정리할 해시는 하나인데 back이 두 번 나갑니다. 두 번째는 실제 페이지까지 뒤로 넘어갑니다. 이미 정리가 진행 중이면 두 번째 요청은 이동만 하고 back은 치지 않도록 막았습니다.

모달 두 개가 한 번에 사라질 때 뒤쪽 배경이 잠깐 보였습니다. 아래 모달의 딤드가 줄어들면서 화면 가장자리가 드러났습니다. 딤드를 화면 크기가 아니라 화면의 두 배로 그려서 해결했습니다.

마지막 것은 원인을 찾는 데 가장 오래 걸린 것치고 고친 코드가 한 줄입니다. 이런 게 몇 번 반복되면서, 안 보이는 상태를 눈으로 보는 장치를 따로 만들었습니다. 지금 몇 개가 등록돼 있고 #modal이 있는지 없는지를 화면에 띄우는 디버깅 오버레이입니다.

남은 것

겹쳐 있는 모달이 웹에서는 한 번에 다 닫힙니다. 해시를 하나만 밀어 넣기 때문입니다. 뒤로가기 한 번에 그 하나가 빠지고, 지금 경로에서 열려 있던 모달이 전부 함께 닫힙니다. 웹뷰는 다릅니다. 트리거를 순번대로 하나씩 집으니 위에서부터 차례로 닫힙니다.

아래 검은 상자가 앞에서 말한 디버깅 오버레이다. 시트까지 열려 entries는 2가 되는데 해시는 끝까지 하나다. 그래서 뒤로가기 한 번에 둘이 함께 닫힌다.

모달마다 해시를 하나씩 쌓으면 웹에서도 하나씩 닫을 수 있습니다. 그런데 그건 여러 주체가 히스토리를 각자 건드리던 두 번째 방식으로 되돌아가는 길입니다. 거기서 상태가 어긋났고, 그래서 해시를 하나로 묶었습니다. 겹친 모달의 개별 제어는 그 단순함의 대가입니다.

부딪히는 자리가 많지는 않습니다. 모달 위에 또 모달을 띄우는 화면 자체가 드물고, 그런 경우에도 위에 오는 게 확인 다이얼로그나 로딩이면 애초에 뒤로가기를 흡수합니다. 다만 환경에 따라 결과가 다르다는 건 사실이라, 겹쳐 띄울 때는 양쪽을 다 눌러 봐야 합니다.

딤드를 두 배로 그린 건 트릭입니다. 원인을 없앤 게 아니라, 사방으로 반 화면씩 더 그려서 딤드의 끝을 화면 밖으로 밀어냈습니다.

className="yf-fixed yf-left-[-50vw] yf-top-[-50vh] yf-h-[200vh] yf-w-[200vw]"

가장 간단한 방법이라 골랐습니다. 모달을 셋씩 겹치는 화면이 없어서 거기까지만 그렸고, 주석에도 “3중 모달 이상은 고려하지 않음”이라고 적어 두었습니다.

100밀리초도 마찬가지입니다. popstate가 올지 안 올지를 브라우저가 미리 알려주지 않으니, 기다려 보는 것 말고 방법이 없었습니다. 같은 문서 안의 해시 이동이라 실제로는 훨씬 빨리 오지만, 숫자를 고른 근거가 관찰이라는 건 사실입니다.

그래서 무엇이 남았나

이번 편에서 나온 것들은 전부 라이브러리 안쪽 이야기입니다. 밖에서 보면 이렇습니다.

$ grep -rl "NativeBackTrigger" apps/flight/src | wc -l
0

서비스 코드에는 백키 처리가 한 줄도 없습니다. 트리거를 렌더하는 곳은 라이브러리 안의 두 군데, 페이지 컨테이너와 바텀시트뿐입니다. 화면을 만드는 사람은 바텀시트에 onHide를 주고, 페이지에 onNativeBack을 줍니다. 그게 전부입니다.

지금 웹인지 웹뷰인지는 한 번도 묻지 않습니다.

지난 편에서 화면 크기는 감출 수 없는 차이라고 했습니다. 데스크톱과 모바일의 디자인이 실제로 다르니까요. 뒤로가기는 반대쪽입니다. 사용자가 보는 결과는 어디서나 같고 방법만 다르니, 그건 감출 수 있는 차이였습니다.

한 벌로 간다는 건 모든 차이를 없앤다는 뜻이 아니라, 어느 쪽인지 매번 판정한다는 뜻에 가깝습니다. 감출 수 있는 건 감추고, 감출 수 없는 건 한 줄로 갈리게. 이 두 편이 그 판정의 양쪽 끝입니다.

다음 편에서는 레이아웃 컨테이너 이야기를 하겠습니다. 환경마다 따로 만들자고 적어 뒀는데, 열어 보니 환경 분기가 한 줄이었습니다.