14편 - 웹 워커, 어디까지 써봤니?
TMAP js sdk(이하 TMAP JS)는 TMAP의 데이터를 브라우저 상에서 해석하여 표현할 수 있도록 제작된 js 지도 엔진입니다. WebGL을 이용한 풀 벡터 방식으로 기존 TMAP native sdk와 동일하게 지도 화면을 표출할 수 있고, 사용자가 직접 데이터를 추가할 수도 있는데요.
오늘은 이 TMAP JS 개발 시 성능을 개선하는데 가장 큰 역할을 했던 웹 워커 사용 방식에 대해 소개하고자 합니다.
일반적인 js 튜닝 포인트
js는 특이하게도 하나의 스레드로 동작하는 언어입니다. 때문에 오랜 시간 동안 스레드를 점유하는 동작을 하는 경우 연산 수행이나 렌더링이 지연되기 때문에 사용자의 인터랙션이 바로 동작하지 않거나 화면이 버벅거리게 됩니다. 지도처럼 화면에 그려지는 그림들이 연속적으로 애니메이션 되는 형태일 때 이런 성능 저하는 특히 두드러져 보이게 되고요.
일반적으로 이런 성능 저하가 일어났을 때 웹개발자들은 Web api들을 이용하여 여러 동작들을 작게, 비동기로 끊어가며 실행 스택이 꽉 차지 않도록 제어하여 여러 pipeline이 무리하게 실행되지 않도록 조절해 나갑니다.
그러나 지도는
그러나 지도는 정말 많은 데이터를 핸들링하는 엔진입니다. 단순히 한 장면만을 표현하는데도 땅, 길, 건물, 아이콘, 텍스트들을 형형색색으로 위치를 잡아 표시해야 하죠. 거기다 지도의 기본 동작인 패닝이나 줌을 한다면? 한순간에 어마어마한 데이터 연산이 필요하게 됩니다. 때문에 이런 부하를 줄이기 위해 미리 해당 정보들을 이미지로 만들어 놓고 그걸 기반으로 지도를 표현하기도 합니다.
하지만 TMAP JS는 기존 TMAP처럼 풀 벡터 렌더링을 목표로 했기 때문에 이러한 접근 방식을 사용할 수 없었고 모든 형상과 스타일을 실시간으로 받아 해석하고 렌더링까지 고스란히 브라우저에서 수행해야 했습니다.
일반적으로 웹 지도에서는 모든 지도 데이터를 한 번에 받을 수도 없을뿐더러 작업 단위의 효율 등을 위해서 타일맵 형태가 널리 사용되고 있는데, 이러한 타일 단위 작업 파편화로 얻을 수 있는 데이터 최적화, 캐시 효율 극대화, 균등한 자원 분배, 부분 가공 및 노출을 통한 반응성 증대 등 서버-클라이언트 구분할 것 없이 할 수 있는 대부분의 지도 자체 성능 튜닝을 진행했음에도 여전히 원하는 성능을 얻을 수 없었습니다.
다행히 대부분의 모던 브라우저들이 WebGL을 지원하므로 GPU 차원에서 렌더링 파이프라인은 매우 효율적으로 사용할 수 있긴 하지만 쏟아지는 부하 자체를 감당할 더 선제적인 방법이 필요했습니다.
워커
그렇습니다 우리는 워커를 사용해야 했습니다.
웹 워커(Web worker)란 싱글 스레드인 js의 단점을 보완하기 위해 만들어진 웹 API입니다. 무거운 작업을 웹 애플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행함으로써 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.
웹 워커의 기본 동작을 먼저 살펴보겠습니다.
<워커 예제-메인스레드>
<워커 예제-워커스레드>
워커를 사용하는 예제들을 찾아보면 이렇듯 메인 스레드에서 무거운 작업의 한 부분을 옮기는 데 사용되곤 합니다.
하지만 사용자가 끊임없이 인터랙션 할 수 있고 동시에 매끄러운 애니메이션도 기대하는 지도라는 API 특성상 우리는 가능한 많은 여유를 메인 스레드에 남겨야 했습니다. 즉 워커에서 할 수 있는 건 전부 다! 넘기기로 한 것이죠.
적용
이제 지도에 적용해 볼 차례입니다. 먼저 지도를 노출하는 파이프라인은 대략 이렇습니다.
다른 플랫폼들과 동일하게 최종 렌더는 메인 스레드에서 수행되기 때문에 우리는 데이터 요청부터 렌더 전처리 과정까지 통째로 워커에 위임하기로 했고, 그에 따라 산출된 feature는 이렇습니다.
main <-> worker 데이터 통신 시스템
워커로 이전된 작업의 스케줄링
여러개의 워커 인스턴스를 위한 풀링
중복코드 없는 하나의 파일 빌드
main <-> worker 통신 시스템 구축, transferble
다른 플랫폼들과는 다르게 js에서 웹 워커 스레드와 메인 스레드는 변수를 공유할 수 없기 때문에 매개변수와 데이터를 모두 인코딩해야 하고 각각의 요청과 응답을 매칭하는 작업도 추가해야 합니다. 우리는 스레드 간 데이터를 주고받는 시스템을 먼저 구축했습니다.
메인 스레드와 워커 스레드 말단에 각각 입구점을 설정하고 모든 postmessage와 onmessage를 집약시켜 실제 사용 시 통신을 의식하지 않고 비동기 구문으로 사용할 수 있도록 했고, 각각의 wrapper 들 내부에서 message 들은 모두 serialize / deserialize 과정을 거쳐 전달 가능한 포맷으로 자동 변경되도록 처리했습니다.
메인 스레드와 워커 스레드의 데이터 전송에는 알아 둬야 할 점들이 더 있었습니다.
바로 데이터가 공유되지 않고 복사된다는 점과 몇 가지 타입들은 지원되지 않는다는 점이었죠. 즉, 복사에 대한 오버헤드도 있다는 걸 염두에 둘 필요가 있으며, Function이나 Dom node 같은 경우는 전송이 불가하고 object의 경우 property 들의 기본 형태만 복사될 뿐 메타데이터 성격의 요소들은 모두 배제되기 때문에 class instance 가 제대로 deserialize 되지 않는 등의 문제가 있습니다. 워커의 개념과 동작을 잘 생각해 보면 당연한 듯한 이 제약들이, 메인 스레드와 워커 스레드 간에 공유해야 하는 로직과 데이터가 많을수록 상당한 부담으로 작용합니다. 모든 메시지마다 전송을 위해 최적화된 객체들을 추가 정의 및 관리하는 것도 수지에 맞지 않는 것 같고... 생각보다 간단한 문제는 아니었습니다.
그래서 우리가 추가 정의한 serialize / deserialize에선, array의 elements 나 object 와 class의 properties 들처럼 중첩 참조 가능한 내부 데이터들까지 전송 가능한 타입별로 일반화된 기준을 만들어 변환을 적용하며, 이때 복사할 데이터를 처리함과 동시에 이동이 필요한 대상도 같이 뽑아내는 게 기본 동작입니다. Transferable Object라는 개념을 적용할 수 있는 대상을 찾고 해당 reference가 이동시킬 대상이라고 알려줄 배열에 넣어주는 거죠. 그 외에 공유가 필요한 class 들의 경우 각각 필요에 맞는 개별 변환 어댑터를 등록하거나 prototype을 이용하여 온전한 class instance를 생성해 주는 동작도 포함됩니다.
<serialize, deserialize 슈도코드>
단순 Boolean, String, Number 등의 기본 타입들은 복사되어도 크게 오버헤드가 나타나지 않았지만, 커다란 지도 데이터를 담은 Array, Object 객체들은 스레드 간 복사만으로도 심각한 성능 저하를 보였습니다. 그래서 필요한 경우 bytebuffer 나 pbf 같은 라이브러리를 이용해 Transferable 타입인 ArrayBuffer로 직접 변환해 주기도 했습니다.
이때 복사 비용과 arraybuffer로 전환 비용 역시 잘 비교해서 따져봐야 하는 부분이겠지만, onmessage 시점에 deserialize 가 무조건 자동 수행되지 않는다는 것만으로도 큰 장점이 될 수 있습니다. 메인 스레드에서 onmessage 가 처리되는 시점은 일반적으로 관리할 수 없는 영역이고, 하나의 frame 안에서 여러 message 가 동시에 몰리거나 데이터 자체가 아주 커서 deserialize 비용이 많이 든다면 높은 확률로 frame-drop 이 발생하게 됩니다. 반면 전송받은 arraybuffer는 deserialize 시점을 직접 관리 가능한 대상이 되고, 실제 필요 시점이나 Idle-time까지 deserialize를 지연시켜주는 것만으로도 부하를 분산시켜 체감 성능을 높이는 효과를 얻게 됩니다. 결국 워커에 어떤 작업을 위임할 것인지 결정짓는 판단 요소로서, 스레드 간 주고받는 데이터의 양과 형태 또한 큰 영향을 끼친다는 결론이 나옵니다.
TMAP JS의 최종 그래픽 렌더링은 WebGL을 통해 수행되고, 타일에 포함되어 있는 geometry 데이터들 또한 gl의 primitive 타입 조합으로 그리기 위한 수많은 vertex 와 index 들로 가공하는 작업이 필요합니다. 이러한 vertex 와 index 들은 Buffer Object 형태로 생성되어야 하며, WebGL에서는 bufferData api 인자로 arraybuffer 타입의 데이터를 넘길 수 있습니다. 렌더 데이터로의 변환 작업이 가장 많은 비용이 발생하는 부분이기도 하고 결과물도 arraybuffer이므로 워커에서 넘겨받기도 마침 적절하기 때문에, 우리는 타일 데이터를 다운로드하고 렌더링 가능한 형태로 가공 처리할 때까지의 과정을 모두 워커로 위임하기로 했습니다.
아래 도식을 통해 전반적인 흐름을 파악할 수 있습니다.
스케쥴링
지도는 사용자 인터랙션에 의해 반응하기 때문에 패닝이나 줌 등의 동작으로 인해 먼저 요청된 행동의 우선순위가 변경되거나 취소되어야 하는 경우가 빈번하게 발생합니다. 때문에 워커 내에서도 단순히 요청이 도착한 순서대로 순차 실행되는 것이 아니라, 우선순위를 반영하여 동작하는 스케줄링 시스템 또한 필요했습니다.
우리는 기존에 만들었던 통신 시스템에 우선순위를 기반으로 하는 스케줄링 구조를 덧붙여 이를 처리하도록 했습니다.
예약된 그룹의 이름으로 main->worker, worker->main 동작을 실행시키면 내부의 큐에서 미리 지정된 우선순위에 따라 소팅하고, 각각의 요청 작업들을 Promise에 기반하여 흐름을 제어할 수 있도록 수정했습니다. 이 구조가 정리되고 나니 단순 하나의 타일 데이터 처리 요청 단위의 작업 스케줄링이 아니라, 한발 더 나아가 보다 세분화된 작업 단위로 나누어 스케줄링 하는 것도 가능했습니다.
앞서 말했듯 하나의 타일 데이터를 처리하는 일련의 과정을 보면 단계도 많고 시간도 많이 걸리는 편입니다. 이러한 단계를 하나의 덩어리로 취급하여 타일 가공을 다루면, 중간에 네트워크 병목이 발생하거나 사용자 인터랙션으로 취소가 요청되더라도 모든 작업이 완료될 때까지 스레드를 점유하게 되어 반응이 느려질 수 있습니다. 그러므로 이러한 단계들 또한 나누어서 스케줄링해 줌으로써 빠른 취소나 전략적 노출 순서 변경 적용 등 각 상황에 맞춰진 보다 빠른 반응성을 확보하였습니다.
풀링
하나의 워커만 사용해도 숨통은 트입니다. 메인 스레드에서 무거운 작업들만 줄어들어도 사용자 인터랙션에서 발생하는 버벅임은 대부분 사라졌습니다. 하지만 눈에 보이는 지도 화면의 최종 완성이 빨라졌다고 볼 수는 없었습니다. 성능이 좋지 않은 클라이언트 환경이거나, 노출되는 데이터가 많은 영역에서는 아직도 타일들이 한 장 한 장 차례대로 채워져가는 화면이 노출되었습니다. 무거운 작업들을 워커로 분리하였다고 해도 워커의 스레드 또한 싱글 스레드. 내부에서는 순차적으로 하나씩 처리할 수밖에 없는 건 마찬가지였던 거죠. 여기서 보다 좋은 성능을 원한다면, 워커의 수를 늘려보는 방법이 있을 것입니다. 클라이언트의 성능이 충분하다는 전제라면 보통 타일을 한 장씩 처리할 때보다 두 장씩 처리하는 게 반응이 빠를 것입니다.
그럼 워커의 수가 3개, 4개... 많아질수록 더 빨라질까요?
일반적인 현실에서는 당연히 그렇겠지만 멀티 스레드 환경에서는 항상 그렇지 않습니다. 사용할 수 있는 자원은 한정적이고, context switching이나 개별 워커로 동일 데이터 복사에 필요한 오버헤드도 있으며, 다수의 워커가 메인 스레드 하나와 통신하는 구조라 병목이 될 수도 있습니다. 심지어 오래된 저사양 단말에서는 하나의 워커만 쓸 때가 더 빠른 경우도 있었습니다. 환경마다 결과는 다를 것이고 현실적으로 모든 상황에서 테스트한 뒤 결과의 우열을 가리고 최고의 결과만을 뽑기는 힘들었기에, 현재는 보통의 환경에서 적절한 성능 상승효과를 체감할 수 있으며 너무 많은 자원을 사용하지 않을 정도의 수준으로 2개의 워커만을 사용하도록 고정되어 있습니다.
이러한 결론은 이후 얼마든지 바뀔 수 있기 때문에 결국 동일한 역할을 수행하는 n 개의 워커를 로드하고 작업을 분산할 수 있는 구조가 필요하였습니다. 또 TMAP JS는 지도 컴포넌트 라이브러리이므로 하나의 페이지에서 여러 개의 지도를 띄울 수도 있어야 합니다. 결국 한 페이지 내에서 얼마나 많은 컴포넌트를 사용할지 예측할 수 없는 상황에서 모든 컴포넌트마다 새로운 워커들을 추가하는 구조는 자원 관리에 취약할게 뻔했고, 또 지도 컴포넌트가 동적으로 제거될 수 있는 상황에도 잘 맞지 않았습니다.
이런 사항들을 고려하여 하나의 페이지 내 여러 지도 컴포넌트들에서 pool 형태로 워커를 공유함으로써, 과도한 워커의 생성을 제한하고 스크립트 로드나 동일 리소스 복제에 대한 비용을 줄일 수 있는 방향으로 설계하였습니다. 실제 구현은 중앙 처리 가능한 작업 대기 큐를 두고 pool에서 사용 가능한 워커를 할당받아 실행하는 방식까진 아니고, 작업 요청 메시지들을 각 워커들에 분산 할당 또는 특정 워커 대상으로 할당한 후 워커 내부에서 지도 컴포넌트별 그룹화를 통해 데이터 관리 및 스케줄링 해주고 있습니다.
빌드
마지막으로 라이브러리 형태로 제공되는 API의 특성상, 이 모든 것을 코드 중복 없이 하나의 파일로 제공할 방법도 필요했습니다.
대부분의 빌더에서 제시하는 워커 빌드는 중복 코드를 처리해 주지 않았습니다. main.js에서 사용한 코드가 worker에서도 필요한 경우, worker.js라는 파일을 별도로(혹은 Blob으로) 작성하고 코드를 중복해서 빌드하도록 되어있는 현황입니다. html을 내장하는 서비스라면 chunks로 분리하여 동적 로딩하는 해결책이 Webpack에 제시되어 있긴 하지만 이는 라이브러리 형태인 TMAP JS에 그닥 적합하지 않았습니다. 우리는 중복코드 제거를 위해 별도의 빌드 시나리오를 정의해야 했습니다. TMAP JS의 코드를 분석해보니 전체 코드의 40% 이상을 메인스레드와 워커스레드 모두에서 동시에 사용하기 때문에 이는 굉장히 필수적인 작업이었습니다.
우리는 rollup의 code splitting 기능을 통해 amd 모듈로 1차 빌드를 하여 공통 코드, 메인 스레드 코드, 워커 스레드 코드가 분리되도록 한 다음 공통 코드를 스크립트 상단에 두고 메인 스레드 스크립트와 워커 스레드 스크립트가 차례로 공통 코드를 참조하도록 하는 merge 스크립트를 정의하여 2차로 빌드하도록 해결했습니다. 좀 말끔하지 않지만… 아무리 뒤져봐도 더 나은 방법을 찾지 못했는데요, 혹시 고민하고 계신 분들에게 도움이 될까 싶어 공유드려봅니다. (또 혹시 좋은 아이디어가 있으신 분들은 댓글로 의견 부탁드립니다.)
<build merge 슈도코드>
결과
이제 이런저런 튜닝의 결과를 살펴볼 시간입니다. 웹 워커를 도입하면 대체 얼마나 성능 차이가 나는지 대략적으로나마 보여드릴게요. 테스트 시나리오는 이렇습니다.
chrome devtool: viewport 340x600 이용
초기 로딩이 완료된 상태에서
zoom과 center를 변경하는 flyTo animation 실행
flyTo 실행 ~ animation이 완료된 시점까지 performance 측정
한눈에 봐도 엄청난 차이를 보입니다.
성능 개선 전에는 모든 연산이 메인 스레드에서 수행되기 때문에 해당 애니메이션이 필요로 하는 최소 fps 마저 따라잡지 못하고 흰 화면만이 노출되지만, 부하를 워커 스레드로 분산함으로써 메인 스레드의 유휴상태가 확보되었고 render pipeline이 예전보다 훨씬 더 많이 수행된 것을 알 수 있습니다.
실행 패턴의 차이를 좀 더 자세히 보시려면 아래 devtool performance 결과를 참고하세요.
마치며
지금까지 웹 워커를 지도에 적용하는 사례를 살펴보았습니다. 약간의 처리와 더불어 사용하려고 하는 조건에 조금만 맞게 시스템을 구축하면 웹 워커는 js 언어의 강력한 도구가 될 수 있다는 것을 느낄 수 있었다면 좋겠습니다.
요즘엔 웹 워커의 사용성을 높이기 위해 구글에서 작성한 Comlink라는 라이브러리도 활발히 사용되고 있는듯합니다. Comlink를 사용하면 스레딩을 지원하는 다른 프로그래밍 언어처럼 웹 작업자와 메인 스레드 간에 변수를 공유할 수 있으므로, 워커 도입을 고려하고 있다면 한번 살펴보는 것도 좋을 것 같습니다. 도움이 되셨기를 바랍니다.