Swift와 WebAssembly로 Goodnotes를 웹에 구현한 방법
Bringing Goodnotes to the web with Swift and WebAssembly
핵심 요약
- Goodnotes는 iOS 앱에 쓰던 Swift 코드의 상당 부분을 WebAssembly 웹 앱과 공유해 필기 렌더링과 문서 처리 동작을 플랫폼 간 일관되게 유지했다.
- WASI Threads, Web Workers, SharedArrayBuffer를 이용한 병렬 처리로 필기 인식과 문서 색인 작업을 백그라운드로 옮겼으며, 복잡한 작업 중 INP가 2배 이상 개선됐다.
- WebAssembly에서는 libdispatch 사용이 불가능하고 wasm32의 Int가 32비트인 점을 고려해야 하며, 멀티스레딩에는 브라우저의 Cross-Origin Isolation 요구사항이 따른다.
도입
Goodnotes는 2021년 웹 앱 개발을 시작하면서 10년 넘게 축적한 Swift 코드와 디지털 잉크 렌더링 엔진을 재작성하지 않고 활용하는 방식을 택했다. 60FPS 이상의 실시간 렌더링이 필요했고, 별도 구현은 출시를 늦추고 플랫폼별 동작 차이를 만들 수 있었다. SwiftWasm으로 필기 컴포넌트를 시험한 뒤 WebAssembly 경로를 선택했다.
상세 요약
Swift 코드를 웹으로 가져온 배경
Goodnotes는 2021년 웹 앱 개발을 시작할 당시 10년 넘게 쌓은 Swift 코드베이스를 보유하고 있었다. 여기에는 디지털 잉크 렌더링 최적화, 문서 동기화와 CRDT 기반 충돌 해결, 콘텐츠 검색과 문서 색인 기능이 포함됐다. 실시간 잉크 렌더링은 60FPS 이상을 요구했다. 렌더링 엔진을 JavaScript, Flutter, Kotlin Multiplatform으로 다시 구현하면 개발이 수년 지연되고 플랫폼별 동작 차이가 생길 수 있다고 판단했다.
팀은 성능에 민감한 필기 컴포넌트로 SwiftWasm을 시험한 뒤 이 경로를 채택했다. 핵심은 코드 재사용뿐 아니라, iPad와 웹에서 같은 Swift 코드가 잉크 곡선, 압력 감도, 잉크 흐름을 처리해 동작을 일치시키는 것이었다.
공유 코드와 플랫폼별 구현
아키텍처는 플랫폼 UI와 공유 비즈니스 로직을 분리한다.
- 렌더링 엔진: 노트 콘텐츠와 잉크 스트로크를 실시간 처리한다. 렌더링 로직 대부분은 공유하고, 그래픽 API는 iOS에서 Metal, 웹에서 WebGL을 사용한다.
- 비즈니스 로직: 문서 모델, 필기 인식, 문서 색인은 공유 Swift 패키지로 구현했다.
- 뷰 모델: 도구 상호작용과 사용자 제스처를 처리하는 핵심 뷰 모델을 공유한다.
웹 앱의 Swift 코드는 총 220만 줄이며, 이 가운데 147만 줄을 공유한다. 공유 코드는 웹 앱의 66%, iOS 앱 코드의 34%에 해당한다. 최종 WebAssembly 바이너리는 약 50MB이고 Brotli 압축 후 12MB다. Service Workers를 캐싱에 사용한다. JavaScriptKit으로 JavaScript와 연동해 웹 생태계와 접점을 유지한다.
WebAssembly 대상에서 조정한 부분
WebAssembly 타깃을 함께 지원하면서 다음 차이를 처리했다.
- libdispatch API를 사용할 수 없어 직접적인 사용을 Swift Concurrency의
async/await와 actor로 옮겼다. - wasm32에서
Int는 32비트다. 64비트 값을 전제로 한 코드는Int64를 명시하도록 수정했다. - 네트워크와 기타 I/O는 의존성 주입으로 추상화하고, 플랫폼별 구현을 연결했다.
Web Workers를 통한 병렬 처리
Goodnotes는 WASI Threads, Web Workers, SharedArrayBuffer를 이용해 병렬 실행을 구현했다. 필기 인식과 문서 색인을 백그라운드에서 처리해 복잡한 작업 중에도 메인 스레드의 렌더링을 60FPS 이상으로 유지한다. 멀티스레딩 적용으로 복잡한 작업 중 Interaction to Next Paint(INP)가 2배 넘게 개선됐다.
JavaScript 객체는 생성된 스레드에 격리되므로 Swift actor의 실행 위치를 제어해야 했다. Swift Concurrency의 Custom Actor Executors(SE-0392)와 JavaScriptKit의 SerialExecutor를 사용해 특정 actor를 전용 Web Worker에 고정했다. SharedArrayBuffer 사용에는 브라우저의 Cross-Origin Isolation이 필요하다. 이를 충족하기 어려운 앱에는 단일 스레드 협력형 동시 실행도 선택지로 제시했다.
개발과 디버깅
개발에는 Xcode 또는 SourceKit-LSP를 설정한 VS Code를 사용할 수 있다. Xcode는 WebAssembly 플랫폼을 지원하지 않아 관련 API의 코드 완성 기능이 제한적이다. 반면 .sourcekit-lsp/config.json을 설정하면 Swift SDK 지원을 통해 WebAssembly 타깃의 코드 완성을 사용할 수 있다.
Chrome DevTools에서 Swift 코드의 중단점, 변수 검사, 단계별 실행이 가능하다. Goodnotes는 Swift 변수 반영과 소스 수준 디버깅을 위한 Chrome DevTools 확장 라이브러리를 개발했다. Chrome의 Performance 및 Memory 탭으로 함수별 시간과 메모리 사용을 살피고, 더 상세한 힙 분석에는 wasm-memprof를 활용했다.
Goodnotes는 WebAssembly 관련 변경을 Swift 프로젝트에 반영했으며, Swift 6.2부터 WebAssembly 플랫폼이 지원된다고 설명한다. 관련 사례는 Swift와 WebAssembly로 브라우저에 확장된 Goodnotes에서도 다룬다. 팀은 성능 핵심 컴포넌트부터 검증하고, 플랫폼 추상화와 Swift Concurrency를 활용하며, 멀티스레딩이 필요하다면 SharedArrayBuffer의 보안 요건을 미리 고려하라고 권한다.
결론
이 사례에서 코드 공유는 단순히 구현량을 줄이는 데 그치지 않고, iOS와 웹에서 같은 렌더링 및 문서 로직을 실행해 동작 일관성을 유지하는 기반이 됐다. 다만 WebAssembly 대상의 동시성·정수 폭 차이를 반영하고, 병렬 실행에는 브라우저 보안 설정을 맞춰야 한다. Goodnotes는 성능 핵심 컴포넌트부터 검증하고 플랫폼 추상화와 점진적 도입을 계획하라고 권한다.