서드파티 스크립트 최적화: Partytown·지연 로딩·성능 예산으로 INP 잡기 (2026)

GTM·GA4·챗봇·픽셀이 만드는 INP 병목을 Partytown 웹 워커, 지연 로딩, 성능 예산으로 통제하는 실전 프레임. 파사드 패턴과 CI 예산 자동화 코드까지 프로덕션 사례로 정리했다.

서드파티 스크립트 최적화 가이드 (2026)

업데이트: 2026년 8월 29일

서드파티 스크립트 최적화란 GTM·GA4·픽셀·챗봇·A/B 툴처럼 외부 도메인에서 로드되는 자바스크립트가 메인 스레드를 점유해 INP·LCP를 망치는 문제를, Partytown 같은 웹 워커 격리, 지연 로딩, 성능 예산(Performance Budget)으로 통제하는 작업이다. 솔직히 대형 이커머스에서 매출과 직결되는 태그는 지울 수 없다. 하지만 "언제, 어디서, 어떤 우선순위로" 실행시킬지는 우리 손에 있다. 지난 프로젝트에서 이 프레임 하나로 INP p75를 절반 아래로 떨어뜨린 경험을 코드와 함께 정리했다.

  • 서드파티 스크립트는 평균적으로 모바일 페이지 로드 시간의 40% 이상을 잡아먹는다. 특히 GTM·챗 위젯·픽셀이 Long Tasks의 주범이다.
  • Partytown은 웹 워커 안에서 GTM·GA·픽셀을 실행해 메인 스레드를 비운다. 2026년 기준 GTM 컨테이너, GA4, Meta Pixel, Hotjar가 공식 호환.
  • 지우지 못하는 스크립트는 async·defer·type="module"로 파싱을 늦추고, 상호작용 기반(on:idle, on:visible) 로딩으로 지연시킨다.
  • Third-party 성능 예산은 KB가 아니라 메인 스레드 점유 시간(TBT)으로 잡아야 한다. 나는 서드파티 TBT 300ms를 상한선으로 쓴다.
  • YouTube·Intercom 같은 위젯은 파사드(Facade) 패턴으로 대체하면 초기 로드에서 500KB 이상, INP 200ms 이상을 절약할 수 있다.
  • Lighthouse의 "Third-Party Summary"와 CrUX 필드 데이터를 함께 봐야 한다. 랩 데이터만 보면 챗봇 팝업의 실제 영향을 놓친다.

서드파티 스크립트는 왜 성능을 망치는가

내가 담당하는 이커머스 홈은 상품 상세 페이지 하나에 서드파티 스크립트가 27개 얹혀 있다. GTM 하나가 그 27개를 다시 소환하는 구조다. HTTP Archive의 2026년 상반기 리포트를 보면, 모바일 페이지 상위 1000곳 중 92%가 최소 10개 이상의 서드파티 스크립트를 로드하고, 그중 60%는 서드파티가 메인 스레드를 2초 이상 점유한다. 이게 바로 INP가 500ms를 넘어가는 결정적 이유다.

서드파티 스크립트가 성능을 갉아먹는 경로는 세 가지다. 첫째, 파싱·컴파일 비용. GA4 gtag.js는 gzip 후 45KB 남짓이지만, 파싱·컴파일에 저사양 안드로이드에서 180~250ms가 걸린다. 둘째, Long Tasks. GTM 트리거가 발화되는 순간 dataLayer 이벤트 리스너들이 순차 실행되면서 200ms 이상의 Long Task가 만들어진다. 셋째, 네트워크 커넥션 경합. 서드파티 도메인은 대개 다른 origin이라 새 TCP+TLS 핸드셰이크가 필요하고, HTTP/2 커넥션 풀도 공유하지 못한다.

대형 이커머스에서 특히 아픈 지점은 수익화 태그를 지울 수 없다는 것이다. 광고 리마케팅, 어트리뷰션, A/B 테스트 툴은 매출 리포트에 직접 물려 있다. 그래서 "제거" 대신 "통제"가 목표가 된다. 통제 수단은 세 층으로 나뉜다: (1) 필요 없는 것을 잘라내는 감사, (2) 남은 것을 웹 워커나 파사드로 격리, (3) 앞으로 늘어나지 않게 예산으로 잠그는 것. 이 글은 이 세 층을 실전 코드로 다룬다.

서드파티 스크립트를 어떻게 감사하나요?

감사(audit)의 목적은 두 가지다. "어떤 스크립트가 얼마나 비싼가"와 "그 스크립트를 정말 쓰고 있나". 나는 감사할 때 세 가지 도구를 병행한다. Lighthouse의 Third-Party Summary, Chrome DevTools의 Performance 패널, 그리고 Third Party Web 리포트에서 도메인별 실제 사용자 데이터를 뽑아 대조한다.

Lighthouse만 돌리면 서드파티가 "메인 스레드를 500ms 점유했다"는 요약은 볼 수 있지만 어떤 태그가 어떤 이벤트에서 튀는지는 안 보인다. 그래서 나는 DevTools에서 Performance 녹화를 켠 다음 실제 사용자 플로우(상품 클릭 → 카트 담기 → 결제 시작)를 흉내낸다. 태스크 색상이 노란색(script)인 블록을 클릭해 스택 트레이스를 열면 GTM인지, Optimizely인지, 아니면 우리 코드인지 즉시 드러난다.

다음은 내가 CI에서 매일 돌리는 감사 스크립트다. Playwright로 페이지를 열고, 서드파티 도메인별로 리소스 크기와 파싱 시간을 뽑아 CSV로 떨어뜨린다.

// audit-third-party.mjs
// 서드파티 스크립트 도메인별 크기·지속시간을 뽑는다.
import { chromium } from 'playwright';
import fs from 'node:fs';

const FIRST_PARTY = ['mysite.com', 'cdn.mysite.com', 'img.mysite.com'];

const browser = await chromium.launch();
const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 5)' });
const page = await ctx.newPage();

const entries = [];
page.on('response', async (res) => {
  const url = new URL(res.url());
  if (FIRST_PARTY.some((d) => url.hostname.endsWith(d))) return;
  const timing = res.request().timing();
  entries.push({
    host: url.hostname,
    path: url.pathname,
    status: res.status(),
    bytes: Number(res.headers()['content-length'] ?? 0),
    ttfbMs: timing.responseStart - timing.requestStart,
  });
});

await page.goto('https://mysite.com/product/12345', { waitUntil: 'networkidle' });

// 도메인별로 집계
const byHost = entries.reduce((acc, e) => {
  acc[e.host] ??= { bytes: 0, count: 0, ttfbMs: 0 };
  acc[e.host].bytes += e.bytes;
  acc[e.host].count += 1;
  acc[e.host].ttfbMs += e.ttfbMs;
  return acc;
}, {});

fs.writeFileSync(
  'third-party-audit.csv',
  'host,requests,kb,avg_ttfb_ms\n' +
    Object.entries(byHost)
      .sort((a, b) => b[1].bytes - a[1].bytes)
      .map(([h, v]) => `${h},${v.count},${(v.bytes / 1024).toFixed(1)},${(v.ttfbMs / v.count).toFixed(0)}`)
      .join('\n'),
);
await browser.close();

이 CSV를 팀 Slack에 매일 자동 포스팅하면, 어느 날 갑자기 connect.facebook.net의 요청이 3개에서 11개로 늘어난 게 눈에 띈다. 대개 마케팅팀이 GTM에서 새 태그를 추가한 날이다. 보이면 협상할 수 있다. 안 보이면 3개월 후 INP가 700ms인 이유를 찾다가 하루를 태운다.

Partytown이란 무엇이며 어떻게 도입하나요?

Partytown은 Builder.io가 개발한 오픈소스 라이브러리로, 서드파티 스크립트를 웹 워커(Web Worker) 안에서 실행해 메인 스레드를 비우는 도구다. 웹 워커는 DOM에 접근할 수 없다는 근본적인 제약이 있지만, Partytown은 서비스 워커와 Atomics·SharedArrayBuffer로 프록시를 만들어 메인 스레드의 DOM을 워커에서 동기적으로 흉내낸다. 결과적으로 GTM은 자기가 메인 스레드에 있다고 착각하면서, 실제로는 워커에서 돈다.

2026년 8월 기준 Partytown v0.11이 안정판이고, Google Tag Manager, GA4, Meta Pixel, Hotjar, LinkedIn Insight, TikTok Pixel이 공식 호환 리스트에 있다. 나는 프로덕션에서 Next.js 15 앱에 Partytown을 걸어 GTM+GA4를 워커로 옮겼고, 저사양 안드로이드에서 INP p75가 612ms → 218ms로 떨어졌다.

Next.js 15에서 Partytown 셋업은 다음과 같다. next.config.js에 헤더를 추가하고, @builder.io/partytown을 설치한 뒤 <Script>strategy="worker"만 붙이면 된다.

// next.config.js
export default {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'Cross-Origin-Embedder-Policy', value: 'credentialless' },
          { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
        ],
      },
    ];
  },
};

// app/layout.tsx
import Script from 'next/script';
import { Partytown } from '@builder.io/partytown/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <head>
        {/* Partytown 러너를 먼저 등록 */}
        <Partytown
          debug={process.env.NODE_ENV !== 'production'}
          forward={['dataLayer.push', 'gtag']}
        />
        {/* GTM을 worker 전략으로 로드 — 메인 스레드는 자유 */}
        <Script
          id="gtm"
          strategy="worker"
          src={`https://www.googletagmanager.com/gtm.js?id=GTM-XXXX`}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

forward 배열이 핵심이다. 메인 스레드에서 마케팅팀이 window.dataLayer.push({event: 'add_to_cart'})를 호출하면, Partytown이 그 호출을 워커의 GTM으로 릴레이한다. 즉, 트래킹 코드는 그대로 두고 실행 위치만 바꾸는 것이다. Long Tasks와 scheduler.yield로 INP를 개선하는 방법과 함께 쓰면 서드파티가 남긴 자투리까지 청소된다.

Partytown이 안 되는 경우

모든 스크립트를 워커로 옮길 수 있는 건 아니다. DOM을 실시간으로 조작하는 라이브러리(예: Optimizely의 비주얼 에디터, Adobe Target의 mbox가 초기 페인트 전에 DOM을 바꾸는 경우)는 워커에서 돌리면 깜빡임이 생긴다. 이런 스크립트는 뒤에 나오는 파사드나 상호작용 지연 로딩으로 다뤄야 한다.

구글 태그 매니저를 지연 로딩할 수 있나요?

결론부터: 가능하고, 대부분의 이커머스에서 안전하다. GTM은 초기 로드 시점에 있어야 하는 게 아니라 사용자 상호작용 직전에 있으면 된다. 나는 requestIdleCallback 또는 첫 사용자 인터랙션(pointerdown, keydown, scroll) 중 먼저 오는 이벤트에 GTM을 로드한다.

// gtm-lazy.ts
// GTM을 idle 또는 첫 상호작용 시점까지 지연 로드
const GTM_ID = 'GTM-XXXX';

function loadGtm() {
  if ((window as any).__gtmLoaded) return;
  (window as any).__gtmLoaded = true;

  // 표준 GTM 스니펫 — 이 부분은 GTM 문서 그대로
  (window as any).dataLayer = (window as any).dataLayer || [];
  (window as any).dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });

  const s = document.createElement('script');
  s.async = true;
  s.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`;
  document.head.appendChild(s);
}

// idle이 오면 3초 안에 무조건 로드
const idleId = 'requestIdleCallback' in window
  ? requestIdleCallback(loadGtm, { timeout: 3000 })
  : setTimeout(loadGtm, 3000);

// 사용자가 먼저 상호작용하면 즉시 로드
const interactionEvents = ['pointerdown', 'keydown', 'scroll'] as const;
const onFirstInteraction = () => {
  interactionEvents.forEach((e) =>
    window.removeEventListener(e, onFirstInteraction, { capture: true } as any),
  );
  if ('cancelIdleCallback' in window) cancelIdleCallback(idleId as number);
  else clearTimeout(idleId as number);
  loadGtm();
};
interactionEvents.forEach((e) =>
  window.addEventListener(e, onFirstInteraction, { once: true, passive: true, capture: true }),
);

지연 로드에서 자주 나오는 걱정은 "그 사이에 발생한 이벤트를 놓치지 않느냐"다. 걱정할 필요 없다. GTM 로드 전에 dataLayer.push()가 여러 번 일어나도, GTM은 로드되는 순간 큐를 재생한다. 나는 이 패턴으로 GA4의 "페이지 뷰" 이벤트가 유실되지 않는 것을 6개월간 검증했다. 다만 광고 컨버전 태그가 결제 완료 페이지에 있다면, 그 페이지에서는 지연 없이 즉시 로드하는 예외 처리를 반드시 넣어라. 그렇지 않으면 지연 로드가 완료되기 전에 사용자가 이탈해 컨버전이 유실될 수 있다.

파사드 패턴: 챗봇·유튜브·소셜 임베드 대체

파사드(Facade) 패턴은 서드파티 위젯의 자리를 가벼운 정적 대체물로 채워두고, 사용자가 클릭했을 때만 진짜 위젯을 로드하는 기법이다. 대표적으로 YouTube 임베드, Intercom·Zendesk 챗 위젯, Disqus 댓글, 소셜 공유 버튼이 여기 해당한다.

이커머스에서 챗봇 위젯 하나가 초기 로드에 얹는 비용을 실측한 적이 있다. Intercom 위젯은 스크립트 자체가 gzip 후 187KB, 파싱·초기화에 저사양 폰에서 340ms. LCP 이미지 로드와 리소스 경합까지 하면 실질적으로 LCP가 400ms 밀렸다. 파사드로 대체한 뒤 초기 로드에서 그 187KB가 통째로 사라졌다.

YouTube의 경우 lite-youtube-embed가 사실상 표준이다. 직접 만들면 다음처럼 된다.

<!-- 초기 로드에는 썸네일과 재생 버튼만 -->
<div class="yt-facade" data-video-id="dQw4w9WgXcQ">
  <img
    src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
    alt="상품 사용법 영상 썸네일"
    width="480" height="360" loading="lazy"
  />
  <button aria-label="영상 재생" class="yt-play">▶</button>
</div>

<script>
document.addEventListener('click', (e) => {
  const facade = e.target.closest('.yt-facade');
  if (!facade) return;
  const id = facade.dataset.videoId;
  // 클릭이 있어야만 진짜 iframe을 삽입
  facade.innerHTML = `<iframe
    src="https://www.youtube-nocookie.com/embed/${id}?autoplay=1"
    allow="autoplay; encrypted-media" allowfullscreen
    width="480" height="360"></iframe>`;
});
</script>

챗 위젯은 이보다 조금 더 손이 간다. 나는 대개 CSS로만 그린 "채팅 시작" 버튼을 오른쪽 하단에 고정시키고, 클릭 시 실제 Intercom SDK를 import()한다. 이때 preconnect로 챗봇 도메인에 미리 연결해 두면 클릭 후 첫 응답까지의 지연을 100ms 이상 줄일 수 있다.

서드파티 성능 예산은 어떻게 설정하나요?

성능 예산(performance budget)은 마케팅팀이 새 태그를 추가할 때마다 "얼마까지 허용할지" 미리 합의한 상한선이다. 나는 예산을 세 축으로 나눠 관리한다.

지표내가 쓰는 상한선측정 도구
바이트서드파티 전송량(gzip 후)모바일 200KBLighthouse Third-Party Summary
메인 스레드서드파티 스크립트 실행 시간저사양 안드로이드 300msChrome DevTools Performance
커넥션고유 서드파티 도메인 수10개 이하커스텀 Playwright 스크립트
Long Tasks서드파티가 원인인 Long Task 개수3개 이하 / 인터랙션Long Animation Frames API

가장 중요한 축은 두 번째, 메인 스레드 실행 시간이다. 바이트만 보면 GA4 gtag.js가 45KB로 "가볍다"고 보이지만, 파싱·컴파일·실행까지 합치면 저사양 기기에서 200ms 이상을 잡아먹는다. 그 200ms가 그대로 INP에 반영된다. 나는 Lighthouse의 Third-Party Summary가 뱉는 "Main-Thread Blocking Time" 값을 CI에 물려 놓았고, PR에서 이 값이 예산을 넘으면 자동으로 실패시킨다.

// lighthouse-budget.mjs
// 서드파티 메인 스레드 시간을 예산과 비교
import lighthouse from 'lighthouse';
import { launch } from 'chrome-launcher';

const chrome = await launch({ chromeFlags: ['--headless'] });
const result = await lighthouse('https://mysite.com/product/12345', {
  port: chrome.port,
  onlyCategories: ['performance'],
  formFactor: 'mobile',
  throttling: { cpuSlowdownMultiplier: 4 }, // 저사양 안드로이드
});

const audit = result.lhr.audits['third-party-summary'];
const totalBlockingMs = audit.details.summary.wastedMs;

console.log(`서드파티 메인 스레드 시간: ${totalBlockingMs}ms`);

if (totalBlockingMs > 300) {
  console.error('예산 초과! 300ms 상한을 넘었다.');
  process.exit(1);
}
await chrome.kill();

예산을 문서에만 적어두면 지켜지지 않는다. CI에서 실패로 만들어야, 마케팅팀이 새 태그를 추가하려 할 때 "이거 넣으면 CI가 깨집니다"라고 자연스레 협상 자리에 앉는다. 그러면 대개 "그럼 오래된 A/B 툴 태그를 하나 지울까요?"라는 답이 돌아온다. 예산은 협상 도구다.

프로덕션에서 서드파티 스크립트 모니터링

랩 데이터(Lighthouse)만 보면 놓치는 것이 있다. 챗봇 팝업은 페이지 로드 10초 후에 뜨는데, Lighthouse는 3초에 끝난다. Optimizely 실험은 특정 세그먼트에만 스크립트를 주입하는데, Lighthouse는 그 세그먼트가 아니다. 그래서 필드 데이터가 필요하다.

나는 프로덕션에서 두 가지를 수집한다. 첫째, Long Animation Frames API로 각 Long Frame의 스크립트 스택 트레이스를 뽑고 도메인별로 집계. 둘째, web-vitals 라이브러리로 INP 상세 어트리뷰션을 뽑아 어떤 인터랙션에서 어떤 스크립트가 병목인지를 사용자 단위로 본다.

// long-frames-attribution.ts
// Long Animation Frame별로 서드파티 도메인을 집계
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
    // 50ms 미만은 무시
    if (entry.duration < 50) continue;

    for (const script of entry.scripts) {
      const src = script.sourceURL || script.invoker;
      if (!src) continue;
      let host: string;
      try { host = new URL(src).hostname; } catch { continue; }
      if (host.endsWith('mysite.com')) continue; // 퍼스트파티 제외

      // 자체 RUM 엔드포인트로 전송
      navigator.sendBeacon('/rum/long-frame', JSON.stringify({
        host,
        durationMs: Math.round(script.duration),
        pausedMs: Math.round(script.pauseDuration),
        forcedStyleAndLayoutMs: Math.round(script.forcedStyleAndLayoutDuration),
        invokerType: script.invokerType,
      }));
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });

이 데이터를 대시보드에 올려두면, 특정 릴리스 이후 chat.intercom.com의 평균 Long Frame 기여도가 갑자기 두 배가 된 걸 그날 저녁에 발견한다. 다음날 아침 Intercom 담당자에게 티켓을 열면 대개 "저희가 새 위젯 API를 롤아웃했는데 롤백하겠습니다"라는 답이 온다. 이름을 붙여 지목해야 고쳐진다.

자주 묻는 질문

서드파티 스크립트를 async와 defer 중 무엇으로 로드해야 하나요?

기본은 defer다. async는 다운로드 완료 즉시 실행되어 파싱 도중 임의 시점에 메인 스레드를 잡을 수 있고, defer는 문서 파싱이 끝난 후 순서대로 실행된다. 광고·트래킹처럼 실행 순서가 중요한 스크립트, 그리고 초기 렌더 이후에 필요한 대부분의 서드파티는 defer가 안전하다. 단, 스크립트가 document.write를 쓴다면 두 속성 모두 무시되므로 대체 라이브러리를 찾아야 한다.

Partytown을 도입하면 GTM 이벤트가 유실되지 않나요?

유실되지 않는다. Partytown의 forward 설정에 dataLayer.pushgtag를 등록하면 메인 스레드의 호출이 워커의 GTM으로 릴레이된다. 다만 상호작용 발생 직후 몇 밀리초 안에 페이지가 언로드되는 경우(예: 결제 완료 후 즉시 리다이렉트) 유실 가능성이 있으므로, 그런 페이지에서는 navigator.sendBeacon 폴백을 함께 쓰는 것을 권장한다.

서드파티 스크립트가 CLS에도 영향을 주나요?

있다. 대표적으로 광고 배너와 챗봇 위젯이 원인이다. 광고는 응답 크기가 사전에 확정되지 않아 자리를 밀어내고, 챗봇은 로드가 완료되면 뷰포트 하단에 갑자기 나타나 다른 요소를 밀 수 있다. 광고 슬롯은 min-height로 자리를 예약하고, 챗봇은 파사드로 자리를 미리 잡아둬야 한다.

서드파티 도메인에 preconnect를 걸면 언제나 이득인가요?

아니다. preconnect는 미사용 시 커넥션 풀만 낭비한다. 규칙은 간단하다. LCP 경로에 있는 도메인, 또는 첫 상호작용에서 반드시 호출되는 도메인에만 걸어라. 나는 3~4개 이하를 유지한다. 자세한 우선순위 규칙은 preload/prefetch/preconnect 가이드를 참고하면 좋다.

Lighthouse 점수와 실제 사용자 INP가 다른 이유는 무엇인가요?

Lighthouse는 특정 CPU 배율과 네트워크 조건의 랩 시뮬레이션이라, 실제 사용자의 저사양 기기와 불안정한 네트워크를 완전히 반영하지 못한다. 특히 챗봇 팝업이나 A/B 실험 스크립트는 로드 후 수초가 지나 실행되기 때문에 Lighthouse 스코프 밖이다. 필드 데이터(CrUX, web-vitals RUM)와 반드시 병행해야 한다.

Robin Chowdhury
저자 소개 Robin Chowdhury

Frontend performance architect at a large e-commerce site. Spends his days fighting third-party scripts.