원문: When makes sense block main thread

자바스크립트 작업을 실행할 때 브라우저의 메인 스레드(main thread)를 절대 “막지” 말라는 것이 일반적인 경험칙입니다. 하지만 이것이 반드시 지켜야 하는 엄격한 규칙일까요? Victor Ayomipo는 스크린샷 확장 프로그램을 만들며 마주한 사례를 설명합니다. 그는 이 규칙에 예외를 두었고, 메인 스레드를 막는 것이 분명히 올바른 선택이라고 판단했습니다.

현대 웹 개발에는 누구나 들어본 신성한 규칙, 절대 깨서는 안 되는 규칙이 있습니다. 바로 “메인 스레드를 절대 막지 말라”는 규칙입니다.

웹 개발자라면 거의 놓칠 수 없는 이야기입니다. 거의 모든 성능 가이드에 나오고, 솔직히 좋은 조언이기도 합니다. 우리 모두 브라우저의 메인 스레드가 싱글 스레드라는 것을 알고 있습니다. 한 번에 하나의 일만 할 수 있다는 뜻입니다.

게다가 우리가 알다시피, 메인 스레드는 우리만의 것이 아닙니다. 브라우저의 렌더링 엔진, 입력 핸들러, 그리고 다른 중요한 작업과 함께 공유합니다. 그 결과 메인 스레드를 붙잡고 있는 시간이 짧을수록 애플리케이션은 더 반응성 있게 느껴집니다. 그래서 우리는 UI와 모든 계산 사이에는 넘지 말아야 할 단단한 경계가 있어야 한다고 스스로를 설득하며, 백그라운드 워커(background worker)로 작업을 나누게 됩니다.

그리고 이것이 “권장되는” 아키텍처의 모습입니다.

하지만 저는 때로는 데이터를 워커로 옮기는 것이 메인 스레드가 직접 일을 처리하게 두는 것보다 더 느리다고 감히 말하고 싶습니다.

저는 몇 달 전 Fastary라는 스크린샷 기능이 있는 Chrome 확장 프로그램을 만들다가 이 사실을 알게 되었습니다. 캔버스 작업을 처리하기 위해 오프스크린 문서(Offscreen Document, Chrome 확장 프로그램의 백그라운드 프로세스)를 사용한 뒤에도 모든 테스트에서 약 2~3초의 지연이 계속 보였습니다. 결국 스크린샷 작업은 지연 없이 즉시 느껴져야 합니다.

꽤 아이러니합니다. 우리는 UI가 멈추는 것을 피하려고 반사적으로 작업을 메인 스레드 밖으로 옮기지만, 때로는 그 작업을 옮기는 행위, 예를 들어 직렬화(serializing), 복사, 역직렬화(deserializing) 자체가 UI를 멈추게 할 수도 있습니다. 그리고 때로는 백그라운드가 일을 처리하게 하는 권장 접근 방식이 메인 스레드에서 그냥 처리하는 것보다 더 느릴 수 있습니다.

그 이야기를 해보겠습니다.

브라우저 컨텍스트 격리의 아키텍처#

브라우저와 확장 프로그램 런타임은 이유가 있어서 서로 다른 컨텍스트를 격리합니다. 한 스크립트가 전체 시스템을 망가뜨리지 않게 막고, 보안 경계를 강제하며, 무거운 작업을 별도의 스레드에서 실행할 수 있게 합니다. 다음과 같은 것들이 있습니다.

  • 메인 스레드는 UI를 렌더링하고, 입력 이벤트를 처리하며, 대부분의 DOM 상호작용이 일어나는 곳입니다.
  • **웹 워커(Web Workers)**는 DOM에 접근하지 않고 백그라운드 스레드에서 자바스크립트를 실행합니다.
  • **서비스 워커(Service Workers)**는 네트워크 요청을 가로채고, 캐시를 관리하며, 백그라운드 이벤트를 처리합니다.
  • Chrome 확장 프로그램 컨텍스트에는 서비스 워커, 콘텐츠 스크립트(content script), 오프스크린 문서(offscreen document), 팝업, 옵션 페이지가 포함됩니다.

이 모델의 장점은 안정성입니다. 어느 한 컨텍스트가 멈추더라도 다른 컨텍스트가 반드시 멈추지는 않습니다. 보안은 더 강해지고, 무거운 계산 작업을 UI로부터 분리할 수 있습니다.

대가도 있습니다. 이 컨텍스트들은 메모리를 공유하지 않습니다. 한 컨텍스트에서 다른 컨텍스트로 데이터를 보내려면 postMessage() 같은 메시징 API를 사용해야 합니다. 이때 브라우저는 데이터를 어떻게든 복사해야 합니다.

구조화된 복제 알고리즘#

postMessage()구조화된 복제 알고리즘(Structured Clone Algorithm)에 의존합니다. 구조화된 복제 알고리즘은 JSON.stringify()보다 더 많은 종류의 값을 지원하기 때문에 더 똑똑합니다. 순환 참조를 처리할 수 있고, Map, Set, Date 같은 객체도 복사할 수 있습니다.

하지만 여전히 복사입니다. 그리고 그것도 깊은 복사입니다.

브라우저는 객체 그래프를 재귀적으로 순회하고, 새 메모리를 할당하며, 값을 복사한 다음, 반대쪽에서 새 객체를 재구성해야 합니다. 작은 객체라면 아주 빠릅니다. { theme: "dark" } 같은 객체를 워커로 보내는 데 걸리는 시간은 체감하기 어렵습니다.

하지만 데이터가 커지면 이야기가 달라집니다. 8MB 이미지 페이로드(payload)를 보내면 브라우저는 동기적으로 그 8MB를 순회하고 복사해야 합니다. 이 작업은 메인 스레드에서 일어나며, 데이터 크기에 따라 O(n)으로 증가합니다.

데이터를 포장하고, 보내고, 풀고, 다시 시작점으로 돌아오는 데 걸리는 시간이 메인 스레드에서 그냥 처리하는 시간보다 길다면, 왜 그냥 그렇게 하지 않을까요?

전송 가능한 객체는 어떨까요?#

어떤 독자들은 이렇게 말할 수 있습니다. “전송 가능한 객체(Transferable Objects)를 사용하면 되지 않나요?”

정당한 지적입니다. 개발자는 ArrayBuffer, ImageBitmap, MessagePort 같은 전송 가능한 객체를 사용해 구조화된 복제 알고리즘을 우회할 수 있습니다. 브라우저는 데이터를 복사하는 대신 한 컨텍스트에서 다른 컨텍스트로 소유권을 넘깁니다.

속도도 매우 빠를 수 있습니다. 한 벤치마크에서는 32MB ArrayBuffer를 전송하는 데 7ms 미만이 걸렸고, 복제에는 약 300ms가 걸렸습니다. 거의 43배 빠른 셈입니다.

Chrome Developers의 구조화된 복제와 Transferable Objects 비교

Chrome Developers의 구조화된 복제와 Transferable Objects 비교입니다. (이미지 출처: Chrome for Developers) (큰 이미지 보기)

하지만 단점도 있습니다.

  • 보내고 나면 잃게 됩니다. 소유권은 이동합니다. UI가 여전히 그 데이터를 필요로 한다면 복사본을 직접 만들어야 하고, 그러면 처음부터 얻으려던 이점이 약해집니다.
  • 모든 데이터가 전송 가능한 것은 아닙니다. 평범한 자바스크립트 객체는 전송할 수 없습니다. Blob도 전송할 수 없습니다. Base64 문자열도 전송할 수 없습니다.
  • API 제한이 있습니다. chrome.runtime.sendMessage() 같은 Chrome 내부 메시징은 전통적으로 JSON 직렬화를 강제합니다. 전송 가능한 객체의 이점을 얻을 수 없습니다.

그래서 제 스크린샷 확장 프로그램에서는 전송 가능한 객체가 선택지가 아니었습니다.

애초에 컨텍스트를 격리하는 이유#

그렇다면 왜 우리는 컨텍스트를 격리할까요?

대부분의 경우 그 이유는 좋습니다. 오래 실행되는 CPU 작업을 백그라운드 스레드로 보내는 것은 올바른 일입니다. 브라우저는 부드러운 60fps를 유지하기 위해 약 16.6ms마다 페인트해야 합니다. 50ms를 넘는 작업은 일반적으로 긴 작업(long task)으로 간주합니다.

문제는 우리가 “메인 스레드를 절대 막지 말라”는 말을 절대 규칙으로 바꾸었다는 점입니다. “이 작업은 처리 비용이 비싼가, 아니면 옮기는 비용이 비싼가?”라고 묻지 않은 채로 말입니다.

이제 저는 이 규칙이 “메인 스레드를 절대 막지 말라”기보다는 “메인 스레드를 너무 오래 막지 말라”에 가깝다는 것을 깨달았습니다.

올바른 아키텍처가 잘못된 아키텍처가 될 때#

Fastary 확장 프로그램에서 제 목표는 네이티브처럼 부드럽고 즉각적인 스크린샷 경험을 만드는 것이었습니다.

저는 권장되는 길을 따랐습니다. 백그라운드에서 DOM을 다루기 위해 오프스크린 문서를 사용했습니다. 오프스크린 문서 API는 백그라운드에서 숨겨진, 표시되지 않는 문서를 만들 수 있게 해줍니다. DOM과 Canvas에 접근할 수 있기 때문에 스크린샷 자르기, 이미지 이어 붙이기, 이미지 조작, 워터마크 추가 같은 작업에 유용합니다.

그런데 제 경우에는 이 방식이 최선이 아니었습니다.

아키텍처는 이렇게 생겼습니다.

  1. 백그라운드 서비스 워커가 chrome.tabs.captureVisibleTab()으로 스크린샷을 캡처합니다. 이 API는 Base64로 인코딩된 데이터 URL(data URL) 문자열을 반환합니다.
  2. 백그라운드 서비스 워커는 chrome.runtime.sendMessage()를 사용해 이 페이로드를 오프스크린 문서로 보냅니다.
  3. 오프스크린 문서는 이미지를 받아 <img>에 로드하고, 캔버스에 그린 뒤, 자르기 좌표를 적용하고, 결과를 인코딩해 다시 보냅니다.

이론상으로는 깨끗하고 “권장되는” 구조입니다. 실제로는 2~3초의 지연이 있었습니다.

그 이유는 무엇일까요?

captureVisibleTab()은 Base64 URL 문자열을 반환합니다. 1080p 스크린샷 하나는 세부 정보에 따라 1MB 이상이 될 수 있습니다. Retina 디스플레이에서는 브라우저가 기본적으로 두 배 해상도로 캡처하기 때문에 그 크기가 쉽게 두 배가 됩니다.

이제 몇 메가바이트가 될 수 있는 문자열을 확장 프로그램 메시징 계층을 통해 보내야 합니다. 그리고 확장 프로그램 메시징은 JSON 직렬화를 사용합니다. 이 비용은 단순한 “백그라운드 작업”이 아닙니다. 왕복 전체에 걸쳐 발생하는 거대한 동기 통신 비용입니다.

이미지는 적어도 두 번 JSON으로 직렬화됩니다. 한 번은 오프스크린 문서로 갈 때, 한 번은 돌아올 때입니다. 이미지 처리 자체는 빨랐지만, 전송 비용은 빠르지 않았습니다.

Retina 고해상도 DPI 문제#

그 과정에서 저는 미묘한 버그도 발견했습니다. 정확히 말하면 제 무지에 가까웠습니다.

자르기 결과가 어긋났습니다. 크기가 맞지 않았고, 좌표가 정확하지 않았습니다. 이유는 스크린샷과 DOM이 서로 다른 픽셀 좌표계에 있었기 때문입니다.

getBoundingClientRect()는 CSS 픽셀 단위로 측정합니다. DOM은 CSS 픽셀을 사용합니다. 하지만 네이티브 스크린샷은 물리적인 하드웨어 픽셀을 사용합니다. 브라우저는 devicePixelRatio로 이 둘 사이를 매핑합니다. DPR이 2인 화면에서는 400x300 CSS 픽셀 요소가 800x600 물리 픽셀로 캡처됩니다.

참고. 표준 모니터에서는 CSS 픽셀 1개가 물리 픽셀 1개(DPR 1)와 같습니다. Mac Retina 디스플레이나 최신 4K 모니터에서는 보통 DPR이 2 또는 3입니다.

자르기 좌표를 DPR에 맞춰 스케일해야 했다는 뜻입니다. 하지만 오프스크린 문서에는 물리적인 디스플레이가 없으므로 기본 DPR은 1입니다. 활성 탭에서 정확한 devicePixelRatio를 캡처하고, 이미지 페이로드와 함께 직렬화해 전달한 다음, 오프스크린 문서에서 수동으로 스케일해야 했습니다.

복잡도는 계속 불어났습니다.

그래서 저는 의문을 품기 시작했습니다. 황금률을 깨고 메인 스레드에서 작업하면 어떨까?

메인 스레드에서 작업하기#

어떤 개발자들은 UI 작업만 메인 스레드에서 실행해야 한다고 주장합니다. 저는 동의하지 않습니다. 사용자가 명시적으로 시작했고 즉각적인 결과를 기대하는 동작이라면, 엄청나게 빠르다는 조건에서 예외가 될 수 있습니다. 예를 들어 1초 안에 끝나는 작업이라면 말입니다.

그래서 저는 그렇게 했습니다.

오프스크린 문서를 버리고 로직을 다시 설계했습니다. 다음과 같은 구조 대신,

markdown
Background → [serialize] → Offscreen Document → [serialize] → Background → Content Script

전체 이미지 처리를 활성 탭에서 실행하기로 했습니다.

  1. 백그라운드 서비스 워커가 화면을 캡처하고 Base64 문자열을 얻습니다.
  2. 백그라운드는 chrome.scripting.executeScript()를 사용해 페이로드를 활성 탭의 콘텐츠 스크립트로 직접 보냅니다.
  3. 메인 스레드의 콘텐츠 스크립트는 페이로드를 받고, 캔버스에 그린 뒤, 올바른 DPR로 자르고, 결과를 클립보드에 복사합니다.
javascript
// 백그라운드 스크립트
const screenshotUrl = await chrome.tabs.captureVisibleTab(undefined, {
  format: 'png',
});

// 처리 함수를 콘텐츠 스크립트로 활성 탭에 주입합니다
await chrome.scripting.executeScript({
  target: { tabId: activeTab.id },
  func: processAndCopyImage,
  args: [{ base64Image: screenshotUrl, cropData: userSelection }],
});

이 접근 방식은 컨텍스트 왕복과 JSON 직렬화 왕복을 없앴습니다.

Retina DPI 문제도 해결됐습니다. 콘텐츠 스크립트는 실제 활성 탭 안에서 실행되므로 실제 window.devicePixelRatio를 알고 있기 때문입니다.

이제 모두가 생각하는 그 문제를 이야기해보겠습니다.

네, 이미지는 메인 스레드에서 처리되었습니다. 네, 활성 탭 안에서 캔버스를 조작하는 백그라운드 스크립트는 기술적으로 메인 스레드를 막고 있었습니다.

하지만 저는 “메인 스레드를 막지 말라”는 규칙을 “메인 스레드를 너무 오래 막지 말라”로 수정했습니다.

제 경우 약 1초는 정당화할 수 있었습니다. 사용자가 스크린샷을 요청했고, 결과는 빠르게 반환되었으며, UI는 불안정하게 멈추지 않았습니다. 더 중요한 점은 반대 방향의 교훈입니다. 데이터 전송 비용이 처리 비용보다 크다면 프로세스를 격리하지 않는 것이 나을 수 있습니다.

결론. 언제 격리하고 언제 격리하지 않을 것인가#

제가 지금 사용하는 사고 모델은 작업이 어떤 성격인지에 달려 있습니다.

1. 계산 중심 작업(CPU-Bound)#

주된 비용이 데이터 크기가 아니라 실제 계산인 작업입니다. 많은 계산, 무거운 변환, 긴 루프가 필요합니다. 이미지 압축, 오디오 프로파일링, 물리 시뮬레이션이 여기에 해당합니다.

이 경우 전송 비용은 실제 작업에 비해 아주 작습니다. 백그라운드 스레드로 옮기는 것이 합리적입니다.

2. 데이터 중심 작업(Data-Bound)#

반대입니다. 작업이 비싼 이유는 데이터가 크기 때문입니다. 처리 자체는 별것 아니지만, 데이터 이동 비용이 큽니다. 이미지 자르기, 배열 필터링, 얕은 복사가 여기에 해당합니다.

이 경우 작업을 옮기면 더 느려질 수 있습니다. 오프로딩은 공짜가 아닙니다. 제 구체적인 사례에서는 오히려 **음수 합 효율(negative-sum efficiency)**에 가까웠습니다. 50ms짜리 작업을 위해 몇 메가바이트를 옮기는 것은 아무 이득이 없습니다.

간단한 공식은 이렇습니다.

markdown
Total Time = Serialization Cost

- Transit
- Background Processing Time
- Deserialization Cost

백그라운드 처리 시간이 지배적이라면 격리가 이깁니다. 직렬화, 역직렬화, 전송 시간이 처리 비용보다 크다면 격리할 필요가 없습니다.

그리고 작업이 CPU 중심인지 데이터 중심인지 확실하지 않다면 측정하세요. postMessage 호출 전후에 performance.mark()performance.measure()를 감싸서 전송 비용을 프로파일링하세요. 가정하지 마세요. 측정하세요.


🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!