배너 시스템 구축기
코드 한 줄 안 고치고 배너를 바꿀 수 있게
커뮤니티 사이트를 운영하다 보면 배너를 넣고 싶을 때가 옵니다. 제휴 광고, 자체 프로모션, 이벤트 안내. 그런데 배너 하나 바꾸려면 코드 수정하고, 빌드하고, 배포해야 합니다.
AdSense는 이미 붙어 있었지만 자체 배너를 넣을 방법이 없었습니다. "관리자 화면에서 이미지 올리고 URL 넣으면, 사이트 아무 위치에 배너가 뜨는 시스템"이 필요했습니다. 배너가 만료되면 자동으로 AdSense로 전환되고, 클릭 통계도 보고 싶었습니다.
슬롯이라는 개념
배너를 그냥 페이지에 박으면 나중에 관리가 안 됩니다. 그래서 슬롯 개념을 도입했습니다.
사이트 페이지 관리자 화면 ┌───────────────────────┐ ┌──────────────────┐ │ [main-feed-left] │ ←─── │ 슬롯: main-feed-left │ 160x600 배너 자리 │ │ → 배너 A 할당 │ │ │ │ │ │ ──── 글 목록 ──── │ │ 슬롯: board-list-bottom │ │ │ → 배너 B 할당 │ │ [board-list-bottom] │ ←─── │ │ │ 728x90 배너 자리 │ │ 슬롯: post-detail-bottom │ │ │ → (비어있음→AdSense)│ └───────────────────────┘ └──────────────────┘
슬롯은 사이트 코드에 한 번만 심어두면 됩니다. 이후에는 관리자 화면에서 슬롯에 배너를 할당하거나 빼기만 하면 됩니다. 코드 배포 없이.
슬롯에 배너가 없으면? 그 자리에 AdSense가 뜹니다. 배너가 만료돼도 마찬가지. 빈 자리는 절대 생기지 않습니다.
슬롯 렌더링 결정 흐름: 슬롯 요청 들어옴 │ ▼ 활성 배너 있나? ─── YES ──→ 배너 렌더링 (BANNER) │ NO │ ▼ AdSense ID 설정됐나? ─── YES ──→ 광고 렌더링 (ADSENSE) │ NO │ ▼ 아무것도 안 그림 (NONE)
이 결정은 서버가 합니다. 프론트엔드는 서버가 "BANNER야", "ADSENSE야", "NONE이야" 하고 알려주면 그대로 그리기만 합니다. 배너 노출 기간이나 활성 상태를 프론트에서 판단하면 시간대 문제, 캐시 문제가 생기거든요.
클릭 추적: 프라이버시를 지키면서 통계 뽑기
배너를 넣었으면 "얼마나 클릭되는지"를 알아야 합니다. 그런데 클릭 추적에서 신경 써야 할 게 생각보다 많습니다.
서버 경유 리다이렉트
배너를 클릭하면 바로 외부 URL로 가는 게 아니라 서버를 한 번 거칩니다.
사용자 클릭 │ ▼ /api/banners/click?id=42&slot=3 │ ▼ 서버에서: 1. banner_id-slot_id 관계 검증 (위조 방지) 2. 쿠키에서 visitor_id 확인 (없으면 UUID 발급) 3. 남용 체크 (10초 이내 동일 클릭 무시) 4. 클릭 로그 비동기 저장 5. 302 Redirect → target_url
IP는 해시로만
클릭 로그에 IP를 저장하면 개인정보 부담이 생깁니다. 그렇다고 아예 안 남기면 통계 분석이 어렵습니다.
그래서 HMAC-SHA-256 해시로 저장합니다.
원본 IP: 192.168.1.100 SHA-256만 쓰면? → 해시값 역산은 못 하지만, → 모든 IP를 대입해서 해시 비교하면 원본 복원 가능 (IPv4는 고작 43억 개) HMAC-SHA-256 + 서버 비밀키: → 비밀키를 모르면 대입 공격 자체가 불가능 → 원본 IP는 절대 복원할 수 없음 → 그러면서도 "같은 IP인지"는 비교 가능
단순 SHA-256은 IP 주소 공간이 좁아서 대입(brute-force)에 취약합니다. 서버만 알고 있는 비밀키를 솔트로 쓰면 키 없이는 역산이 불가능해집니다.
User-Agent도 저장하지 않습니다. MOBILE인지 PC인지만 구분하면 충분합니다. 수집하지 않는 게 최선의 보안입니다.
쿠키 기반 방문자 식별
고유 클릭과 중복 클릭을 구분하려면 "이 사람이 전에 클릭한 적 있나"를 알아야 합니다. IP로 하면 공유 IP(회사, 카페) 환경에서 다른 사람이 한 사람으로 잡힙니다.
┌────────────────────────────────────────┐ │ 쿠키: _fsf_vid │ │ │ │ 값: UUID (예: a1b2c3d4-...-e5f6) │ │ 수명: 1년 │ │ 범위: /api/banners 경로만 │ │ 속성: HttpOnly, Secure, SameSite=Strict│ │ │ │ → 서버만 읽을 수 있음 (JS 접근 차단) │ │ → 같은 사이트 요청에서만 전송 │ │ → HTTPS에서만 작동 │ └────────────────────────────────────────┘
첫 방문 시 서버가 UUID를 발급하고 쿠키에 넣어줍니다. 이후 같은 브라우저에서 다시 클릭하면 같은 visitor_id가 오니까 고유 클릭을 구분할 수 있습니다.
피드 안에 배너 끼워넣기
메인 피드(글 목록)에 배너를 자연스럽게 끼워 넣는 것도 이 시스템의 핵심 기능입니다.
피드 렌더링: ┌─────────────────────────┐ │ 글 1 │ │ 글 2 │ │ 글 3 │ │ 글 4 │ │ 글 5 │ │ ═══ [배너 A] ════════ │ ← startPosition=5 (5번째 글 뒤) │ 글 6 │ │ 글 7 │ │ ... │ │ 글 12 │ │ ═══ [배너 B] ════════ │ ← interval=7 (7개 글 간격) │ 글 13 │ │ ... │ └─────────────────────────┘
관리자가 시작 위치, 간격, 최대 개수를 설정할 수 있습니다. "5번째 글 뒤에 첫 배너, 이후 7개마다 하나씩, 최대 3개"처럼.
무한 스크롤과 중복 방지
문제는 무한 스크롤입니다. 페이지 1을 로드하고, 스크롤하면 페이지 2가 붙고, 페이지 3이 붙고... 매 페이지마다 배너 삽입 위치를 계산하면 중복이 생깁니다.
페이지 1 (글 1~20): 배너 위치 = 5, 12, 19 페이지 2 (글 21~40): 배너 위치는? → localIndex로 하면: 5, 12, 19 (같은 패턴 반복) → globalIndex로 하면: 26, 33 (이어서 계산) 해결: globalOffset 페이지 1: offset=0 → 글 위치 1~20 → 배너 at 5, 12, 19 페이지 2: offset=20 → 글 위치 21~40 → 배너 at 26, 33
globalOffset은 지금까지 로드한 총 아이템 수입니다. 다음 페이지를 로드할 때 이 값을 기준으로 삽입 위치를 계산하면 중복 없이 이어서 배너가 들어갑니다.
배너 상태: 저장하지 않고 계산하기
배너에는 노출 시작일과 종료일이 있습니다. "2월 1일~2월 28일" 이런 식으로. 그러면 상태가 필요합니다: 예약됨, 활성, 만료됨.
이 상태를 DB에 저장할 수도 있지만, 저장하지 않고 매번 계산하는 방식을 택했습니다.
Banner.getStatus(now): now < startDate → SCHEDULED (예약됨) endDate == null → INDEFINITE (무기한) now <= endDate → ACTIVE (활성) now > endDate → EXPIRED (만료됨)
이렇게 하면 만료 처리를 위한 스케줄러가 필요 없습니다. 시간이 지나면 자연히 상태가 바뀝니다.
다만 안전장치로 매시간 정각에 만료된 배너의 is_active를 false로 바꾸는 스케줄러는 돌려둡니다. DB 조회 성능을 위해서입니다. 하지만 이 스케줄러가 죽어도 isDisplayable() 계산에서 걸러지니 잘못된 배너가 노출될 일은 없습니다.
AdSense와 공존하기
기존에 AdSense가 이미 붙어 있는 사이트에 배너 시스템을 얹으면 충돌이 생깁니다. 특히 모바일에서 CSS display: none으로 숨긴 광고가 여전히 DOM에 남아서 AdSense 렌더링 순서를 방해하는 문제가 있었습니다.
문제 상황: PC에서만 보이는 사이드바 배너 → CSS hidden으로 숨김 → 하지만 <ins> 태그는 DOM에 존재 → Google이 이 <ins>를 광고 슬롯으로 인식 → 모바일 피드의 AdSense가 밀림 해결: minScreenWidth prop 도입 → 화면 너비가 기준 미달이면 컴포넌트 자체를 마운트하지 않음 → DOM에 <ins>가 아예 안 생김 → AdSense 렌더링 간섭 없음
CSS로 숨기는 것과 컴포넌트를 아예 렌더링하지 않는 것은 완전히 다릅니다. 전자는 DOM에 흔적이 남고, 후자는 없습니다. AdSense 같은 외부 스크립트는 DOM 존재 여부에 민감하기 때문에 이 차이가 중요합니다.
또 하나. 한 페이지에 AdSense를 너무 많이 넣으면 정책 위반이 됩니다. 그래서 피드 배너에 maxAdsensePerPage 설정을 둬서 서버가 제어합니다.
피드 슬롯 3개 중 2개가 AdSense인데, maxAdsensePerPage = 1이면? feed-1: ADSENSE → 그대로 (1개) feed-2: ADSENSE → NONE으로 변환 (1개 초과) feed-3: BANNER → 그대로 (배너는 무관)
프론트엔드는 이런 판단을 하지 않습니다. 서버가 이미 결정해서 renderType으로 내려주니까, 프론트는 타입 보고 그리기만 합니다.
코드리뷰에서 잡힌 것들
초기 구현 후 코드리뷰를 받았는데, 꽤 의미 있는 이슈들이 나왔습니다.
┌──────────────────────────────────────────────────────┐ │ 발견된 이슈 9건 │ ├──────────┬────────────────────────────────────────────┤ │ 심각도 │ 내용 │ ├──────────┼────────────────────────────────────────────┤ │ High │ 피드 배너 훅을 만들어놓고 실제 피드에 안 연결함│ │ High │ 텍스트 배너에 텍스트 없이 저장 가능 │ │ Medium │ 상태 필터가 UI에만 있고 서버에서 무시 │ │ Medium │ 피드 비활성화해도 배너가 계속 나옴 │ │ Medium │ 비활성으로 배너 생성이 불가능 │ │ Medium │ 종료일 수정 시 의도치 않게 초기화 │ │ Medium │ 비동기 메서드를 만들어놓고 동기로 호출 │ │ Medium │ CSV에 콤마 들어가면 열 깨짐 │ │ Low │ 슬롯 수정해도 캐시가 30초간 안 바뀜 │ └──────────┴────────────────────────────────────────────┘
비동기인 척하는 동기 코드
가장 아찔했던 건 클릭 로그 저장이었습니다. @Async로 비동기 처리하도록 설계했는데, 실제로는 동기로 호출하고 있었습니다. 같은 클래스 안에서 this.saveClickLogAsync()를 부르면 Spring 프록시를 안 타서 @Async가 무시됩니다.
문제 (self-invocation): ┌─────────────────────────────┐ │ BannerClickService │ │ │ │ recordClick() { │ │ this.saveAsync() ─ X ──→ │ @Async 무시됨 │ } │ (프록시를 안 거침) │ │ │ @Async │ │ saveAsync() { ... } │ │ │ └─────────────────────────────┘ 해결 (별도 빈 분리): ┌──────────────────┐ ┌────────────────────┐ │ BannerClickService│ │ BannerClickLogWriter │ │ │ │ (별도 @Component) │ │ recordClick() { │ │ │ │ writer.save()─┼────→│ @Async │ │ } │ │ save() { ... } │ │ │ │ │ └──────────────────┘ └────────────────────┘ 프록시를 거침 → @Async 동작
BannerClickLogWriter라는 별도 컴포넌트로 분리하면 Spring이 프록시를 통해 호출하니까 @Async가 정상 동작합니다. 비동기 저장이 실패해도 클릭 리다이렉트에는 영향 없고, 로그만 유실됩니다. 통계는 어차피 근사치이니 허용 가능한 트레이드오프입니다.
endDate의 세 가지 의미
배너 수정 API에서 endDate가 null로 오면 어떤 의미일까요?
endDate = null → "종료일을 보내지 않았다" (기존 유지) "무기한으로 바꾸고 싶다" (명시적 null) 어느 쪽인지 구분 불가능!
그래서 clearEndDate 플래그를 추가했습니다.
clearEndDate = true → null로 설정 (무기한 전환) clearEndDate = false → endDate가 있으면 새 값, 없으면 기존 유지
REST API에서 "값 없음"과 "값을 null로 바꿈"을 구분하는 건 자주 나오는 문제인데, boolean 플래그가 가장 단순한 해법이었습니다.
Soft Delete: 지우되 지우지 않는다
배너와 슬롯 모두 Soft Delete를 씁니다. 실제로 DELETE하지 않고 is_deleted = true로만 표시합니다.
이유: 배너 A 삭제 → banner_click_log에 banner_id=A인 로그 1만 건 → 실제 DELETE하면? FK 제약 위반 or CASCADE로 로그까지 삭제 → Soft Delete하면? 로그는 그대로, 배너만 목록에서 사라짐
과거 클릭 통계를 보존하면서도 운영상 "삭제"할 수 있는 방법입니다. 쿼리에는 항상 WHERE is_deleted = false가 붙습니다.
클릭 로그는 어떨까요? 영원히 쌓이면 안 되니 1년 보관 후 배치 삭제합니다. 매일 새벽 3시에 1,000건씩 잘라서 지웁니다. 한 번에 DELETE하면 테이블 락이 걸려서 서비스가 느려질 수 있거든요.
보안: URL 검증은 서버에서
배너의 targetUrl은 사용자가 클릭하면 이동하는 URL입니다. 여기에 javascript:alert('XSS')가 들어가면? 큰일입니다.
targetUrl 검증 규칙: ✓ https:// 로 시작 → 허용 ✗ http:// → 차단 ✗ javascript: → 차단 ✗ data: → 차단 ✗ file: → 차단 ✗ ftp: → 차단 ✗ 2000자 초과 → 차단
"http도 허용하면 안 되나?" 할 수 있지만, 2026년에 https가 안 되는 사이트에 배너 링크를 걸 이유가 없습니다. 단순하게 https만 허용하는 게 공격 표면을 줄이는 가장 확실한 방법입니다.
CSV 내보내기에서도 보안 이슈가 있었습니다. 배너 이름에 =SUM(A1:A10) 같은 문자가 들어있으면 엑셀이 수식으로 실행합니다. 그래서 = + - @로 시작하는 값 앞에 작은따옴표를 붙여서 수식 주입을 차단합니다.
배포 후 맞닥뜨린 것들
"다 됐다" 하고 배포했는데, 현실은 좀 달랐습니다.
AdSense가 갑자기 안 나온다
사이드바에 배너 슬롯을 넣었는데, 모바일에서 피드의 AdSense가 사라지는 현상이 발생했습니다. 원인은 위에서 설명한 CSS 숨김 문제였습니다. minScreenWidth로 해결.
feed-2, feed-3 슬롯이 없으면 500 에러
피드 배너 API가 feed-1, feed-2, feed-3 슬롯을 조회하는데, 아직 슬롯을 하나도 등록하지 않은 상태에서 호출하면 ResourceNotFoundException이 터졌습니다. Spring AOP 프록시를 거치면서 예외 타입이 바뀌는 경우도 있어서, 결국 범용 catch (Exception e)로 감싸고 로그를 남기는 방식으로 수정했습니다. 없는 슬롯은 NONE으로 처리.
관리자가 뭘 먼저 해야 하는지 모른다
배너 시스템을 처음 쓰는 관리자는 "슬롯이 뭐야? 어디서 등록해?" 하게 됩니다. 그래서 세 가지를 추가했습니다:
- 미등록 프리셋 슬롯 안내 (노란색 알림)
- 프리셋 버튼 클릭으로 자동 입력
- 사용 가이드 탭 (배너 관리 첫 화면)
기술적으로 대단한 건 없지만, 이런 것들이 없으면 시스템을 만들어놔도 안 쓰게 됩니다.
숫자로 보는 결과
┌──────────────────────────────────────┐ │ Phase 80 구현 요약 │ ├──────────────────┬───────────────────┤ │ 백엔드 테스트 │ 111개 통과 │ │ 프론트엔드 테스트 │ 109개 통과 │ │ DB 테이블 │ 4개 신규 │ │ API 엔드포인트 │ 18개 (공개 3 + 관리 15)│ │ 프론트 컴포넌트 │ 12개 신규 │ │ 관리자 페이지 │ 9개 │ │ 코드리뷰 이슈 │ 9건 발견 → 9건 수정 │ │ 배포 후 수정 │ 4건 │ └──────────────────┴───────────────────┘
마무리
배너 시스템을 만들면서 느낀 건 광고 시스템이라는 게 겉보기보다 훨씬 까다롭다는 것입니다.
배너 이미지 올리고 URL 연결하는 건 쉽습니다. 어려운 건 그 주변입니다. 클릭을 어떻게 추적하되 개인정보는 어떻게 보호할 것인가. AdSense와 어떻게 공존시킬 것인가. 배너가 만료되면 빈 자리를 어떻게 메울 것인가. 피드 스크롤에 배너를 어떻게 중복 없이 끼울 것인가. 관리자가 코드를 모르는데 어떻게 쓰게 할 것인가.
특히 프라이버시와 분석의 균형이 인상적이었습니다. IP를 해시로만 저장하고, User-Agent를 아예 버리고, 쿠키에 UUID만 넣는 것만으로도 필요한 통계는 다 뽑을 수 있었습니다. 수집하지 않는 게 최선의 보안이라는 원칙이 실무에서도 통한다는 걸 확인했습니다.
코드리뷰에서 나온 9건의 이슈도 유익했습니다. @Async self-invocation 함정, endDate null의 이중 의미, CSV 수식 주입 같은 건 직접 겪어봐야 기억에 남는 종류입니다.
다음 단계로는 노출(임프레션) 로깅을 추가해서 CTR(클릭률)을 계산할 수 있게 만들 계획입니다. 클릭 수만으로는 "이 배너가 얼마나 효과적인지"를 정확히 판단할 수 없으니까요.
현재: 클릭 수만 측정 → "배너 A가 100번 클릭됐다" → 많이 노출돼서 많이 클릭된 건지, 적게 노출됐는데 잘 클릭된 건지 모름 향후: 노출 + 클릭 → CTR → "배너 A: 10,000번 노출, 100번 클릭 = CTR 1%" → "배너 B: 2,000번 노출, 80번 클릭 = CTR 4%" → B가 4배 더 효과적이라는 걸 알 수 있음
슬롯과 배너라는 추상화가 잘 깔려 있으니, 그 위에 무엇이든 쌓아올릴 수 있습니다.

댓글
댓글을 작성하려면 이 필요합니다.