웹과 네이티브, 조화로운 공존은 가능한가? 플로팅웹뷰 도입으로 찾은 희망

3 weeks ago 7

여는 글

B마트, 장보기·쇼핑, 전국특가를 비롯한 배달의민족의 커머스 서비스는 끊임없이 진화하고 있습니다. 가게와 상품을 탐색하는 UX를 개선하기 위해서, 커머스 페이지에 다양한 UI 디자인을 연구하고 적용해 왔는데요, 이러한 활동의 일환으로 최근에 페이지에서 웹과 네이티브 영역을 적절히 섞어 구현해야 하는 상황이 생겼습니다.

이때 웹과 네이티브가 혼재된 영역보다 상위에 겹쳐서 띄우는 투명한 웹뷰를 사용하였고, 이를 ‘플로팅웹뷰’로 부르기로 하였습니다. 이 글에서는 플로팅웹뷰 도입 배경부터, 기존 웹 개발 패러다임에 큰 도전이 되었던 팝업 이슈를 중심으로 웹 프론트엔드에서 마주했던 난관과, 이에 대해 다방면으로 해결책을 고민하며 극복해 나간 경험을 공유하고자 합니다.

웹 프론트엔드에 드리운 그림자

태초에 커머스 서비스 페이지의 세계는 균형을 이루었습니다. 대부분의 페이지는 웹 혹은 네이티브로만 구현되었고, 일부 웹 페이지의 상단 헤더 정도에만 네이티브 구현 영역이 살짝 들어간 정도였습니다. 이 시기에는 웹과 네이티브 영역이 잘 격리되어서 서로를 침범하지 않았으므로, 개발할 때에 네이티브 영역과의 충돌을 크게 고려하지 않았습니다. 그러나 사용자의 탐색 경험을 개선해 나가다 보니, 네이티브 영역이 늘어나면서 평화의 시대는 종말을 맞이하게 되었습니다.

최초의 트리거는 네이티브 내비게이션 탭바의 등장이었습니다. 장보기·쇼핑의 가게홈 페이지를 비롯한 웹 페이지에서, 웹 기반의 하단 내비게이션 탭바와 최소 주문금액 안내 바는 페이지 로딩 완료 전까지 미노출되다 보니, 페이지를 이동하는 사용자에게 매끄럽지 못한 UX를 제공하는 문제가 있었습니다.

또한 기존에는 페이지 방문 기록이 직렬로 쌓여서 탐색 경험에 혼란을 야기하다 보니, 병렬로 내비게이션의 탭마다 독립적인 방문 기록을 쌓는 방식이 거론되었습니다. 하지만 웹 기반의 내비게이션 탭바에서는 웹뷰 밖의 앱 페이지 방문 기록에 접근할 수 없으므로, 앱 레벨에서 전체적인 방문 기록의 제어가 필요했습니다. 이러한 점들을 해결하기 위해, 웹 페이지에도 네이티브로 구현된 내비게이션 탭바가 도입되었습니다.

이후에 iOS26에서 소개된 Liquid Glass 디자인이 웹과 네이티브 영역과의 충돌에 쐐기를 박았는데요, 하단 바들이 플로팅 형태로 웹 영역 위에 겹치게 바뀌면서 아래 사진과 같은 형태가 되었습니다.

조화와 공존의 시대가 다가오다

결국, 페이지에서 웹과 네이티브 영역이 조화롭게 공존해야 하는 시대가 찾아왔습니다. 더 이상 네이티브 영역과 충돌을 배제할 수 없는 현실을 받아들이며, 웹 프론트엔드에서 어떤 이슈가 있는지 분석하면서 빠르게 대응하기 시작했습니다. 웹의 safe-area-inset이 가변적으로 바뀐 점도 대비하고, 플로팅 형태의 바의 제어나 로그 기록을 위해 앱에 전달할 데이터도 논의해야 했지만, 여기서는 팝업 이슈에 대해 중점적으로 다루어 보겠습니다.

조화를 받아들이지 못하는 팝업

커머스 서비스 페이지는 많은 곳에서 다이얼로그나 바텀시트를 적극적으로 활용하고 있습니다. 예를 들면, 배달시간 날짜와 시간을 안내하는 다이얼로그와 쿠폰을 다운로드하기 위한 바텀시트가 있습니다. 편의상 다이얼로그나 바텀시트처럼 화면 최상단에 뜨는 컴포넌트들을 ‘팝업’이라 부르겠습니다.

기존 웹에서 팝업은 간단하게 최상위의 레이어에 노출하면 되는 컴포넌트였습니다. 하지만 네이티브 영역과 상생하면서 이런 순진함은 구시대적 사고방식이 되어버렸는데요, 아래 사진과 같이 팝업이 네이티브 영역까지 뻗칠 수 없거나 가려지는 이슈가 생겼습니다.

조화의 상징 플로팅웹뷰, 도입해야겠죠?

이를 해결하기 위해 초기에는 팝업을 띄우면서 앱과 통신하여 네이티브 영역을 미노출하거나 암전하는 방법도 고려했지만, 구조적인 문제로 다른 방법을 찾게 되었습니다. 네이티브 영역보다 더 상위 레이어에 팝업을 노출시키는 접근법이 필요했는데요, 그래서 아래 그림처럼 최상위에 뜨면서 배경이 투명하고 화면 전체를 가득 채우는 웹 페이지 즉, 서두에 언급된 플로팅웹뷰에 팝업을 노출하는 방식이 제안되었습니다.

사실 플로팅웹뷰의 등장은 웹 프론트엔드에게 커다란 난관이었습니다. 쉽게 비유하면, 팝업을 iframe으로 띄우게 되면 일어나는 일을 상상해 보세요. 팝업을 새로운 웹 페이지로 분리하면 아래와 같은 고통을 마주하게 됩니다.

  • 성능 이슈: 간단한 팝업이라도 페이지를 새로 다운로드하고 초기화해야 해서 로딩 시간이 늘고, 메모리 사용량도 증가합니다.
  • 팝업 UX 저하: 팝업을 열고 닫는 애니메이션의 흐름이 끊기면서 UX가 저하됩니다.
  • 복잡한 설계와 고난도 작업 필요: 별도 페이지에 뜬 팝업에서 받은 사용자 입력을, 팝업을 호출하는 페이지가 전달받고 처리할 수 있어야 합니다. 이를 위해서, 기존의 팝업 컴포넌트를 정교하게 분리하여 각 페이지에 나눠 배치하고 서로 통신하도록 만들어야 합니다.

그럼에도 불구하고, 플로팅웹뷰는 확실히 문제를 해소할 수 있고 미래에 네이티브 영역이 다양한 형태로 진화하여도 유연하게 대응할 수 있으므로, 착잡한 심정으로 도입하기로 결정합니다. 이후에 Liquid Glass 디자인 때문에 네이티브 영역이 더 복잡해졌는데 웹에서 대응 범위가 줄어든 점을 생각하면, 이 결정은 선견지명이었다고 볼 수 있겠습니다.

플로팅웹뷰로 팝업 끼얹기

한때의 열혈 플로팅웹뷰 반대론자는 어느덧 변절하여 선봉장이 되었습니다. 이제부터 팝업을 플로팅웹뷰에 띄우도록 호출하는 컴포넌트로부터 독립적으로 동작할 수 있도록 분리해야 하는데, 편의상 이를 ‘디커플링’이라고 하겠습니다. 디커플링에 앞서 고려해야 할 사항을 다음과 같이 정리하였습니다:

  • 다중 서비스 적용 및 확장성 고려: 커머스 서비스에 모두 적용하면서 중복 코드와 복잡도를 낮출 것. 또한, 미래 확장 가능성을 인지하고 설계할 것.
  • 하위 버전 유지 고려: 구버전 앱에서는 기존 팝업을 유지해야 함. 코드의 중복은 피할 것.
  • 명확한 작업 과정 도출: 작업 규칙과 세부 과정을 명확히 정하여 작업 효율성을 늘리고 일관성을 확보할 것. 그렇다면 다른 팀원 혹은 AI에게 도움받기 쉬워짐.
  • 제한된 일정: 일정이 많지 않음. 작업 범위도 최소화 필요. 플로팅웹뷰의 오버헤드로 인한 성능 저하는 감수하고, 성능 최적화에 시간 낭비를 줄일 것.

이 점들을 명심하며 아래와 같이 다양한 관점에서 플로팅웹뷰와 디커플링에 대해 깊게 고민을 해보고 작업을 진행하였습니다.

할 일을 줄이자: 작업 대상을 식별하고 좁히기

처음에는 호기롭게 네이티브 영역이 있는 웹 페이지 전체의 팝업을 디커플링하려다 보니 일정에 비해 지나치게 작업 범위가 넓어져서, 작업 범위를 최소화하였습니다. 조금 비겁하지만 네이티브 영역이 헤더와 같이 충돌 영향도가 적은 페이지의 팝업은 과감하게 작업 대상에서 제외했습니다. 약간의 스타일 이슈가 생기겠지만 기능에는 영향이 없었기 때문에 개발적 허용이라 하겠습니다. 또한 스낵바와 툴팁처럼 safe-area-inset을 사용하여 네이티브 영역과 겹치지 않게 피할 수 있는 작은 컴포넌트도 대상에서 제외하였습니다.

성능 최적화, 고민은 넓게 실행은 최소한으로

기존과 다르게 플로팅웹뷰 팝업은 노출되는 데 억겁의 시간이 소요됩니다. 별도 페이지이기 때문에 페이지가 다운로드되고 초기화되는 데 시간이 추가적으로 소요되기 때문입니다. 그래서 로딩 속도를 개선할 수 있는 방법을 몇 가지 생각해 보았습니다:

  • 로딩을 지연하는 요소를 최소화: API 응답 값을 그대로 팝업의 prop으로 전달하여 재호출이나 로딩 지연을 방지하기.
  • 미리 로딩하기: 호출할 팝업 내용이 예측 가능하다면 prefetch 하거나, 앱에서 팝업 페이지 자체를 미노출 상태로 preload 하기.
  • 네이티브 팝업 활용: 일부 팝업을 플로팅웹뷰 대신에 네이티브 팝업을 띄우고 내용물로 웹 콘텐츠를 제공하기. 다만, 해결 방법이 파편화되기도 하고 네이티브 요소를 제어하지 못하는 제약이 있음.

이에 따라 작업을 하면서 로딩을 지연하는 요소를 최소화하였고, 나머지는 제한된 일정에 빠른 진행을 위해 후속 과제로 두었습니다. 그 외 자잘한 상상과 시도는 노력 대비 효과가 낮은 오버 엔지니어링이라 판단하고 폐기하였습니다.

웹 페이지 간 통신, 간단한 길을 찾아보자

플로팅웹뷰 도입 전까지는 웹 페이지들은 독립적이었기 때문에 데이터를 주고받지 않았습니다. 하지만 호출 페이지가 디커플링된 팝업을 구성하는 데이터를 전송하고 팝업으로부터 사용자 입력 데이터를 반환받기 위해서, 플로팅웹뷰 페이지와 호출 페이지 간의 통신이 필요해졌습니다. 앱 개발팀에서 이를 위해 플로팅웹뷰에 URL 쿼리 파라미터로 넣을 데이터와 플로팅웹뷰가 닫히는 이벤트의 콜백을 설정하는 기능을 제공했습니다. 그러나, 플로팅웹뷰에게 API 응답 값처럼 커다란 값을 쿼리 파라미터로 넘기면 크기 제한으로 잘리는 참사가 일어나기 때문에, 이를 대체할 몇 가지 통신 방법을 정리해 보았습니다.

접근법 특징 결정 이유
로컬/세션 스토리지로 JSON 문자열 교환 단순 명료, 같은 오리진, 이벤트 기반 실시간 통신 O 다른 오리진을 고려하여 차선책
IndexedDB로 객체 교환 코드가 복잡함, 같은 오리진, 이벤트 기반 실시간 통신 O 구현 복잡도가 높음
Broadcast Channel로 객체 전송 같은 오리진, 실시간 통신 O ⛔ iOS 15.4부터 지원하여 사용 불가
postMessage로 JSON 문자열 전송 다른 오리진 통신 가능, 실시간 통신 O ⛔ 플로팅웹뷰의 레퍼런스를 알 수 없어 연결이 불가
앱 내부 스토리지로 JSON 문자열 교환 단순 명료, 다른 오리진 통신 가능, 수명을 앱에서 관리, ⚠️ 브라우저 개발자도구에서 디버깅이 어려움. 단순함, 다른 오리진 통신 가능, 실시간 불필요

결론적으로 플로팅웹뷰에 데이터를 넘길 때에는 앱 스토리지를 사용하기로 결정했는데, 데이터를 JSON 직렬화해야 한다는 점을 제외하면 단순 명료하기 때문입니다. 또한 미래에 외부 도메인의 마케팅 팝업이 사용될 가능성을 고려하고, 웹 페이지들이 결국 앱 내에서 구동되므로 데이터의 수명을 앱에게 맡기면 부작용이 감소할 것으로 판단하였습니다. 그리고 플로팅웹뷰가 닫힐 때에는 앱에서 제공하는 콜백을 그대로 사용하기로 결정하였습니다.

새로운 페이지의 경로 정하기: 규칙이 가져오는 효능

플로팅웹뷰라는 새로운 페이지를 만들다 보니 두 가지 규칙 고민이 생겼습니다:

  • 다중 페이지와 단일 페이지: 팝업마다 각각 플로팅웹뷰 페이지를 만들기 VS. 단일 페이지만 만들고 모든 팝업 처리하기.
  • 경로 정하기: 호출하는 페이지의 하위 e.g., ~/bmart/home/{팝업이름}로 정하기 VS. 최상위 e.g., ~/floating-webview/{팝업이름}로 정하기.

하나의 페이지만 두는 건 중복 코드가 적은 장점이 있고, 페이지를 여러 개 둘 때는 호출하는 페이지의 하위 경로에 배치하는 것이 심신 안정에 도움을 줄 수 있겠지만, 최상위 경로에 팝업마다 별도 페이지를 만들어주면 아래와 같은 강점이 있어서 이렇게 결정했습니다.

  • 플로팅웹뷰 구별 용이: URL의 경로를 통해 페이지가 플로팅웹뷰 페이지인지 아닌지 쉽게 구별 가능.
  • 모니터링과 디버깅 용이: 에러가 어떤 팝업에서 발생했는지 효과적으로 식별 가능.
  • 작업이 수월해짐: 일관된 규칙으로 디커플링 작업 효율이 증가.

중복 코드를 줄이고 일관된 구조 확보하기

디커플링을 곧바로 하나하나 시도해 보는 것은 효율적이지도 않고, 공통 로직도 각자 구현되어 파편화될 우려가 있습니다. 그래서 먼저 작업 대상들을 분석하면서 효율적이고 일관적인 디커플링을 위해 필요한 아키텍처를 설계해 보겠습니다.

디커플링된 컴포넌트들이 공통으로 해야 하는 액션들이 있습니다. 예를 들면, 플로팅웹뷰 호출, 플로팅웹뷰와 데이터 교환, 팝업 닫힘 이벤트 처리가 있습니다. 그래서 중복 코드를 줄이고 제어의 일관된 구조를 확보하기 위해, 이 액션들을 담당하며 중개하는 CallerCallee 컴포넌트를 두었습니다. Caller는 호출하는 컴포넌트에 위치하고 Callee는 플로팅웹뷰 페이지에 위치하며, 각각 플로팅웹뷰를 호출하고 호출받는 역할을 수행합니다.

또한 플로팅웹뷰가 지원되지 않는 구버전을 위해 팝업을 두벌을 만들어 분기하는 정직한 방법은 비일관성과 지나친 중복 코드를 야기하게 됩니다. 이 문제를 피하기 위해, 버전에 상관없이 팝업은 한 벌로 두고 중개 컴포넌트가 알아서 처리해 주는 구조를 생각했습니다. 그래서 중개 컴포넌트가 플로팅웹뷰 사용 여부에 따라 팝업을 렌더링하는 주체를 나누고, 호출 컴포넌트가 팝업에 prop을 제공하는 방식을 조정하도록 설계했습니다. 정리하면 제어 구조는 다음 그림과 같습니다. 이제 팝업을 이벤트 기반으로 닫힘을 처리하도록 수정하고 중개 컴포넌트를 추가하면, 구버전과 신버전 모두 동일한 팝업 컴포넌트를 사용할 수 있게 됩니다.

그리고 이 일관된 구조 덕분에 디커플링 후에, 아래와 같은 부가 이슈들을 마주해도 쉽게 처리할 수 있었고 구조가 더 견고해졌습니다.

  • 에러 바운더리 이슈: 팝업에 에러 발생 시 플로팅웹뷰에서 에러 화면이 나오고 이게 최상위에서 사용자 입력을 받고 있다 보니 마치 앱이 정지된 것처럼 보임. ➡️ Callee에 플로팅웹뷰를 자동으로 닫는 에러 화면을 추가하여 해결.
  • 자연스러운 뒤로 가기: 기기의 뒤로 가기 버튼 동작을 Callee에서 처리하여 자연스럽게 플로팅웹뷰가 꺼지도록 구현.
  • 누락되는 닫힘 이벤트: 팝업이 로딩 완료되기 전에 기기 뒤로 가기로 플로팅웹뷰를 닫아버리면, 팝업이 닫혔지만 이벤트가 발생하지 않음. ➡️ 호출 페이지로 포커스되면 Caller에서 닫힘 이벤트를 받은 것처럼 동작하도록 처리하여 해결.

예시로 보는 디커플링

이제 위의 내용들을 적용하여 도출한 디커플링 세부 작업 과정을 “상품목록에서 정렬 기준을 선택하는 바텀시트”를 예시로 살펴보겠습니다.

  • 컴포넌트 분리하기: 바텀시트 UI를 가지는 팝업 컴포넌트 F와, 로직 및 상태를 가지는 컴포넌트 A로 분리하여 AF를 호출하는 구조로 만듭니다.
  • 바텀시트를 닫는 이벤트 처리하기: 위에서 소개된 아키텍처를 적용하기 위해, 바텀시트가 닫힐 때 발행하는 이벤트 기반으로 로직을 재구성합니다.
    • 변경 전: 사용자가 주문 많은 순을 클릭 → onClick을 통해 값을 받고 정렬 상태를 업데이트하는 API 호출 후 바텀시트를 닫음.
    • 변경 후: 사용자가 주문 많은 순을 클릭 → F는 닫히면서 이벤트를 발행 → A는 onClose를 통해 값을 받아 정렬 상태를 업데이트하는 API 호출.
  • 중개 컴포넌트 적용과 페이지 분리: _F_에 CallerCallee가 붙은, F_CallerF_Callee를 만듭니다.
    • F_Caller는 기존 페이지에 있는 A 아래에 둡니다.
    • F_Callee는 새로운 플로팅웹뷰 페이지 ~/floating-webview/sort-bottom-sheet에 둡니다.
  • Fprop 정의: 팝업 구성에 필요한 값으로 정의합니다.
    • 예시에서는 현재 선택된 정렬 상태 값만 전달하면 충분합니다.
    • 일반적인 상황에서는 API 응답 값을 가공하여 넘겨서, API의 중복 호출을 방지하고 로딩 시간을 절감할 수 있습니다.
    • 또한 JSON 문자열화될 것이므로 순환 참조 객체나 함수를 넣지 않도록 주의해야 합니다.
  • 컨텍스트 분리: 팝업에 필요한 컨텍스트를 플로팅웹뷰 페이지의 상위에 배치합니다.
    • 예시에서는 디자인 시스템 컨텍스트만 필요합니다.
    • 일반적인 상황에서는 코드 복잡도를 낮추기 위해, 의존하는 컨텍스트를 최소화하는 것이 좋습니다.

디커플링이 완료되면 최종 형태는 아래 그림과 같이 구성됩니다. 이렇게 디커플링 작업 과정이 정리되니 반복 작업을 도출하여 라이브러리화할 수 있었고, 페이지와 컨텍스트 분리는 AI의 도움을 받으면서 작업에 속도를 붙이면서 프로젝트를 성공적으로 마무리할 수 있었습니다.

할 일을 더 줄이자: 반복되는 부분은 라이브러리로 만든다

디커플링을 여러 서비스에 걸쳐 진행하면서 발견한 공통 작업들을 정리하여 라이브러리로 만들었습니다. 라이브러리는 유연하게 각 서비스의 설정에 맞추어 중개 컴포넌트를 생성하고, 이를 팝업에 쉽게 적용해 주는 기능을 제공합니다. 덕분에, 디커플링 작업의 양을 줄였고 여러 커머스 서비스에 걸쳐서 일관된 아키텍처를 적용하였습니다.

마무리

지금까지 웹과 네이티브 영역의 경계가 허물어져가는 환경에서 마주한 팝업 이슈와, 이를 극복해 나가는 과정을 살펴보았습니다. 플로팅웹뷰는 최상위에 팝업을 노출해 주는 적절한 해결책이었고, 웹에 도입하면서 다방면의 기술적 고민과 설계 및 작업을 거쳐 팝업 컴포넌트의 디커플링을 성공적으로 수행했습니다. 또한, 이를 체계적으로 정리하여 라이브러리화함으로써, 커머스의 세 서비스에 걸쳐 일관된 아키텍처를 적용하고 개발 생산성을 향상시킬 수 있었습니다.

결론적으로, 웹과 네이티브 영역이 조화롭게 공존하는 새로운 사용자 경험을 성공적으로 제공하는 동시에, 웹 프론트엔드에서는 효율적이면서 일관성과 유연성을 확보하는 성공적인 아키텍처의 사례로 여러분들의 기억에 남았으면 좋겠습니다.

Read Entire Article