본문으로 건너뛰기

SPA 광고 적용 (React)

SDK 로드​

애플리케이션의 인덱스 페이지에서 NAM SDK를 로드합니다.

<!DOCTYPE html>
<html lang="en">
<head>
<title>React App</title>
<script async src="https://sdk.naverads.net/libs/glad/prod/gfp-core.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
경고

NAM SDK는 반드시 한 번만 로드되어야 합니다.

이를 보장하기 위해 초기 index.html 템플릿의 <head> 태그 내에 스크립트를 추가해 SDK를 로드하는 것을 권장하지만, React 컴포넌트 내부에서 동적으로 SDK를 로드하려는 경우, SDK가 중복으로 로드되지 않도록 주의하세요.

SDK 호출​

NAM SDK의 메소드 호출 시, SDK가 초기화가 완료된 것을 보장하기 위해, 명령어 대기열을 사용해야합니다.

React에서는 Cutsom Hook을 정의하면, 매번 명령어 대기열을 사용하지 않고도 더 쉽게 SDK 메소드를 호출할 수 있습니다.

Suspense를 사용해 SDK가 초기화 될 때까지 렌더링을 지연시키는 방법입니다. SDK의 초기화 상태를 보장하며 Type-Safe하게 SDK 메소드에 접근할 수 있습니다.

use-glad-sdk.ts
let sdk;

window.gladsdk = (window.gladsdk || { cmd: [] });
const suspender = new Promise((resolve) => {
window.gladsdk.cmd.push(resolve);
}).then(() => {
sdk = window.gladsdk;
});

export function useGladSdk() {
if (!sdk) {
throw suspender;
}
return sdk;
}
MyComponent.tsx
import { Suspense } from 'react';
import { useGladSdk } from './use-glad-sdk';

const MyAd = () => {
const gladSdk = useGladSdk();

useEffect(() => {
const adSlot = gladSdk.defineAdSlot({
adUnitId: 'ad_unit_id',
adSlotElementId: 'slot',
});
gladSdk.displayAd(adSlot)
}, [gladSdk]);

return <div id="slot" />
}

const MyComponent = () => {
return (
// SDK가 초기화되지 않은 경우 fallback 렌더링 됨
<Suspense fallback={null}>
<MyAd />
</Suspense>
)
}

광고 슬롯 정의​

여러 광고 단위를 게재하려는 경우 광고 슬롯을 관리하는 Hook과 컴포넌트를 정의해 코드의 재사용성을 높일 수 있습니다.

use-ad-slot.ts
import { useEffect, useRef } from 'react';
import { useGladSdk } from './use-glad-sdk';

type AdSlotInfo = Record<string, any>;
type AdEventListener = (ad: any, error?: unknown) => void;
interface AdEventListeners {
onAdLoaded?: AdEventListener;
onAdClicked?: AdEventListener;
onAdImpressed?: AdEventListener;
onAdMuteCompleted?: AdEventListener;
onError?: AdEventListener;
}
const defaultListeners = {};

/**
* 주어진 AdSlotInfo에 대해 AdSlot을 생성하고,
* 해당 AdSlot에 대해 이벤트 리스너를 추가하는 Hook
*/
export function useAdSlot(adSlotInfo: AdSlotInfo, eventListeners: AdEventListeners = defaultListeners) {
const gladSdk = useGladSdk();
const [adSlot, setAdSlot] = useState(() => gladSdk.defineAdSlot(adSlotInfo));
const adSlotInfoRef = useRef<AdSlotInfo>();
const eventListenersRef = useRef(eventListeners);

if (!deepEqual(adSlotInfoRef.current, adSlotInfo)) {
adSlotInfoRef.current = adSlotInfo;
}
const optimalAdSlotInfo = adSlotInfoRef.current;

useEffect(() => {
const _adSlot = gladSdk.defineAdSlot(optimalAdSlotInfo);
setAdSlot(_adSlot);
return () => {
gladSdk.destroyAdSlots([_adSlot]);
};
}, [gladSdk, optimalAdSlotInfo]);

useEffect(() => {
eventListenersRef.current = eventListeners;
}, [eventListeners]);

useEffect(() => {
const eventMap: Record<string, string> = {
[gladSdk.event.AD_LOADED]: 'onAdLoaded',
[gladSdk.event.AD_CLICKED]: 'onAdClicked',
[gladSdk.event.AD_IMPRESSED]: 'onAdImpressed',
[gladSdk.event.AD_MUTE_COMPLETED]: 'onAdMuteCompleted',
[gladSdk.event.ERROR]: 'onError',
};
const disposers = Object.entries(eventMap).map(([event, listenerName]) => {
const listener: AdEventListener = (ad, error) => {
if (ad.slot !== adSlot) return;
eventListenersRef.current[listenerName]?.(ad, error);
}
gladSdk.addEventListener(event, listener);
return () => gladSdk.removeEventListener(event, listener);
});
return () => {
disposers.forEach((dispose) => dispose());
};
}, [gladSdk, adSlot]);

return adSlot;
};

function deepEqual(a: unknown, b: unknown): a is typeof b {
// implement your deep compare method for better stability.
return JSON.stringify(a) === JSON.stringify(b)
}
NaverAd.tsx
import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from 'react';

type NaverAdRef = ComponentRef<'div'>;
type NaverAdProps = ComponentPropsWithoutRef<'div'> & {
adSlot: any;
};

/**
* AdSlot을 props로 받아 광고를 노출할 Element를 생성하는 컴포넌트
*/
export const NaverAd = forwardRef<NaverAdRef, NaverAdProps>((props, ref) => {
const { adSlot, ...divProps } = props;

return <div {...divProps} ref={ref} id={adSlot.getAdSlotElementId()} />;
});
NaverAd.displayName = 'NaverAd';

광고 노출​

정의한 Hook들과 컴포넌트를 이용해 광고를 노출합니다.

MyComponent.tsx
import { useEffect } from 'react';
import { NaverAd } from './NaverAd';
import { useAdSlot } from './use-ad-slot';
import { useGladSdk } from './use-glad-sdk';

const adSlotInfo = {
adUnitId: 'ad_unit_id',
adSlotElementId: 'slot',
customParam: {
category: 'entertainment',
hobby: ['music', 'sports'],
},
};

const MyComponent = () => {
const gladSdk = useGladSdk();
const adSlot = useAdSlot(adSlotInfo, {
onAdLoaded(ad: any) {
// @TODO implements code
},
onAdClicked(ad: any) {
// @TODO implements code
},
});

useEffect(() => {
gladSdk.displayAd(adSlot);
}, [gladSdk, adSlot]);

return (
<NaverAd adSlot={adSlot} />
);
}

컨텍스추얼 타겟팅 적용​

컨텍스추얼 타겟팅을 SPA에 적용하는 예제입니다. 화면이 바뀔 때마다 본문을 다시 분석하고, 분석 결과(context)를 포함해 하단 광고 슬롯을 새로 정의합니다. 주의 사항은 SPA 적용 시 주의 사항을 참고해주세요.

스크립트 로드​

인덱스 페이지에서 NAM SDK와 함께 ctx-sdk.js를 한 번만 로드합니다. 앱 번들과 ctx-sdk.js의 실행 순서에 관계없이 동작하도록 선행 큐(gladsdkCtxCheck.q)를 사용합니다.

<!DOCTYPE html>
<html lang="en">
<head>
<title>React App</title>
<script async src="https://sdk.naverads.net/libs/glad/prod/gfp-core.js"></script>
<script>
window.gladsdkCtxCheck = window.gladsdkCtxCheck || { q: [] };
window.gladsdkCtxCheck.q.push(['load']); // 키워드 번들 미리 받기
</script>
<script defer src="https://sdk.naverads.net/libs/ctx-check/ctx-sdk.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

본문 분석​

본문을 분석해 context를 반환하는 함수와, 화면마다 본문을 다시 분석하는 Hook을 정의합니다.

ctx-context.ts
type CtxContext = Record<string, unknown>;
type CtxResult = { context?: CtxContext | null; error?: string };

window.gladsdkCtxCheck = window.gladsdkCtxCheck || { q: [] };

/**
* 호출 시점의 본문을 분석해 광고 슬롯에 전달할 context를 반환하는 함수
* 분석 결과가 없거나, 스크립트가 로드되지 않았거나, 3초 안에 응답이 없으면 null을 반환한다.
*/
export function recommendContext(root: string | Element): Promise<CtxContext | null> {
return new Promise((resolve) => {
let done = false;
const finish = (res: CtxResult | null) => {
if (done) return; // 분석 결과와 바깥 타이머 중 먼저 온 쪽으로 한 번만 응답
done = true;
clearTimeout(timer);
resolve(res && !res.error && res.context ? res.context : null);
};
// 스크립트 미로드 등으로 응답이 없으면 context 없이 진행한다. 타이머는 maxMs보다 길게 지정한다.
const timer = setTimeout(() => finish(null), 3000);

window.gladsdkCtxCheck.q.push(['recommend', { idle: true, maxMs: 2000, root }, finish]);
});
}
use-ctx-context.ts
import { useEffect, useState } from 'react';
import { recommendContext } from './ctx-context';

type CtxContext = Record<string, unknown>;

/**
* pageKey가 바뀔 때마다 본문을 다시 분석하는 Hook
* 분석 중이면 undefined, 분석 결과가 없으면 null을 반환한다.
*/
export function useCtxContext(pageKey: string | undefined, root: string) {
const [result, setResult] = useState<{ pageKey: string; context: CtxContext | null }>();

useEffect(() => {
if (!pageKey) return; // 본문이 렌더링되기 전에는 분석하지 않는다.
let cancelled = false;
recommendContext(root).then((context) => {
if (!cancelled) setResult({ pageKey, context }); // 이미 다른 화면으로 이동했다면 결과를 버린다.
});
return () => {
cancelled = true;
};
}, [pageKey, root]);

return result?.pageKey === pageKey ? result.context : undefined;
}

화면 유형별로 본문 영역이 다르면 각 화면에서 해당 화면의 본문 선택자로 호출합니다. 예를 들어 기사 화면은 useCtxContext(article?.id, '#postViewArea'), 상품 상세 화면은 useCtxContext(product?.id, '#productDetail')처럼 지정합니다.

광고 노출​

상단 광고는 분석을 기다리지 않고 즉시 요청하고, 하단 광고는 분석이 끝난 뒤 context를 포함해 요청합니다. 광고 컴포넌트에 화면별 key를 지정하면 화면이 바뀔 때 이전 광고 슬롯이 제거되고 새 광고 슬롯이 정의됩니다. 같은 adSlotElementId로 다시 정의하면 기존 광고 슬롯이 그대로 반환되므로, 광고 영역 식별키(adSlotElementId)는 화면마다 다르게 지정합니다.

ArticlePage.tsx
import { useEffect } from 'react';
import { NaverAd } from './NaverAd';
import { useAdSlot } from './use-ad-slot';
import { useCtxContext } from './use-ctx-context';
import { useGladSdk } from './use-glad-sdk';

type CtxContext = Record<string, unknown>;

const TopAd = ({ pageKey }: { pageKey: string }) => {
const gladSdk = useGladSdk();
const adSlot = useAdSlot({ adUnitId: 'TOP_UNIT_ID', adSlotElementId: `ad-top-${pageKey}` });

useEffect(() => {
gladSdk.displayAd(adSlot);
}, [gladSdk, adSlot]);

return <NaverAd adSlot={adSlot} />;
};

const BottomAd = ({ pageKey, context, keyword }: { pageKey: string; context: CtxContext | null; keyword?: string }) => {
const gladSdk = useGladSdk();
const adSlot = useAdSlot({
adUnitId: 'BOTTOM_UNIT_ID',
adSlotElementId: `ad-bottom-${pageKey}`, // 화면마다 다른 광고 영역 식별키
keyword, // 매체 주입 키워드 (선택)
...(context ? { context } : {}), // context가 null이면 설정하지 않는다.
});

useEffect(() => {
gladSdk.displayAd(adSlot);
}, [gladSdk, adSlot]);

return <NaverAd adSlot={adSlot} />;
};

const ArticlePage = ({ articleId }: { articleId: string }) => {
const article = useArticle(articleId); // 본문 데이터 (로딩 중이면 undefined)

// 1) 제목을 먼저 갱신한다. 분석은 호출 시점의 document.title과 og:title을 읽는다.
useEffect(() => {
if (!article) return;
document.title = article.title;
document.querySelector('meta[property="og:title"]')?.setAttribute('content', article.title); // og:title을 사용하는 경우
}, [article]);

// 2) 본문이 렌더링된 뒤 분석한다.
const context = useCtxContext(article?.id, '#postViewArea');

return (
<>
<TopAd key={articleId} pageKey={articleId} />
{article && <article id="postViewArea">{article.body}</article>}
{article && context !== undefined && (
<BottomAd key={article.id} pageKey={article.id} context={context} keyword={article.keyword} />
)}
</>
);
};
경고
  • 제목 갱신은 분석보다 먼저 실행되어야 합니다. 위 예제처럼 제목을 갱신하는 Effect를 분석 Hook보다 앞에 선언하면, Effect는 선언 순서대로 실행되므로 제목이 먼저 갱신됩니다. 다른 컴포넌트나 라이브러리에서 제목을 비동기로 갱신하는 경우 갱신이 끝난 뒤 분석하세요.
  • og:title을 사용하는 페이지는 og:title도 화면마다 갱신하세요. 갱신하지 않으면 최초 화면의 제목이 계속 분석에 섞입니다. head 관리 라이브러리(react-helmet-async 등)로 og:title을 관리하고 있다면 해당 라이브러리로 갱신합니다.
  • 같은 화면의 여러 광고 슬롯에 context를 전달할 때는 useCtxContext를 화면에서 한 번만 호출하고 결과를 각 광고 슬롯에 나눠 전달하세요.