WegglePlus 홈
블로그

우리 서비스에 광고 배너 시스템을 직접 만들었습니다 — 설계부터 구현까지

Marco0

우리 서비스에 광고 배너 시스템을 직접 만들었습니다 — 설계부터 구현까지#

서비스를 운영하다 보면 어느 순간 "배너 하나 넣어야겠다"는 필요가 생깁니다. 이때 가장 먼저 떠오르는 선택지는 Google AdSense 같은 외부 SDK 연동입니다. 세팅 몇 줄로 끝나고, 최적화도 알아서 해줍니다. 하지만 우리는 그 길을 택하지 않았습니다.

이 글은 왜 자체 배너 시스템을 선택했는지, 그리고 어떻게 만들었는지에 대한 풀스택 구현 이야기입니다. 데이터베이스 설계부터 Kotlin/Spring Boot 백엔드, Next.js 프론트엔드, 접근성까지 — 실제로 마주쳤던 버그와 트레이드오프를 솔직하게 담았습니다.


왜 외부 SDK 대신 직접 만들었나?#

외부 광고 플랫폼은 강력하지만 제어권이 없습니다. 어떤 광고가 노출될지, 언제 노출될지, 어떤 스타일로 렌더링될지 — 모두 플랫폼이 결정합니다. 우리 서비스는 여러 기능 페이지(블로그, 퀴즈, 디지털 고정비용 계산기, 직무 스킬)를 운영하고 있고, 각 페이지마다 의도에 맞는 배너를 직접 기획해서 노출하고 싶었습니다.

또한 외부 SDK는 사용자 데이터를 제3자에게 전송합니다. 작은 서비스일수록 이 부분이 사용자 신뢰에 직접 영향을 미칩니다. 자체 시스템을 만들면 트래킹 없이 순수하게 "우리가 원하는 메시지를 원하는 위치에 원하는 시간에" 보낼 수 있습니다.

결정적으로, 우리 서비스의 배너는 광고 수익 목적이 아닌 내부 기능 홍보 목적이었습니다. 이 경우 외부 SDK는 오히려 과잉입니다. 직접 만드는 게 더 가볍고 빠릅니다.


DB 설계: 단순하게 시작해서 점진적으로 진화#

처음 설계는 단순했습니다. 배너 콘텐츠(HTML)와 노출 위치(target), 크기(size), 순서(display_order)만 있으면 충분하다고 생각했습니다. 실제 테이블 초기 버전(V4 마이그레이션)은 이렇게 생겼습니다.

CREATE TABLE ad_banners (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,       -- HTML 콘텐츠 직접 저장
    target VARCHAR(32) NOT NULL, -- blog|typefy|dfc|job_skill
    size VARCHAR(32) NOT NULL,   -- medium|large
    display_order INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

운영하다 보니 요구사항이 추가되었습니다.

  • V23: start_at, end_at 추가 — 특정 기간에만 노출하는 이벤트 배너 필요
  • V24: is_active 추가 — 기간과 관계없이 즉시 ON/OFF 가능해야 한다는 요구
  • V25: target enum 확장 — 새로운 서비스 페이지 추가에 따라
-- 현재 최종 스키마
CREATE TABLE ad_banners (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    target VARCHAR(32) NOT NULL,  -- blog|typefy|dfc|job_skill
    size VARCHAR(32) NOT NULL,    -- medium|large
    display_order INTEGER NOT NULL DEFAULT 0,
    start_at TIMESTAMPTZ,         -- NULL = 즉시 시작
    end_at TIMESTAMPTZ,           -- NULL = 무제한
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

start_atend_at을 NULL 허용으로 설계한 것이 핵심입니다. NULL은 "조건 없음"을 의미합니다. 이 단순한 컨벤션 하나로 "즉시 시작 + 무기한 노출"부터 "특정 날짜 범위 노출"까지 모든 케이스를 커버합니다.


백엔드: Kotlin + Spring Boot#

Entity 설계와 @Enumerated(EnumType.STRING)#

targetsize는 DB에서 VARCHAR지만, 코드에서는 enum으로 다룹니다. JPA를 사용할 때 enum을 DB에 저장하는 방식은 두 가지입니다 — EnumType.ORDINAL(숫자)과 EnumType.STRING(문자열).

우리는 EnumType.STRING을 선택했습니다. 이유는 명확합니다.

  • ORDINAL은 enum 순서가 바뀌면 기존 데이터가 깨집니다
  • STRING은 enum 항목이 추가되어도 기존 데이터에 영향이 없습니다
  • DB에서 직접 쿼리할 때 사람이 읽을 수 있습니다
@Entity
@Table(name = "ad_banners")
class AdBanner(
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    val target: AdBannerTarget,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    val size: AdBannerSize,

    val startAt: OffsetDateTime? = null,
    val endAt: OffsetDateTime? = null,

    @Column(nullable = false)
    val isActive: Boolean = true,
    // ...
)

NULL을 다루는 JPQL 쿼리#

활성 배너를 조회하는 쿼리에서 NULL 처리가 핵심입니다. start_at IS NULL이면 "시작 조건 없음"이고, end_at IS NULL이면 "종료 조건 없음"입니다.

@Query("""
    SELECT b FROM AdBanner b
    WHERE (:target IS NULL OR b.target = :target)
      AND b.isActive = true
      AND (b.startAt IS NULL OR b.startAt <= :now)
      AND (b.endAt IS NULL OR b.endAt >= :now)
    ORDER BY b.displayOrder ASC
""")
fun findActiveBanners(
    @Param("target") target: AdBannerTarget?,
    @Param("now") now: OffsetDateTime
): List<AdBanner>

(:target IS NULL OR b.target = :target) 패턴은 target 파라미터가 null일 때 전체를 반환하고, 값이 있을 때는 해당 target만 필터링합니다. 관리자 화면에서 전체 조회와 필터 조회를 동일한 쿼리로 처리할 수 있어서 코드 중복이 줄어듭니다.

인증: JWT 대신 Bearer Token 고정 인증#

관리자 API는 별도 인증이 필요합니다. 처음엔 JWT를 고려했지만, 이 서비스의 관리자 API는 팀 내부에서만 사용하는 단순 운영 도구입니다. JWT의 토큰 발급·갱신·검증 인프라를 만드는 비용 대비 이점이 크지 않았습니다.

대신 AdminTokenFilter를 구현해서 고정 Bearer Token을 검증합니다.

@Component
class AdminTokenFilter(
    @Value("\${admin.token}") private val adminToken: String
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token = request.getHeader("Authorization")
            ?.removePrefix("Bearer ")
            ?.trim()

        if (token != adminToken) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
            return
        }
        filterChain.doFilter(request, response)
    }
}

토큰은 환경변수로 관리하고, 운영 환경에서는 충분한 길이의 랜덤 값을 사용합니다. 단순하지만 이 용도에는 충분합니다.

주의: 문자열 != 비교는 타이밍 어택에 취약합니다. 팀 내부 도구이므로 실용적으로 선택했지만, 외부에 노출되는 API에는 MessageDigest.isEqual()처럼 constant-time 비교를 사용해야 합니다.

공개 API vs 관리자 API 분리#

API는 두 종류로 분리했습니다.

구분경로특징
공개 APIGET /api/v1/ad-banners활성 배너만, 인증 없음
관리자 APIGET /api/v1/admin/ad-banners전체 + 페이징, Bearer Token 필요

공개 API에서 비활성 배너를 노출하는 것은 명백한 버그입니다. 두 API를 처음부터 분리해두면 "실수로 전체 노출" 같은 사고를 방지할 수 있습니다. 관리자 API는 페이징을 지원해서 배너가 많아져도 성능 문제가 없도록 설계했습니다.


프론트엔드: Next.js AdBannerSlider#

target prop을 required로 강제한 이유 — 실제 버그 픽스 스토리#

AdBannerSlider 컴포넌트의 첫 번째 버전은 target이 optional prop이었습니다.

// 초기 버전 — 문제 있음
interface AdBannerSliderProps {
  target?: AdBannerTarget; // optional이었던 시절
  size: 'medium' | 'large';
}

그런데 어느 날 배너가 이상하게 노출된다는 리포트가 들어왔습니다. 확인해보니 특정 페이지에서 target을 전달하지 않아서, 백엔드 쿼리의 :target IS NULL 조건이 발동해 전체 배너가 모두 노출되고 있었습니다. 블로그 페이지에서 퀴즈 배너가 나오고, 계산기 배너가 나오는 상황이었습니다.

수정은 간단했습니다. target을 required로 바꾸는 것만으로 컴파일 타임에 오류를 잡을 수 있었습니다.

// 수정 후 — 컴파일 타임에 강제
interface AdBannerSliderProps {
  target: AdBannerTarget; // required
  size: 'medium' | 'large';
}

이 경험으로 "런타임 버그를 타입 시스템으로 컴파일 타임 오류로 바꾸는 것"이 얼마나 효과적인지 다시 한번 체감했습니다.

useEffect deps 버그 — target 변경 시 배너가 바뀌지 않던 문제#

또 다른 버그입니다. 처음 구현할 때 useEffect의 의존성 배열에 size만 포함했습니다.

// 버그 있는 버전
useEffect(() => {
  fetchBanners(target, size);
}, [size]); // target이 빠짐

단일 페이지에서는 문제가 없었습니다. 하지만 target이 동적으로 변경되는 환경(예: 탭 전환으로 다른 서비스 섹션을 보여줄 때)에서 배너가 초기 값 그대로 유지되는 문제가 발생했습니다.

// 수정 후
useEffect(() => {
  fetchBanners(target, size);
}, [size, target]); // target 추가

ESLint의 react-hooks/exhaustive-deps 규칙을 켜두면 이런 실수를 바로 잡아줍니다. 이 버그를 계기로 프로젝트에 해당 규칙을 활성화했습니다.

dangerouslySetInnerHTML과 XSS 트레이드오프#

배너 콘텐츠는 DB에 HTML로 저장됩니다. 이를 렌더링하려면 React에서 dangerouslySetInnerHTML을 사용해야 합니다.

<div
  dangerouslySetInnerHTML={{ __html: banner.content }}
  className="bnr-content"
/>

이름부터 "위험하다"고 경고하는 이 API를 사용한 이유는 배너 콘텐츠의 작성 주체가 관리자로 제한되어 있기 때문입니다. 사용자가 입력하는 UGC(User Generated Content)라면 절대 사용하지 않았을 것입니다.

그래도 방어 레이어는 추가했습니다. 백엔드에서 jsoup의 Safelist를 활용해 허용된 태그·속성만 남기고 나머지는 제거합니다.

import org.jsoup.Jsoup
import org.jsoup.safety.Safelist

fun sanitizeBannerContent(html: String): String {
    val safelist = Safelist.relaxed()
        .addTags("style", "section", "figure")
        .addAttributes(":all", "class", "style", "aria-*", "role", "data-*")
    return Jsoup.clean(html, safelist)
}

Safelist.relaxed()<script>, javascript: URL, 이벤트 핸들러 속성(onclick 등)을 자동으로 제거합니다. 만약의 실수에도 XSS가 발생하지 않도록 이중 방어를 구성한 셈입니다.

터치 제스처와 자동재생#

모바일 UX를 위해 터치 스와이프를 구현했습니다. 임계값은 40px로 설정했습니다. 이 값보다 작으면 의도하지 않은 스와이프가 너무 자주 발생하고, 너무 크면 스와이프 반응이 둔합니다. 실기기 테스트를 통해 40px이 가장 자연스러운 지점임을 확인했습니다.

const SWIPE_THRESHOLD = 40;

const handleTouchEnd = () => {
  const diff = touchStartX - touchEndX;
  if (Math.abs(diff) > SWIPE_THRESHOLD) {
    diff > 0 ? goNext() : goPrev();
  }
};

자동재생은 3초 간격으로 동작하며, 마우스가 슬라이더 위에 올라오면 일시정지합니다. 사용자가 배너를 읽으려는 의도가 있을 때 자동으로 넘어가버리는 UX 문제를 방지합니다.

const [isPaused, setIsPaused] = useState(false);

// goNext는 useCallback으로 메모이제이션해서 deps에 포함
const goNext = useCallback(() => {
  setCurrentIndex(prev => (prev + 1) % banners.length);
}, [banners.length]);

useEffect(() => {
  if (isPaused || banners.length <= 1) return;
  const timer = setInterval(goNext, 3000);
  return () => clearInterval(timer);
}, [isPaused, currentIndex, banners.length, goNext]);

CSS 충돌 방지: 고유 클래스 접두어#

배너 콘텐츠는 HTML을 직접 저장합니다. 한 페이지에 여러 배너가 동시에 렌더링될 수 있고, 각 배너의 HTML 안에 포함된 CSS 클래스 이름이 서로 충돌할 수 있습니다.

예를 들어 두 배너가 모두 .title 클래스를 사용한다면, 한쪽의 스타일이 다른 쪽에 영향을 줍니다.

이 문제를 해결하기 위해 배너 콘텐츠 내 모든 클래스에 4자리 랜덤 접두어를 붙이는 규칙을 도입했습니다.

<!-- 충돌 가능한 구조 -->
<div class="title">...</div>

<!-- 고유 접두어 적용 -->
<div class="bnr-a3f2-title">...</div>

배너 콘텐츠를 작성할 때 bnr-[4자리]-* 형식을 사용하도록 관리자 가이드를 문서화했습니다. 콘텐츠 제작 도구인 create-banner 스킬도 이 규칙을 자동으로 적용합니다.

WAI-ARIA 접근성#

캐러셀 컴포넌트는 스크린 리더 접근성이 취약하기 쉽습니다. WAI-ARIA 명세의 캐러셀 패턴을 참고해서 다음 속성을 적용했습니다.

<section
  role="region"
  aria-roledescription="carousel"
  aria-label="광고 배너"
>
  <div
    role="group"
    aria-roledescription="slide"
    aria-label={`${currentIndex + 1} / ${banners.length}`}
    aria-live="polite"
  >
    {/* 배너 콘텐츠 */}
  </div>
  <button aria-label="이전 배너">‹</button>
  <button aria-label="다음 배너">›</button>
</section>

aria-live="polite"는 자동재생으로 콘텐츠가 바뀔 때 스크린 리더가 현재 읽고 있는 내용을 방해하지 않고 변경 사항을 알려줍니다. "assertive" 대신 "polite"를 사용한 이유는 배너 전환이 긴급하거나 중요한 알림이 아니기 때문입니다.


돌아보며: 직접 만든 것의 가치#

자체 배너 시스템을 만드는 데 외부 SDK를 연동하는 것보다 당연히 더 많은 시간이 걸렸습니다. 하지만 얻은 것이 있습니다.

  1. 완전한 제어권 — 어떤 배너를, 언제, 어디에 노출할지 코드 레벨에서 결정합니다
  2. 타입 안전성target required prop 하나로 런타임 버그를 컴파일 타임에 잡습니다
  3. 접근성 — 외부 SDK가 처리해주지 않는 WAI-ARIA 구현을 직접 챙길 수 있습니다
  4. 학습 — 요구사항이 진화하면서(V4 → V25) 스키마 설계와 마이그레이션 전략을 실전으로 배웠습니다

물론 모든 상황에서 직접 만드는 게 정답은 아닙니다. "광고 수익 최적화"가 목표라면 외부 플랫폼이 훨씬 낫습니다. 중요한 것은 우리 서비스의 맥락에서 무엇이 더 적합한가를 따지는 것입니다.


비슷한 고민을 하고 계시거나
같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️
📩 ksy90101@gmail.com

이 글이 도움이 되셨나요?

카카오톡 오픈채팅방에서 더 깊은 이야기를 나눠보세요.

오픈채팅방 참여하기

댓글

0

의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.

댓글을 불러오는 중...