스크롤은 기다려 주지 않는다 — 달력 성능 하루의 기록
스티키 제목이 출렁이고, 월간 달력이 빠른 스크롤에 빈칸을 보였습니다. 라이브러리를 바꿔 보고, 창을 넓혀 보고, 배경 격자를 깔아 봤다가 전부 되돌리고 남은 건 하나였습니다. 행을 가볍게 만드는 것.
스크롤은 기다려 주지 않는다 — 달력 성능 하루의 기록
오늘은 페칫 모바일 달력의 성능만 붙들었습니다. 증상은 둘이었어요. 일간 타임테이블에서 블록 제목을 화면 상단에 붙여 두는 스티키가 부드럽지 않았고, 월간 달력을 빠르게 튕기면 행이 채워지는 모습이 보였습니다. 둘 다 “느리다”보다 “거칠다”에 가까운 문제라서, 뭘 재야 할지부터 헷갈리는 종류였습니다.
결론부터 말하면, 하나는 방식을 바꿔서 풀었고 하나는 못 풀었습니다. 못 푼 쪽에서 시도한 것들이 오히려 더 배울 게 많았어요.
1. 스티키 제목이 출렁인 이유
블록이 길어서 위로 스크롤돼 나가도 제목은 화면 위쪽에 남아 있어야 합니다. 처음 구현은 단순했습니다. 스크롤 오프셋을 읽어서 제목을 그만큼 반대로 밀어 올리는 것. reanimated로 UI 스레드에서 돌리니 원리상 매끄러워야 했죠.
그런데 실기기에서는 제목이 스크롤을 반 박자 뒤따라오며 미세하게 출렁였습니다. 픽셀 단위로 반올림해 보고, 스크롤 이벤트를 매 프레임 받도록 바꿔 봤지만 그대로였습니다.
원인은 방식 자체에 있었습니다. 스크롤 오프셋은 네이티브 스크롤뷰가 콘텐츠를 이미 그린 뒤에 전달됩니다. 그 값을 받아 제목을 옮기는 건 아무리 빨라도 한 프레임 늦습니다. “스크롤 값을 읽어서 무언가를 움직인다”는 구조는 태생적으로 반 박자 늦은 거예요.
해결: 움직이지 않게 만든다
그래서 제목을 움직이지 않게 만들었습니다. 스크롤 콘텐츠 밖, 화면 상단에 고정된 오버레이 층을 두고, 블록 위 가장자리가 화면 위로 넘어가는 순간 같은 모양의 제목을 거기에 켭니다. 붙어 있는 동안 오버레이는 전혀 움직이지 않으니 지연이 있을 자리가 없습니다. 스크롤 값은 “켤지 말지”를 판정하는 데만 쓰고 위치에는 쓰지 않습니다.
여기서 두 번 더 손이 갔습니다.
- 켜고 끄는 판정을 JS로 넘겨 렌더하면 두어 프레임 늦게 나타났습니다. 판정과 표시(opacity)를 UI 스레드에서 바로 끝내고, 탭 가능 여부만 JS로 넘겼습니다.
- 블록 끝에 닿아 제목이 밀려 올라가며 사라지는 느낌은 살리고 싶었는데, 오버레이를 밀면 다시 스크롤 추종이라 출렁였습니다. 답은 교대였습니다. 그 구간에서는 오버레이를 끄고, 블록 안의 원래 제목을 블록 바닥에 붙여 둡니다. 블록 안 제목은 스크롤 콘텐츠의 일부라 네이티브 스크롤과 함께 완벽히 매끄럽게 올라가고, 화면 밖으로 자연스럽게 잘립니다. 둘이 같은 자리에서 교대하도록 높이 계산을 양쪽에서 같은 식으로 맞췄습니다. 1픽셀만 어긋나도 빠른 스크롤에서 깜빡였거든요.
이 방식의 핵심은 한 줄입니다. 매끄러워야 하는 움직임은 네이티브 스크롤에 맡기고, JS는 그 움직임이 필요한 순간을 고르기만 한다.
2. 월간 달력이 빈칸을 보인 이유
월간 달력은 주 단위 행을 세로로 쌓은 가상화 리스트입니다. 화면 근처 행만 만들고, 스크롤에 맞춰 새 행을 그립니다. 손가락이 그 속도를 넘으면 아직 안 그려진 행이 흰 빈칸으로 들어옵니다.
여기서 시도한 것들을 순서대로 적어 둡니다. 전부 되돌렸습니다.
- 창을 넓힌다. 미리 그리는 행을 늘리면 빈칸은 줄지만, 스크롤할 때마다 그만큼 더 그려야 해서 끊깁니다. 24주로 늘렸더니 “너무 느리다”가 돌아왔습니다.
- 행을 두 단계로 그린다. 첫 프레임은 격자만, 다음 프레임에 할 일. 격자는 빨리 보였지만 행마다 커밋이 하나 더 생겨 처리량이 깎였습니다.
- 리스트 뒤에 배경 격자를 깐다. 안 그려진 자리에 격자가 비치게. 동작은 했지만 “그려진 척”하는 게 마음에 안 들어 뺐습니다.
- FlashList로 바꾼다. 셀을 재활용하니 뷰 생성 비용을 아끼겠지 싶었는데, 체감이 눈곱만큼이었습니다.
- 감속을 세게 한다. 튕김이 짧아지니 빈칸도 줄지만, 멀리 가려면 여러 번 쓸어야 합니다. 달력에서 그건 싫었습니다.
FlashList가 왜 별 효과가 없었는지가 오늘의 진단이었습니다. 재활용이 아끼는 건 뷰의 생성과 파괴입니다. 그런데 달력 행은 구조가 같을 뿐 내용은 전부 다릅니다. 날짜 숫자 7개, 배경 7개, 바의 제목과 색까지. 재활용해도 그 행의 뷰 대부분에 속성 갱신이 나가니, 무거운 행은 여전히 무겁습니다. 균일한 높이의 행 100여 개가 있는 달력은 애초에 가상화 비용이 작은 자리라, 리스트 종류로 얻을 게 없었습니다.
그래서 행을 가볍게 만들었다
진짜 병목은 행 하나를 그리는 비용이었습니다. 뜯어 보니 이랬습니다.
- 날짜 셀 하나가 선택·오늘·지난 날·공휴일을 판정하려고 파생 atom 4개와 구독 5개를 만들고 있었습니다. 행당 35개. 행이 재활용되거나 새로 그려질 때마다 이 구독을 전부 갈아 끼웠습니다. 행이 한 번만 구독해서 셀에는 불리언만 넘기도록 바꿨습니다. 행당 4개.
- 행이 그려질 때마다 그 주의 날짜별 할 일을 추리고 바를 몇 줄에 놓을지 다시 계산했습니다. 주 단위로 결과를 캐시해서 같은 데이터면 계산 없이 씁니다.
- 셀 하나에 터치 영역이 둘, 감싸는 뷰가 하나 더 있었습니다. 바깥 뷰 자체를 터치 영역으로 삼아 셀당 뷰 두 개를 줄였습니다.
- 가장 뜻밖이었던 건 루틴의 가상 발화였습니다. 스크롤로 조회 범위가 늘어날 때마다 이 가상 항목들이 전부 새 객체로 다시 만들어졌고, 매일 반복 루틴이 하나만 있어도 모든 주의 할 일 배열이 “바뀐 것”으로 판정돼 행 전부가 다시 그려졌습니다. 스크롤할 때마다 바가 다시 계산되는 것처럼 보이던 게 이것이었습니다. 같은 루틴·같은 날이면 이전 객체를 그대로 쓰게 했습니다.
리스트 설정은 원래대로 돌려놓았습니다. 창 크기도, 배치도. 오늘 남은 건 행이 가벼워졌다는 사실 하나이고, 그건 리스트가 무엇이든 유효합니다. 빠른 튕김의 빈칸은 완전히 없어지지 않습니다. 가상화의 성질이고, 이걸 없애려면 가상화를 버려야 하는데 그 대가는 치르지 않기로 했습니다.
교훈
증상이 “거칠다”일 때 첫 질문은 “무엇이 매 프레임 일하고 있나”다. 스티키는 JS가 매 프레임 위치를 옮기고 있었고, 달력은 매 스크롤마다 행 전체가 다시 그려지고 있었습니다. 둘 다 라이브러리나 설정값의 문제가 아니라 “누가 언제 일하는가”의 문제였어요.
라이브러리는 무거운 것을 가볍게 만들어 주지 않는다. 재활용이든 창 크기든, 행 하나의 비용이 그대로면 옮겨 다니는 것뿐입니다. 시도한 다섯 가지를 되돌리고 나서야 그게 보였습니다.
되돌리는 것도 작업이다. 오늘 커밋에 남은 diff는 파일 네 개입니다. 하루 동안 만졌던 것의 절반 이상은 되돌렸고, 그 되돌림 덕분에 남은 변경이 무엇을 위한 것인지 분명해졌습니다.