CSS filter 한 줄은 어떻게 iOS 웹뷰를 꺼뜨렸을까

CSS filter 한 줄은 어떻게 iOS 웹뷰를 꺼뜨렸을까

지도를 스크롤할 때마다 꺼지던 iOS 웹뷰의 원인을 CSS filter 한 줄에서 찾고, 그래픽 메모리를 300MB 안팎에서 46MB로 줄였던 경험을 공유해요.

15 min read

최근에 보드게임처럼 칸을 따라 올라가는 지도 화면을 만들고 있어요. QA중에 이런 리뷰가 들어왔습니다.

"지도를 계속해서 스크롤하다 보면 앱이 꺼져요."

해당 버그는 iOS에서는 발생했습니다. 웹에서는 문제가 없었고, Sentry에도 에러 로그가 안남았어요.

결론부터 말하면 범인은 지도 타일마다 걸어 둔 CSS filter 한 줄이었어요. 이번 글에서는 그 한 줄을 어떻게 찾았는지, 왜 메모리를 수백 MB나 잡아먹었는지의 트러블슈팅을 기록해봤어요.

지도는 세로로 아주 길어요. 아래에서 위로 칸이 이어지고 미션을 하나 끝낼 때마다 캐릭터가 한 칸씩 올라가요. 아이들은 지도를 위아래로 스크롤하며 앞으로 갈 칸이나 지나온 칸을 구경해요.

할로윈 같은 시즌 이벤트가 열리면 지도 전체가 할로윈 맵으로 바뀌어요. 길 타일마다 주황빛이 퍼지도록 UI가 설계되어있어요.

2221201918171615141312111098765✓✓✓✓
지도

주황빛이 번지는 할로윈 맵이에요.

제보받은 계정의 화면 기록을 시간순으로 뽑아 봤어요.

13:28:20  지도 화면 진입
13:28~29  지도를 위아래로 스크롤
13:29:07  앱 첫 화면 기록   ← 재시작
13:29     다시 진입, 또 스크롤
13:29:27  앱 첫 화면 기록   ← 또 재시작

지도를 위아래로 스크롤하다가 앱이 첫 화면부터 다시 켜지는 일이 두 번 있었어요. 그런데 그 사이에 에러 기록은 하나도 없었어요.

JS 에러였다면 에러 화면이 뜨거나 Sentry에 한 건은 쌓였을 거예요. 둘 다 없었으니 페이지를 돌리던 프로세스가 통째로 사라졌다고 추측했어요.

iOS 앱 안의 웹뷰(WKWebView)는 웹페이지를 앱과 다른 프로세스에서 그려요. iOS는 메모리를 너무 많이 쓰는 프로세스를 경고 없이 종료해 버리고요. 이때 JS는 에러를 던질 틈도 없이 멈추고 웹뷰는 페이지를 처음부터 다시 불러와요.

아이폰 웹뷰는 Safari와 같은 WebKit 엔진으로 페이지를 그려요. 그래서 브라우저를 코드로 조작하는 테스트 도구인 Playwright로 WebKit을 띄우고, 화면을 아이폰 크기(390×844)에 3배 해상도로 맞췄어요. 지도 데이터는 제보받은 계정이 받던 응답을 그대로 넣어 그 아이가 보던 지도를 똑같이 만들었어요.

메모리는 macOS의 footprint 명령으로 쟀어요. 프로세스가 메모리를 어디에 얼마나 쓰는지 항목별로 보여 주는 명령이에요. 그중 화면에 그릴 이미지를 담아 두는 그래픽 메모리를 0.15초마다 재서 화면 위에 숫자로 띄웠어요. 전체 메모리도 봤지만 JS 가비지 컬렉션 때문에 한 번 재는 사이에도 수백 MB씩 오르내려 비교에 쓰기 어려웠어요.

제보받은 계정의 지도

스크롤할 때마다 그래픽 메모리가 300MB 넘게 오르는 지도 화면
화면 위 숫자가 그래픽 메모리예요.
MB01002003000초5초10초15초20초
그래픽 메모리

스크롤해서 새 타일이 화면에 들어올 때마다 그래픽 메모리가 300MB 넘게 뛰었어요. 스크롤을 멈추면 내려갔다가, 다시 스크롤하면 또 치솟았어요. 같은 조작을 여러 번 해 보니 최댓값은 215~333MB 사이에서 나왔어요.

스크롤을 계속하면 왜 꺼지는지도 여기서 보였어요. 스크롤할 때마다 화면에 새로 들어온 타일을 전부 다시 그려야 해요. 그때마다 페이지가 원래 쓰던 메모리 위에 300MB 안팎이 단번에 얹혀요. iOS는 앱 안의 웹페이지 프로세스가 쓸 수 있는 메모리에 한도를 둬요. 이게 되풀이되다 어느 순간 한도를 넘으면 그대로 종료돼요. 웹 에서는(MacOS) 메모리가 넉넉해서 이 재현에서 프로세스가 죽지는 않았어요.

할로윈 맵에는 섬 그림자, 색 보정, 칸 애니메이션 같은 효과가 여럿 걸려 있어요. 어느 것이 범인인지 보려고 효과를 하나씩 끄고 같은 조작을 반복했어요. 효과를 끌 때는 조건마다 새 페이지를 열고 첫 화면을 그리기 전에 CSS를 끼워 넣었어요.

지도를 오가며 스크롤할 때 그래픽 메모리 최댓값여러 번 잰 범위예요. 진한 막대가 가장 낮게, 옅은 막대가 가장 높게 나온 값이에요.
그대로215~333MB
filter 전부 끔2~3MB
길 빛 효과만 끔22~38MB
빛 효과에서 drop-shadow만 뺌38MB

할로윈 맵 길에 건 빛 효과 하나만 껐는데 300MB 가까이 쓰던 그래픽 메모리가 22~38MB로 떨어졌어요. 그 효과가 이 CSS예요.

.night-road-tile {
  filter: hue-rotate(-22deg) saturate(1.9) brightness(1.02)
    drop-shadow(0 0 6px rgba(255, 140, 40, 0.6));
}

CSS filter는 요소를 다 그린 결과에 이미지 효과를 입혀요. 앞의 세 함수가 길 색을 바꾸고, drop-shadow가 길 둘레에 주황빛을 번지게 해요. box-shadow 대신 drop-shadow를 쓴 건 마름모 타일 모양을 따라 빛이 번지게 하려고였어요.

그런데 drop-shadow만 빼도 메모리가 치솟지 않았어요.

그림자 효과 하나가 어떻게 300MB를 쓸 수 있을까요?

브라우저가 화면을 그리는 순서부터 따라가 볼게요.

1) 브라우저는 화면을 네 단계로 그려요

스타일어떤 CSS가 걸리는지 계산
레이아웃위치와 크기 계산
페인트픽셀로 그리기
합성따로 그린 층을 겹쳐 화면에
filter 없음요소를 그리던 자리에 바로 그려요.
filter 있음임시 캔버스에 먼저 그리고, 그 그림 전체에 효과를 입힌 다음 옮겨 그려요.
filter는 레이아웃이 아니라 페인트 단계의 비용이에요.

화면에서 뭔가 바뀌면 브라우저는 스타일 계산, 레이아웃, 페인트, 합성 순서로 화면을 다시 만들어요. filter는 요소의 크기나 위치를 바꾸지 않으니 레이아웃은 그대로예요. 대신 페인트 단계가 달라져요.

filter는 요소와 그 안의 내용을 먼저 한 장으로 다 그려 놓은 다음 그 그림 전체에 효과를 입혀요. 그러려면 효과를 입히기 전에 그림을 올려 둘 임시 캔버스(오프스크린 버퍼)가 따로 있어야 해요. drop-shadow는 빛이 번질 자리까지 그려야 하니 임시 캔버스가 요소보다 조금 더 커요.

임시 캔버스는 픽셀마다 4바이트를 써요. 아이폰은 3배 해상도라 CSS 1px이 실제로는 3×3, 9픽셀이에요. 같은 크기의 임시 캔버스라도 일반 모니터보다 9배 무거워요.

2) drop-shadow가 끼면 임시 캔버스 방식으로 그려요

WebKit이 filter를 처리하는 방법은 세 가지예요.

방법언제 쓰나추가 메모리
GPU에 맡기기요소에 따로 합성 레이어(GPU가 따로 들고 있다가 겹쳐 그리는 층)가 있을 때적음
그리면서 바로 적용색 보정처럼 그리는 도중에 계산할 수 있을 때없음
임시 캔버스에 그린 뒤 적용위 두 방법을 쓸 수 없을 때임시 캔버스만큼

WebKit은 이 중에서 filter에 적힌 함수들이 모두 지원하는 방법을 골라요. hue-rotate, saturate, brightness는 색을 바꾸는 계산이라 그리면서 바로 적용할 수 있어요. drop-shadow는 공개된 WebKit 소스 기준으로 이 방법을 지원하지 않아요. 하나라도 끼면 filter 전체가 임시 캔버스 방식으로 바뀌어요.

GPU에 맡기는 방법은 요소에 따로 합성 레이어가 있을 때만 쓸 수 있어요. WKWebView는 filter가 붙었다는 이유만으로 합성 레이어를 만들지 않아요. 저희 타일은 위치만 잡아 둔 평범한 div였으니 임시 캔버스 방식으로 그려졌어요.

타일 30장짜리 테스트 페이지로 확인해 봤어요. 색을 바꾸는 함수만 걸면 그래픽 메모리가 0MB, 거기에 drop-shadow를 더하면 11MB였어요.

3) 임시 캔버스는 타일마다 하나씩 생겨요

임시 캔버스는 filter가 걸린 요소마다 하나씩 생겨요. 테스트 페이지에서 footprint를 보니 그래픽 메모리 덩어리가 정확히 타일 수(30개)만큼 있었어요. 아래 버튼으로 직접 바꿔 보세요. 점선 상자가 임시 캔버스예요.

filter 계산 10번 · 임시 캔버스 10장

타일마다 filter를 걸면 타일 수만큼 임시 캔버스가 생겨요. 실제 지도에는 이런 타일이 40장 넘게 있었어요.

이때 임시 캔버스 하나는 368KB였어요. 타일(94×48)에 빛이 번질 여백을 더하고, 9배 픽셀에 4바이트를 곱한 크기 그대로예요. 30장을 합쳐도 11MB라 아직 큰일은 아니에요.

4) 스크롤 영역 안에서는 임시 캔버스가 20배 커져요

그런데 실제 지도는 페이지가 아니라 overflow-y: auto를 준 상자 안에서 스크롤돼요. 같은 타일 30장을 이런 스크롤 영역 안에 넣었더니 결과가 확 달라졌어요.

타일 30장, 3배 해상도그래픽 메모리임시 캔버스 하나
일반 페이지11MB368KB
스크롤 영역 안142~215MB최대 7.3MB
스크롤 영역 안, filter는 부모에 한 번8MB–

임시 캔버스 수는 30개 그대로인데 하나가 20배 커졌어요. 스크롤 영역 폭을 줄이면 임시 캔버스도 같이 작아졌어요. 임시 캔버스 크기가 타일이 아니라 스크롤 영역 폭을 따라가고 있었던 거예요.

타일 40여 장이 장당 7MB 안팎을 잡으면 300MB 가까이 되고, 스크롤할 때마다 이걸 다시 그려요. 실제 지도에서 잰 215~333MB와 같은 범위예요. iOS의 WebKit은 이 그래픽 메모리를 웹페이지 프로세스 몫으로 달아 두기 때문에, 임시 캔버스가 늘수록 웹페이지 프로세스가 한도에 가까워지고 넘으면 iOS가 종료해요.

안드로이드 웹뷰는 Chromium 엔진으로 그려요. 같은 재현을 Chromium으로 돌려 보니 고치기 전과 후의 그래픽 메모리가 거의 같았어요. 타일 30장 테스트 페이지에서도 타일마다 건 경우와 묶음에 한 번 건 경우가 똑같이 18~23MB였어요.

Chromium은 filter를 요소마다 임시 캔버스에 따로 그려 두지 않아요. 화면을 작은 조각으로 나눠 그리면서, 조각 하나를 그리는 순간에만 임시 캔버스를 만들어 filter를 적용하고 바로 놓아요. 그래서 타일 40장에 filter를 걸어도 임시 캔버스가 40개씩 쌓이지 않아요. 요소마다 임시 캔버스를 붙잡고 있는 건 WebKit의 방식이라 아이폰에서만 문제가 됐어요.

원인을 알고 나니 고치는 건 간단했어요. 타일마다 똑같이 걸던 filter를 타일을 감싼 부모에 한 번만 걸면 돼요.

// 고치기 전: 타일마다 filter

{
  roads.map((road) => (
    <RoadTile key={road.key} road={road} style={{ filter: NIGHT_GLOW }} />
  ))
}
// 고친 후: 묶음에 한 번

<div className="absolute inset-0" style={{ filter: NIGHT_GLOW }}>
  {roads.map((road) => (
    <RoadTile key={road.key} road={road} />
  ))}
</div>

부모에 건 filter는 안의 타일 전체를 한 장의 그림으로 보고 한 번만 계산해요. 타일이 10장이든 40장이든 임시 캔버스는 하나예요.

고친 코드로 같은 재현을 다시 돌려 봤어요.

고치기 전 · 타일마다 filter

고치기 전 지도. 스크롤할 때마다 그래픽 메모리가 300MB 넘게 오른다

고친 후 · 묶음에 한 번

고친 후 지도. 같은 조작에서 그래픽 메모리가 46MB 아래에 머문다
고치기 전후 그래픽 메모리 최댓값
고치기 전215~333MB
고친 후46MB

최댓값이 300MB 안팎에서 46MB로 내려왔어요.

고친 코드는 filter를 부모로 옮긴 몇 줄이 전부였어요. 정작 오래 걸린 건 에러 하나 없이 꺼지는 앱에서 단서를 찾는 일이었어요.

CSS 한 줄도 화면 밖에서 메모리를 쓰고 있고, 그 비용이 엔진마다 다르다는 걸 이번에 제대로 알게 됐어요.

참고