Quartz에 Coupang Partners 배너 일괄 삽입하기(Adblock 대응 포함)

변경 요약

  • 데스크톱/모바일에 맞는 쿠팡 파트너스 배너 컴포넌트를 신규 추가하고, 모든 페이지 하단(afterBody)에 노출되도록 레이아웃에 삽입.
  • 광고 차단(Adblock) 등으로 배너 iframe이 숨겨졌을 때 고지 문구만 보이는 혼란을 방지하기 위해, 배너 표시 여부와 동기화되도록 고지 문구 노출을 제어.

변경된 파일

  • quartz/components/CoupangBanner.tsx
  • quartz/components/scripts/coupangBanner.inline.ts
  • quartz.layout.ts

주요 구현 포인트

  • 배너 컴포넌트(CoupangBanner.tsx)
    • variant: 'desktop' | 'mobile' 옵션으로 소스/크기를 분리.
    • 접근성/성능: title, loading="lazy", referrerPolicy="unsafe-url" 적용.
    • 고지 문구(.coupang-banner__disclaimer)를 컴포넌트 내부에 포함하고, CSS로 기본 스타일 제공.
  • 동기화 스크립트(coupangBanner.inline.ts)
    • 배너 iframe의 가시성 변화를 관찰하여 고지 문구의 표시/숨김을 동기화.
    • SPA 내비게이션(nav 이벤트) 시 바인딩/해제 및 MutationObserver, requestAnimationFrame 클린업 처리.
  • 레이아웃 삽입(quartz.layout.ts)
    • afterBody 슬롯에 DesktopOnly(CoupangBanner({ variant: 'desktop' })), MobileOnly(CoupangBanner({ variant: 'mobile' })) 추가.

왜 변경했는가

  • 수익화 배너를 레이아웃 차원에서 일관되게 노출하려는 목적.
  • 광고 차단 환경에서 고지 문구만 남아 UX가 혼란스러운 문제를 해소하고, 실제 배너 노출과 고지를 일치시킴.

운영 체크리스트

  • 쿠팡 위젯 id, trackingCode는 본문 코드에서 비식별 플레이스홀더로 제공됩니다. 실제 배포 전, 본인 계정의 값으로 교체하세요.
  • 프리뷰: npx quartz build --serve로 데스크톱/모바일 폭에서 iframe/고지 문구 동작 확인.

구현 코드(비식별 예시)

아래 예시는 그대로 복사해도 제 쿠팡 파트너스 코드가 사용되지 않도록 비식별 처리(플레이스홀더)되어 있습니다. 쿠팡 파트너스에서 iframe 형태의 배너를 생성해서 적용하세요.

1) 배너 컴포넌트: quartz/components/CoupangBanner.tsx

import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import coupangBannerScript from "./scripts/coupangBanner.inline"
 
type BannerVariant = "desktop" | "mobile"
 
interface CoupangBannerOptions {
  variant?: BannerVariant
}
 
const widgetSrc = (id: string, width: number, height: number, trackingCode: string) =>
  `https://ads-partners.coupang.com/widgets.html?id=${encodeURIComponent(id)}&template=banner&trackingCode=${encodeURIComponent(trackingCode)}&subId=&width=${width}&height=${height}`
 
const bannerSources: Record<BannerVariant, { src: string; width: number; height: number; title: string }> = {
  desktop: {
    // TODO: 쿠팡 파트너스 생성 코드의 id/trackingCode로 치환하세요.
    src: widgetSrc("YOUR_WIDGET_ID_DESKTOP", 728, 90, "YOUR_TRACKING_CODE"),
    width: 728,
    height: 90,
    title: "Coupang 광고 배너 (데스크탑)",
  },
  mobile: {
    // TODO: 쿠팡 파트너스 생성 코드의 id/trackingCode로 치환하세요.
    src: widgetSrc("YOUR_WIDGET_ID_MOBILE", 320, 50, "YOUR_TRACKING_CODE"),
    width: 320,
    height: 50,
    title: "Coupang 광고 배너 (모바일)",
  },
}
 
export default ((options?: CoupangBannerOptions) => {
  const variant: BannerVariant = options?.variant ?? "desktop"
  const banner = bannerSources[variant]
 
  const CoupangBanner: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
    return (
      <div class={classNames(displayClass, "coupang-banner", `coupang-banner--${variant}`)}>
        <iframe
          src={banner.src}
          width={banner.width}
          height={banner.height}
          frameBorder="0"
          scrolling="no"
          referrerPolicy="unsafe-url"
          loading="lazy"
          title={banner.title}
        />
        <p class="coupang-banner__disclaimer">
          이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
        </p>
      </div>
    )
  }
 
  CoupangBanner.css = bannerStyles
  CoupangBanner.afterDOMLoaded = coupangBannerScript
 
  return CoupangBanner
}) satisfies QuartzComponentConstructor
 
const bannerStyles = `
.coupang-banner iframe {
  display: block;
  width: 100%;
  height: auto;
  border: none;
}
 
.coupang-banner__disclaimer {
  margin-top: 1px;
  margin-bottom: 1rem;
  font-size: 0.875rem;
  color: var(--gray);
  line-height: calc(1em + 1px);
  text-align: left;
}
 
.coupang-banner__disclaimer[hidden] {
  display: none !important;
}
 
.coupang-banner--desktop iframe {
  aspect-ratio: 728 / 90;
}
 
.coupang-banner--mobile iframe {
  aspect-ratio: 320 / 50;
}
`

2) 배너-고지 문구 동기화 스크립트: quartz/components/scripts/coupangBanner.inline.ts

// Hides the Coupang affiliate disclaimer when ad blockers remove or hide the banner iframe.
const bannerSelector = ".coupang-banner"
const disclaimerSelector = ".coupang-banner__disclaimer"
const iframeSelector = "iframe"
 
const setVisibility = (node: HTMLElement, visible: boolean) => {
  const currentlyHidden = node.hasAttribute("hidden")
  if (visible && currentlyHidden) {
    node.removeAttribute("hidden")
  } else if (!visible && !currentlyHidden) {
    node.setAttribute("hidden", "")
  }
}
 
const isIframeVisible = (iframe: HTMLIFrameElement | null) => {
  if (!iframe) {
    return false
  }
 
  const style = window.getComputedStyle(iframe)
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
    return false
  }
 
  const rect = iframe.getBoundingClientRect()
  return rect.width > 0 && rect.height > 0
}
 
const bindBanner = (banner: Element) => {
  const disclaimer = banner.querySelector<HTMLElement>(disclaimerSelector)
  if (!disclaimer) {
    return
  }
 
  let lastVisible: boolean | undefined
  const setDisclaimerVisibility = (iframe: HTMLIFrameElement | null) => {
    const visible = isIframeVisible(iframe)
    if (lastVisible === visible) {
      return
    }
    lastVisible = visible
    setVisibility(disclaimer, visible)
  }
 
  const attachIframeListeners = (iframe: HTMLIFrameElement) => {
    if (iframe.dataset.coupangBannerBound === "true") {
      return
    }
 
    const handleLoad = () => setDisclaimerVisibility(iframe)
    const handleError = () => setVisibility(disclaimer, false)
 
    iframe.addEventListener("load", handleLoad)
    iframe.addEventListener("error", handleError)
    iframe.dataset.coupangBannerBound = "true"
 
    window.addCleanup(() => {
      iframe.removeEventListener("load", handleLoad)
      iframe.removeEventListener("error", handleError)
      delete iframe.dataset.coupangBannerBound
    })
  }
 
  const evaluate = () => {
    const iframe = banner.querySelector<HTMLIFrameElement>(iframeSelector)
    if (iframe) {
      attachIframeListeners(iframe)
    }
    setDisclaimerVisibility(iframe ?? null)
  }
 
  evaluate()
 
  const rafId = window.requestAnimationFrame(evaluate)
  window.addCleanup(() => window.cancelAnimationFrame(rafId))
 
  const observer = new MutationObserver(evaluate)
  observer.observe(banner, { childList: true, subtree: true, attributes: true })
  window.addCleanup(() => observer.disconnect())
 
  const delayedCheck = window.setTimeout(evaluate, 2000)
  window.addCleanup(() => window.clearTimeout(delayedCheck))
}
 
document.addEventListener("nav", () => {
  const banners = document.querySelectorAll(bannerSelector)
  banners.forEach((banner) => bindBanner(banner))
})

3) 레이아웃에 일괄 삽입: quartz.layout.ts

import * as Component from "./quartz/components"
 
export const sharedPageComponents = {
  head: Component.Head(),
  header: [],
  afterBody: [
    // ... 필요 컴포넌트들
    Component.DesktopOnly(Component.CoupangBanner({ variant: "desktop" })),
    Component.MobileOnly(Component.CoupangBanner({ variant: "mobile" })),
    // ... 필요 컴포넌트들
  ],
  // ...
}

검증 및 체크리스트

  • 프리뷰 검증: npx quartz build --serve 실행 → 데스크톱/모바일 폭에서 배너 로딩, 고지 문구 표시 확인. (테스트 환경에서는 쿠팡 배너가 뜨지 않습니다. 유니콘 기반 광고차단도 자동으로 작동하지 않습니다. 실제 테스트는 웹 - cloudflare 미리보기 배포 등 - 에서 수행하세요.)
  • 접근성: iframetitle 제공 여부 확인, 고지 문구가 스크린 리더에 이중 노출되지 않도록 hidden 속성 토글이 정상 동작하는지 확인.
  • 레이아웃: afterBody 위치가 페이지 흐름을 저해하지 않는지, 배너와 본문/푸터 간 간격 확인.

주의사항(비식별 처리)

  • 본문 코드의 YOUR_WIDGET_ID_*, YOUR_TRACKING_CODE는 예시용입니다. 쿠팡 파트너스에서 배너 iframe 코드를 발행하고, 이를 이용해서 수정하세요.
  • 코드 스니펫은 제 개인 식별 정보가 제거된 상태이며, 그대로 복사해도 제 배너로 수익이 귀속되지 않습니다.