Partytown으로 서드파티 스크립트를 Web Worker로 옮기기: GA·GTM, Next.js·Astro, INP 개선

이 글의 핵심

Partytown은 서드파티 스크립트를 메인 스레드가 아닌 Web Worker에서 실행하도록 도와, 긴 작업과 이벤트 처리로 인한 INP·TBT 악화를 완화하는 도구입니다. DOM 접근은 프록시로 중재되며, 페이지 코드가 메인 스레드에서 호출하는 dataLayer.push·gtag는 forward 설정으로 워커 쪽 스크립트에 전달됩니다.

들어가며

Partytown은 서드파티(third-party) 스크립트를 브라우저의 메인 스레드가 아니라 Web Worker에서 실행하도록 돕는 런타임·도구 모음입니다. 마케팅·분석·A/B 테스트·위젯 등으로 삽입되는 스크립트는 종종 메인 스레드에서 길게 실행되거나 이벤트 처리와 경쟁하여 INP(Interaction to Next Paint)·TBT(Total Blocking Time) 같은 지표를 악화시킵니다. Partytown은 이런 코드를 워커 쪽으로 격리해, 사용자 입력에 대한 반응성을 지키려는 목적에서 널리 논의됩니다.

이 글은 Partytown이 왜 동작하는지, 어디까지 기대할 수 있는지, 프로덕션에서 어디가 깨지는지를 중심으로 GA·GTM 연동과 Next.js·Astro 통합, 측정과 디버깅을 다룹니다.


Partytown이 해결하려는 문제

메인 스레드 병목과 서드파티

브라우저에서 JavaScript는 기본적으로 메인 스레드 한 줄에서 HTML 파싱, 스타일·레이아웃, 페인트, 사용자 입력 처리, 페이지에 삽입된 서드파티 코드까지 함께 처리합니다. 서드파티 스크립트가 긴 작업(Long Task)을 만들면, 그동안 입력 지연이 누적됩니다. Core Web Vitals 관점에서는 INP가 특히 이런 경쟁과 연결됩니다.

전통적인 완화책은 지연 로드(첫 입력 이후 로드), 스크립트 개수·순서 조정, 리소스 힌트, 태그 정리입니다. Partytown은 여기에 더해 실행 위치 자체를 워커로 옮기는 접근입니다.

“옮긴다”의 의미

서드파티 코드는 원래 window·document에 직접 접근한다고 가정하고 작성되는 경우가 많습니다. Web Worker에는 DOM이 없으므로, Partytown은 워커 안에서 돌아가는 스크립트의 DOM·윈도우 접근을 가로채 메인 스레드에 위임하는 프록시(proxy) 계층을 둡니다. 즉 코드 물리적 위치는 워커, 화면·문서와의 상호작용은 메인이라는 이중 구조입니다.

이 구조 덕분에 CPU 작업(스크립트 평가·내부 루프)의 상당 부분이 메인에서 벗어날 수 있지만, 대신 메인↔워커 메시지, 프록시 호출 비용이 생깁니다. 따라서 무조건 빨라진다기보다 특정 유형의 태그·사이트 구조에서 체감 이득이 큰 경우가 많다고 이해하는 것이 안전합니다.


핵심 개념

type="text/partytown"

일반 스크립트는 type을 생략하거나 text/javascript입니다. Partytown은 로드하되 메인에서 바로 실행하지 않고, 런타임이 워커로 가져가 실행할 대상으로 인식하도록 type="text/partytown"을 사용합니다. 빌드 도구·프레임워크 통합에서는 이 속성을 자동으로 붙이거나, 래퍼 컴포넌트가 주입합니다.

런타임 라이브러리(~partytown)

Partytown은 페이지에 작은 부트스트랩 코드와 함께, 워커·메인 간 브리지를 구성하는 라이브러리 파일이 필요합니다. 통상 public/~partytown 같은 경로에 복사해 두며, 배포 시 정적 파일로 함께 올라가야 합니다. 이 경로가 빌드 산출물에 없으면 아무것도 실행되지 않거나 404로 끊깁니다.

forward — 메인에서 워커로 전달할 호출

서드파티 스크립트를 워커로 옮기면 gtag나 dataLayer의 실제 구현도 워커 안에 있게 됩니다. 그런데 버튼 클릭 핸들러처럼 페이지 자신의 코드는 여전히 메인 스레드에서 dataLayer.push(...)나 gtag('event', ...)를 호출합니다. forward 옵션에 이름을 적으면 Partytown이 메인 window에 같은 이름의 대리 함수를 만들어 두고, 호출 인자를 워커로 넘겨 워커 쪽 실제 함수를 실행합니다. 즉 방향은 메인에서 워커입니다.

resolveUrl — 프록시와 CORS

일부 서드파티 스크립트는 절대 URL로 추가 리소스를 불러옵니다. 기업 프록시·CSP·광고 차단 환경에서는 자신의 오리진으로 리라이트하거나 역프록시가 필요할 수 있습니다. Partytown은 URL 해석을 바꿀 수 있는 훅을 제공하며, 이를 통해 동일 출처로 보이게 하거나 허용 도메인만 통과시키는 패턴이 사용됩니다.

동기화 모델과 SharedArrayBuffer

서드파티 코드는 document.title을 읽는 식으로 DOM 값을 동기적으로 받기를 기대하지만, postMessage는 비동기라 그대로는 결과를 돌려줄 수 없습니다. Partytown은 두 가지 방법으로 워커를 멈춰 두고 메인의 답을 기다립니다. 페이지가 COOP/COEP 헤더로 크로스 오리진 격리 상태면 SharedArrayBuffer와 Atomics.wait를 쓰고, 그렇지 않으면 워커가 동기 XHR을 보내고 Partytown의 서비스 워커가 이를 가로채 메인에서 받은 값을 응답으로 돌려줍니다. 격리 헤더는 다른 서드파티 리소스 로딩을 깨뜨리기 쉬워, 실제로는 서비스 워커 경로로 동작하는 사이트가 많습니다.


웹 워커 통신 메커니즘

메인 스레드와의 메시지 흐름

워커는 기본적으로 postMessage 기반 비동기 통신입니다. Partytown은 스크립트가 DOM 노드를 읽거나 쓰는 호출을 순차적으로 처리하기 위해 내부 프로토콜로 메인과 동기화합니다. 개발자가 이 프로토콜을 직접 다루지는 않지만, 통신량이 많은 스크립트는 프록시 오버헤드로 이득이 줄어들 수 있습니다.

프록시 비용이 커지는 경우

다음과 같은 패턴은 워커 이전 이점이 상쇄되거나 실패하기 쉽습니다.

  • document.write에 의존하는 동기 삽입
  • 메인 전용 API(일부 Clipboard, 파일 업로드 UI 등)에 대한 가정
  • 매 프레임 DOM을 세밀하게 읽고 쓰는 위젯

이럴 때는 Partytown 적용 전 태그 단위로 나누어 실험하거나, 해당 스크립트는 메인에 남기는 편이 낫습니다.

forward가 필요한 이유

GTM·GA4는 데이터 레이어와 전역 함수를 통해 후속 태그·동의 모듈·히트 전송을 연결합니다. GTM을 워커로 옮기면 GTM이 감시하는 dataLayer 배열은 워커 쪽에 있으므로, 메인의 페이지 코드가 dataLayer.push를 호출해도 전달하지 않으면 GTM은 이벤트를 보지 못합니다. 그래서 GTM이면 dataLayer.push, gtag.js면 gtag처럼 페이지 코드가 실제로 호출하는 전역 식별자를 forward에 넣습니다.


Google Analytics·GTM 통합

gtag.js(측정 ID 기반)

GA4를 gtag.js로 로드하는 경우, 스크립트 태그의 src는 Google이 호스팅하는 gtag/js?id=... 형태입니다. Partytown 문서의 패턴은 gtag.js와 초기화 스크립트를 모두 type="text/partytown"으로 두고, dataLayer와 gtag 정의도 워커 쪽 스크립트 안에 넣는 것입니다. 측정 ID와 동의 모드 설정은 사이트에 맞게 바꿔야 합니다.

<!-- Partytown 설정에 forward: ['gtag'] (또는 'dataLayer.push') 지정 -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  window.gtag = function gtag(){ dataLayer.push(arguments); };
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>

흔한 실수는 기존 스니펫처럼 gtag 함수를 메인 스레드의 일반 <script>에서 정의하는 것입니다. 그러면 forward가 만든 대리 함수를 덮어쓰거나, 메인의 dataLayer에만 데이터가 쌓이고 워커의 gtag.js는 그것을 보지 못합니다. 페이지 코드에서 이벤트를 보낼 때는 forward에 등록한 gtag(...)를 그대로 호출하면 워커로 전달됩니다.

Google Tag Manager 컨테이너

GTM은 컨테이너 스니펫 하나로 다수의 태그를 로드합니다. Partytown으로 컨테이너 자체를 워커에 올리면, 내부 태그들도 워커 맥락에서 실행됩니다. 이는 의도한 시나리오(메인 부하 감소)일 수 있지만, 특정 커스텀 HTML 태그가 메인 DOM을 직접 조작한다고 가정하면 깨질 수 있습니다.

실무에서는 다음 순서를 권장합니다.

  1. GTM 미리보기로 태그·트리거 목록을 확정합니다.
  2. 커스텀 스크립트·비표준 태그를 식별합니다.
  3. Partytown ON 상태에서 스테이징에서 전환·이벤트·데이터 레이어를 검증합니다.
  4. 문제 태그는 메인 로드로 분리하거나 태그 구현을 수정합니다.

동의(Consent)·광고·링크 스팸 방지

동의 모드, 광고 태그, 연결된 도메인은 지역·정책·브라우저에 따라 다르게 동작합니다. Partytown은 실행 위치만 바꾸는 레이어이므로, CMP(동의 관리)와의 순서, dataLayer 초기화 시점, 스크립트 차단 확장 프로그램은 별도로 점검해야 합니다. “Partytown 적용 후 전환이 0”에 가까워지면, 페이지 코드가 호출하는 함수가 forward에 빠졌거나 메인 스레드 쪽에서 dataLayer·gtag를 따로 정의해 워커와 갈라진 것은 아닌지 먼저 확인합니다.


Next.js 통합

App Router(app/layout.tsx)

Next.js App Router에서는 @builder.io/partytown/react의 Partytown 컴포넌트를 루트 레이아웃의 <head>에 넣고, 서드파티 스크립트는 type="text/partytown"인 일반 <script> 태그로 같은 <head>에 둡니다. next/script의 strategy="worker"는 Partytown 기반 실험 기능이지만 Pages Router에서만 지원되므로 App Router에서는 쓸 수 없습니다. 또 next/script의 afterInteractive처럼 하이드레이션 이후에 클라이언트에서 삽입되는 스크립트는 Partytown이 초기화 때 스캔하지 못하므로, ptupdate 이벤트를 직접 보내지 않으면 실행되지 않을 수 있습니다.

// app/layout.tsx — 개념 예시 (경로·ID는 프로젝트에 맞게)
import { Partytown } from '@builder.io/partytown/react';

const GA_ID = 'G-XXXXXXXXXX';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <head>
        <Partytown forward={['gtag']} />
        <script
          type="text/partytown"
          src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
        />
        <script
          type="text/partytown"
          dangerouslySetInnerHTML={{
            __html: `
              window.dataLayer = window.dataLayer || [];
              window.gtag = function gtag(){dataLayer.push(arguments);};
              gtag('js', new Date());
              gtag('config', '${GA_ID}');
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Partytown 컴포넌트는 런타임 초기화 스니펫을 넣고, 기본값으로 /~partytown/에서 라이브러리를 불러옵니다. 동의 모드를 쓴다면 gtag('consent', 'default', ...) 호출이 config보다 먼저 오도록 같은 스크립트 안에서 순서를 맞춥니다.

Pages Router·정적 복사

Pages Router를 쓰는 경우에도 _document.tsx 등에서 동일하게 Partytown를 넣을 수 있습니다. 중요한 것은 partytown copylib로 public/~partytown(또는 설정한 경로)에 라이브러리가 복사되고, 프로덕션 빌드 산출물에 포함된다는 점입니다. package.json에 빌드 전 복사 스크립트를 넣어 두면 실수를 줄일 수 있습니다.

{
  "scripts": {
    "postinstall": "partytown copylib public/~partytown"
  }
}

모노레포·서브패키지에서는 public 루트가 어디인지 한 번 더 확인합니다.


Astro 통합

Astro는 @astrojs/partytown 통합으로 설정을 단순화할 수 있습니다. astro.config.mjs에 통합을 추가하며, partytown 옵션에서 config.forward 등을 지정합니다.

// astro.config.mjs — 개념 예시
import { defineConfig } from 'astro/config';
import partytown from '@astrojs/partytown';

export default defineConfig({
  integrations: [
    partytown({
      config: {
        forward: ['dataLayer.push', 'gtag'],
      },
    }),
  ],
});

통합은 Partytown 초기화 스크립트를 주입하고 라이브러리 파일을 빌드 산출물의 ~partytown에 복사합니다. 페이지에서는 여전히 type="text/partytown"이 붙은 스크립트를 선언해야 하며, 배포(Cloudflare Pages 등)에서 ~partytown 자원이 404가 아닌지 빌드 산출물로 확인합니다. Astro 버전별 옵션 이름은 해당 버전 문서를 따르는 것이 안전합니다.

---
// src/layouts/Layout.astro — 개념 예시
---
<!DOCTYPE html>
<html lang="ko">
  <head>
    <script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
    <script type="text/partytown" is:inline>
      window.dataLayer = window.dataLayer || [];
      window.gtag = function gtag(){ dataLayer.push(arguments); };
      gtag('js', new Date());
      gtag('config', 'G-XXXXXXXXXX');
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

성능 측정과 개선

측정 전후 프로토콜

Partytown 도입은 “체감이 빨라졌다”만으로 끝내기 어렵습니다. 아래를 동일 브랜치·동일 태그 구성에서 비교합니다.

  1. Lighthouse(랩): TBT, CLS, 상황에 따라 INP 관련 힌트
  2. Chrome DevTools Performance: Long Task 길이·빈도, 메인 스레드 유휴 구간
  3. 필드 데이터: PageSpeed Insights, RUM(가능 시), Search Console Core Web Vitals

랩과 필드가 다르게 나오는 것이 정상입니다. Partytown은 특히 메인 스레드 혼잡도가 높은 페이지에서 랩 지표 개선이 두드러질 수 있습니다.

기대할 수 있는 것·없는 것

기대할 수 있는 것은 서드파티 스크립트의 평가와 내부 연산이 메인 스레드에서 빠져 긴 작업이 줄어드는 효과입니다. 반면 네트워크가 병목이라면, 즉 서버가 느리거나 스크립트 자체가 크다면 다운로드 시간은 그대로입니다. 또 DOM을 자주 읽고 쓰는 태그는 프록시와 메시지 오버헤드 때문에 오히려 느려질 수 있으므로, 태그별로 전후를 비교해야 합니다.

Core Web Vitals와의 정렬

LCP는 주로 히어로 리소스·서버 응답·렌더 차단과 연결되고, Partytown이 직접 건드리는 비중은 사이트마다 다릅니다. INP·TBT는 메인 스레드 여유와 연관이 깊어 Partytown과 목표가 잘 맞는 편입니다. 그래도 실제 GTM 내부 태그가 메인에서 무엇을 하는지에 따라 결과는 달라집니다.


디버깅과 트러블슈팅

debug 모드

개발·스테이징에서 Partytown의 debug(또는 통합 옵션의 디버그 플래그)를 켜면 브리지·프록시 관련 로그를 더 자세히 볼 수 있습니다. 프로덕션에서는 로그 노이즈·정보 노출을 고려해 끄는 것이 일반적입니다.

자주 나오는 증상

증상가능 원인점검
분석 히트가 안 잡힘forward 누락, 메인에서 gtag·dataLayer를 따로 정의정의가 text/partytown 스크립트 안에 있는지, forward 목록
~partytown 404copylib 누락, 잘못된 public 경로빌드 산출물에 파일 존재 여부
일부 태그만 실패해당 태그가 DOM·동기 API에 의존문제 태그를 메인으로 분리
로컬만 되고 배포만 실패헤더·CSP·경로 차이배포 URL에서 네트워크 탭·콘솔

CSP(콘텐츠 보안 정책)

script-src, worker-src, connect-src에 Partytown과 로드하는 서드파티 도메인이 포함되어야 합니다. Partytown은 워커를 blob: URL로 만들 수 있고 서비스 워커도 등록하므로, worker-src에 blob:과 자기 출처가 허용되는지도 확인합니다. 정책이 엄격한 프로젝트에서는 배포 직후에만 차단되는 경우가 많으므로, 스테이징에 프로덕션 CSP를 미러링하는 것이 좋습니다.

호환성 전략

“전부 Partytown”보다 “측정·태그는 워커, 위젯·레거시는 메인”처럼 경계를 나누는 설계가 운영 난이도를 낮춥니다. 신규 태그가 추가될 때마다 스테이징 검증을 자동화하면 회귀를 줄일 수 있습니다.


같이 보면 좋은 글