보상광고 시청 후 다음 보상광고를 로드할 때 실패합니다

안녕하세요.
수고하십니다.
광고 시청 후에 다음 광고를 위해 프리로드를 진행하도록 구현했습니다.
이전에는 잘 작동했었는데 광고 2.0 적용 후 보상광고 시청 후에 다음 광고를 로드할 때 로드 에러가 발생하고 있습니다.
에러 내용은 다음과 같습니다.

code=0, message=Internal error

전면광고는 정상적으로 로드되고 있습니다.
보상광고 로드만 문제가 발생하고 있습니다.
광고 ID는 제대로 설정한 상태이고, 테스트 광고ID와 실제 광고ID 모두 같은 문제가 발생하고 있습니다.

확인 부탁 드립니다.

// appintoss-ads.ts — 기존 기능 유지 + 이벤트 중복 전달 방지 (로드=load만 / 시청=ensure_show만)

// Toss Web Framework SDK
import { GoogleAdMob } from '@apps-in-toss/web-framework';

// ==== 타입 (기존과 동일/호환) ====
type FullScreenEvent = { type: string; data?: any };

type LoadOpts = {
    adUnitId: string;
    onEvent?: (e: FullScreenEvent) => void;
    onError?: (e: unknown) => void;
};

// 전역 브리지 타입 (jslib가 런타임에 주입)
declare global {
    interface Window {
        AITAds?: {
            // 광고 지원여부 확인 (캐시됨)
            supported: () => { interstitial: boolean; rewarded: boolean };

            // 광고 미리 로드
            loadInterstitial: (opts: LoadOpts) => Promise<void>;
            loadRewarded: (opts: LoadOpts) => Promise<void>;

            // 로드 보장 + 즉시 표시 (표시 후 자동 프리로드)
            ensureAndShowInterstitial: (opts: { adUnitId: string }) => Promise<void>;
            ensureAndShowRewarded: (opts: { adUnitId: string }) => Promise<void>;
        };

        __AIT_SendAdEvent?: (p: {
            kind: "interstitial" | "rewarded";
            phase: "load" | "ensure_show";
            eventType: string;
            adUnitId: string;
            error?: string;
            responseInfo?: unknown;
        }) => void;
    }
}

// ==== 내부 상태(최소) ====
// - 로딩/로디드 상태: 기존 s.loaded/s.loading 사용 유지
const interstitialSlots = new Map<string, { loading: boolean; loaded: boolean; lastLoadedAt?: number }>();
const rewardedSlots     = new Map<string, { loading: boolean; loaded: boolean; lastLoadedAt?: number }>();

function getSlot(kind: "interstitial" | "rewarded", adUnitId: string) {
    const map = kind === "interstitial" ? interstitialSlots : rewardedSlots;
    let s = map.get(adUnitId);
    if (!s) {
        s = { loading: false, loaded: false };
        map.set(adUnitId, s);
    }
    return s;
}

// - 지금 표시(show) 중인지 (이벤트 phase 자동 결정용)
const showingInterstitial = new Set<string>();
const showingRewarded     = new Set<string>();

// ==== 지원 여부 (캐시) ====
let _supportCache: { interstitial: boolean; rewarded: boolean } | null = null;
function supported(): { interstitial: boolean; rewarded: boolean } {
    if (_supportCache) return _supportCache;
    const ok = !!GoogleAdMob;
    _supportCache = { interstitial: ok, rewarded: ok };
    return _supportCache;
}

// ==== 프리로드 후처리 (기존 명칭 유지) ====
function markConsumed(kind: "interstitial" | "rewarded", adUnitId: string) {
    const s = getSlot(kind, adUnitId);
    s.loaded = false;
    s.loading = false;
}

// ==== 이벤트 전송 ====
function sendEvent(
    kind: "interstitial" | "rewarded",
    _phase: "load" | "ensure_show",   // 호출부의 인자는 무시하고 내부에서 재결정
    adUnitId: string,
    eventType: string,
    extra?: { error?: string; responseInfo?: unknown }
) {
    // 1) 이벤트 타입으로 phase 결정
    let phase: "load" | "ensure_show";
    switch (eventType) {
        case "loaded":
            phase = "load";
            break;

        case "requested":
        case "show":
        case "impression":
        case "clicked":
        case "dismissed":
        case "failedToShow":
        case "userEarnedReward":
        case "done":
            phase = "ensure_show";
            break;

        case "error":
            // 에러는 로드/표시 둘 다에서 발생 가능
            phase = (kind === "interstitial"
                ? showingInterstitial.has(adUnitId)
                : showingRewarded.has(adUnitId))
                ? "ensure_show"
                : "load";
            break;

        default:
            // 혹시 새로운 타입이 추가돼도 표시 중이면 표시 단계로 보내는 게 안전
            phase = (kind === "interstitial"
                ? showingInterstitial.has(adUnitId)
                : showingRewarded.has(adUnitId))
                ? "ensure_show"
                : "load";
            break;
    }

    window.__AIT_SendAdEvent?.({
        kind,
        phase,
        adUnitId,
        eventType,
        ...(extra ?? {}),
    });
}

// ==== 로드 ====
function doLoadInterstitial(opts: LoadOpts): Promise<void> {
    const { adUnitId, onEvent, onError } = opts;
    const s = getSlot("interstitial", adUnitId);
    if (s.loaded) return Promise.resolve();
    if (s.loading) {
        return new Promise<void>((resolve) => {
            const check = () => (s.loaded ? resolve() : setTimeout(check, 50));
            check();
        });
    }

    s.loading = true;
    s.loaded = false;

    return new Promise<void>((resolve, reject) => {
        const cleanup = GoogleAdMob.loadAppsInTossAdMob({
            options: { adGroupId: adUnitId } as any,
            onEvent: (e: FullScreenEvent) => {
                if (e.type === "loaded") {
                    s.loaded = true;
                    s.loading = false;
                    s.lastLoadedAt = Date.now();
                    resolve();
                }
                onEvent?.(e);

                const t = String((e as any)?.type ?? "");
                sendEvent("interstitial", "load", adUnitId, t);

                const ri = (e as any)?.data?.responseInfo;
                if (t === "loaded" && ri) {
                    sendEvent("interstitial", "load", adUnitId, "loaded", { responseInfo: ri });
                }
            },
            onError: (err: unknown) => {
                s.loading = false;
                s.loaded = false;
                reject(err);
                onError?.(err);

                sendEvent("interstitial", "load", adUnitId, "error", { error: String(err) });
            },
        });

        if (typeof cleanup === "function") {
            // 필요 시 정리 로직
        }
    });
}

function doLoadRewarded(opts: LoadOpts): Promise<void> {
    const { adUnitId, onEvent, onError } = opts;
    const s = getSlot("rewarded", adUnitId);
    if (s.loaded) return Promise.resolve();
    if (s.loading) {
        return new Promise<void>((resolve) => {
            const check = () => (s.loaded ? resolve() : setTimeout(check, 50));
            check();
        });
    }

    s.loading = true;
    s.loaded = false;

    return new Promise<void>((resolve, reject) => {
        const cleanup = GoogleAdMob.loadAppsInTossAdMob({
            options: { adGroupId: adUnitId } as any,
            onEvent: (e: FullScreenEvent) => {
                if (e.type === "loaded") {
                    s.loaded = true;
                    s.loading = false;
                    s.lastLoadedAt = Date.now();
                    resolve();
                }
                onEvent?.(e);

                const t = String((e as any)?.type ?? "");
                sendEvent("rewarded", "load", adUnitId, t);

                const ri = (e as any)?.data?.responseInfo;
                if (t === "loaded" && ri) {
                    sendEvent("rewarded", "load", adUnitId, "loaded", { responseInfo: ri });
                }
            },
            onError: (err: unknown) => {
                s.loading = false;
                s.loaded = false;
                reject(err);
                onError?.(err);

                sendEvent("rewarded", "load", adUnitId, "error", { error: String(err) });
            },
        });

        if (typeof cleanup === "function") {
            // 필요 시 정리 로직
        }
    });
}

// ==== 표시(Show) ====
async function doShowInterstitial(adUnitId: string): Promise<void> {
    try {
        showingInterstitial.add(adUnitId);
        
        await GoogleAdMob.showAppsInTossAdMob({
            options: { adGroupId: adUnitId } as any,
            onEvent: (e: any) => {
                const eventType = String(e?.type ?? "");
                sendEvent("interstitial", "ensure_show", adUnitId, eventType);
            },
            onError: (e: unknown) => {
                sendEvent("interstitial", "ensure_show", adUnitId, "error", { error: String(e) });
                throw e;
            },
        });
    } finally {
        showingInterstitial.delete(adUnitId);
        markConsumed("interstitial", adUnitId);
    }
}

async function doShowRewarded(adUnitId: string): Promise<void> {
    try {
        showingRewarded.add(adUnitId);
        
        await GoogleAdMob.showAppsInTossAdMob({
            options: { adGroupId: adUnitId } as any,
            onEvent: (e: any) => {
                const eventType = String(e?.type ?? "");
                sendEvent("rewarded", "ensure_show", adUnitId, eventType);
            },
            onError: (e: unknown) => {
                sendEvent("rewarded", "ensure_show", adUnitId, "error", { error: String(e) });
                throw e;
            },
        });
    } finally {
        showingRewarded.delete(adUnitId);
        markConsumed("rewarded", adUnitId);
    }
}

// ==== 퍼블릭 API(기존 시그니처 유지) ====
async function ensureAndShowInterstitial(opts: { adUnitId: string }): Promise<void> {
    const { adUnitId } = opts;
    const s = getSlot("interstitial", adUnitId);
    if (!s.loaded) {
        await doLoadInterstitial({ adUnitId });
    }
    await doShowInterstitial(adUnitId);
}

async function ensureAndShowRewarded(opts: { adUnitId: string }): Promise<void> {
    const { adUnitId } = opts;
    const s = getSlot("rewarded", adUnitId);
    if (!s.loaded) {
        await doLoadRewarded({ adUnitId });
    }
    await doShowRewarded(adUnitId);
}

// ==== window 등록(기존 이름/동작) ====
window.AITAds = {
    supported,
    loadInterstitial: (opts: LoadOpts) => doLoadInterstitial(opts),
    loadRewarded: (opts: LoadOpts) => doLoadRewarded(opts),
    ensureAndShowInterstitial,
    ensureAndShowRewarded,
};

@Dylan 안녕하세요.
연휴 앞 두고 많이 바쁘시죠?
혹시 오늘 중으로는 확인이 어려우실까요?
검수 전에 마지막으로 오늘 광고 테스트를 해보니 그동안 잘 작동하던 광고 로드가 제대로 안 돼서 고민중입니다.

답변이 계속 안달리는 것을 보아 저라도 써봅니다.

혹시 안드로이드신가요?

저도 어제부터 동일한 증상, 동일한 에러 메시지가 떴습니다.

인앱광고 2.0아닌 라이브 버전에서도 광고가 로딩되지 않아서 여러 방향으로 확인해봤는데

제 기기 외에 테스트한 아이디가 다른 5개 기기에서는 이상이 없었어요

특정 아이디/기기에 시청리밋이나 아이디 제한이 걸린게 아닌가하는 의심이 있습니다.

저희도 연휴 전이라 빠른 확인이 필요해서 급하게 다른 계정을 등록해서 확인했습니다.

가능하시다면 다른 기기/계정을 추가하셔서 광고시청 테스트를 해보시는 것을 추천드려요.

OMG

같은 문제 겪고 있습니다.
테스트광고도 빌드하고 토스에 올려야 광고 테스트를 할 수 있는데.
실패 메세지는 네트워크 시도를 한참 타고 뒤에 나와서 문제가 뭔지 파악하는데 20번의 빌드 시도가 있었네요.
@IAN_R 글 남겨 주셔서 감사합니다.

1개의 좋아요

헉 여러분 너무 죄송합니다 @jh1332 @Line1 @IAN_R

지금도 발생하는 증상일까요?

연휴 끝나면 제일 먼저 확인해보겠습니다. 내부 담당자분들이랑 함께 확인이 필요할 것 같아요

1개의 좋아요

어제부터 광고가 안나오던 기기에서 아직도 안나오고 있습니다. (테스트 광고 ID 사용중)

어제 테스트 광고 연속으로 한 6번 정도 봤던 것 같아요. 그 이후부터 안됨.

코드는 광고를 보고나면, 바로 다시 광고를 로딩하게끔 되어 있었습니다.

  • 테스트 광고 id 사용이였는데 막히면 안된다고 생각하고.
  • 실제로도 광고 여러번 연속으로 보고자 하는 사람이 있을텐데 이게 막히면 안될 것 같아요.
  • 아니면 광고 횟수에 제약을 둬야 할듯. (애드몹과 같은 광고 제공사가 막는게 아닌, 이건 컨트롤 못하니까.)

글 쓰면서 빌드한 다른 테스트 버전을 같은 기기에 전달해서 열어보니까 이제 또 되네요;;;;

같은 페이지에서 6번 이상 연속으로 봤는데 문제 없이 나옵니다.

시간이 많이 흘러서 그런지, 새로운 빌드를 올려서 그런지, 뭔가 캐시가 클리어되어서 그런지 모르겠습니다. 테스트 광고 ID 사용해서 광고가 없던건 아닐텐데.

지켜보고 문제 생기면 또 공유하겠습니다.

@IAN_R @Line1 @seonjeong

답글 달아주신 분들께 감사드립니다.
해당 문제로 인해 광고가 동작하지 않는다는 이유로 검수 반려돼서 결국 추석 연휴 전에 출시를 못하게 됐습니다.
제 기기는 안드로이드이고, iOS에서도 같은 증상이 나타나고 있습니다.
항상 광고 로드에 실패하는 것은 아니고, 어느 순간부터 광고 로드에 실패하는 현상이 있습니다.
이전 버전에서는 겪지 못한 증상이고 광고 2.0부터 발생했습니다.
에러 메시지는 본문에 남겼듯이 아래와 같습니다.

code=0, message=Internal error

연휴 끝난 이후에 꼭 확인 부탁드립니다.

모두 일하시느라 피곤하셨을 텐데 이번 연휴 즐겁게 보내세요~ ^^

@jh1332 @Line1

android, iOS 기기 상관없이 발생하는 증상이며, 테스트 광고 id와 실제 광고 id 모두 재현되는 증상으로

특정 순간부터 보상형 광고 로드에 실패하는 현상이 발생하는 것으로 인지했습니다. (전면형 광고 이상 없음)

그럴 경우, 보상형 광고 로드 시 “Internal error” 오류가 발생하고 있어 내부 확인해보겠습니다.

애드몹 구조가 변경되어 이슈가 발생할 수 있는 점 양해 부탁드립니다.

명절 지나고 빠른 시일 내로 확인 후 말씀드리겠습니다. :folded_hands:

1개의 좋아요

@Line1
실패 메세지는 네트워크 시도를 한참 타고 뒤에 나와서 문제가 뭔지 파악하는데 20번의 빌드 시도가 있었네요.
요거 무슨 내용인지 추가로 알 수 있을까요?

보상형 광고 로드 요청 시 바로 에러가 return 되는 것이 아닌 네트워크 시도를 하라고 뜨는 걸까요?

SDK와 토스 앱 버전도 공유 부탁드립니다.

아래와 같은 코드로 광고를 로드 했는데요.

    const cleanup = GoogleAdMob.loadAppsInTossAdMob({
      options: {
        adGroupId: adId,
      },
      onEvent: (event) => {
        setLog(prev => prev + '\n' + `4 ${event.type}`);
        switch (event.type) {
          case 'loaded':
            setAdLoadStatus('loaded');
            break;
        }
      },
      onError: (error) => {
        setLog(prev => prev + '\n' + `5 ${error}`);
        console.error('광고 불러오기 실패', error);
      },
    });

테스트 빌드시 로그 확인이 어려워 setLog로 화면에 로그를 표시해 주고 있었는데.

onError 5 “광고 불러오기 실패” 가 아주 뒤늦게 불리었습니다. (30~60초) 그래서 매번 실행 직후에는 문제 로그가 찍히지 않았고, 왜 아무 로그가 안뜨지 하고 금방 포기하고 다른 시도를 하며 빌드를 하였습니다.

토스 5.231.1 안드로이드
├── @apps-in-toss/framework@1.2.0
├── @babel/core@7.23.9
├── @babel/plugin-proposal-class-properties@7.18.6
├── @babel/plugin-proposal-nullish-coalescing-operator@7.18.6
├── @babel/plugin-proposal-numeric-separator@7.18.6
├── @babel/plugin-proposal-optional-chaining@7.21.0
├── @babel/plugin-proposal-private-methods@7.18.6
├── @babel/plugin-proposal-private-property-in-object@7.21.11
├── @babel/plugin-transform-flow-strip-types@7.27.1
├── @babel/preset-env@7.28.3
├── @babel/preset-react@7.27.1
├── @babel/preset-typescript@7.27.1
├── @babel/runtime@7.18.9
├── @granite-js/native@0.1.21
├── @granite-js/plugin-router@0.1.21
├── @granite-js/react-native@0.1.21
├── @testing-library/react-native@12.9.0
├── @toss-design-system/react-native@1.2.1
├── @types/babel__core@7.20.5
├── @types/jest@29.5.14
├── @types/node@22.18.6
├── @types/react@18.3.3
├── jest@29.7.0
├── react-native@0.72.6
├── react-test-renderer@18.2.0
├── react@18.2.0
└── typescript@5.9.2

오늘 해보면 다시 광고 로드가 잘 되고 있는데. 지금 생각해 보면 일시적인 광고 요청 실패였던 것 같습니다. (LTE WIFI 다 연결된 실사용 폰에서 확인하여 네트워크 연결 문제는 아니였던 것 같음). 일시적인 admob 측 에러나 해당 기기에서의 요청이 제대로 처리 안된(혹은 거부된) 종류의 에러. 광고 없음이나 (테스트 ad id 인데;;?) 거부면 즉시 결과가 나왔을 것 같은데…..저는 그랬습니다!

jh1332님은 아직도 문제를 겪고 있다고 보니 이야기 같이 들어보면 좋을 듯!

@Line1 @IAN_R @jh1332 말씀 주신 증상 개선되어 안내드립니다. 너무 늦게 처리되어 죄송합니다 :folded_hands:

2개의 좋아요

문제가 뭐였을까요? 궁금해서..

꼭 알려주진 않으셔도 됩니다!

해당 부분은 sdk 업데이트를 진행해야 하는걸까요?
광고 로딩이 되지 않는다는 유져 CS가 너무 많아서, SDK 업데이트가 필요한지.
아니면 특정 Toss App 이후로 대응이 되었는지 확인이 필요하여 문의드립니다.

@hsahn 님 안녕하세요

헛 위에 문의는 테스트용 광고 ID에서 발생했던 이슈여서 긴급하게 조치했습니다.

광고 로딩이 되지 않는다는 유저 CS는 실 서비스에서 발생한 이슈로 보이는데요.

아래 내용 전달해주시면 빠르게 확인해볼게요.

  • appName
  • 전면형/보상형 광고 중 어떤 광고인지?
  • 광고 id
  • Android/iOS 둘 다 발생하는지?
  • 오류 메시지는 어떻게 return 되나요?

@seonjeong

안녕하세요.

테스트용 광고/라이브 이슈가 연계되어있는 것 같아서 같이 남깁니다.

아래는 그때 연계글입니다.

인앱광고 2.0 보상형 특정 유저에게 인터널에러 발생 - 개발 - 앱인토스 개발자 커뮤니티

500 인터널 에러가 리턴되고 Android, 리워드 광고에서 발생합니다.

특정 앱보다는 유저에게 발생하고 발생한 뒤에는 내부 앱이든 외부의 다른 앱이든 모든 광고가 막힙니다.

유저로서 타사의 앱을 하다가 막히면 이후 자체 앱에서도 500인터널 에러가 반환됩니다.

10월초 경 발생하고 10월 중순경 개선안내가 나오고 괜찮아졌는데 10월 27-28일 경부터 다시발생했습니다.