
메인 스레드를 '차단'하는 것이 의미가 있을 때
요약
웹 개발에서 메인 스레드 차단 방지가 중요하다고 알려져 있지만, 작업을 백그라운드로 옮기는 과정(직렬화/복사) 자체가 오히려 성능 저하를 일으킬 수 있습니다. 특히 Chrome 확장 프로그램의 Offscreen Document 사용 사례에서 지연 시간이 발생한 것이 이를 보여줍니다.
핵심 포인트
- 메인 스레드 차단 방지가 중요하지만, 백그라운드 작업 이동 과정이 병목일 수 있다.
- Offscreen Document 같은 격리된 환경도 직렬화/복사 오버헤드가 성능에 영향을 준다.
- 브라우저는 메인 스레드 외에도 Web Workers, Service Workers 등 여러 독립적인 컨텍스트로 구성된다.
현대 웹 개발에서 모두가 들어본, 절대 깨지면 안 되는 신성한 규칙이 있습니다. 바로 ‘메인 스레드를 절대로 차단하지 마라’는 규칙입니다.
웹 개발자라면 거의 빠지지 않는 내용이며, 솔직히 좋은 조언임은 분명합니다. 우리 모두 알고 있듯이 브라우저의 메인 스레드는 **싱글 스레드(single-threaded)**라서 한 번에 하나의 작업만 수행할 수 있습니다.
게다가 우리가 아는 것처럼, 메인 스레드는 우리 것만이 아닙니다입니다. 우리는 브라우저의 렌더링 엔진, 입력 핸들러 및 기타 중요한 작업들과 이 메인 스레드를 공유합니다. 결과적으로, 우리가 메인 스레드를 붙잡고 있는 시간이 적을수록 앱이 더 반응성이 좋게 느껴집니다. 이것은 UI와 모든 계산 사이에 명확한 경계가 있어야 하며 그 선을 넘어서는 안 된다고 스스로 납득했기 때문에 백그라운드 워커(background workers)와 작업을 공유하게 만듭니다.
그리고 그것이 바로 '권장되는' 아키텍처의 모습입니다.
하지만 저는 때로는 데이터를 워커로 옮기는 것이 메인 스레드가 직접 작업을 수행하는 것보다 느릴 수 있다고 감히 말하고 싶습니다.
저는 몇 달 전 Fastary라는 이름의 스크린샷 기능을 가진 Chrome 확장 프로그램을 만들면서 이를 알게 되었습니다. 캔버스 작업을 처리하기 위해 Offscreen Document (Chrome 확장 프로그램의 백그라운드 프로세스)를 사용했음에도 불구하고, 모든 테스트에서 약 2~3초의 지연 시간을 발견했습니다. 스크린샷 작업은 어차피 즉각적이고 끊김 없이 느껴져야 하니까요.
아이러니하게도, 우리는 UI가 멈추는 것을 피하기 위해 반사적으로 작업을 메인 스레드에서 멀리 옮기지만, 때로는 그 작업을 옮기는 행위(예: 직렬화(serializing), 복사(copying), 역직렬화(deserializing)) 자체가 UI를 멈추게 할 수 있습니다. 그리고 때로는 백그라운드가 작업을 수행하도록 하는 권장 접근 방식이 메인 스레드에서 직접 작업하는 것보다 느릴 수도 있습니다.
이에 대해 이야기해 봅시다.
브라우저 컨텍스트 격리(Browser Context Isolation)의 아키텍처
상황을 파악하기 위해, 우리는 왜 브라우저 컨텍스트를 격리하는지 그리고 이들이 서로 어떻게 통신하는지에 대해, 특히 통신 부분에 중점을 두고 이해해 봅시다.
브라우저는 단일 환경 그 이상입니다. 여러 개의 다른 환경이 동시에 실행되고 있으며, 각각 자체적인 메모리 공간, 접근할 수 있는 것들, 그리고 규칙을 가지고 있습니다:
- **메인 스레드(main thread)**는 우리가 가장 익숙한 부분입니다. 이곳에서 JavaScript 로직이 실행되고, DOM이 존재하며, 스타일이 렌더링되고, 사용자가 상호작용합니다.
- Web Workers는 DOM 접근 없이도 JavaScript를 실행할 수 있는 별도의 스레드입니다. 우리는 주로 무거운 데이터 작업을 위해 이것을 사용합니다.
- Service Workers는 네트워크 요청 가로채기(intercepting)를 담당하는 네트워크 관련 프록시이며, 페이지가 닫혔을 때도 실행될 수 있습니다.
- 그리고 Chrome extension contexts가 있습니다. 여기에는 백그라운드 서비스 워커, 콘텐츠 스크립트, 그리고 Offscreen Documents(이 글과 관련된 부분들)가 포함됩니다.
이 모든 것들은 서로 격리되어 있습니다. 웹 워커나 백그라운드 스크립트는 메인 스레드와 다른 메모리 공간에 존재합니다. 따라서 그들의 변수나 로직을 마음대로 접근하여 읽을 수 없으며, 이것이 “shared-nothing” 아키텍처로 알려져 있습니다.
이렇게 격리된 환경들은 어떻게 통신할까요? 이들은 postMessage()와 같은 API를 사용하여 명시적으로 서로 메시지를 주고받습니다.
구조화된 클론 알고리즘 (The Structured Clone Algorithm)
postMessage()는 브라우저에게 데이터 조각을 가져가 요청한 컨텍스트로 전달하라고 지시합니다. 하지만 이를 수행하기 위해, 브라우저는 구조화된 클론 알고리즘(Structured Clone Algorithm, SCA)에 의존합니다.
여러분은 아마도 JSON.stringify()에 대해 익숙할 것입니다. SCA는 이와 유사하지만 훨씬 강력하고 똑똑합니다. 가장 간단한 형태에서 SCA는 깊고 재귀적인 복사 작업, 즉 클로닝(cloning)입니다. 이는 주어진 전체 데이터 구조를 순회하며 모든 값을 복제하고, 이를 전송 가능한 형식으로 직렬화(serialize)한 다음, 그 바이트들을 대상 컨텍스트로 전송하고, 마지막으로 수신 측에서 원래의 객체를 재구성합니다.
SCA는 빠르거나, 아니면 어쩌면 '빠른 듯' 합니다... `{theme:
브라우저는 데이터를 보내는 컨텍스트가 즉시 접근 권한을 잃고, 받는 컨텍스트가 완전히 제어권을 갖는 방식으로 핸드오프(hand-off)를 수행합니다. 실제로 믿기 어려울 정도로 빠릅니다. Chrome Developers의 벤치마크에 따르면, 대용량 32MB ArrayBuffer를 전송하는 데는 SCM으로 복제(cloning)할 때 약 300ms가 걸리는 것에 비해 7ms도 채 걸리지 않습니다. 이는 무려 43배의 속도 향상입니다.

하지만 좋은 것들에는 항상 단점이 있듯이, 여기에도 단점들이 있습니다. 몇 가지를 들자면 다음과 같습니다:
- 전송하면 데이터를 잃습니다.
만약 UI가 여전히 그 데이터(예: 이미지 미리보기 표시용)를 필요로 한다면, 더 이상 접근할 수 없습니다. - 모든 데이터가 전송 가능한 것은 아닙니다.
일반 JS 객체는 안 됩니다. Blob도 안 되고. Base64 문자열조차도 안 됩니다. - API 제한 사항.
브라우저 확장 프로그램의 컨텍스트에서 Chrome의 내부 메시징(chrome.runtime.sendMessage)은 전통적으로 모든 것을 JSON 직렬화(JSON serialization)를 거치도록 강제합니다.
따라서 제 스크린샷 확장 프로그램의 경우, Transferable objects는 선택 사항이 아니었습니다.
왜 컨텍스트를 격리해야 하는가
애초에 우리가 굳이 컨텍스트를 격리하는 것이 왜 필요할까요? 그냥 모두 메인 스레드에 두면 안 될까요?
오래 실행되는 CPU 작업을 백그라운드 스레드로 오프로딩(offloading)하는 것은 확실히 올바른 방법입니다. 브라우저는 부드럽게 작동하려면 매 16.6ms마다 새로운 프레임을 그려야 합니다. 이는 50ms보다 오래 걸리는 모든 작업은 일반적으로 “긴 작업”으로 간주됨을 의미합니다. 백그라운드로 오프로딩하는 것은 확실히 올바른 방법입니다.
하지만 문제는 우리가 이 “메인 스레드를 절대 차단하지 말라”는 것을 절대적인 규칙으로 만들어 버렸다는 것입니다. 그 과정에서 _“이 작업이 처리하기에 비용이 많이 드는지, 아니면 이동시키기에 비용이 많이 드는지?”_라는 질문을 던지지 않았습니다.
저는 이제 이 규칙이 “메인 스레드를 절대 차단하지 말라”기보다는 “메인 스레드를 너무 오랫동안 차단하지 말라”는 것에 가깝다는 것을 깨닫게 되었습니다.
올바른 아키텍처가 잘못된 아키텍처일 때
Fastary 확장 프로그램을 개발하면서 목표는 네이티브 앱처럼 부드럽고 즉각적으로 작동하는 느낌을 주는 것이었습니다.
이미 알고 계시겠지만, 저는 백그라운드에서 DOM 작업을 처리하기 위해 권장되는 접근 방식인 Offscreen Document를 사용했습니다. 하지만 예상치 못한 방향으로 흘러갔습니다.
Offscreen Document API는 확실한 승자입니다. 완전히 백그라운드에서 실행되는 숨겨진, 표시되지 않는 문서를 생성할 수 있습니다. 이 문서에는 DOM이 있고 Canvas도 지원합니다. 예를 들어, 스크린샷을 자르거나(crop), 여러 스크린샷을 합치거나(stitch), 무거운 이미지 조작을 수행하거나, 워터마크를 추가하고 싶다면 Offscreen Document가 완벽하게 적합했습니다.
하지만 이것이 최선의 접근 방식은 아니었습니다. 제가 사용한 아키텍처는 다음과 같았습니다:
- 백그라운드 Service Worker가
chrome.tabs.captureVisibleTab()을 사용하여 스크린샷을 캡처하고, 이는 Base64로 인코딩된 데이터 URL 문자열을 반환합니다. - 백그라운드 Service Worker는
chrome.runtime.sendMessage()를 사용하여 이 이미지 페이로드(image payload)를 Offscreen Document로 전송합니다. - Offscreen Document가 이미지를 수신하고, 이를
<img>요소에 로드한 다음, 사용자의 자르기 좌표(crop coordinates)를 적용하기 전에 캔버스(canvas)에 그립니다. 그런 다음 결과를 인코딩하여 처리된 이미지를 백그라운드 워커로 다시 전송합니다.
하지만 테스트해보니 스크린샷이 즉각적이지 않았습니다. 앞서 말씀드렸듯이, 일관되게 2~3초의 지연(lag)이 있었습니다.
저는 captureVisibleTab()이 스크린샷을 캡처할 때 Base64 URL 문자열을 반환한다는 것을 알아냈고, 표준 1080p 화면 기준으로 이 문자열은 이미지의 상세도에 따라 약 1MB 이상일 수 있다는 것도 알게 되었습니다. 특히 MacBooks와 같은 최신 Retina 디스플레이에서는 기본적으로 이미지 크기를 두 배로 늘리는 경향이 있어 상황이 더욱 흥미로워집니다.
이미지 페이로드가 두 배가 될 수 있고 확장 메시징은 JSON 직렬화(serialization)에 의존하기 때문에 (현재 시점 기준), 우리는 왕복 전체를 소모하는 **대규모 동기 통신(massive synchronous communication)**을 다룰 가능성이 있습니다.
이미지 문자열 데이터는 적어도 두 번 JSON 직렬화됩니다. Offscreen Document로 들어갈 때 한 번, 그리고 처리된 결과를 백그라운드 워커로 되돌아올 때 한 번입니다. Offscreen Document 내부에서 수행되는 실제 이미지 처리(자르기) 자체는 의심할 여지 없이 빨랐지만, 전송 오버헤드에 대해서는 그렇게 말할 수 없습니다.
Retina 고밀도 DPI 문제 (The Retina High-DPI Problem)
레이턴시 자체가 충분하지 않은 것처럼, 저는 상당히 미묘한 버그를 발견했습니다. 지금 생각해보니 그것은 제 무지함에 가까웠습니다. 스크린샷을 찍은 후, 자른 결과가 완전히 잘못되었습니다. 이미지를 이상하게 크기 조정하거나 부정확한 좌표가 나오는 방식이었습니다.
알고 보니 사용자가 자를 영역을 선택할 때, 콘텐츠 스크립트(content script)는 getBoundingClientRect()를 사용하여 박스 좌표를 얻는데, 이는 CSS 픽셀 단위로 측정됩니다. 이것이 DOM이 사용하는 단위입니다. 하지만 Chrome에서 네이티브하게 스크린샷이 캡처될 때는 브라우저가 자동으로 자르지 않습니다. 대신 전체 화면을 캡처하기 위해 **물리적 하드웨어 픽셀(physical hardware pixels)**을 사용합니다. 그리고 브라우저는 하나의 CSS 픽셀을 나타내야 하는 물리적 픽셀의 개수를 알기 위해 devicePixelRatio (DPR)를 사용합니다. 기본적으로, DPR이 2인 Retinal 디스플레이에서 사용자가 400x300 CSS 픽셀 영역을 하이라이트하면, 실제 캡처된 이미지 영역은 800x600 물리적 픽셀입니다.
참고: CSS 픽셀 하나는 표준 모니터에서 1개의 물리적 픽셀과 같습니다 (DPR이 1일 때). 하지만 Mac Retina 디스플레이나 최신 4K 모니터의 경우, DPR은 보통 2 또는 3입니다.
정확한 크롭을 위해서는 이 두 가지 다른 측정 시스템에 적절한 DPR(Device Pixel Ratio)을 적용해야 합니다. 즉, 크롭 좌표를 DPR로 스케일링해야 합니다. 하지만 기억할 점은 Offscreen Document는 물리적인 디스플레이가 없다는 것입니다. 따라서 어떤 이미지를 처리하더라도 기본 DPR은 1이 됩니다. 이를 해결하려면 활성 탭에서 정확한 devicePixelRatio를 가져와 직렬화(serialize)하고, 이미지 페이로드와 함께 전달하며, Offscreen Document 내부에서 수동으로 스케일링 계산을 수행해야 합니다. 복잡성이 쌓이기 시작합니다.
만약 제가 황금률을 깨고 대신 메인 스레드에서 작업을 처리한다면 어떨까요?
메인 스레드에서 작업하기
어떤 개발자들은 UI 작업만이 메인 스레드에서 실행되어야 한다고 주장할 것이지만, 저는 전적으로 동의하지 않습니다. 개인적으로는 사용자가 명시적으로 호출한(explicitly-invoked) 액션 중 즉각적인 결과가 필요한 경우, 그 작업이 믿을 수 없을 만큼 빠르다면 (예: 1초), 메인 스레드에서 실행되도록 허용할 수 있다고 생각합니다.
제가 한 일은 이렇습니다. Offscreen Document를 제거하고 로직을 재설계했습니다. 이전 방식:
Background → [serialize] → Offscreen Document → [serialize] → Background → Content Script
대신, 전체 이미지 처리를 활성 탭에서 실행하기로 결정했습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기