showFullScreenAd 호출 시 failedToShow 반복 발생 — 보상형 광고 표시 불가 (RN)

이 글의 성격은 무엇인가요?

질문 / 문제 해결

내용을 설명해주세요

  • 개발 환경: React Native (0.84)
  • SDK version: 2.4.7
  • 테스트 환경: QR 테스트 환경 (iOS 실기기)

안녕하세요. 리워드 전면 광고 개발 중 오류가 도저히 해결되지 않아 문의 남깁니다.
loadFullScreenAd 호출 후 loaded 이벤트는 정상 수신됩니다.
isLoaded = true 상태를 확인한 뒤 showFullScreenAd를 호출하면 failedToShow 이벤트가 항상 발생하며 광고가 표시되지 않습니다.

사용자에게는 다음 순서로 메시지가 표시됩니다:

  1. 첫 번째 탭 — isLoaded = false 상태에서 버튼을 누른 경우:
    “광고를 준비하고 있어요. 잠시 후 다시 시도해 주세요.”
  2. 두 번째 탭 — isLoaded = true 상태에서 버튼을 누른 경우:
    Error: Ad failed to show (-> failedToShow 이벤트에서 생성된 에러)

재현 코드

// 컴포넌트 마운트 시 광고 로드
loadCleanupRef.current = loadFullScreenAd({
options: { adGroupId }, // 테스트/운영 ID 모두 시도
onEvent: (event) => {
if (event.type === ‘loaded’) {
setIsLoaded(true); // ← 정상 수신됨 (여기까지는 OK)
setIsLoading(false);
}
},
onError: (err) => { onError?.(err); },
});

// isLoaded = true 확인 후 광고 표시
showCleanupRef.current = showFullScreenAd({
options: { adGroupId },
onEvent: (event) => {
if (event.type === ‘userEarnedReward’) {
onReward(event.data);
} else if (event.type === ‘dismissed’) {
setIsShowing(false);
setIsLoaded(false);
} else if (event.type === ‘failedToShow’) {
//. ← 항상 여기로 진입
setIsShowing(false);
setIsLoaded(false);
onError?.(new Error(‘Ad failed to show’));
}
},
onError: (err) => { onError?.(err); },
});


시도한 것들

  1. 테스트 ID / 운영 ID 교체 — 동일 증상
  2. 다른 iOS 기기, 다른 토스 계정으로 테스트 — 동일 증상
  3. @apps-in-toss/framework 2.0.5 → 2.4.7 업그레이드 — 동일 증상
  4. ATT 허용 확인 — 설정 앱에서 추적 허용 상태 확인
  5. loadFullScreenAd.isSupported() = true — SDK 지원 환경 확인
  6. loaded 이벤트 정상 수신 확인 — isLoaded = true 이후에 show() 호출

질문

  1. loaded 이벤트까지는 정상인데 showFullScreenAd에서 항상 failedToShow가 발생하는 알려진 원인이 있나요?
  2. 콘솔 QR (intoss-private://) 배포 환경에서 showFullScreenAd가 동작하지 않는 제약이 있나요? (샌드박스 앱은 인앱 광고 미지원임을 알고 있어 실
    토스 앱 QR 배포로 테스트했습니다)
  3. failedToShow 발생 시 더 상세한 에러 코드나 원인을 확인할 수 있는 방법이 있나요?

감사합니다.

appName (선택)

dalto-tarot

@Dylan 님, 안녕하세요. 혹시 이 문제 관련해서 도움을 부탁드릴 수 있을까요?
코드 로직의 문제라기보다 설정 관련된 문제로 추정되는데요.
아니면 검토할만한 부분을 알려주시면 정말 큰 도움이 될 것 같습니다.
감사합니다.

안녕하세요 :slight_smile:
cleanup 호출 시점이 언제인가요 ? loadFullScreenAd 호출은 어디에 위치하고 있는지도 확인이 필요할 것 같아요.

혹시 코드를 공유주실 수 있으신가요 ?

import { loadFullScreenAd, showFullScreenAd } from '@apps-in-toss/web-framework';
import { useState, useEffect } from 'react';

function AdComponent() {
  const AD_GROUP_ID = 'ait.dev.43daa14da3ae487b';
  const [isAdLoaded, setIsAdLoaded] = useState(false);

  useEffect(() => {
    // 컴포넌트 마운트 시 광고 로드
    const unregister = loadFullScreenAd({
      options: { adGroupId: AD_GROUP_ID },
      onEvent: (event) => {
        if (event.type === 'loaded') {
          setIsAdLoaded(true);
        }
      },
      onError: (error) => {
        console.error('광고 로드 실패:', error);
      },
    });

    return () => unregister();
  }, []);

  const handleShowAd = () => {
    showFullScreenAd({
      options: { adGroupId: AD_GROUP_ID },
      onEvent: (event) => {
        switch (event.type) {
          case 'requested':
            console.log('광고 표시 요청됨');
            break;
          case 'show':
            console.log('광고 화면 표시됨');
            break;
          case 'impression':
            console.log('광고 노출 기록됨 (수익 발생)');
            break;
          case 'clicked':
            console.log('광고 클릭됨');
            break;
          case 'dismissed':
            console.log('광고가 닫힘');
            setIsAdLoaded(false);
            // 다음 광고 로드
            loadNextAd();
            break;
          case 'failedToShow':
            console.error('광고 표시 실패');
            break;
          case 'userEarnedReward':
            console.log('리워드 획득:', event.data);
            // 사용자에게 리워드 지급
            grantReward(event.data.unitType, event.data.unitAmount);
            break;
        }
      },
      onError: (error) => {
        console.error('광고 표시 실패:', error);
      },
    });
  };

  const loadNextAd = () => {
    loadFullScreenAd({
      options: { adGroupId: AD_GROUP_ID },
      onEvent: (event) => {
        if (event.type === 'loaded') setIsAdLoaded(true);
      },
      onError: console.error,
    });
  };

  const grantReward = (unitType: string, unitAmount: number) => {
    // 리워드 지급 로직
    console.log(`${unitType} ${unitAmount}개 지급`);
  };

  return (
    <button onClick={handleShowAd} disabled={!isAdLoaded}>
      광고 보기
    </button>
  );
}

안녕하세요. 도움 주셔서 정말 감사드립니다.

cleanup 호출 시점과 loadFullScreenAd 위치에 대한 간략한 설명 먼저 공유드립니다.


cleanup 호출 시점

loadFullScreenAd의 cleanup(unregister)은 아래 두 경우에만 호출됩니다:

  1. load() 재호출 직전 — 중복 구독 방지를 위해 기존 구독을 먼저 해제하고 새로 등록
  2. 컴포넌트 언마운트 시useEffect cleanup과 reset() 에서 호출

show() 호출 시에는 loadFullScreenAd cleanup을 호출하지 않습니다. load 구독은 show가 끝날 때까지 살아있습니다.


loadFullScreenAd 호출 위치

커스텀 훅 useRewardedAdload() 함수 내부에 있고, 이 load()는 채팅 페이지 컴포넌트 마운트 시 useEffect(() => { rewardedAd.load(); }, []) 로 한 번 호출됩니다.


참고 사항

  • @apps-in-toss/framework 2.4.7 (RN) 사용 중입니다. 답변 주신 예시는 @apps-in-toss/web-framework 기준인 것 같은데, RN과 web 간에 showFullScreenAd 동작에 차이가 있을 수 있을까요?
  • loaded 이벤트 수신(isLoaded = true) 후 즉시 show()를 호출해도 동일하게 failedToShow가 발생합니다.

혹시 위 패턴에서 잘못된 부분이 있으면 알려주시면 감사하겠습니다.

아래는 전체 코드(src/components/ad-gate/useRewardedAd.ts) 공유드립니다.


import { useCallback, useEffect, useRef, useState } from 'react';
import { loadFullScreenAd, showFullScreenAd } from '@apps-in-toss/framework';

export interface RewardData {
  unitType: string;
  unitAmount: number;
}

interface UseRewardedAdOptions {
  adGroupId: string;
  onReward: (data: RewardData) => void;
  onDismissed?: () => void;
  onError?: (err: unknown) => void;
}

interface UseRewardedAdResult {
  load: () => void;
  show: () => void;
  reset: () => void;
  isLoaded: boolean;
  isLoading: boolean;
  isShowing: boolean;
  error: unknown | null;
}

/**
 * 앱인토스 통합 광고 SDK(`loadFullScreenAd` / `showFullScreenAd`)를 보상형(rewarded) 용도로 감싼 훅.
 *
 * 사용 흐름:
 *   1) `load()` 호출 → `isLoaded`가 true가 되면 광고 재생 준비 완료
 *   2) `show()` 호출 → 광고 재생
 *      - 시청 완료 시 `onReward` (=> sendAdvertiseFinish 트리거)
 *      - 보상 없이 닫음/실패 시 `onDismissed` / `onError`
 *   3) `reset()`으로 재사용 전에 초기화
 *
 * 주의:
 *   - adGroupId는 앱인토스 콘솔에서 생성한 광고 그룹 ID. 개발/QA는 공식 테스트 ID 사용.
 *   - SDK 미지원 환경(웹 미리보기 등)에서는 `onError` 콜백으로 실패 통지 후 no-op.
 *   - 언마운트 시 load/show 구독이 cleanup 된다.
 */
export function useRewardedAd({
  adGroupId,
  onReward,
  onDismissed,
  onError,
}: UseRewardedAdOptions): UseRewardedAdResult {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [isShowing, setIsShowing] = useState(false);
  const [error, setError] = useState<unknown | null>(null);

  const loadCleanupRef = useRef<(() => void) | null>(null);
  const showCleanupRef = useRef<(() => void) | null>(null);
  const rewardReceivedRef = useRef(false);

  const _cleanupLoad = useCallback(() => {
    loadCleanupRef.current?.();
    loadCleanupRef.current = null;
  }, []);

  const _cleanupShow = useCallback(() => {
    showCleanupRef.current?.();
    showCleanupRef.current = null;
  }, []);

  const reset = useCallback(() => {
    _cleanupLoad();
    _cleanupShow();
    setIsLoaded(false);
    setIsLoading(false);
    setIsShowing(false);
    setError(null);
    rewardReceivedRef.current = false;
  }, [_cleanupLoad, _cleanupShow]);

  const load = useCallback(() => {
    if (!loadFullScreenAd.isSupported()) {
      const err = new Error('Rewarded ad is not supported in this environment');
      setError(err);
      onError?.(err);
      return;
    }
    if (isLoading || isLoaded) return;

    setIsLoading(true);
    setError(null);
    _cleanupLoad();

    loadCleanupRef.current = loadFullScreenAd({
      options: { adGroupId },
      onEvent: (event) => {
        if (event.type === 'loaded') {
          setIsLoaded(true);
          setIsLoading(false);
        }
      },
      onError: (err) => {
        setIsLoading(false);
        setIsLoaded(false);
        setError(err);
        onError?.(err);
      },
    });
  }, [adGroupId, isLoaded, isLoading, _cleanupLoad, onError]);

  const show = useCallback(() => {
    if (!showFullScreenAd.isSupported()) {
      const err = new Error('Rewarded ad is not supported in this environment');
      setError(err);
      onError?.(err);
      return;
    }
    if (!isLoaded || isShowing) return;

    setIsShowing(true);
    rewardReceivedRef.current = false;
    _cleanupShow();

    showCleanupRef.current = showFullScreenAd({
      options: { adGroupId },
      onEvent: (event) => {
        if (event.type === 'userEarnedReward') {
          rewardReceivedRef.current = true;
          onReward(event.data);
        } else if (event.type === 'dismissed') {
          setIsShowing(false);
          setIsLoaded(false);
          if (!rewardReceivedRef.current) {
            onDismissed?.();
          }
        } else if (event.type === 'failedToShow') {
          setIsShowing(false);
          setIsLoaded(false);
          const err = new Error('Ad failed to show');
          setError(err);
          onError?.(err);
        }
      },
      onError: (err) => {
        setIsShowing(false);
        setIsLoaded(false);
        setError(err);
        onError?.(err);
      },
    });
  }, [adGroupId, isLoaded, isShowing, _cleanupShow, onReward, onDismissed, onError]);

  useEffect(() => {
    return () => {
      _cleanupLoad();
      _cleanupShow();
    };
  }, [_cleanupLoad, _cleanupShow]);

  return { load, show, reset, isLoaded, isLoading, isShowing, error };
}

@Dylan 님, 안녕하세요.

추가로 필요할 수 있을 것 같아 다음 코드도 공유드립니다.

src/components/ad-gate/AdGateModal.tsx

import React from 'react';
import { Modal, View, type TextStyle, type ViewStyle } from 'react-native';
import { Button } from '../Button';
import { Text } from '../Text';
import type { ThemedStyle } from '../../theme';
import { useAppTheme } from '../../utils/useAppTheme';

interface AdGateModalProps {
  open: boolean;
  onConfirm: () => void;
  onCancel: () => void;
  /**
   * 광고 로드가 아직 완료되지 않았을 때 confirm 버튼을 비활성화할지 여부.
   * 기본 false (버튼 항상 활성화, show 시 SDK가 알아서 지연 처리).
   */
  confirmDisabled?: boolean;
  /**
   * 에러 메시지가 있으면 본문 아래 보조 문구로 표시.
   */
  errorMessage?: string | null;
}

/**
 * 채팅 중 `<|ADVERTISE_START|>` 신호를 수신했을 때 띄우는 안내 모달.
 *
 * 사용자가 [광고 보고 계속하기]를 선택하면 `onConfirm`이 호출되고,
 * 호출자가 `useRewardedAd.show()`를 트리거해 실제 광고가 재생된다.
 * [지금 종료]를 선택하면 `onCancel`이 호출되며, 서버측 pending은 유지된다.
 */
export function AdGateModal({ open, onConfirm, onCancel, confirmDisabled, errorMessage }: AdGateModalProps) {
  const { themed } = useAppTheme();

  return (
    <Modal visible={open} transparent animationType="fade" onRequestClose={onCancel}>
      <View style={themed($backdrop)}>
        <View style={themed($container)}>
          <Text style={themed($title)}>잠깐! 대화를 이어가려면</Text>
          <Text style={themed($description)}>짧은 광고를 보고 달토와의 대화를 계속할 수 있어요.</Text>
          {errorMessage ? <Text style={themed($errorText)}>{errorMessage}</Text> : null}

          <View style={themed($buttonRow)}>
            <Button
              text="지금 종료"
              onPress={onCancel}
              style={themed($cancelButton)}
              textStyle={themed($cancelButtonText)}
            />
            <Button
              text="광고 보고 계속하기"
              onPress={onConfirm}
              disabled={confirmDisabled}
              style={themed($confirmButton)}
              textStyle={themed($confirmButtonText)}
              pressedStyle={themed($confirmButtonPressed)}
            />
          </View>
        </View>
      </View>
    </Modal>
  );
}

const $backdrop: ThemedStyle<ViewStyle> = () => ({
  flex: 1,
  backgroundColor: 'rgba(0, 0, 0, 0.4)',
  justifyContent: 'center',
  alignItems: 'center',
  padding: 24,
});

const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
  width: '100%',
  maxWidth: 360,
  backgroundColor: 'white',
  borderRadius: 16,
  padding: spacing.xl,
  gap: spacing.sm,
});

const $title: ThemedStyle<TextStyle> = ({ colors }) => ({
  fontSize: 18,
  fontWeight: '700',
  color: colors.text,
});

const $description: ThemedStyle<TextStyle> = ({ colors }) => ({
  fontSize: 14,
  lineHeight: 20,
  color: colors.textDim,
});

const $errorText: ThemedStyle<TextStyle> = () => ({
  fontSize: 13,
  lineHeight: 18,
  color: '#c00',
});

const $buttonRow: ThemedStyle<ViewStyle> = ({ spacing }) => ({
  flexDirection: 'row',
  gap: spacing.xs,
  marginTop: spacing.md,
});

const $cancelButton: ThemedStyle<ViewStyle> = () => ({
  flex: 1,
  backgroundColor: '#F2F2F2',
  borderColor: 'transparent',
  borderRadius: 12,
  minHeight: 48,
});

const $cancelButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
  color: colors.textDim,
  fontSize: 14,
  fontWeight: '600',
});

const $confirmButton: ThemedStyle<ViewStyle> = () => ({
  flex: 1,
  backgroundColor: '#925DDC',
  borderColor: 'transparent',
  borderRadius: 12,
  minHeight: 48,
});

const $confirmButtonText: ThemedStyle<TextStyle> = () => ({
  color: 'white',
  fontSize: 14,
  fontWeight: '700',
});

const $confirmButtonPressed: ThemedStyle<ViewStyle> = () => ({
  backgroundColor: '#7A4DBE',
});

전달주신 코드로 테스트하니 에러가 발생해서 확인해보니,
Modal이 네이티브 UIViewController를 점유해서 showFullScreenAd 호출 시 failedToShow가 발생합니다. position: 'absolute’로 구현한 오버레이 컴포넌트로 변경해보시겠어요 ?

@Dylan 님, 도움주셔서 정말 감사합니다!

말씀주신대로 수정 후 정상적으로 광고 노출되는 것을 확인했습니다.