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
- State
Suspense를 사용해 SDK가 초기화 될 때까지 렌더링을 지연시키는 방법입니다. SDK의 초기화 상태를 보장하며 Type-Safe하게 SDK 메소드에 접근할 수 있습니다.
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;
}
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>
)
}
Suspense의 사용을 원하지 않는 경우, State를 통해 SDK 초기화 여부를 확인 후 SDK 메소드를 호출할 수도 있습니다.
import { useState } from 'react';
let sdk;
const initListeners: (() => void)[] = [];
window.gladsdk = (window.gladsdk || { cmd: [] });
window.gladsdk.cmd.push(() => {
sdk = window.gladsdk;
initListeners.forEach(listener => listener());
});
export function useGladSdk() {
const [gladSdk, setGladSdk] = useState(sdk);
useEffect(() => {
if (gladSdk) return;
initListeners.push(() => {
setGladSdk(sdk);
});
}, [gladSdk])
return gladsdk;
}
import { useGladSdk } from './use-glad-sdk';
const MyComponent = () => {
const gladSdk = useGladSdk();
useEffect(() => {
if (!gladSdk) return; // SDK가 초기화되지 않은 경우 undefined
const adSlot = gladSdk.defineAdSlot({
adUnitId: 'ad_unit_id',
adSlotElementId: 'slot',
});
gladSdk.displayAd(adSlot)
}, [gladSdk]);
return <div id="slot" />
}
아래 예제들은 모두 Suspense를 기반으로 한 useGladSdk를 기준으로 제작되었습니다.
State 기반의 useGladSdk를 사용하는 경우 gladSdk에 접근할 때 항상 위의 예제처럼 gladSdk의 존재 유무를 확인하세요. 실수를 방지하기 위해 Typescript의 strict 옵션을 true로 설정하는 것을 권장합니다.
광고 슬롯 정의
여러 광고 단위를 게재하려는 경우 광고 슬롯을 관리하는 Hook과 컴포넌트를 정의해 코드의 재사용성을 높일 수 있습니다.
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)
}
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들과 컴포넌트를 이용해 광고를 노출합니다.
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을 정의합니다.
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]);
});
}
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)는 화면마다 다르게 지정합니다.
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를 화면에서 한 번만 호출하고 결과를 각 광고 슬롯에 나눠 전달하세요.