김재욱의 이모저모

카카오 애드핏 반응형 광고가 안 떠서 삽질한 이야기 본문

Front-end

카카오 애드핏 반응형 광고가 안 떠서 삽질한 이야기

kjyook 2025. 10. 25. 23:42
728x90

문제 발견

블로그에 카카오 애드핏 광고를 달았다. PC용 배너(728x90)랑 모바일용 배너(320x50)를 각각 만들어서 반응형으로 보여주려고 했다.

근데 이상한 일이 벌어졌다:

  • PC 화면으로 페이지 로드 → PC 광고 잘 뜸 ✅
  • 개발자 도구로 모바일 화면 전환 → 광고 안 뜸 ❌
  • 새로고침하면 → 그제서야 모바일 광고 뜸 ✅

뭐지? 왜 화면 크기 바꾸면 안 되는 거지?

초기 구현 (잘못된 방법)

1단계: layout.tsx에서 전역 스크립트 로드

// layout.tsx
export default async function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="//t1.daumcdn.net/kas/static/ba.min.js"
          strategy="lazyOnload"
        />
      </body>
    </html>
  );
}

공식 문서대로 했다. lazyOnload로 스크립트를 전역으로 불러오면 알아서 .kakao_ad_area 클래스를 찾아서 광고를 띄워준다고 했다.

2단계: Tailwind hidden으로 반응형 처리

// ad-banner-pc.tsx
const AdBannerPC = () => {
  return (
    <div className="hidden md:block"> {/* 768px 이상에서만 보임 */}
      <AdBanner adUnit="DAN-xxx" width={728} height={90} />
    </div>
  );
};

// ad-banner-mobile.tsx
const AdBannerMobile = () => {
  return (
    <div className="md:hidden"> {/* 768px 미만에서만 보임 */}
      <AdBanner adUnit="DAN-yyy" width={320} height={50} />
    </div>
  );
};

// books/page.tsx
const BooksPage = () => {
  return (
    <main>
      <AdBannerPC />
      <AdBannerMobile />
    </main>
  );
};

Tailwind의 hiddenmd:block으로 화면 크기에 따라 보여주려고 했다.

문제:

  • PC 배너와 Mobile 배너가 둘 다 DOM에 존재
  • 하나는 display: none으로 숨겨져 있을 뿐

왜 안 되는가: 카카오 애드핏 스크립트 동작 원리

1. 페이지 로드
   ↓
2. ba.min.js 스크립트 실행 (lazyOnload)
   ↓
3. DOM 전체를 스캔해서 .kakao_ad_area 찾음
   ↓
4. 찾은 광고 영역 초기화 (display: none → block으로 바꾸고 광고 삽입)
   ↓
5. 완료! (이후론 아무것도 안 함)

문제점:

  • 스크립트는 한 번만 실행됨

  • PC 화면으로 로드했을 때:

    • PC 배너: hidden md:block → 768px 이상이라 보임 → 초기화됨 ✅
    • Mobile 배너: md:hidden → 768px 이상이라 숨겨짐 → 스크립트가 건너뜀
  • 화면을 모바일로 전환했을 때:

    • Mobile 배너가 CSS 덕분에 보이긴 함
    • 근데 스크립트는 이미 실행 완료라서 재초기화 안 됨 ❌

핵심: hidden은 CSS만 바꿀 뿐이지, 스크립트를 다시 실행시키지 않는다!

해결 시도 1: 조건부 렌더링으로 바꿔보자

"아, DOM에 둘 다 있어서 그런가? 그럼 하나만 렌더링하면 되겠네!"

// ad-banners-wrapper.tsx
'use client';

const AdBannersWrapper = () => {
  const [isMobile, setIsMobile] = useState<boolean | null>(null);

  useEffect(() => {
    const mediaQuery = window.matchMedia('(max-width: 767px)');

    const handleChange = (e) => {
      setIsMobile(e.matches);
    };

    handleChange(mediaQuery);
    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, []);

  if (isMobile === null) return null; // SSR 깜빡임 방지

  return isMobile ? <AdBannerMobile /> : <AdBannerPC />;
};

로직:

  • matchMedia로 화면 크기 감지
  • 모바일이면 Mobile 배너만, PC면 PC 배너만 렌더링
  • 화면 전환하면 컴포넌트가 언마운트/마운트됨

예상:

  • 컴포넌트가 새로 마운트되면 광고도 새로 뜨겠지?

결과:

  • 여전히 안 됨 ❌

왜?

  • 새로운 <ins class="kakao_ad_area">를 DOM에 추가해도
  • 스크립트는 이미 실행 완료라서 새 엘리먼트를 감지 못 함
  • 스크립트를 다시 실행시켜야 함!

해결 시도 2: 스크립트를 재실행하면 되는 거 아니야?

맞다! 문제는 layout.tsx에서 스크립트를 한 번만 로드하는 거였다.

해결책: 각 배너가 자체적으로 스크립트 로드하기

// ad-banner.tsx (수정 전)
const AdBanner = ({ adUnit, width, height }) => {
  return (
    <div>
      <ins
        className="kakao_ad_area"
        data-ad-unit={adUnit}
        data-ad-width={width}
        data-ad-height={height}
      />
    </div>
  );
};

이건 그냥 HTML만 렌더링하는 Passive한 컴포넌트였다.

// ad-banner.tsx (수정 후)
'use client';

import { useEffect, useRef } from 'react';

declare global {
  interface Window {
    adfit?: {
      destroy?: (unit: string) => void;
    };
  }
}

const AdBanner = ({ adUnit, width, height }) => {
  const scriptElementRef = useRef<HTMLScriptElement | null>(null);

  useEffect(() => {
    // 1. 스크립트 동적 생성
    const script = document.createElement('script');
    script.src = '//t1.daumcdn.net/kas/static/ba.min.js';
    script.async = true;

    scriptElementRef.current = script;
    document.body.appendChild(script);

    // 2. cleanup: 컴포넌트 언마운트 시
    return () => {
      // 스크립트 제거
      if (scriptElementRef.current) {
        document.body.removeChild(scriptElementRef.current);
      }

      // 광고 정리 (중요!)
      const globalAdfit = 'adfit' in window ? window.adfit : null;
      if (globalAdfit && globalAdfit.destroy) {
        globalAdfit.destroy(adUnit);
      }
    };
  }, [adUnit]);

  return (
    <div>
      <ins
        className="kakao_ad_area"
        style={{ display: 'none' }}
        data-ad-unit={adUnit}
        data-ad-width={width}
        data-ad-height={height}
      />
    </div>
  );
};

핵심 변화:

  1. Client 컴포넌트로 변경 ('use client')
  2. useEffect에서 스크립트를 동적으로 생성
  3. 컴포넌트 마운트 시 → 스크립트 추가
  4. 컴포넌트 언마운트 시 → 스크립트 제거 + adfit.destroy() 호출

layout.tsx에서 전역 스크립트 제거

// layout.tsx (수정 후)
export default async function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Script 태그 제거! */}
      </body>
    </html>
  );
}

전역 스크립트는 이제 필요 없다. 각 배너가 자기 스크립트를 관리한다.

동작 원리

Before (안 되던 방식)

1. layout.tsx에서 전역 스크립트 로드
   ↓
2. 스크립트 실행 → DOM의 모든 .kakao_ad_area 초기화
   ↓
3. PC 배너만 보여서 PC 광고만 초기화됨
   ↓
4. 화면 전환 → Mobile 배너가 보이긴 하지만
   ↓
5. 스크립트는 이미 실행 완료라 광고 안 뜸 ❌

After (되는 방식)

1. PC 화면으로 로드
   ↓
2. AdBannersWrapper → AdBannerPC 렌더링
   ↓
3. AdBannerPC 마운트 → useEffect 실행
   ↓
4. script 태그 생성 → ba.min.js 로드
   ↓
5. 스크립트가 PC 광고 초기화 ✅
   ↓
6. 화면을 모바일로 전환
   ↓
7. AdBannerPC 언마운트 → cleanup 실행
   - script 제거
   - adfit.destroy('PC광고유닛') 호출
   ↓
8. AdBannerMobile 마운트 → useEffect 실행
   ↓
9. 새 script 태그 생성 → ba.min.js 다시 로드
   ↓
10. 스크립트가 Mobile 광고 초기화 ✅

핵심: 컴포넌트가 바뀔 때마다 스크립트가 재실행된다!

adfit.destroy()가 뭐야?

카카오 애드핏은 window.adfit 전역 객체를 만든다.
이 객체에 destroy(unit) 메서드가 있는데, 특정 광고 유닛을 정리해준다.

// TypeScript 타입 선언
declare global {
  interface Window {
    adfit?: {
      destroy?: (unit: string) => void;
    };
  }
}

// 안전하게 호출하기
const globalAdfit = 'adfit' in window ? window.adfit : null;
if (globalAdfit && globalAdfit.destroy) {
  globalAdfit.destroy('DAN-xxx'); // 광고 유닛 정리
}

왜 필요한가:

  • 같은 광고 유닛이 여러 번 초기화되면 문제가 생김
  • 컴포넌트 언마운트 시 깔끔하게 정리해야 함
  • 메모리 누수 방지

결과

✅ PC ↔ Mobile 화면 전환 시 광고 정상 표시
✅ 새로고침 불필요
✅ 각 배너가 독립적으로 스크립트 관리
✅ 메모리 누수 없이 깔끔하게 정리

배운 점

1. CSS만으로는 동적 콘텐츠를 제어할 수 없다

display: none으로 숨기는 건 시각적으로만 숨기는 거다.
외부 스크립트가 DOM을 스캔하는 타이밍에는 영향을 못 준다.

2. 외부 스크립트는 실행 타이밍이 중요하다

카카오 애드핏처럼 DOM을 스캔하는 스크립트는:

  • 언제 실행되는가?
  • 어떤 엘리먼트를 스캔하는가?
  • 재실행이 가능한가?

이걸 이해해야 한다.

3. Next.js Script 컴포넌트의 한계

<Script strategy="lazyOnload"> 는 편리하지만:

  • 한 번만 실행됨
  • 동적 컴포넌트에는 적합하지 않음
  • 반응형 광고는 각 컴포넌트에서 직접 로드하는 게 나음

4. useEffect + cleanup 패턴의 중요성

useEffect(() => {
  // 마운트 시 리소스 생성
  const resource = createResource();

  return () => {
    // 언마운트 시 리소스 정리
    cleanupResource(resource);
  };
}, [deps]);

이 패턴만 제대로 써도 대부분의 메모리 누수를 방지할 수 있다.

5. 조건부 렌더링이 항상 해결책은 아니다

"DOM에서 제거하면 되겠지?"라고 생각했지만,
진짜 문제는 스크립트 재실행이었다.

문제의 본질을 파악하는 게 중요하다.

응용: 다른 광고 서비스에도 적용 가능

이 패턴은 카카오 애드핏뿐만 아니라:

  • Google AdSense
  • 네이버 애드포스트
  • 기타 외부 스크립트 기반 위젯

모두 동일하게 적용할 수 있다.

useEffect(() => {
  const script = document.createElement('script');
  script.src = '광고스크립트URL';
  script.async = true;
  document.body.appendChild(script);

  return () => {
    document.body.removeChild(script);
    // 각 서비스의 cleanup API 호출
  };
}, [dependencies]);

마무리

처음엔 "왜 hidden이 안 돼?"라고 생각했는데,
알고 보니 외부 스크립트의 실행 타이밍 문제였다.

문제를 제대로 이해하니까 해결책도 명확해졌다:

  • 전역 스크립트 로드 (X)
  • 각 컴포넌트가 자체 스크립트 로드 (O)

비슷한 문제로 삽질하는 사람들한테 도움이 되면 좋겠다! 🚀

728x90