안드로이드 앱에서 스크롤 내 화면 노출 추적하기
앱 내에서 유저의 활동 데이터를 수집하려 할 때 가장 먼저, 가장 자주 알고 싶은 것은 ‘유저들이 이 화면을 얼마나 보았는가’일 것입니다. 이것이 집계 되어야 그 중에 얼마나 되는 유저가 클릭을 했는지, 구매를 했는지, 이탈을 했는지 등을 분석할 수 있기 때문입니다. 저도 그동안 여러 회사를 다니며 데이터 분석가 분들께 가장 자주 받았던 요청이 화면이 얼마나 많이 노출됐는지 추적할 수 있게 이벤트를 심어 달라는 것이었습니다.
이 글에서는 노출을 추적하고자 하는 화면을 ‘뷰’, 뷰가 노출됐을 때 그것을 기록하는 것을 ‘뷰 이벤트’라 부르겠습니다. 개발자들이 뷰 이벤트를 ‘심어’ 두면 유저가 뷰를 볼 때마다 데이터 대시보드에 뷰 이벤트가 ‘찍히게’ 됩니다.
개발자가 뷰 이벤트를 심어 달라는 요청을 받았을 때 마주하는 첫번째 어려움은 뷰 이벤트를 언제 찍어야 하는지 명확하지 않다는 점입니다. 홈버튼을 눌러 앱을 최소화한 뒤 다시 앱에 들어왔을 때에는 뷰 이벤트를 다시 찍어야 할까요? 팝업이 떠서 화면을 반만 가렸다가 팝업을 꺼서 다시 완전히 드러났을 때에는 어떨까요? 이처럼 뷰 이벤트를 찍어야 할지 말아야 할지 모호한 상황이 많습니다. 요청을 주시는 분들은 그런 다양한 상황까지는 생각해보지 못한 경우가 많아, 개발자들이 요청 받은 데이터 이벤트를 심던 중에야 비로소 모호한 상황을 마주하게 됩니다. 그때그때마다 뷰 이벤트를 찍을지 말지 결정하다 보면, 최악의 경우 뷰 이벤트마다 서로 다른 조건들이 정의됩니다. 이런 혼란 속에서는 기능 수정 중에 뷰 이벤트 발동 조건이 틀어져 버려도 그 사실을 인지하지 못한 채 지나가기도 합니다.
그래도 이 경우 기술적으로 문제가 있는 것은 아닙니다. 어떤 조건 하에 뷰 이벤트가 찍히게 할 것인지 명확하게 정의하고 관계자들 간에 잘 공유한다면 그 이후에는 문제될 것이 없습니다. 이보다 더 큰 어려움은 스크롤 안에 들어가 있는 뷰의 노출을 추적하는 것입니다. 이 경우에는 뷰의 노출을 추적하는 것이 기술적으로 어렵습니다.
유저가 화면을 스크롤할 때 앱이 버벅인다는 느낌을 받지 않으려면 뷰가 화면에 드러나기 전에 미리 뷰를 생성해야 합니다. 예를 들어 이미지를 포함한 뷰가 화면에 들어오고 나서야 생성된다면 유저는 이미지를 불러오는 과정을 지켜 보게 됩니다. 화면에 들어오기 전에 미리 뷰를 생성하여 이미지를 로드해 둔다면 유저는 스크롤을 따라 끊김 없이 뷰를 볼 수 있습니다. 따라서 안드로이드 OS는 스크롤 안에 들어가는 뷰를 화면에 들어오기 전에 미리 생성해 둡니다. 그리고 안드로이드 앱 개발자는 이렇게 뷰를 생성하는 타이밍에 원하는 로직을 실행시킬 수 있습니다.
문제는 이 타이밍이 뷰를 생성하는 타이밍이지, 노출하는 타이밍이 아니라는 점입니다. 스크롤을 하기 전에 다음에 올 뷰를 미리 생성하기 때문에, 만약 유저가 스크롤하지 않는다면 생성만 된 채 노출은 되지 않을 수 있습니다. 만약 뷰를 생성하는 타이밍에 뷰 이벤트를 심어 두었다면 실제 노출에 비해 뷰 이벤트가 많이 찍히게 됩니다.
그렇다면 뷰를 노출하는 타이밍에 뷰 이벤트를 심어 두면 되지 않을까 싶은데, 안드로이드 OS가 그런 타이밍을 따로 잡아주지 않습니다. 아무리 실력이 대단한 개발자여도 제공되지 않는 타이밍을 잡아서 뷰 이벤트를 심을 수는 없습니다. 오차를 감수하고 생성하는 타이밍에 뷰 이벤트를 심든가, 스크롤 높이를 계산하고 각 뷰의 높이를 더하고 빼가며 온갖 엣지 케이스를 대비해 개발해 내야 합니다.
제가 현재 일하고 있는 회사에서는 오차를 감수하는 쪽으로 방향을 잡아 왔습니다. 그러다 올해, 앱을 Jetpack Compose를 이용하는 쪽으로 옮겨 가기 시작하면서 이것을 개선할 수 있겠다는 희망이 생겼습니다. Jetpack Compose에는 스크롤이 들어가는 뷰(LazyColumn, LazyRow)에서 현재 스크롤 안에 들어와 있는 뷰를 추적하는 것이 가능하기 때문입니다. 다시 말해 Column 또는 Row의 스크롤을 움직일 때마다, 현재 자식 뷰 중 어떤 것이 스크롤 안에 딱 들어와 있는지를 가려내는 것이 가능합니다.
그 방법에 대해서는 이미 인터넷에 많이 공유되고 있고, 이 블로그는 가급적 코드를 싣지 않는 것을 기조로 하고 있지 않기 때문에 자세히 언급하지는 않겠습니다. 다만 이 방법으로도 커버할 수 없는 케이스가 많다는 것을 발견하는 데는 그리 오래 걸리지 않았습니다.
먼저 스크롤 안에 다시 스크롤이 들어가는 경우(Nested scroll), 정확히 뷰 이벤트를 측정하기 어렵습니다. 예시로 리디 앱을 캡처해 보았는데, 전체적으로 세로 스크롤(Column)로 구성되는 뷰인데 ‘웹툰 실시간 랭킹’ 부분은 가로 스크롤(Row)로 구성되어 있습니다. 그리고 그 안에는 다시 웹툰이 3개씩 세로로 묶여 있는 형태(Column)임을 알 수 있습니다. 뷰 이벤트를 찍을지 판단할 Row로서는 1, 2, 3위 웹툰을 모두 자신의 스크롤 안에 위치시키고 있습니다. 하지만 Column의 스크롤 사정 상 3위 웹툰은 완전히 보여지고 있지 않습니다. 즉 Row는 3위 웹툰이 보여지고 있다고 판단하여 3위 웹툰의 뷰 이벤트를 찍겠지만, 실제로는 3위 웹툰이 완전히 노출된 상태가 아닙니다.
또 스크롤 내에 뷰가 삽입되거나 삭제되는 경우도 난감합니다. 앞서 말했듯 Column 또는 Row가 ‘스크롤을 움직일 때마다’ 보여지고 있는 자식 뷰를 추적하는데, 스크롤을 전혀 움직이지 않은 상태에서 중간에 뷰가 삽입되거나 삭제되면 보이던 뷰가 안 보이는 곳으로 넘어갈 수도, 또는 그 반대가 될 수도 있습니다.
이런 이유 때문에 뷰가 노출되고 있는지를 판단하는 주체는 스크롤을 갖고 있는 Column 또는 Row가 아니라, 뷰 이벤트의 대상이 될 뷰 자체가 되어야 한다고 판단했습니다. 다행히 Jetpack Compose에는 onGloballyPositioned 라는 조정자가 있어서, 각 뷰의 위치가 변했을 때 어떤 로직을 실행시키는 것이 가능합니다. 따라서 각 뷰의 위치 정보를 통해 뷰의 노출 여부를 판단할 수 있다면, Column이나 Row의 도움을 받지 않고 뷰 자체의 노출 여부를 알 수 있습니다.
그러나 뷰의 위치 정보를 통해 뷰의 노출 여부를 판단하는 것이 쉽지 않습니다. onGloballyPositioned를 통해 이 뷰가 부모 뷰에서 차지하는 상대적 위치, 또는 전체 창을 기준으로 한 위치를 알 수 있습니다. 이 중 부모 뷰에서 차지하는 상대적 위치로는 부모 뷰가 어딨는지를 모르는 상태기 때문에 뷰의 노출 여부를 알 수 없습니다. 그렇다면 전체 창을 기준으로 한 위치를 잡아야 하는데, 이 경우 스크롤 밖에서 미리 뷰를 만들어 두는 것이 다시 문제가 될 수 있습니다. 만약 스크롤 영역이 핸드폰 화면의 위 절반만 차지한다면, 미리 만들어 두는 뷰는 화면의 아래쪽 절반에 숨겨져 있어서 실제로 보이지는 않되 핸드폰 화면 안쪽에 위치가 잡힐 수 있습니다.
결국 포기해야 하는 욕심일까 하던 중에 돌파구를 찾아냈습니다. 뷰가 차지하고 있는 공간, 그 뷰의 부모 뷰가 차지하고 있는 공간, 다시 그 부모 뷰가 차지하고 있는 공간…을 모두 겹쳤을 때 뷰가 차지하고 있는 공간과 동일한 공간이 나온다면, 그 뷰는 화면에 완전히 보여지고 있다고 판단할 수 있습니다. 부모 뷰와 겹친 영역은 원래 뷰가 차지하고 있는 공간보다 작거나 같을 수 밖에 없는데, 만약 작다면 뷰는 다 드러나지 못하고 그 작은 공간만큼만 드러나고 있다는 의미가 되기 때문입니다. 앞서 리디 앱 캡처에서, 3위 웹툰은 부모인 스크롤 뷰가 완전히 드러나지 않아 뷰가 차지해야 할 공간이 완전히 확보되지 않은 것을 확인할 수 있습니다. 이 방법으로 접근하면 아무리 많은 스크롤을 중첩시켜도 뷰가 노출되고 있는지 여부를 정확히 판단할 수 있습니다.
이 아이디어에서 시작해서 Visibility Tracker라는 오픈소스 라이브러리를 만들었습니다. 오픈소스 라이브러리로 만들면서 더 다양한 경우에 대응하기 위해 보여지는지 안 보여지는지만 측정하는 게 아니라 몇 퍼센트 보여지고 있는지까지 알 수 있도록 확장하였습니다. 뿐만 아니라 원한다면 홈 버튼으로 앱을 최소화했다가 돌아오는 경우, 다른 화면에 가려졌다가 백버튼으로 돌아오는 경우 등에도 대응할 수 있도록 파라미터를 제공합니다.
다만 한계는 있습니다. 만약 팝업이 떠서 뷰의 일부를 가릴 경우, 이 팝업은 뷰와 부모 자식 관계가 아니기 때문에 추적을 하는 것이 불가능합니다. 이 부분에 대해서는 아직 해결책을 찾지 못하였습니다. 오픈소스 라이브러리니 꼭 제가 아니더라도 누군가 답을 찾아올 수도 있지 않을까 기대하고 있습니다.