Skip to content

✨ Feat: ad.picke.store 제휴 광고(쿠팡 파트너스·애드픽) 연동 - #443

Merged
Roy-wonji merged 9 commits into
devfrom
feat/#441
Sep 8, 2026
Merged

✨ Feat: ad.picke.store 제휴 광고(쿠팡 파트너스·애드픽) 연동#443
Roy-wonji merged 9 commits into
devfrom
feat/#441

Conversation

@Roy-wonji

Copy link
Copy Markdown
Contributor

#️⃣ 연관된 이슈

📝 작업 내용

✨ Feat

내용 파일
소재·클릭 로그·일별 노출 집계 엔티티 AdCreative.java, AdClickLog.java, AdImpressionDaily.java
지면별 광고 조회(가중 로테이션)·노출 집계 AdController.java, AdQueryService.java
클릭 리다이렉트와 매체별 추적 파라미터 병합 AdClickController.java, AdClickService.java, AffiliateLinks.java
매체별 링크 빌더 (쿠팡 subId / 애드픽 pass-through) CoupangLinkBuilder.java, AdpickLinkBuilder.java
관리자 소재 CRUD·노출/클릭/CTR·클릭 내역 AdminAdController.java, AdminAdService.java
ad.picke.store 루트 공개 지면 AdLandingController.java, templates/ad/landing.html
광고 API Swagger 그룹 분리 SwaggerConfig.java

🐛 Fix

내용 파일
정확 일치 화이트리스트 분리로 루트·/error 401 해소 JwtFilter.java, SecurityConfig.java

📌 공유 사항

  1. 이 PR은 🐛 Fix: BattleVoteServiceImplTest 시간대 불일치로 15시(UTC) 이후 CI 실패 #442(fix/#440) 위에 쌓았습니다. 그쪽이 머지되면 base가 dev로 자동 전환됩니다. 먼저 #442를 봐주세요.
  2. 배포 전 별도 작업 없습니다. ddl-auto=update라 테이블은 자동 생성됩니다. docs/db/의 SQL은 수동 관리·사후 검증용입니다.
  3. COUPANG_PARTNERS_ID 환경변수 주입이 필요합니다. 다른 시크릿과 같이 기본값을 두지 않았습니다. 값은 AF6830373입니다.
  4. 지면(AdSlotCode)은 iOS Presentation 모듈 실제 화면 기준으로 5개 잡았습니다. 앱팀 확정 후 실제로 붙이는 지면에만 소재를 등록하면 되고, 소재 없는 지면은 빈 배열이라 앱이 알아서 숨깁니다.
  5. 애드픽은 서브아이디 파라미터 규격 확인 전이라 pass-through입니다. 확인되면 ADPICK_SUB_ID_PARAM 환경변수만 채우면 배포 없이 지면별 성과 분리가 켜집니다.

✅ 체크리스트

  • 컨벤션에 맞는 Type을 선택했나요?
  • Development에 이슈를 연동했나요?
  • Merge 하려는 브랜치가 올바르게 설정되어 있나요?
  • 로컬에서 실행했을 때 에러가 발생하지 않나요?

📸 스크린샷

@Check 제약이 실제 DDL에 나가는지 스키마 생성으로 확인했습니다.

create table ad_creatives (..., 
  constraint ck_ad_creatives_network check (network in ('COUPANG', 'ADPICK')),
  constraint ck_ad_creatives_slot check (slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')),
  constraint ck_ad_creatives_status check (status in ('DRAFT', 'ACTIVE', 'PAUSED')));

💬 리뷰 요구사항

  1. CHECK 제약이 enum과 어긋나는 것이 #378의 본질이라 봤습니다. 제약 문자열과 enum 상수를 대조하는 AdCheckConstraintTest를 넣어, 값을 추가하고 제약 갱신을 잊으면 운영이 아니라 CI에서 먼저 깨지게 했습니다. 다만 ddl-auto=update는 기존 제약을 갱신하지 않으므로 실제 ALTER는 여전히 수동입니다. 이 조합이 적절한지 의견 부탁드립니다.
  2. 클릭 로그를 익명(ip_hash + user_agent)으로 뒀습니다. /c/{code}가 외부 브라우저에서 열려 Authorization 헤더가 없고, 공개 URL에 사용자 식별자를 실으면 열거 문제가 생겨서입니다. 지면별 CTR과 정산 대조에는 userId가 필요 없다고 판단했는데, 본인 클릭 어뷰징 탐지가 필요하면 다시 논의하면 좋겠습니다.
  3. ad_click_logs.creative_id, ad_impression_daily.creative_id에 FK를 걸지 않았습니다. 소재를 지워도 집계 이력은 남기려는 의도인데, 팀 컨벤션과 맞는지 봐주세요.

BattleVoteServiceImpl은 "오늘"을 LocalDate.now(KST)로 판단하는데
테스트는 시스템 기본 시간대의 LocalDate.now()를 썼다.

UTC 러너에서는 15:00 UTC(=KST 자정) 이후로 두 값이 하루씩 어긋나
오늘 배틀이 어제 배틀로 취급되면서 크레딧 차감 분기가 반대로 탔다.
같은 브랜치에서 14:42 UTC 실행은 통과하고 15:25 UTC 실행은 실패했다.

테스트도 KST 기준으로 맞춘다.
JwtFilter 가 SecurityConfig 보다 먼저 돌면서 토큰이 없으면 무조건 401 을 던진다.
그런데 isWhitelisted 가 startsWith 매칭이라 "/" 를 넣으면 전체 인증이 무력화된다.
정확히 일치할 때만 통과하는 EXACT_WHITELIST 를 분리해 "/" 와 "/error" 를 넣는다.

/error 가 빠져 있던 탓에 존재하지 않는 모든 경로가 404 대신 401 로 나오고 있었다.
ad.picke.store 루트가 인증 오류 JSON 을 반환하던 원인이다.
- ad 도메인 신설: 소재(AdCreative), 클릭 로그, 일별 노출 집계
- 앱 API: GET /api/v1/ads (지면별 가중 로테이션), POST /api/v1/ads/impressions
- 클릭 리다이렉트: GET /c/{code} 에서 매체별 추적 파라미터를 병합해 302, 클릭은 비동기 적재
- 관리자 API: 소재 CRUD, 소재별 노출/클릭/CTR, 클릭 내역 목록
- 쿠팡은 subId 를 병합해 지면별 성과를 분리하고,
  애드픽은 규격 확인 전까지 pass-through 이며 파라미터명을 설정값으로 둔다
- 소재 등록 시 제휴 링크의 lptag 를 우리 파트너스 아이디와 대조해 남의 링크를 막는다
- enum 컬럼에 CHECK 제약을 선언하고, 제약과 enum 이 어긋나면 테스트가 먼저 깨지게 한다
- 노출 집계와 게재 기간 판단을 KST 로 고정
- ad.picke.store 루트 공개 지면 추가 (매체 심사에서 접속 확인 대상)
- Swagger "3. 광고 API" 그룹으로 분리
ERD 는 docs/erd/ad.puml, 참고 스키마는 docs/db 에 둔다.
운영은 ddl-auto=update 라 테이블은 배포 시 자동 생성되며, SQL 은 수동 관리·사후 검증용이다.
@Roy-wonji

Copy link
Copy Markdown
Contributor Author

CI 관련 참고입니다.

deploy.ymldev/main 대상 PR에만 걸려 있어서, base가 fix/#440인 지금은 체크가 돌지 않습니다.

#442를 먼저 머지하면 이 PR의 base가 dev로 자동 전환되면서 CI가 돕니다.

지금 base를 dev로 바꾸면 CI는 돌지만, #442에서 고친 BattleVoteServiceImplTest 2건이 15시(UTC) 이후 실행에서 그대로 실패합니다. 그래서 순서를 지키는 쪽으로 뒀습니다.

로컬 검증 결과입니다.

TZ=UTC ./gradlew test
204 tests completed, 2 failed

실패 2건은 ScenarioAudioPipelineServiceTest, ScenarioServiceImplTest로 로컬에 ffmpeg가 없어 나는 것이며 CI에서는 통과합니다.

애드픽은 캠페인 리스트 JSON API 를 인증 없이 공개한다.
쿠팡 오픈API 와 달리 승인 절차가 없어 애드픽 소재는 사람이 등록하지 않고 주기 동기화로 채운다.

- 수집한 캠페인을 같은 ad_creatives 에 source=ADPICK_API 로 저장해
  로테이션·노출 집계·클릭 추적 경로를 그대로 태운다
- 애드픽 가이드가 최대 1분 1회 이하 호출을 요구하고 실제로 연달아 호출하면 403 을 준다.
  요청마다 부르지 않고 스케줄러로만 호출한다
- apRemain 이 0 이면 게재하지 않는다. 잔여가 없으면 클릭해도 전환이 잡히지 않는다
- 피드에서 사라진 캠페인은 지우지 않고 내린다. 쌓인 집계를 계속 읽을 수 있어야 한다
- PAUSED 는 동기화가 되돌리지 않는다. 별도 플래그 없이 어드민의 끄기 스위치로 쓴다
- 동기화 소재는 수정·삭제를 막고 게재 상태만 바꾸게 한다 (PATCH /{id}/status)
- apOS 로 OS 가 갈리므로 target_os 를 두고 GET /api/v1/ads 에 os 파라미터를 받는다
  iOS 사용자에게 Android 캠페인을 보여주면 클릭해도 전환이 일어나지 않는다
- affId 가 비어 있으면 동기화를 건너뛴다. 가입 전에도 나머지 기능은 그대로 돈다

@si-zero si-zero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

짚고 갈 것

1. AdQueryService.increaseImpression 경쟁 조건 처리 (중요)

@Transactional 안에서 insert 하다 DataIntegrityViolationException 잡고 같은 트랜잭션에서 increment를 다시 부르는데, PostgreSQL은 statement 하나 터지면 그 트랜잭션이 abort 상태로 들어가서 뒤따르는 쿼리가 전부 current transaction is aborted 로 실패함. H2에서만 통과하고 운영에선 복구 increment가 동작 안 함. 게다가 increaseImpression이 예외로 빠져나가면 호출부(recordImpressions)가 UnexpectedRollbackException 맞아서 배치 전체가 날아감.

  • insert를 Propagation.REQUIRES_NEW 서브트랜잭션으로 빼서 실패를 격리하거나
  • PG 네이티브 INSERT ... ON CONFLICT DO UPDATE + H2용 fallback 분기
  • 아니면 재시도 자체를 트랜잭션 밖(서비스 상위)에서

셋 중 하나로 가야 함. 지금 구조는 동시 노출이 실제로 겹치는 순간 깨짐.

2. 브랜치가 dev보다 뒤처져 있음 (중요)

base가 cba91cb인데 그 뒤로 dev에 #444(S3→Railway)가 JwtFilter / SecurityConfig / application.yml 을 건드렸음. rebase 하면 충돌남. 특히:

  • dev가 화이트리스트에 추가한 /webjars, /webjars/** 가 이 PR엔 없음 → 리베이스하면서 빠뜨리지 말 것
  • dev가 정리한 /swagger-ui, /v3/api-docs 줄 trailing whitespace가 이 PR에서 다시 살아남

#442 머지 후 base가 dev로 바뀌면 여기서 걸릴 거라 미리 정리해두는 게 나음.

3. POST /api/v1/ads/impressions 무인증

노출 집계가 인증 없이 열려 있어서 아무나 curl 반복으로 노출수 부풀릴 수 있음. 익명 설계라 감수하는 거면 OK인데, CTR이 정산 대조용이라 이 수치가 조작 가능하다는 건 인지하고 가면 좋겠음. 최소한 codes 개수 상한 정도는.

4. AdClickController.resolveClientIp

X-Forwarded-For 첫 값을 그대로 신뢰함 → 클라이언트가 헤더 넣으면 스푸핑됨. ip_hash가 정밀 어뷰징 탐지용은 아니니 지금은 괜찮은데, 나중에 본인클릭 필터링 붙일 거면 프록시 홉 수 기준으로 잘라야 함.

5. 잘못된 landing URL 방어

AffiliateLinks.mergebuild(true)(인코딩 완료 가정)라, 어드민이 인코딩 안 된 URL 넣으면 등록은 되고 클릭 시점에 500 남. validateCoupangOwnership은 COUPANG + 파트너스ID 있을 때만 URL 파싱하니까 애드픽 수동 소재나 단축링크는 검증을 안 탐. 등록 시점에 landingUrl 한 번 파싱해서 형식 검증하는 게 안전함.

6. 애드핏 계획 문서 섞임

docs/superpowers/plans/2026-07-16-adfit-reward.md(1119줄) + specs 2개가 이번 쿠팡/애드픽 PR에 같이 들어와 있음. 이건 리워드 광고 쪽 계획이라 이 PR이랑 무관한데, 의도한 거 아니면 빼는 게 맞음.

리뷰 요청 답변

1. CHECK 제약 + AdCheckConstraintTest 조합 — 합리적임. ddl-auto=update가 제약 갱신 안 하는 것도 SQL 파일 주석에 명시돼 있고, 어긋나면 CI에서 먼저 깨주니까 안전망으로 충분함. 다만 enum 추가할 때 수동 ALTER 잊을 위험은 여전히 사람한테 남으니, 배포 체크리스트에 "ad enum 바뀌면 제약 ALTER" 한 줄 박아두면 좋겠음.

2. 익명 클릭 로그 — 동의함. 공개 URL에 userId 실는 거 열거 위험 맞고, 정산 대조엔 userId 필요 없음. 본인클릭 어뷰징은 나중에 ip_hash + UA 빈도로 사후 필터 가능하니 지금 구조로 놔둬도 됨.

3. creative_id FK 미설정 — 소재 삭제해도 집계 이력 남기려는 의도 이해되고, 우리 다른 집계성 테이블도 논리참조 쓰는 편이라 컨벤션이랑 어긋나지 않음. OK.

Base automatically changed from fix/#440 to dev September 7, 2026 07:04
- AdImpressionRecorder 를 분리해 집계 행 삽입을 REQUIRES_NEW 로 격리한다.
  PostgreSQL 은 제약 위반 시 트랜잭션 전체를 중단시켜, 되돌리기가 같은
  트랜잭션 안에 있으면 UnexpectedRollbackException 으로 번진다.
- 노출 집계 요청에 소재 20개 상한을 둔다. 익명 공개 API 라 배치 크기를 막는다.
- 관리자 소재 등록 시 랜딩 URL 형식을 검증한다. 클릭 시점 500 을 앞당겨 잡는다.
- 광고 도메인·관리자 테스트 64건 통과.
- enum 을 늘렸을 때 ddl-auto=update 가 CHECK 제약을 갱신하지 않는 문제를
  배포 체크리스트로 남긴다. ALTER 는 사람이 직접 돌려야 한다.
- 2026-07-16 adfit 리워드 문서는 이번 PR 범위가 아니라 삭제한다.
  origin/feat/ad-affiliate 에 그대로 남아 있다.
@Roy-wonji
Roy-wonji merged commit c99065e into dev Sep 8, 2026
1 check passed
@Roy-wonji
Roy-wonji deleted the feat/#441 branch September 8, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants