15 KiB
가로형 서브 배너 API 계약
상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포. 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다.
2026-11-03 언어 후속 Phase 7 검증도 완료했다. 전체 2,928개 통과, 테스트 공백 수정 후 영향 HTTP 26개·전체 lint·bootJar 재확인 및 독립 리뷰 APPROVE다. 위 2,682개는 기존 Phase 2~6 이력이며 후속 증거는 plan-task.md Phase 7에 기록한다. 운영 DDL·배포는 미실행이다.
1. 공통 형식
애플리케이션 성공 응답은 네 필드를 가진 ApiResponse<T> 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 GET의 data는 §2의 응답 객체다.
{
"success": true,
"message": null,
"data": [],
"errorProperty": null
}
data는 조회의 응답 객체 또는 목록, 생성·수정의 전체 배너 객체, 삭제의 null이다. 성공 요청은 HTTP 200이다. 일부 요청 오류도 기존 handler에 따라 HTTP 200의 success:false로 응답하므로 HTTP 상태와 success를 모두 확인한다.
{
"id": 123,
"placement": "HOME_RECOMMENDATION",
"imageUrl": "https://cdn.example.test/sub-banner.jpg",
"webUrl": "https://example.test/promo?x=a%2Fb",
"androidDeepLink": "sampleapp://promo/item?id=a%2Fb",
"iosDeepLink": null
}
| 필드 | 타입 | 규칙 |
|---|---|---|
id |
integer JSON number | 불투명 식별자. 소비 클라이언트의 정밀도 안전 ID 전략을 사용한다. |
placement |
enum | HOME_RECOMMENDATION 또는 CONTENT_RECOMMENDATION |
imageUrl |
string | 서버가 생성한 저장 키를 기반으로 만든 절대 http 또는 https URL. 클라이언트 파일명은 저장 키가 아니다. |
webUrl |
string 또는 null | 절대 http 또는 https URL |
androidDeepLink |
string 또는 null | Android 대상 링크 원문 |
iosDeepLink |
string 또는 null | iOS 대상 링크 원문 |
공개 SubBanner는 정확히 위 6필드이며 lang·날짜·활성 필드를 추가하지 않는다. 앱 응답은 국가별 언어·기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 null로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. webUrl은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 javascript, data, file, content scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다.
관리자 전용 AdminSubBanner는 SubBanner의 기본 필드에 nullable startDate, endDate와 필수 lang을 더한 정확히 9필드다. 관리자 GET 및 POST/PUT 응답은 모두 AdminSubBanner를 반환한다. 날짜는 Asia/Seoul 기준 yyyy-MM-dd HH:mm 문자열 또는 null이고, lang 응답은 KO, EN, JA 중 하나다.
요청의 lang은 선택적 문자열이며 기존 Lang.fromCode 규칙을 사용한다. KO/EN/JA 및 ko/en/ja를 대소문자 구분 없이, 앞뒤 공백을 제거해 허용한다. 생성 생략은 KO, 수정 생략은 저장된 언어 유지다. 명시적 null, 숫자·boolean·배열·객체, blank 문자열, 알 수 없는 코드는 HTTP 400이며 이미지 업로드·DB 쓰기 전에 거부한다. 날짜의 null 해제와 달리 언어 null은 허용하지 않는다.
관리자 기간 의미는 기존 콘텐츠 배너 예약과 동일하다. null/null은 무제한, 시작만 있으면 시작부터, 종료만 있으면 해당 종료 경계까지 포함하며 시작과 종료가 같은 분인 것도 유효하다. 입력·응답은 분 단위지만 실제 노출 판정은 초 단위로 양 끝을 포함한다. 시작 경계는 입력 분의 :00, 종료 경계는 입력 분의 :59다. 예를 들어 종료 입력이 2026-11-01 12:00이면 12:00:59까지 포함하고 12:01:00부터 제외한다. API 문자열에는 초, Z, offset을 표현하거나 받지 않는다.
요청 날짜는 선택적이다. 생성 시 생략 또는 null은 해당 경계를 제한 없음으로 둔다. 수정 시 생략은 저장된 경계를 유지하고 명시적 null은 그 경계만 해제한다. 두 날짜의 유효 범위는 수정 후 유지되는 값과 새 값을 합쳐 검증한다. 입력은 엄격한 Asia/Seoul yyyy-MM-dd HH:mm 분 단위다. 잘못된 형식, 문자열이 아닌 값, 빈 문자열, 초/시간대가 포함된 값, 존재하지 않는 KST 현지 시각(1988-05-08 02:30), 시작이 종료보다 늦은 값은 HTTP 400이다. 존재하는 겹침 시각은 기존의 앞선 offset을 사용한다. 자동 교환이나 날짜 넘김 보정, 과거 날짜 제한은 없다.
UTC로 변환된 경계 instant는 1970-01-01 00:00:01부터 2038-01-19 03:14:07 사이여야 한다. 이에 따라 시작 입력 허용 범위는 KST 1970-01-01 09:01부터 2038-01-19 12:14, 종료 입력은 1970-01-01 09:00부터 2038-01-19 12:13이다. 범위 바깥을 포함해 변환 instant가 저장 가능 범위를 벗어나면 HTTP 400이다. 이 범위 안의 유효한 과거 날짜는 허용된다.
2. 앱 추천 조회
| 항목 | 계약 |
|---|---|
| Method / Path | GET /api/v2/home/recommendations |
| Query | 서브 배너용 query 없음. endpoint가 HOME_RECOMMENDATION placement를 결정한다. |
| 접근 | 익명 조회 가능. 로그인 사용자도 기존 클라이언트 헤더를 유지해 조회한다. |
| 성공 | HTTP 200, ApiResponse<HomeRecommendationResponse> |
data |
기존 홈 추천 응답 객체의 모든 필드와 subBanners: SubBanner[]를 포함한다. 기존 banners 및 추천 섹션은 유지한다. |
data.subBanners |
해당 placement의 활성 배너 중 회원 국가별 언어와 현재 노출 기간에 맞는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 []. 언어·날짜·활성 필드는 응답하지 않는다. |
{
"success": true,
"message": null,
"data": {
"lives": [],
"banners": [],
"recentlyActiveCreators": [],
"recentDebutCreators": [],
"firstAudioContents": [],
"aiCharacters": [],
"genreCreators": [],
"cheerCreators": [],
"popularCommunityPosts": [],
"subBanners": [{"id":123,"placement":"HOME_RECOMMENDATION","imageUrl":"https://cdn.example.test/sub-banner.jpg","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null}]
},
"errorProperty": null
}
GET /api/v2/audio/recommendations도 익명 조회 가능하며 로그인 사용자는 기존 클라이언트 헤더를 유지한다. 이 endpoint는 CONTENT_RECOMMENDATION을 사용하고 기존 banners, originalSeries, latestAudios, newAndHotAudios, freeAudios, pointAudios, mostCommentedAudios, recommendedAudios 필드에 subBanners: SubBanner[]를 추가한다. data.subBanners에는 동일한 전체·활성·기간 필터 및 id 오름차순 규칙이 적용되며, 빈 결과는 []다. 두 API 모두 기존 추천 data를 배너 배열로 대체하지 않으며 별도 placement query를 보내지 않는다.
GET /api/v2/sub-banners는 더 이상 공개 앱 조회 경로가 아니며 별도 요청으로 사용하지 않는다. 이를 새 alias로 취급하지 않는다. 서버가 반환한 항목에 기존 OS별 링크 필터를 적용하며 날짜와 활성 상태는 클라이언트에서 판단하지 않는다.
두 추천 GET은 Authorization 헤더 없이 200으로 조회할 수 있다. 아래 인증 오류 설명은 관리자 등 인증이 필요한 경로에 적용되며, 앱 추천 조회에 새 로그인 조건을 추가하는 것이 아니다. 서브 배너 전용 HTTP 요청·서버 fallback은 없다.
두 endpoint의 서브 배너 언어는 기존 MemberContentPreferenceService.resolveCountryCode(member)로 판정한다. 강제 회원 매핑이 접속 국가 헤더보다 우선하며, 일반 회원·익명은 CloudFront-Viewer-Country를 사용하고 누락 시 KR이다. JP는 JA, KR·US 등 그 외는 KO만 조회한다. Accept-Language나 LangContext로 선택하지 않고 별도 언어 query도 받지 않는다. EN은 관리자에서 저장·조회할 수 있지만 현재 공개 국가 매핑으로는 노출되지 않는다. 이벤트 연결을 파싱하거나 외부 링크를 번역하지 않으며 기존 링크 원문을 유지한다.
3. 관리자 목록과 변경
관리자 경로는 Authorization: Bearer <token> 인증과 ADMIN 역할이 필요하다. 유효한 비관리자 요청이 반드시 403을 반환하는 것은 아니며 현재 정책 응답은 HTTP 200 success:false다.
| Method / Path | 목적 | 성공 data |
|---|---|---|
GET /admin/sub-banners?placement={placement} |
placement별 삭제되지 않은 활성 레코드, 기간·언어 무관. 필수 query, id 오름차순 | AdminSubBanner[] |
POST /admin/sub-banners |
신규 생성 | 생성된 AdminSubBanner |
PUT /admin/sub-banners/{id} |
원래 네 JSON 필드는 교체, 날짜와 언어 필드는 선택적 생략 보존 | 수정된 AdminSubBanner |
DELETE /admin/sub-banners/{id} |
소프트 삭제 | null |
삭제는 내부 is_active 값을 false로 변경하며 행·이미지 key·주소·기간·생성 시각과 S3 이미지는 보존한다. 생성 기본값은 true다. 삭제된 id는 관리자/공개 목록에서 제외하고, 수정·재삭제는 없는 대상과 동일하게 404다. 성공 응답은 기존 HTTP 200 및 data:null을 유지한다. isActive 요청/응답 필드나 복구·활성 토글 API는 추가하지 않는다.
POST와 PUT은 multipart/form-data다. image 파트는 파일이며, request 파트는 Content-Type: application/json인 JSON이다. 별도 이미지 업로드 endpoint는 없다.
{
"placement": "HOME_RECOMMENDATION",
"webUrl": "https://example.test/promo",
"androidDeepLink": "sampleapp://promo",
"iosDeepLink": null,
"startDate": "2026-11-01 09:30",
"endDate": null,
"lang": "JA"
}
- POST:
image필수. - PUT은 기존 네 필드
placement,webUrl,androidDeepLink,iosDeepLink를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소null은 해당 주소를 지운다. 이는 전체 JSON replacement가 아니며 아래 날짜와 언어 필드는 생략 보존한다. lang생성 생략은 KO, 수정 생략은 기존 언어 유지다. 언어를 바꾸려면 유효한 문자열을 보내며 명시적 null은 지우기가 아니라 HTTP 400이다.startDate,endDate는 요청에서 각각 선택적이다. POST에서 생략 또는 null이면 무제한 경계다. PUT에서 생략하면 기존 경계를 보존하고, null이면 그 경계만 해제한다. 둘 다 보낼 필요는 없다. PUT은 날짜 두 필드를 모두 보내도 된다.- 기간만 수정하더라도
placement와 세 주소는 현재 저장된 값을 그대로 보낸다. 예를 들어 현재 주소가 아래 예시와 같을 때{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30"}는 생략한endDate를 유지한다. 같은 주소를 유지하며"startDate":null,"endDate":null을 보내면 양쪽 경계만 해제한다. 기간 변경을 위해 기존 주소를 임의로 null로 바꾸지 않는다. - PUT에서
image생략 시 기존 이미지를 유지하고, 포함 시 교체한다. - 수정 화면은 목록 응답의 전체 객체로 채우며 별도 상세 GET은 두지 않는다.
- 파일 내용은 이미지여야 하며 MIME 감지로 검사한다. GIF를 허용한다. 빈 파일과 비이미지는 HTTP 400이다. 현재 서버 설정의 최대 파일/요청 크기는 각각 1024 MB이며, 이는 제품 권장 용량이 아니다. reverse proxy나 배포 설정은 더 낮은 제한을 둘 수 있다.
관리자 목록에는 삭제되지 않은 활성 레코드의 미래 시작·현재 노출·종료된 기간·양쪽 null 상태가 모두 포함되며 기간으로 숨기지 않는다. 소프트 삭제된 행은 기간과 무관하게 제외한다. 같은 placement/id 오름차순을 사용한다.
4. 기존 배너 예약 근거
예약 의미는 기존 콘텐츠 배너 예약 동작을 따른다. 날짜의 외부 API 표현은 위 형식과 경계 규칙을 따른다.
5. 오류와 재시도
서비스가 검증하는 JSON/URI/날짜/타입/placement 오류와 업로드된 빈 파일 또는 비이미지는 HTTP 400이다. 유효한 정수 ID로 조회한 수정·삭제 대상이 없으면 HTTP 404이며, 이들 상태 오류는 표준 Spring Boot 형식 {"timestamp":...,"status":...,"error":...,"path":...}을 사용하고 ApiResponse가 아니다. 인증되지 않은 요청은 HTTP 401과 같은 표준 형식이다. 유효한 비관리자 요청은 현재 권한 정책에 따라 HTTP 200 ApiResponse의 success:false를 반환하며 403을 보장하지 않는다. image/request multipart 파트 누락 및 malformed multipart는 기존 handler에 따라 HTTP 200 success:false다. 프레임워크 바인딩 오류까지 모두 400이라고 가정하지 않는다. 소비자는 모든 응답에서 HTTP 상태와 success를 함께 확인하며 구체적인 오류 코드나 errorProperty 값을 가정하지 않는다.
{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":null}
이 예시는 HTTP 200의 success:false 응답 예시다. 표준 Spring Boot 상태 오류의 body는 위 ApiResponse 형식이 아니다. Idempotency-Key는 제공되지 않는다. POST/PUT/DELETE를 자동 재시도하지 않고 중복 제출을 막는다. 결과를 알 수 없는 timeout 뒤, 특히 POST는 재시도 전 GET으로 상태를 대조한다.
6. 제공 상태와 호환성
관리자 CRUD·소프트 삭제·두 추천 API 통합을 구현하고 최종 전체 2,682개 테스트와 lint·bootJar·독립 리뷰를 통과했다. 실제 HTTP/DB 및 단계별 실행 증거는 plan-task.md에 있다. DDL은 독립 H2에 적용해 검증했지만 native MySQL 및 운영 DB에는 적용하지 않았다. 구현·검증 완료를 운영 배포 완료로 간주하지 않는다.
서버 배포 전에 신규 환경은 create-sub-banner.sql, 기존 테이블은 20261102_add_sub_banner_lang.sql을 적용해 lang VARCHAR(10) NOT NULL DEFAULT 'KO'를 준비해야 한다. 기존 is_active 등 선행 스키마도 필요하다. 이관 파일은 동적 SQL 없는 단일 ALTER TABLE이며 lang 컬럼이 없는 기존 테이블에 한 번만 실행한다. 이미 컬럼이 있거나 신규 CREATE를 적용한 DB에서는 실행하지 않는다. 기존 행은 컬럼 추가 시 KO 기본값을 가지며 별도 UPDATE는 필요하지 않다. KO는 기존 행의 언어를 추론한 결과가 아니므로 운영 담당자가 JA 소재를 수동 분류하고 연결 이벤트 언어와 일치하는지 검토해야 한다. 외부 링크는 원문 그대로이며 자동 언어 분류·링크 파서는 없다. 통합 추천 조회도 새 컬럼을 사용하므로 migration 전에 서버 코드를 배포하지 않는다. SQL의 운영 적용·native MySQL 검증·배포는 이번 작업에서 수행하지 않는다.