Notion이 WASM SQLite로 브라우저에서의 속도를 향상시킨 방법
(notion.so)- 3년 전 Notion은 SQLite 데이터베이스를 사용하여 클라이언트에 데이터를 캐싱함으로써 Mac과 Windows용 Notion 앱의 속도를 성공적으로 향상시킴
- 이번에는 브라우저를 통해 Notion에 접속하는 사용자들에게도 같은 개선 사항을 제공할 수 있게 되었음
- 이 글은 WebAssembly(WASM) 구현의 sqlite3를 사용하여 브라우저에서 Notion의 성능을 개선한 방법을 심층 분석한 내용
- SQLite를 사용하면 모든 최신 브라우저에서 페이지 탐색 시간이 20% 개선되었음
- 특히 인터넷 연결 등의 외부 요인으로 인해 API 응답 시간이 특히 느린 사용자의 경우 그 차이가 더욱 두드러졌음
- 예를 들어 호주 사용자의 경우 페이지 탐색 시간이 28%, 중국 사용자는 31%, 인도 사용자는 33% 빨라졌음
핵심 기술: OPFS와 Web Workers
- WASM SQLite 라이브러리는 데이터를 세션 간에 유지하기 위해 Origin Private File System(OPFS)이라는 최신 브라우저 API를 사용함
- OPFS는 Web Workers에서만 사용 가능함
- Web Worker는 브라우저에서 대부분의 JavaScript가 실행되는 메인 스레드와 다른 별도의 스레드에서 실행되는 코드로 생각할 수 있음
- Notion은 Webpack과 함께 번들링되는데, Web Worker를 로드하기 위한 사용하기 쉬운 구문을 제공함
- Web Worker를 설정하여 OPFS를 사용하여 SQLite 데이터베이스 파일을 만들거나 기존 파일을 로드하도록 했고, 이 Web Worker에서 기존 캐싱 코드를 실행함
- Comlink 라이브러리를 사용하여 메인 스레드와 Worker 간에 메시지 전달을 쉽게 관리함
SharedWorker 기반 접근 방식
- 최종 아키텍처는 Roy Hashimoto가 GitHub 토론에서 제시한 새로운 솔루션을 기반으로 함
- 한 번에 하나의 탭만 SQLite에 액세스하면서 다른 탭에서도 SQLite 쿼리를 실행할 수 있도록 하는 접근 방식
- 이 새로운 아키텍처는 어떻게 작동하는가?
- 간단히 말해, 각 탭에는 SQLite에 쓸 수 있는 전용 Web Worker가 있음
- 그러나 실제로 Web Worker를 사용할 수 있는 것은 한 탭뿐임
- SharedWorker는 "활성 탭"이 무엇인지 관리하는 역할을 함
- 활성 탭이 닫히면 SharedWorker는 새로운 활성 탭을 선택해야 한다는 것을 알고 있음
- SQLite 쿼리를 실행하려면 각 탭의 메인 스레드가 해당 쿼리를 SharedWorker로 보내고, SharedWorker는 활성 탭의 전용 Worker로 리디렉션함
- 탭은 원하는 만큼 동시에 SQLite 쿼리를 실행할 수 있으며, 항상 단일 활성 탭으로 라우팅됨
- 각 Web Worker는 모든 주요 브라우저에서 작동하는 OPFS SyncAccessHandle Pool VFS 구현을 사용하여 SQLite 데이터베이스에 액세스함
간단한 접근 방식이 작동하지 않은 이유
- 앞에서 설명한 아키텍처를 구축하기 전에, 탭마다 전용 Web Worker를 두고 각 Web Worker가 SQLite 데이터베이스에 쓰는 보다 간단한 방식으로 WASM SQLite를 구동하려고 시도함
- 그러나 어느 것도 그대로 사용하기에는 Notion의 요구 사항에 충분하지 않았음
걸림돌 #1: 크로스 오리진 격리
- OPFS via sqlite3_vfs를 사용하려면 사이트가 "크로스 오리진 격리" 상태여야 함
- 크로스 오리진 격리를 페이지에 추가하려면 로드할 수 있는 스크립트를 제한하는 몇 가지 보안 헤더를 설정해야 함
- 이 헤더를 설정하는 것은 상당한 작업이 될 수 있음
- Notion은 웹 인프라의 다양한 기능을 구동하기 위해 많은 제3자 스크립트에 의존하고 있어 완전한 크로스 오리진 격리를 달성하려면 각 벤더에게 새로운 헤더를 설정하고 iframe 작동 방식을 변경하도록 요청해야 했음 - 이는 현실적으로 어려운 요구 사항이었음
- 테스트에서는 Chrome과 Edge 브라우저에서 사용 가능한 SharedArrayBuffer용 Origin Trials를 사용하여 사용자 하위 집합에 이 변형을 제공함으로써 중요한 성능 데이터를 얻을 수 있었음
- 이러한 Origin Trials를 사용하면 크로스 오리진 격리 요구 사항을 일시적으로 우회할 수 있었음
걸림돌 #2: 손상 문제
- OPFS via sqlite3_vfs를 소수의 사용자에게 제공했을 때, 일부 사용자에게 심각한 버그가 발생하기 시작함
- 이 사용자들은 페이지에 잘못된 데이터를 보게 됨
- 예를 들어 잘못된 동료에게 할당된 댓글이나 미리 보기가 완전히 다른 페이지인 새 페이지에 대한 링크 등
- 이 버그의 영향을 받은 사용자의 데이터베이스 파일을 보면 SQLite 데이터베이스가 어떤 식으로든 손상된 패턴이 있었음
- 특정 테이블의 행을 선택하면 오류가 발생했고, 행 자체를 검사했을 때 동일한 ID를 가진 여러 행에 서로 다른 내용이 있는 등의 데이터 일관성 문제가 발견됨
- SQLite 데이터베이스가 어떻게 그런 상태가 되었는지에 대해, 동시성 문제로 인해 발생한 것으로 추측함
- 여러 탭이 열려 있고, 각 탭에는 SQLite 데이터베이스에 대한 활성 연결이 있는 전용 Web Worker가 있었기 때문
- Notion 애플리케이션은 서버에서 업데이트를 받을 때마다, 즉 탭이 동시에 같은 파일에 쓰게 될 때마다 캐시에 자주 씀
- 이미 SQLite 쿼리를 함께 일괄 처리하는 트랜잭션 접근 방식을 사용하고 있었지만, OPFS API 측의 동시성 처리가 부족하여 손상이 발생한 것으로 강하게 의심됨
- 그래서 손상 오류를 로깅하기 시작했고 Web Locks를 추가하고 포커스된 탭만 SQLite에 쓰도록 하는 등의 몇 가지 땜빵 접근 방식을 시도함
- 이러한 조정으로 손상률은 낮아졌지만, 프로덕션 트래픽에 다시 기능을 켤 수 있을 만큼 충분하지는 않았음
- 그래도 동시성 문제가 손상에 상당히 기여하고 있다는 것을 확인할 수 있었음
- Notion 데스크톱 앱에서는 이 문제가 발생하지 않았음
- 해당 플랫폼에서는 단일 부모 프로세스만 SQLite에 씀
- 앱에서 원하는 만큼 많은 탭을 열 수 있지만 항상 단일 스레드만 데이터베이스 파일에 액세스함
걸림돌 #3: 대안은 한 번에 하나의 탭에서만 실행될 수 있음
- OPFS SyncAccessHandle Pool VFS 변형도 평가했음
- 이 변형은 SharedArrayBuffer가 필요하지 않아 Safari, Firefox 및 SharedArrayBuffer용 Origin Trial이 없는 기타 브라우저에서 사용할 수 있음
- 이 변형의 단점은 한 번에 하나의 탭에서만 실행될 수 있다는 것
- 후속 탭에서 SQLite 데이터베이스를 열려고 하면 단순히 오류가 발생함
- 한편으로 이는 OPFS SyncAccessHandle Pool VFS가 OPFS via sqlite3_vfs 변형의 동시성 문제가 없다는 것을 의미함
- 소수의 사용자에게 제공했을 때 손상 문제가 발견되지 않은 것으로 이를 확인함
- 다른 한편으로는 모든 사용자 탭이 캐싱의 혜택을 받기를 원했기 때문에 이 변형을 그대로 출시할 수 없었음
문제 해결
- 어느 변형도 그대로 사용할 수 없다는 사실이 위에서 설명한 SharedWorker 아키텍처를 구축하게 된 계기가 되었음
- 이 아키텍처는 이 SQLite 변형 중 하나와 호환됨
- OPFS via sqlite3_vfs 변형을 사용할 때는 한 번에 하나의 탭만 쓰기 때문에 손상 문제를 피할 수 있음
- OPFS SyncAccessHandle Pool VFS 변형을 사용하면 SharedWorker 덕분에 모든 탭에서 캐싱이 가능함
- 이 아키텍처가 두 변형에서 모두 작동하고, 측정 지표에서 성능 향상이 눈에 띄며, 손상 문제가 없다는 것을 확인한 후, 어떤 변형을 제공할지 최종 선택해야 할 때가 되었음
- OPFS SyncAccessHandle Pool VFS를 선택했는데, 크로스 오리진 격리 요구 사항이 없어 Chrome과 Edge 이외의 브라우저로 출시하는 것을 막지 않았기 때문
성능 저하 완화
- 이 개선 사항을 사용자에게 제공하기 시작했을 때, 로드 시간이 느려지는 등 수정해야 할 몇 가지 성능 저하가 발견됨
페이지 로드가 느려짐
- 첫 번째 발견은 Notion 페이지 간 이동은 더 빨라졌지만 초기 페이지 로드는 더 느려졌다는 것
- 프로파일링 결과, 페이지 로드는 일반적으로 데이터 가져오기에 병목 현상이 발생하지 않는다는 것을 깨달음
- Notion의 앱 부팅 코드는 API 호출이 완료되기를 기다리는 동안 다른 작업(JS 파싱, 앱 설정 등)을 실행하므로 탐색만큼 SQLite 캐싱의 혜택을 받지 못함
- 느려진 이유는 사용자가 WASM SQLite 라이브러리를 다운로드하고 처리해야 했기 때문
- 이는 페이지 로드 프로세스를 차단하여 다른 페이지 로드 작업이 동시에 발생하지 않도록 함
- 이 라이브러리의 크기가 몇 백 킬로바이트이기 때문에 추가 시간이 측정 지표에서 눈에 띄게 나타남
- 이를 해결하기 위해 라이브러리 로드 방식을 약간 수정함
- WASM SQLite를 완전히 비동기식으로 로드하고 페이지 로드를 차단하지 않도록 함
- 이는 초기 페이지 데이터가 SQLite에서 로드되는 경우는 거의 없을 것이라는 의미였음
- 이는 괜찮았는데, SQLite에서 초기 페이지를 로드하여 얻는 속도 향상이 라이브러리 다운로드로 인한 속도 저하보다 크지 않다는 것을 객관적으로 판단했기 때문
- 변경 사항을 적용한 후 초기 페이지 로드 측정 지표는 실험의 테스트 그룹과 대조 그룹 사이에 동일해짐
느린 기기는 캐싱의 혜택을 받지 못함
- 측정 지표에서 발견한 또 다른 현상은 Notion 페이지에서 다른 페이지로 이동하는 중간값 시간은 더 빨라졌지만, 95번째 백분위수 시간은 더 느려졌다는 것
- Notion을 가리키는 브라우저가 있는 모바일 폰과 같은 특정 기기는 캐싱의 혜택을 받지 못했고 오히려 더 나빠졌음
- 이 수수께끼에 대한 답을 모바일 팀에서 실행한 이전 조사에서 발견함
- 네이티브 모바일 애플리케이션에서 이 캐싱을 구현했을 때 구형 Android 폰과 같은 일부 기기는 디스크에서 매우 느리게 읽었음
- 따라서 디스크 캐시에서 데이터를 로드하는 것이 API에서 동일한 데이터를 로드하는 것보다 더 빠를 것이라고 가정할 수 없었음
- 모바일 조사 결과, 페이지 로드에는 이미 두 개의 비동기 요청(SQLite와 API)을 서로 "경쟁"시키는 일부 로직이 있었음
- 탐색 클릭을 위한 코드 경로에서 이 로직을 단순히 다시 구현함
- 이는 실험의 두 그룹 간 탐색 시간의 95번째 백분위수를 동일하게 만들어 줌
결론
- 브라우저에서 Notion에 SQLite의 성능 개선 사항을 제공하는 것은 나름의 어려움이 있었음
- 특히 새로운 기술과 관련하여 일련의 알 수 없는 문제에 직면했고 그 과정에서 몇 가지 교훈을 얻었음:
- OPFS는 기본적으로 동시성을 우아하게 처리하지 않음. 개발자는 이를 인식하고 그에 맞게 설계해야 함
- Web Workers와 SharedWorkers(그리고 이 글에서 언급되지 않은 사촌 Service Workers)는 서로 다른 기능을 가지고 있으며, 필요한 경우 이들을 결합하는 것이 유용할 수 있음
- 2024년 봄 현재 정교한 웹 애플리케이션에서 크로스 오리진 격리를 완전히 구현하는 것은 쉽지 않음. 특히 타사 스크립트를 사용하는 경우 더욱 그러함
- 사용자를 위해 브라우저에 SQLite로 데이터를 캐싱한 결과, 앞서 언급한 탐색 시간 20% 향상을 보았고 다른 측정 지표가 저하되는 것은 보지 못했음
- 중요한 것은 SQLite 손상으로 인한 문제가 관찰되지 않았다는 것
- 이 최종 접근 방식의 성공과 안정성은 SQLite의 공식 WASM 구현을 담당한 팀, 그리고 대중에게 실험적 접근 방식을 제공한 Roy Hashimoto 덕분이라고 생각함
제 firefox에서 노션 페이지를 열면 먹통이 되어 쓸 수가 없는데 이거 때문이려나요.. (노션앱은 잘 동작해서 일단 그걸로 쓰고 있음)