Imported from seroak/cv3-broadcast-list (
AGENTS.md). Install upstream withnpx skills add seroak/cv3-broadcast-list. Copyright stays with the author.
CV3 기술과제 — 프로젝트 규칙
라방바 데이터랩(live.ecomm-data.com/assignment) 방송 랭킹 테이블을 재현하는 과제.
이 문서는 이 프로젝트에 고유한 규칙만 담는다. 언어·톤·TDD 절차·커밋 위생 등 일반 작업 규칙은
전역 AGENTS.md를 따른다.
1. 데이터 SSOT (면접 설명 대비 — 반드시 숙지)
API 계약
- lb:
POST {VITE_ECOMM_BASE_URL}/api/ranking/list - hs:
POST {VITE_ECOMM_BASE_URL}/api/ranking/list_hs - 요청 바디(공통):
{"period": number, "cid": number|null, "date": string|null}period:0=실시간(기본) /1=24시간 /2=72시간 /3=1주일 /4=1개월 /5=특정일자 (date지정 시 자동으로 5가 됨)cid: 카테고리 필터.null=전체. 최상위 cid 또는 리프 cid를 그대로 실어 보낸다 (아래 "카테고리 필터" 절 참고)date:"YYMMDD"(6자리, 예:"260715"). 캘린더가 내부적으로 다루는 "YYYY-MM-DD" 값에서toApiDateParam(src/lib/ecomm/format.ts)으로 변환.
- 응답:
{ list: BroadcastItem[], mask: true, ...(labang_cnt|count 등 부가 필드) }—list는 10개보다 많을 수 있음, 화면엔 상위 10개만 표시. 부가 최상위 필드는 Zod가 non-strict라 자동 무시됨(스키마 변경 불필요). - 이전에는 과제 페이지가 쓰는
POST /api/assignment/list({"type":"lb"|"hs"})를 썼으나, 기간·카테고리 필터를 실제로 동작시키기 위해 실사이트 홈(/)이 필터 변경 시 호출하는ranking/list(_hs)로 전면 전환했다. 동등성 실측:period=0, cid=null, date=null조합이assignment/list(type:"lb")와 상위 5개 제목까지 완전히 일치함을 확인했고, 항목(list 원소) 필드 구조도 동일해 기존schema.ts/normalize.ts를 그대로 재사용한다.
카테고리(cid) 필터 — 서버 알고리즘은 검증하지 않음
실사이트 필터 UI는 2단 드롭다운(최상위 분류 → 그 아래 리프)이고, 최상위만 골라도 즉시 적용되며
리프까지 고르면 cid가 리프 id로 덮어써진다. 우리는 서버가 정확히 어떻게 매칭하는지
재현하려 하지 않는다 — 실시간으로 계속 바뀌는 데이터셋 위에서 순차 curl 테스트를 해봤더니
결과가 요청마다 달라져(데이터 자체 변동 때문인지 필터 로직 때문인지) 신뢰성 있게 재현할 수
없었다. 실사이트 프론트가 보내는 것과 동일한 파라미터를 그대로 전달하는 것으로 충분하다고
판단했다 — 서버가 뭘 하든 같은 요청 = 같은 동작. 카테고리 트리는 CATEGORY_TREE
(category-tree.ts, 조회 함수는 categories.ts)를 그대로 재사용(getTopCategories/getLeafCategories).
특정일자 캘린더 (Calendar.tsx) — 실사이트 커스텀 팝업 재현
네이티브 <input type="date"> 대신 실사이트와 동일한 커스텀 캘린더 팝업으로 구현한다(사용자
요청으로 재현 범위를 넓힘). 실측한 규칙:
- 숨김/선택불가 규칙은 "오늘보다 미래인가" 하나로 통일된다 — 이번 달의 아직 안 온 날짜도,
다음 달로 넘어가는 스필오버 날짜도 전부 "미래"이기 때문에 별도 케이스 분기가 필요 없다
(
buildCalendarGrid,calendarGrid.ts가 순수함수로 그리드+플래그를 계산, TDD). - 이전 달로 넘어간 스필오버 날짜(예: 7월 달력의 6월 28~30일)는 회색(
#E0E0E0)으로 표시되지만 선택 가능하다 — "미래"가 아니라 "과거"이기 때문. - 다음 달 이동 화살표는 현재 보고 있는 달이 오늘이 속한 달 이상이면
visibility: hidden으로 감춘다(다음 달로 진입 자체가 불가능). - 날짜를 클릭하면 즉시 선택되고 팝업이 닫힌다 — 별도의 "확인" 버튼 없음(데스크톱 기준).
- "오늘" 버튼은 오늘이 속한 달로 뷰를 이동함과 동시에 오늘 날짜를 즉시 선택+적용한다.
- 트리거 표시 형식은 "YYYY.MM.DD"(
formatCalendarDisplayValue), 팝업 내부 상태는<input type=date>와 동일한 "YYYY-MM-DD" 계약을 유지해BroadcastListPage의 날짜 상태 로직은 그대로 재사용한다(교체된 건 입력 UI뿐). - 아이콘(달력 트리거 아이콘, 좌우 화살표)도 ad_channel과 같은 원칙으로 실사이트 정적 자산을
핫링크한다(
/__modules/Input/calendar_gray.svg,/__modules/Input/arrow_down_gray.svg).
CORS (실험으로 검증됨)
서버는 요청 Origin을 그대로 반사(reflect)해서 허용하는 개방형 정책이다(ranking/list,
ranking/list_hs 둘 다 확인).
curl -i -X OPTIONS https://live.ecomm-data.com/api/ranking/list \
-H 'Origin: https://example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
# → access-control-allow-origin: https://example.com (요청한 오리진 그대로 반사)
어떤 오리진에서 호출해도 통과된다. 그래서 서버 프록시 없이 브라우저에서 직접 fetch한다. (브라우저 자동화 도구로 확인한 결과는 web-security가 꺼져 있어 신뢰 불가 — 위 curl preflight로 서버가 실제로 내려주는 헤더를 직접 확인한 것만 근거로 삼는다.)
인증
로그인/세션 쿠키가 필요 없다. 쿠키 없는 서버 대 서버 curl 요청도 200과 전체 list를 받는다.
자물쇠(🔒)는 접근 제어가 아니라, 비로그인 응답에서 visit_cnt/sales_cnt/sales_amt가
그냥 null로 오는 것뿐이다. 과제 페이지 자신도 로그아웃 상태면 🔒 로그인을 표시한다 —
과제 안내문에도 "자물쇠까지는 구현하지 않아도 된다"고 명시되어 있다.
데이터는 실시간으로 바뀐다
방송이 계속 시작/종료되므로 목록·순서가 분 단위로 변한다. "값이 과제 테이블과 동일해야 한다"는 고정 스냅샷과 일치시키라는 뜻이 아니라, 같은 API 응답을 같은 변환 로직으로 그대로 렌더링하면 같은 시점에 같은 값이 나온다는 뜻으로 해석한다.
2. 필드 매핑 (lb ↔ hs 필드명이 다름 — 정규화 필수)
| 컬럼 | 라이브(lb) | 홈쇼핑(hs) |
|---|---|---|
| 제목 | title |
hsshow_title |
| 분류 | cid(리프 ID) → 이름 매핑 필요 |
cat.cat_name (응답에 내장) |
| 방송시간 | datetime_start "2607211758" (YYMMDDHHmm) |
hsshow_datetime_start "202607211830" (YYYYMMDDHHmm) |
| 조회수 | visit_cnt (비로그인 null) |
visit_cnt (null) |
| 판매량 | sales_cnt (null) |
sales_cnt (null) |
| 매출액 | sales_amt (null) |
sales_amt (null) |
| 상품수 | product_cnt |
item_cnt |
- 방송시간 표시 포맷:
26.07.21 (화) 17:58— 요일 계산 포함. null값은🔒 로그인으로 렌더한다. 이렇게 하면 값이 과제 페이지와 일치한다.
lb 분류명 해석
lb 응답은 분류명 없이 리프 cid만 준다. /api/home/gnb 응답의 cats 객체가 전체 카테고리
트리를 담고 있다: { [cid]: { pid: 부모cid|null, name } }. 최상위는 pid: null인 항목들
(50000000 패션의류 / 50000001 패션잡화 / 50000002 화장품·미용 / 50000003 디지털·가전 /
50000004 가구·인테리어 / 50000005 출산·육아 / 50000006 식품 / 50000007 스포츠·레저 /
50000008 생활·건강 / 50000009 여가·생활편의 / 50000010 면세점). 리프 cid에서 pid를 따라
최상위까지 올라간 name이 화면에 표시되는 분류다(hs의 cat도 항상 최상위 분류를 담아준다).
이 cats 트리는 개발 중 curl로 한 번 추출해 src/lib/ecomm/categories.ts에 정적 상수로
구워넣는다. 런타임에 gnb를 다시 호출하지 않는다 — 이 트리는 분류명 표시(resolveTopCategoryName)
뿐 아니라 필터 바의 카테고리 드롭다운 옵션(getTopCategories/getLeafCategories)에도 재사용된다.
lb 플랫폼 배지 해석
실사이트는 제목 아래 플랫폼 배지(네이버쇼핑LIVE 등)를 표시한다. hs 응답은 platform_name을
내장하지만 lb 응답은 platform_id만 준다. 편성표 페이지(/schedule/lb) 데이터의
platform_id/platform_name 쌍에서 17개 매핑을 실측 추출해
src/lib/ecomm/platforms.ts에 상수로 고정했다. 매핑에 없는 platform_id는 추측하지 않고
배지를 표시하지 않는다(null).
ad_channel 광고 채널 아이콘
일부 항목(lb 다수, hs는 드묾 — 1개월 범위 100건 중 2건 실측)은 ad_channel: string[]을 갖는다
(토스·캐시워크·OK캐쉬백 등 제휴 광고 채널). 실사이트는 플랫폼 배지와 같은 줄에 이어서 값마다
<img src="/__modules/Table/icons/{value}.png">를 렌더한다(20x20px, gap 4px). 아이콘 파일명
규칙이 값 자체와 정확히 일치하는 결정적 관계라 플랫폼 배지와 달리 이름 매핑 테이블이 필요
없고, 미관찰 채널이 나와도 자동으로 대응된다. 이미지는 실사이트 URL을 그대로 핫링크한다
(${VITE_ECOMM_BASE_URL}/__modules/Table/icons/{channel}.png) — 자체 다운로드/호스팅은 미관찰
채널에 대응 못 해 오히려 "미표시 예외 처리"가 다시 필요해지므로 채택하지 않았다. 이 제3자 정적
자산 의존은 데이터 API 자체가 이미 감수하고 있는 리스크의 연장선이다(새 리스크 범주 아님).
ad_channel은 브라우저 응답에서는 생략되지만 서버 간 요청에서는 null로 올 수 있으므로 Zod
스키마에 nullish()로 명시해야 한다 — 선언하지 않으면 non-strict 파싱 과정에서 자동으로 제거되어
정규화 단계까지 도달하지 못한다.
3. 아키텍처 규칙
ranking/list(_hs)는 클라이언트(브라우저)에서 직접 호출한다. 서버 프록시를 두지 않는다.- API base URL은 하드코딩하지 않고
.env의VITE_ECOMM_BASE_URL로 분리한다. - 원본 API 응답은 Zod 스키마로 검증한 뒤에만 사용한다 (
src/lib/ecomm/schema.ts). - lb/hs는 각각 원본 스키마를 갖되, 화면에는 정규화 함수(
normalize.ts)를 거친 단일Broadcast타입만 전달한다. 컴포넌트가 lb/hs 필드명 차이를 알아선 안 된다. - 순수 변환 로직(정규화, 날짜 포맷, 분류 트리 해석)은 React 컴포넌트와 분리된 함수로 작성해 Vitest로 테스트 가능하게 한다.
- 필터 상태(기간/날짜/카테고리)는 상호 배타 규칙을 지킨다: 기간 라디오를 고르면 날짜를 초기화,
날짜를 고르면 기간 라디오 선택을 해제하고 API 호출 시
period=5로 계산한다. 최상위 카테고리를 바꾸면 리프 선택을 초기화한다.
4. 불변식 (구현 시 항상 지킬 것)
- 유형별로 항상 최대 10개까지만 표시한다 (원본
list는 더 많을 수 있음 — 반드시 slice). - 컬럼 순서 고정: (순위) → 방송정보(제목+플랫폼 배지) → 분류 → 방송시간 → 조회수/시청률 → 판매량 → 매출액 → 상품수. 헤더 문구는 과제 문서 표기("조회수/시청률")를 따른다(문서 우선 조항).
- header/footer/sidebar는 구현하지 않는다. 랭킹 섹션(필터 바+테이블)만.
- 스타일은 실사이트 랭킹 섹션에서 실측한 디자인 토큰(Noto Sans KR, 브랜드 옐로 #F5BD20,
순위 #FCA600 등 —
src/index.css상단 변수 참고)을 따른다. 디자인은 평가 대상이 아니므로 픽셀 퍼펙트에 과투자하지 않는다. - 유형(라방/홈쇼핑)·기간·특정일자·카테고리 필터는 모두 실제로 동작한다(
ranking/list(_hs)의period/cid/date파라미터를 그대로 전달). 결과가 0건이면 "조건에 맞는 방송이 없습니다"를 표시한다(실사이트 문구 재현).
5. 커밋 규칙
작업 단위로 커밋을 나눈다 (과제 요구사항 — 하나로 뭉치지 않는다). 지금까지의 단위:
- 1단계(과제 기본 구현): 문서 정의 → 스캐폴딩 → Zod 스키마/클라이언트/정규화·포맷·분류 해석 함수+테스트(TDD) → 토글+테이블 UI → E2E 테스트 → README 마무리
- 2단계(실사이트 스타일 재현): 플랫폼 배지 정규화(TDD) → 순위 컬럼+필터 바 UI → 디자인 토큰 적용 → E2E 갱신 → 문서 갱신
- 3단계(필터 실동작화): ranking API 전환(TDD) → 기간/특정일자/카테고리 UI 연결 → E2E 갱신 → 문서 갱신
사용자가 명시적으로 요청한 경우에만 커밋한다.
6. 검증 규칙
- 순수 변환 함수는 TDD로 작성한다 (RED → GREEN). 완료를 주장하기 전
pnpm test실제 결과를 보여준다 — 매번 그 시점의 실측 통과 수를 표기한다(예시 숫자를 그대로 베끼지 않는다). - 코드 변경을 마무리하기 전
pnpm lint와pnpm typecheck도 실행한다. pnpm dev로 실행한 뒤 라이브/홈쇼핑 토글 전환이 실제로 동작하는지, 값이 과제 페이지와 같은 시점에 일치하는지 최소 1회 확인한다.
7. 이해 가능성 우선
면접에서 이 코드를 직접 설명하고 그 자리에서 일부를 수정하게 된다. 과한 추상화나 불필요한 레이어를 만들지 않는다. 코드는 처음 보는 사람도 바로 읽을 수 있게 쓴다.