diff --git a/docs/research/2026-07-22-801-search-backend-country-locale-contract.md b/docs/research/2026-07-22-801-search-backend-country-locale-contract.md new file mode 100644 index 000000000..65a01b841 --- /dev/null +++ b/docs/research/2026-07-22-801-search-backend-country-locale-contract.md @@ -0,0 +1,122 @@ +--- +title: "검색 백엔드의 country/region·locale 파라미터 계약 — #801 재검증 리서치" +date: 2026-07-22 +status: final +doc: snapshot +essence: aligned +owner: kiyori +tags: + - research + - search + - backend + - i18n + - market + - meilisearch +related_issues: + - "#801 (Rust SearchQuery/EntitySearchQuery country·locale 지원 여부)" + - "#789 (front-end map)" + - "#796 / #799 (D-SEARCH vs D-BACKEND 결정)" +--- + +# 검색 백엔드의 country/region·locale 파라미터 계약 — #801 재검증 + +> **검증 기준 커밋:** `origin/dev` = **`a3d5702b`** (2026-07-22 fetch). 브랜치명만으로는 머지 후 앵커 복원이 불가하므로 커밋을 고정한다 — 이 문서 자신이 2주 만에 앵커가 수십 줄 이동함을 보인다. +> 방법: 리포 소스·마이그레이션·`packages/api-server/openapi.json`(체크인된 계약) 1차 소스만. **라이브 시스템 조회 없음**(prod 크레덴셜 미보유). + +## 결론 + +**검색 계약에는 country/region 축이 전혀 존재하지 않으며, 유일하게 존재하는 `locale`은 필터축이 아니라 응답 표시명 폴백축이다.** posts 검색(`GET /api/v1/search`)의 `SearchQuery`는 `q/category/media_type/context/has_adopted/sort/page/limit` 8개뿐으로 country·locale 모두 미수용(`packages/api-server/src/domains/search/dto.rs:13-45`), entity 검색(`GET /api/v1/search/entities`)의 `EntitySearchQuery.locale`은 Meili 쿼리에 전달되지 않고 히트 수신 **후** `map_entity_hit`의 이름 선택에만 쓰인다(`service.rs:374-380`, `service.rs:410-424`). Meilisearch 색인 쪽도 `ENTITIES_FILTERABLE = ["kind"]` 단독이고 `POSTS_FILTERABLE`에도 country가 없으며(`services/search/index_config.rs:11-17,33`), entities 문서 빌더가 만드는 필드에 country가 아예 포함되지 않는다(`batch/entity_reindex.rs:64-74`). Market 축(`market.rs`)은 검색 핸들러에 **한 번도 배선되어 있지 않다**(`RequestMarket`은 catalog_entities·solutions 핸들러 전용). 국가 데이터 소스는 `public.brands.country_of_origin`(브랜드 설립국, default `'NA'`) 하나뿐이며 그마저 컬럼/메타데이터 이중 접근으로 갈라져 있고 검색 색인에 유입되지 않는다. → **2026-07 baseline은 CONFIRMED**(라인 드리프트만 존재), 결정은 **D-BACKEND**. + +## 근거 표 + +| 주장 | file:line | 확인 | +| --- | --- | --- | +| posts `SearchQuery`에 country/region/locale 필드 없음 (q/category/media_type/context/has_adopted/sort/page/limit뿐) | `packages/api-server/src/domains/search/dto.rs:13-45` | ✅ 확인 (baseline `14-45` → 현재 `13-45`, 필드 구성 동일) | +| `SearchQuery`에 `deny_unknown_fields` 없음 → axum `Query` 추출 시 `locale`/`country` 키가 조용히 **버려짐** | `dto.rs:13`(derive 목록), `domains/search/handlers.rs:42` | ✅ 확인 (파생은 `Deserialize, IntoParams, ToSchema`뿐, serde 기본=미지정 필드 무시) | +| posts 검색의 Meili 필터 구성에 country 분기 없음 (category/media_type/context/has_adopted 4개만) | `domains/search/service.rs:45-60` | ✅ 확인 | +| `EntitySearchQuery.locale`은 "응답 표시명 로케일(ko\|en, 미지정 시 ko→en 폴백)"으로 doc-comment에 명시 | `dto.rs:250-263` | ✅ 확인 (**baseline과 라인 완전 일치**) | +| `locale`이 Meili 필터/쿼리에 전달되지 않음 — `advanced_search`에 넘기는 filters는 `kind_filter(kind)` 결과뿐, sort·facet 인자는 빈 벡터 | `service.rs:410-415`, `service.rs:366-371` | ✅ 확인 | +| `locale`은 히트 수신 후 `pick_localized(name_en, name_ko, locale)` 이름 선택 + `enrich_entity_hits` 표시명에만 사용 | `service.rs:374-380`, `service.rs:422-424` | ✅ 확인 | +| `ENTITIES_FILTERABLE = ["kind"]` 단독 | `packages/api-server/src/services/search/index_config.rs:33` | ✅ 확인 (baseline "11-26 블록" → 현재 33행 단독 상수) | +| `POSTS_FILTERABLE`에 country/market 없음 (category_codes/context/has_adopted_solution/media_type/status) | `index_config.rs:11-17` | ✅ 확인 | +| Meili `localizedAttributes`는 **색인 설정 시점 정적 값**이지 요청별 필터가 아님 (posts=kor/eng, entities=kor/eng/jpn) | `index_config.rs:83-96`, `index_config.rs:154-167` | ✅ 확인 — 형태소 분석용 언어 힌트일 뿐 검색 결과 대상 집합을 좁히지 않음 | +| entities Meili 문서에 country 필드 자체가 없음 (id/entity_id/kind/name_ko/name_en/name_ja/profile_image_url/logo_image_url/aliases) | `packages/api-server/src/batch/entity_reindex.rs:64-74` | ✅ 확인 — 색인 설정을 고쳐도 **색인할 데이터가 없음** | +| 체크인된 OpenAPI 계약: `/api/v1/search`=q,category,media_type,context,has_adopted,sort,page,limit / `/search/entities`=q,type,locale,limit / `/search/similar`=q,entity_type,limit / `/search/recent`=limit → **어느 엔드포인트도 country/market 미수용** | `packages/api-server/openapi.json` (paths `/api/v1/search*`) | ✅ 확인 | +| `country_of_origin` 컬럼은 `public.brands`에 실재 (text NOT NULL DEFAULT 'NA') | `supabase/migrations/20260518152600_entity_enrichment_pipeline.sql:24` | ✅ 확인 (baseline과 동일 라인) | +| 해당 컬럼 의미 = "Country where the brand was founded; NA if unknown" (**설립국 = 공급자 origin**) | `supabase/migrations/20260518152600_entity_enrichment_pipeline.sql:71-72` | ✅ 확인 | +| 리포 전체 마이그레이션 중 country를 언급하는 파일은 이 1건뿐 — artists/groups/posts에 country 컬럼 없음 | `supabase/migrations/` grep `country` | ✅ 확인 (`grep -rln country supabase/migrations/` → 1 파일) | +| SeaORM `brands` 엔티티가 `country_of_origin`을 매핑하지 않음 | `packages/api-server/src/entities/brands.rs:7-35` | ✅ 확인 (id/name_ko/name_en/name/logo_image_url/primary_instagram_account_id/metadata/aliases/created_at/updated_at) | +| ⚠️ 신규: 같은 country 값이 **두 경로로 갈라져 접근**됨 — 관리자 gap 조회는 실제 컬럼(`c.country_of_origin`), 브랜드 상세는 metadata JSON(`b.metadata ->> 'country_of_origin'`) | `packages/api-server/src/domains/admin/catalog_gaps.rs:755,772`, `packages/api-server/src/domains/catalog_entities/service.rs:127-131` | ✅ 확인 — 데이터 준비도 판단 시 핵심 리스크 | +| Market 축 정의: ISO 3166-1 alpha-2 + INTL, `SUPPORTED=[KR,JP,US,INTL]`, `LIVE=[KR]`, fallback=INTL, **locale과 직교**임이 주석에 명시 | `packages/api-server/src/market.rs:1-11` | ✅ 확인 | +| Market 결정 = `?market=` 쿼리 > INTL, `RequestMarket` axum extractor | `market.rs:41-49`, `market.rs:92-116` | ✅ 확인 | +| **`RequestMarket`이 검색 핸들러에 전혀 배선되지 않음** — 사용처는 catalog_entities·solutions 핸들러뿐 | `packages/api-server/src/domains/catalog_entities/handlers.rs:80`, `packages/api-server/src/domains/solutions/handlers.rs:135,163` (그리고 `domains/search/handlers.rs`에는 부재) | ✅ 확인 — market 축의 실제 적용점은 affiliate variant 픽(`market.rs:70-86`) | +| locale 어휘: `SUPPORTED_LOCALES = ["ko","en","ja"]`, `DEFAULT_LOCALE="ko"` — 언어축이며 국가축 아님 | `packages/api-server/src/i18n.rs:8,11` | ✅ 확인 | +| FE(entities): `searchEntities`는 Orval 생성 클라이언트 경유 → mutator가 **모든 요청에 `locale`을 전역 주입** | `packages/web/lib/api/mutator/custom-instance.ts:15-22,37`, `packages/web/lib/hooks/useEntitySearch.ts:41-49`, `packages/web/lib/hooks/useCatalogEntitySearch.ts:25-29` | ✅ 확인 — locale은 실제로 전선에 실려 감(단, 위 근거대로 필터엔 미참여) | +| FE(posts): `useSearch`는 Orval을 **쓰지 않고** `@decoded/shared/api/search`의 수기 `fetch` 사용 → mutator 미경유, locale/country 주입 없음 | `packages/web/lib/hooks/useSearch.ts:12-20,93-106`, `packages/shared/api/search.ts:28-42,74-81` | ✅ 확인 — `buildSearchParams`가 세팅하는 키는 q/category/media_type/context/has_adopted/sort/page/limit 8개뿐 | +| FE에 market 파라미터를 검색에 실어 보내는 코드 없음 (`packages/web/lib/market.ts`는 어휘 정의·통화 매핑 전용) | `packages/web/lib/market.ts:1-30` (파일 전체. 테스트 외 import 0건) | ✅ 확인 | + +## Drift since 2026-07 baseline + +baseline의 **주장은 모두 유효**하며, 변경은 라인 위치와 표현 범위뿐이다. + +1. **`index_config.rs` — 가장 큰 드리프트.** baseline은 "`ENTITIES_FILTERABLE = ["kind"]` 단독, POSTS에도 country 없음 → `index_config.rs:11-26`"이라고 한 블록으로 묶었으나, 현재 트리에서는 상수가 분리·확장되어 `POSTS_FILTERABLE`이 `11-17`(5개 항목), `SOLUTIONS_FILTERABLE`이 `21-28`, `ENTITIES_SEARCHABLE`/`ENTITIES_FILTERABLE`이 `32-33`에 있다. `ENTITIES_FILTERABLE`의 값 자체는 `["kind"]`로 불변. +2. **`dto.rs` `SearchQuery` — 1행 상향 이동.** baseline `14-45` → 현재 `13-45`(구조체 선언이 derive 줄부터 시작). 필드 집합은 완전 동일. +3. **`service.rs` `map_entity_hit` — 소폭 이동.** baseline `374-387` → 현재 함수 시그니처 `374`, 이름 픽 `379-380`, 호출부 `410-424`. 의미 변화 없음. +4. **드리프트 없음(주목할 점):** `EntitySearchQuery`는 `dto.rs:250-263`으로 baseline과 **완전히 동일**하고, 마이그레이션 `…:24`도 동일하다. 이 근-제로 드리프트가 이번 판정을 "drifted"가 아닌 **CONFIRMED**로 만드는 근거다. +5. **baseline에 없던 신규 발견 2건:** (a) 브랜드 상세 조회가 컬럼이 아니라 `metadata` JSON에서 country를 읽는 이중 경로(`catalog_entities/service.rs:127-131` vs `admin/catalog_gaps.rs:755`), (b) FE posts 검색이 Orval 클라이언트를 우회하는 별도 수기 fetch 경로(`packages/shared/api/search.ts`)라서 mutator의 전역 locale 주입 대상이 아님. 둘 다 baseline이 다루지 않은 축이다. + +## 설계 함정 — origin ≠ market ≠ locale + +세 개의 서로 다른 축이 "국가처럼 보이는 문자열"을 공유하기 때문에 혼동되기 쉽다. 코드는 이미 셋을 분리해 두었다. + +| 축 | 의미 | 정본 | 값 | 현재 적용점 | +| --- | --- | --- | --- | --- | +| **origin** | 브랜드가 **설립된** 나라 (공급자 속성) | `public.brands.country_of_origin` (`…entity_enrichment_pipeline.sql:24,71-72`) | ISO alpha-2 추정 + `'NA'` 기본값 | 관리자 catalog gap 화면, 브랜드 상세 응답. **검색·색인에는 전혀 미유입** | +| **market** | **사용자가 구매하는 시장** (커머스/배송 축) | `packages/api-server/src/market.rs:1-11` | `KR/JP/US/INTL`, live=`KR` | affiliate variant 픽(`market.rs:70-86`), catalog_entities·solutions 핸들러. **검색 핸들러엔 미배선** | +| **locale** | 표시 **언어** 축 (market과 직교임이 `market.rs:2`에 명시) | `packages/api-server/src/i18n.rs:8,11` | `ko/en/ja`, 기본 `ko` | 응답 표시명 폴백(`pick_localized`), Meili localizedAttributes 색인 힌트 | + +**함정 요약:** "한국 사용자에게 한국 브랜드를"이라는 요구를 `country_of_origin`으로 구현하면 **설립국 필터**가 되어, KR 시장에 정상 배송되는 해외 브랜드가 사라지고 KR 설립이지만 KR 미배송인 브랜드가 남는다. 사용자 시장 축은 `market`이며, 그 축의 데이터는 브랜드가 아니라 **variant(offer) 레벨**에 붙어 있다(`market.rs:61-66` `VariantOffer{market, affiliate_url}`). 또한 `locale=ko`를 국가 필터로 재해석하는 것도 금지 — `market.rs:2`가 직교성을 명시적으로 선언했고, 현재 `locale`은 결과 집합이 아니라 표시명만 바꾼다(`service.rs:422-424`). + +## 결정 영향 (#796 · #799): D-SEARCH vs D-BACKEND + +주어진 판정 규칙 — *"백엔드 계약이 이미 지원 → D-SEARCH(프론트가 파라미터만 실어 보내면 됨) / 계약에 없음 → D-BACKEND(백엔드 선행 작업 필요)"* — 을 위 증거에 적용하면 결론은 **D-BACKEND**다. + +> ⚠️ **시제 주의: #796·#799는 이미 CLOSED다**(각각 2026-07-09T08:33Z · 10:11Z). 즉 본 문서는 **미결 결정의 입력이 아니라 이미 내려진 결정의 사후 확인**이다 — #801의 *07-09 조사 코멘트*가 그 결정의 입력이었고(#796 해소 코멘트가 "#801로 언블록 후 grilled"라고 적고 있다), 이 07-22 재검증은 그 판단이 현재 트리에서도 유효한지를 확인한 것이다. 아래 4단 선행 작업은 "결정 대기 항목"이 아니라 **확정된 D-BACKEND의 실행 목록**으로 읽을 것. + +country/region 축을 검색에 도입하려면 **프론트에서 파라미터를 추가하는 것만으로는 아무 효과가 없고**, 최소 다음 4단이 백엔드에 선행되어야 한다. + +1. **축 정의 확정** — origin이 아니라 market 축이어야 한다면, `RequestMarket`(`market.rs:92-116`)을 `domains/search/handlers.rs`에 배선(현재 부재). origin 축이라면 `country_of_origin`의 컬럼/metadata 이중화(`catalog_entities/service.rs:127-131` vs `admin/catalog_gaps.rs:755`) 정리가 선행. +2. **DTO 확장** — `SearchQuery`(`dto.rs:13-45`) / `EntitySearchQuery`(`dto.rs:250-263`)에 필드 추가. 현재는 미지정 키가 조용히 버려져 **FE가 보내도 무증상 무시**된다. +3. **색인 문서 확장** — `entity_reindex.rs:64-74`의 문서 빌더에 country 필드 추가 + `ENTITIES_FILTERABLE`(`index_config.rs:33`)에 등재. Meilisearch에서 `filterableAttributes`에 없는 속성으로 필터를 걸면 요청 자체가 거부되므로, 순서는 **문서 필드 → filterable 등록 → 재색인 → 쿼리** 고정. +4. **데이터 적재** — brands 외 artists/groups/posts에는 country 컬럼이 아예 없다(마이그레이션 grep 1건). 사람 중심 검색(아티스트/그룹) 국가 필터는 스키마 신설이 필요하다. + +### 비용 등급이 다른 이웃 항목 — `entity_id` filterable + +country 축과 같은 바구니에 담지 말 것. `entity_id`는 **이미 Meili 문서에 실려 있다**(`packages/api-server/src/batch/entity_reindex.rs:66` — `"entity_id": id.to_string()`) — 빠진 것은 `ENTITIES_FILTERABLE`(`index_config.rs:33` = `["kind"]`) 등재뿐이다. 즉 **설정 1줄 + 재색인**이면 해제되며, 스키마 신설도 데이터 적재도 필요 없다. country(4단계, 스키마+적재 포함)와 티어를 같이 매기면 값싼 언블록이 비싼 항목 뒤에 줄 서게 된다. + +**부수 결론(프론트 관점, #789):** entity 검색 typeahead는 이미 `locale`을 전선에 싣고 있고(`custom-instance.ts:37`) 백엔드가 이를 표시명 폴백으로 처리하므로, **다국어 표시명 관련 작업은 D-SEARCH로 프론트에서 마무리 가능**하다. 반면 posts 검색은 Orval 우회 경로(`packages/shared/api/search.ts`)라 locale조차 실리지 않으므로, 표시명 i18n을 posts 검색까지 넓히려면 그 자체로 별도 FE 배선 작업이 필요하다. + +## 남은 갭 (NOT-IN-CODE — 본 세션 미실행) + +아래는 **소스만으로 확정할 수 없는 런타임/데이터 사실**이다. 본 세션은 prod 크레덴셜이 없고 라이브 접근이 금지되어 **명령만 기록하고 실행하지 않았다.** 실행은 크레덴셜 보유 세션에서 수행할 것. + +1. **라이브 Meilisearch의 실제 `filterableAttributes` 확인** — `index_config.rs`는 "부팅 시 설정하려는 값"이지 "현재 인덱스에 적용된 값"이 아니다. 과거 수동 설정이 남아 있을 가능성 배제 불가. + ``` + # 실행하지 말 것 (기록용) + curl -s -H "Authorization: Bearer $MEILI_MASTER_KEY" "$MEILI_HOST/indexes/entities/settings" + curl -s -H "Authorization: Bearer $MEILI_MASTER_KEY" "$MEILI_HOST/indexes/posts/settings" + ``` +2. **`brands.country_of_origin` 실적재율 확인** — 기본값이 `'NA'`이므로 컬럼 존재 ≠ 데이터 존재. 커버리지가 낮으면 필터 도입 시 결과가 붕괴한다. 컬럼과 metadata 두 경로의 값 불일치도 함께 봐야 한다. + ```sql + -- 실행하지 말 것 (기록용) + SELECT country_of_origin, count(*) FROM public.brands GROUP BY 1 ORDER BY 2 DESC; + SELECT count(*) FILTER (WHERE country_of_origin <> 'NA') AS col_filled, + count(*) FILTER (WHERE metadata ->> 'country_of_origin' IS NOT NULL) AS meta_filled, + count(*) FILTER (WHERE country_of_origin <> 'NA' + AND metadata ->> 'country_of_origin' IS NOT NULL + AND country_of_origin <> metadata ->> 'country_of_origin') AS mismatched, + count(*) AS total + FROM public.brands; + ``` +3. **#796 / #799 본문 대조** — 본 세션은 두 이슈 본문을 읽지 않았다. 위 D-BACKEND 판정은 태스크가 제시한 매핑 규칙에 증거를 적용한 결과이므로, 이슈가 규정한 결정 항목 표현과 1:1로 맞추는 확인이 남아 있다. +4. **`market` 축의 variant 데이터 커버리지** — market 기반 검색 필터를 택할 경우 `VariantOffer`(`market.rs:61-66`) 수준의 market 값이 얼마나 적재됐는지가 실현 가능성을 좌우한다. 본 리서치 범위(검색 계약) 밖이라 미조사. diff --git a/docs/research/2026-07-22-802-style-mood-taxonomy-ssot-reconciliation.md b/docs/research/2026-07-22-802-style-mood-taxonomy-ssot-reconciliation.md new file mode 100644 index 000000000..e6be99f47 --- /dev/null +++ b/docs/research/2026-07-22-802-style-mood-taxonomy-ssot-reconciliation.md @@ -0,0 +1,277 @@ +--- +title: "Style Mood 택소노미 SSOT 실내용 확정 — vault v1 ↔ DB 시드 ↔ 코드 9무드 대조" +date: 2026-07-22 +status: final +doc: snapshot +essence: aligned +owner: kiyori +tags: + - research + - style-dna + - taste-onboarding + - taxonomy + - ssot +related_issues: + - https://github.com/decodedcorp/decoded/issues/802 + - https://github.com/decodedcorp/decoded/issues/789 +--- + +# Style Mood 택소노미 SSOT 실내용 확정 (#802) + +**질문:** 선언된 정본 vault `style-mood-taxonomy.md` v1 (→ DB `style_moods`)의 실제 내용을 코드 9무드와 대조. 라벨·순서·설명·ja 유무 → SSOT 실내용 확정 = D-STYLEDNA 비준 근거. + +**검증 기준 커밋:** `origin/dev` = **`a3d5702b`** (2026-07-22 fetch). 앵커는 그 시점 기준. + +**vault 원문:** (조사 시점 2026-06-10 이후 무변경, `origin/main`과 동일 — 단 vault PR decodedcorp/decoded-docs#18이 머지되면 위 B1·B2가 해소되고 라인 앵커가 이동한다) + +--- + +## 결론 + +**Verdict: CONFIRMED (데이터) / 부분 OVERTURNED (baseline 갭1)** + +1. **SSOT 데이터는 깨끗하다 — 비준 가능.** vault 정본 표 9행과 DB 시드 9행이 **key·sort_order·ko name·en name·brand_examples·ko description 전부 바이트 단위로 일치**한다(프로그램 대조, 불일치 0). 코드 3개 정의 사이트(py/rs/ts)의 KEY 배열도 동일 순서로 일치. +2. **DB 시드는 이중 소스지만 내용은 완전 동일.** `supabase/migrations/…sql`과 `packages/api-server/migration/src/m20260610_000001…rs`가 같은 9행을 각각 INSERT한다. 9행 전 필드 프로그램 대조 결과 **완전 동일**. 둘 다 `ON CONFLICT (key) DO UPDATE` 멱등 upsert라 실행 순서에 관계없이 수렴한다. +3. **baseline 갭1(messages 라벨 drift)은 이미 해소됨 — OVERTURNED.** PR #912 (`6bd50568`, 2026-07-11, "#849")이 `en.json`의 "Casual daily"/"Chic edgy"를 DB 정본 "Daily Casual"/"Edgy Chic"으로 교정하고 `ko.json`을 한국어 정본으로 로컬라이즈했다. 동시에 재발 탐지용 **T10 drift 가드**(`style-moods-drift.test.ts`)가 신설되었다 — 로컬 실행 6/6 통과. ⚠️ **단 이 가드는 자동 실행되지 않는다**: `.github/workflows/*`의 `bun run` 호출은 `check-doc-frontmatter`·`check-schema-drift` 2건뿐이고 vitest를 도는 워크플로가 없으며, pre-push 훅이 위임하는 `packages/web/scripts/pre-push.sh`에도 vitest 단계가 없다(generate:api·eslint·prettier·tsc, build·playwright는 env 옵트인). 즉 현재는 **수동 실행 가드**이고, CI 배선은 후속 필요(가드 파일 자체의 docblock이 "CI에서 drift를 실패시킨다"고 적고 있으나 워크플로 표면에는 근거가 없다). +4. **baseline 갭2(vault 문서 내부 모순)는 그대로 존속 — CONFIRMED.** vault 문서는 2026-06-10 이후 한 번도 수정되지 않았다. status/제목/tag 3중 불일치와 표 순서 ↔ 산문 순서 불일치가 오늘도 그대로다. + **후속(2026-07-22, 동일 배치):** vault PR [decodedcorp/decoded-docs#18](https://github.com/decodedcorp/decoded-docs/pull/18)이 아래 B1·B2를 R1대로 해소했다. **그 PR이 머지되면 본 문서의 vault 라인 앵커는 +4행 이동**하며(`:4→:5`, `:5→:6`, `:8→:9`, `:16-26→:20-30`, `:33-41→:37-45`, `:45-53→:49-57`) B1/B2/R1은 이력으로 읽어야 한다. +5. **따라서 비준 블로커는 "데이터 문제"가 아니라 "문서 위생 문제"다.** 정본 데이터(DB 시드)는 지금 비준해도 안전하다. 다만 그 근거로 인용되는 vault 문서가 스스로를 `draft`/`v0.1`이라 부르고 있어 **clean 비준(문서를 v1 정본으로 인용)은 막힌다**. vault 3줄 수정이면 해제. +6. **ja는 declared-but-unpopulated 이상으로 나쁘다.** 데이터에 ja 없음 + `ja`가 아예 라우팅 로케일이 아님 + 실제 구현 폴백이 선언(`ja→en→ko`)과 다르게 `locale→ko`(en 단계 없음)다. + +--- + +## 9무드 대조 표 + +`sort_order`는 vault 표 순서 = DB `sort_order` 컬럼. 코드 3파일은 순서 있는 배열이므로 인덱스가 곧 순서다. + +| sort_order | key | vault ko / en | DB 시드 ko / en | web messages ko / en | cody_prompts.py | scoring.rs | mood_vectors.rs | styleDna.ts | +|---|-----|---------------|-----------------|----------------------|----|----|----|-----| +| 1 | `street` | 스트릿 / Street | 스트릿 / Street | 스트릿 / Street | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 2 | `casual_daily` | 데일리 캐주얼 / Daily Casual | 데일리 캐주얼 / Daily Casual | 데일리 캐주얼 / Daily Casual | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 3 | `minimal` | 미니멀 / Minimal | 미니멀 / Minimal | 미니멀 / Minimal | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 4 | `chic_edgy` | 시크 엣지 / Edgy Chic | 시크 엣지 / Edgy Chic | 시크 엣지 / Edgy Chic | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 5 | `preppy` | 프레피 / Preppy | 프레피 / Preppy | 프레피 / Preppy | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 6 | `lovely_feminine` | 러블리 페미닌 / Lovely Feminine | 러블리 페미닌 / Lovely Feminine | 러블리 페미닌 / Lovely Feminine | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 7 | `sporty` | 스포티 / Sporty | 스포티 / Sporty | 스포티 / Sporty | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 8 | `classic_elegant` | 클래식 엘레강스 / Classic Elegant | 클래식 엘레강스 / Classic Elegant | 클래식 엘레강스 / Classic Elegant | ✅ | ✅ | →scoring.rs (import) | ✅ | +| 9 | `retro_vintage` | 레트로 빈티지 / Retro Vintage | 레트로 빈티지 / Retro Vintage | 레트로 빈티지 / Retro Vintage | ✅ | ✅ | →scoring.rs (import) | ✅ | + +**불일치 셀 없음.** `cody_prompts.py`(`MOOD_VOCABULARY`) · `scoring.rs`(`MOOD_KEYS`) · `styleDna.ts`(`MOOD_KEYS`)는 각각 **독립 정의**이며, 셋 다 KEY+순서만 보유하고 라벨/설명/ja는 없다(설계상 정상: 표시 라벨은 DB `translations` 소관). + +`mood_vectors.rs`는 **자체 정의를 갖지 않는다** — `scoring.rs`에서 `MOOD_KEYS`를 import한다(`mood_vectors.rs:3-5`). 따라서 셀을 ✅로 표기하지 않았다: 일치는 사후 확인된 우연이 아니라 **컴파일 타임에 보장되는 구조적 동일성**이며, 이를 ✅로 적으면 독립 증거를 하나 과대계상하게 된다. `sort_order` 열은 vault 표 행 순서 = DB `sort_order` 컬럼 = 코드 배열 인덱스+1로, 세 축이 모두 같은 값이다. + +부가 필드 대조(표에 미표시, 전부 일치): + +| 필드 | vault | DB 시드 | 판정 | +|------|-------|---------|------| +| `sort_order` 1..9 | 표 행 순서 | `sort_order` 컬럼 | 9/9 일치 | +| `brand_examples` | 표 "대표 브랜드" 열 | JSONB 배열 | 9/9 일치 (배열 원소·순서까지) | +| ko `description` | "소비자용 설명문" 산문 | `translations.ko.description` | 9/9 **문자 단위 완전 일치** | +| en `description` | 없음 | **없음** (`en`은 `name`만) | 양쪽 부재 → 정합하지만 en 로케일 결손 | +| ja (name/description) | 없음 | **없음** | 데이터 부재 | + +### 서피스 6개(실질 독립 소스 5) — 독립 정의 사이트 구분 + +"9무드가 N개 파일에서 일치"를 과대계상하지 않기 위해 **독립 정의**와 **import 파생**을 구분한다. + +| 서피스 | 성격 | 위치 | +|--------|------|------| +| vault 표 | 인간 정본 | `Project/style-mood-taxonomy.md` (vault) | +| DB 시드 (SQL) | 데이터 정본 | `supabase/migrations/20260610120000_taste_onboarding_tables.sql` | +| DB 시드 (SeaORM) | 데이터 정본 **중복 소스** | `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs` | +| py `MOOD_VOCABULARY` | 독립 정의 | `packages/ai-server/src/services/raw_posts/processors/cody_prompts.py` | +| rs `MOOD_KEYS` | 독립 정의 | `packages/api-server/src/domains/onboarding/scoring.rs` | +| ts `MOOD_KEYS` | 독립 정의 | `packages/web/lib/utils/styleDna.ts` | + +아래는 **독립 증거가 아니라 구조적 보장**(import이므로 어긋날 수 없음) — 일치 근거로 세지 않는다: + +- `mood_vectors.rs`는 `scoring.rs`에서 `MOOD_KEYS`를 import (`mood_vectors.rs:3-5`) +- `cody_describe_parser.py`는 `cody_prompts.py`에서 `MOOD_VOCABULARY`를 import (`cody_describe_parser.py:28`) +- `qa/sweep/content-quality.ts`는 `styleDna.ts`에서 `MOOD_KEYS`를 import (`content-quality.ts:23`) + +즉 baseline이 말한 "코드 3파일"(cody_prompts/scoring/mood_vectors)은 실제로는 **독립 정의 2개 + import 1개**이며, 진짜 세 번째 독립 정의는 프론트 `styleDna.ts`다. + +--- + +## 근거 (주장 | file:line) + +경로는 monorepo 워크트리 기준 repo-relative. vault는 위 GitHub URL 기준 라인. + +| 주장 | 근거 | +|------|------| +| vault 정본 표 = 9행, key/이름/커버리지/브랜드 | vault `style-mood-taxonomy.md:16-26` | +| vault 소비자용 설명문 9개 (ko만) | vault `style-mood-taxonomy.md:33-41` | +| DB 시드 9행 INSERT (translations/brand_examples/sort_order/is_active) | `supabase/migrations/20260610120000_taste_onboarding_tables.sql:103-114` (행 `:104`–`:112`) | +| DB 시드는 멱등 upsert | 같은 파일 `:113-114` `ON CONFLICT (key) DO UPDATE SET translations = EXCLUDED.translations, …` | +| SeaORM 마이그레이션이 동일 9행을 중복 시드 | `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs:109-120` (행 `:110`–`:118`, ON CONFLICT `:119`) | +| ja→en→ko 폴백은 COMMENT로만 선언 | `supabase/migrations/20260610120000_taste_onboarding_tables.sql:11` · 동일 문구 `m20260610_000001_taste_onboarding.rs:23` | +| py 9무드 정의 + "순서·철자 변경 금지" 계약 명시 | `packages/ai-server/src/services/raw_posts/processors/cody_prompts.py:32-46` (주석 `:32-35`, tuple `:36-46`) | +| py 파서가 동일 tuple로 검증/정규화 (single SoT) | `packages/ai-server/src/services/raw_posts/processors/cody_describe_parser.py:28`, `:195-226` | +| py 스키마가 api-server `style_moods`와 동일해야 함을 명시 | `packages/ai-server/src/services/raw_posts/processors/schemas.py:204-212` | +| rs 9무드 정의 | `packages/api-server/src/domains/onboarding/scoring.rs:8-18` | +| rs 배치가 정의를 import (중복 정의 아님) | `packages/api-server/src/batch/mood_vectors.rs:3-5` | +| API 목록 응답은 `sort_order` 오름차순 | `packages/api-server/src/domains/onboarding/service.rs:227-229` | +| ts 9무드 정의 | `packages/web/lib/utils/styleDna.ts:5-15` | +| web messages 무드 라벨 (en, DB와 일치) | `packages/web/messages/en.json:310-320` | +| web messages 무드 라벨 (ko, 한국어 정본) | `packages/web/messages/ko.json:310-320` | +| checked-in DB 시드 스냅샷 (live DB 미조회) | `packages/web/tests/fixtures/style-moods.snapshot.json:1-14` | +| T10 drift 가드 — messages ↔ snapshot ↔ 코드 MOOD_KEYS | `packages/web/tests/style-moods-drift.test.ts:26-58` | +| 홈 rail 라벨은 DB `translations` 를 로케일 인지로 선택 | `packages/web/lib/components/main-renewal/StyleMoods.tsx:96-102` | +| 실제 폴백 구현은 `locale→ko` (en 단계 없음) | `packages/web/lib/components/main-renewal/StyleMoods.tsx:101-102` | +| 지원 로케일은 ko/en 2개 — ja 라우트 없음 | `packages/web/i18n/routing.ts:4` | +| 홈 `MOOD_RAILS`는 의도적 큐레이팅 자유 문자열 | `packages/web/app/[locale]/(shell)/page.tsx:47-60` | +| 위 자유 문자열을 drift 가드에서 의도적으로 제외한다는 기록 | `packages/web/qa/sweep/content-quality.ts:27-37` | + +### 프로그램 대조 결과 (수기 확인 아님) + +vault 표/산문 · supabase SQL 시드 · SeaORM Rust 시드에서 9행을 각각 파싱해 전 필드 동등성을 검사: + +- **supabase SQL 시드 ≡ SeaORM Rust 시드**: 9/9 행 완전 동일 (translations JSON, brand_examples, sort_order, is_active). +- **vault 표 ≡ DB 시드**: 9/9 행에서 `"{ko.name} {en.name}"`이 vault "이름" 셀과 일치, `sort_order`가 vault 표 행 순서와 일치, `brand_examples`가 vault "대표 브랜드" 열과 원소·순서까지 일치. +- **vault 산문 ≡ DB `translations.ko.description`**: 9/9 문자 단위 일치. +- **en `description`**: 9행 모두 `translations.en`에 `name`만 존재. + +### T10 가드가 검사하지 **않는** 범위 (중요) + +`style-moods-drift.test.ts`는 messages ↔ **checked-in snapshot** ↔ 코드 `MOOD_KEYS`를 본다. snapshot은 수기 유지 투영이다 — 파일 자체가 "Manually re-sync when the seed changes"라고 밝힌다(`style-moods.snapshot.json:2`). 따라서 다음 세 축은 **어떤 자동 가드도 없다**(가드 자신의 실행 축까지 포함하면 네 번째 — 위 결론 3 참조: vitest를 도는 워크플로가 없다): + +1. **vault ↔ DB 시드** (= #802가 비준하려는 바로 그 비교) +2. **supabase SQL 시드 ↔ SeaORM Rust 시드** (같은 테이블의 두 시드 소스) +3. **DB 시드 ↔ snapshot 픽스처** (시드를 고치고 픽스처를 안 고치면 가드가 옛 값을 정본으로 착각) + +본 문서의 프로그램 대조는 2026-07-22 시점 1회 스냅샷이며, 지속 보장이 아니다. + +--- + +## Drift since baseline + +baseline(사전 조사, 이슈 코멘트) 대비 현재 트리에서 실제로 바뀐 것. + +| 항목 | baseline | 2026-07-22 현재 | 판정 | +|------|----------|-----------------|------| +| **갭1** messages 라벨 drift (`en.json` "Casual daily"/"Chic edgy") | 존재 | **해소** — DB 정본 "Daily Casual"/"Edgy Chic"으로 교정 (`en.json:310-320`) | **OVERTURNED** | +| **갭1** `ko.json`이 영어 재사용 | 존재 | **해소** — 한국어 정본으로 로컬라이즈 (`ko.json:310-320`) | **OVERTURNED** | +| drift 가드 부재 | 언급 없음 | **신설** — `style-moods-drift.test.ts` 6케이스, 로컬 실행 6/6 통과 | 신규(개선) | +| **갭2** vault 문서 내부 모순 | 존재 | **존속** — vault 파일 2026-06-10 이후 무변경 | **CONFIRMED** | +| ja 데이터 부재 | 존재 | **존속** + 구현 폴백이 선언과 불일치까지 확인 | **CONFIRMED (악화 서술)** | +| 무드 집합/순서/라벨 변경 | — | **변경 없음** — `style_moods`를 건드리는 마이그레이션은 위 2개뿐, 둘 다 `#663`(`c8a0849f`)에서 동시 생성 후 무수정 | 변화 없음 | + +**해소 출처:** `6bd50568` (2026-07-11) — `feat(web): Style DNA 무드 SSOT 화해 + 9무드 분포 탭 + T10 drift 가드 (#849) (#912)`. 커밋 본문이 "DB `public.style_moods` 를 표시 SSOT 로 비준(#793 결정)"이라 명시한다. 즉 **#802가 확정하려던 "DB = 표시 SSOT" 결론은 #793/#849 라인에서 이미 코드로 집행되었고**, #802는 그 집행의 상류 근거(vault 실내용)를 사후 확인하는 위치다. + +**앵커 라인 드리프트(baseline → 현재):** `scoring.rs:8`(baseline `:8` → 현재 `:8`, 배열 원소는 `:9-17`) · `cody_prompts.py:32`(→ tuple은 `:36-46`) · `mood_vectors.rs:3`(→ import `:3-5`) · DB 시드 `:104`(유지) · ja COMMENT `:11`(유지). 실질 이동은 cody_prompts.py뿐. + +**마이그레이션 전수 확인:** `style_moods`를 참조하는 마이그레이션 파일은 `supabase/migrations/20260610120000_taste_onboarding_tables.sql`와 `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs` **2개뿐**이며, 둘 다 커밋 `c8a0849f`(#663)에서 생성된 이후 수정 이력이 없다. 무드 집합·순서·라벨을 바꾼 후속 마이그레이션이나 별도 `ON CONFLICT DO UPDATE` 시드는 **없다**. + +--- + +## 비준 블로커 + +### B1. vault 문서 내부 모순 — 존속 (문서 위생, 데이터 무결성 아님) + +front-matter가 확정을 선언하는데 제목과 tag는 초안을 선언한다. 셋이 같은 파일 8줄 안에 공존한다. + +- front-matter (`:5`): `status: v1 확정 (2026-06-10 kiyori)` +- front-matter (`:4`): `tags: [... , draft]` +- H1 (`:8`): `# Style Mood 택소노미 초안 (v0.1)` + +"v1 확정"과 "초안 (v0.1)"과 `draft`가 동시에 참일 수 없다. DB COMMENT는 이 문서를 **"vault style-mood-taxonomy.md v1"**으로 인용하는데(`…tables.sql:11`), 인용 대상이 스스로를 v0.1 초안이라 부르는 상태다. + +### B2. 표 순서 ↔ 산문 순서 불일치 — 존속 + +같은 문서 안의 두 순서가 `classic_elegant` 위치에서 어긋난다. + +- 표(`:16-26`, = DB `sort_order` 정본): street → casual_daily → minimal → chic_edgy → **preppy → lovely_feminine → sporty → classic_elegant** → retro_vintage +- 산문 "소비자용 설명문"(`:33-41`): street → casual_daily → minimal → chic_edgy → **classic_elegant → preppy → lovely_feminine → sporty** → retro_vintage + +`classic_elegant`가 표에서 8번, 산문에서 5번이다. 나머지 3개(preppy/lovely_feminine/sporty)가 한 칸씩 밀린 형태 — 산문에서 `classic_elegant` 블록만 위로 옮겨진 편집 흔적으로 보인다. **내용은 동일**(설명문 문자열이 DB와 9/9 일치)하므로 데이터 영향은 없고, "결과 화면 노출 순서"를 이 문서에서 읽으려는 독자가 오해할 위험만 남는다. + +### B1/B2가 비준을 막는 방식 + +- **막지 않는 것:** DB `style_moods` 시드를 표시 SSOT로 비준하는 결정. 데이터는 vault와 완전 일치하고 코드 계약과도 일치하므로 지금 비준해도 안전하다. (#793/#849에서 이미 사실상 비준됨) +- **막는 것:** 그 비준의 **근거 문서로 vault v1을 인용하는 행위**. 인용 대상이 `draft`/`v0.1`인 한 "정본 v1에 근거함"이라는 문장이 자기 반박이다. → vault 3줄 수정으로 해제 가능한 위생 문제. + +--- + +## 권고 (비준 전 정리 항목) + +우선순위 순. R1만 하면 clean 비준이 가능하고, 나머지는 후속. + +**R1 — vault 문서 위생 (비준 선결, vault 레포 3~4줄 수정).** +`Project/style-mood-taxonomy.md`에서 (a) H1을 `# Style Mood 택소노미 (v1)`으로, (b) `tags`에서 `draft` 제거, (c) `status`를 `v1 확정` 단일 서술로 정리, (d) 산문 블록의 `classic_elegant` 항목을 표 순서(8번) 위치로 이동하거나 산문 서두에 "표시 순서는 표 기준" 한 줄 명시. **주의: 설명문 문자열은 절대 건드리지 말 것** — DB `translations.ko.description`과 문자 단위로 일치하는 상태이며, 여기서 한 글자라도 바뀌면 즉시 vault↔DB drift가 발생하는데 이를 잡을 자동 가드가 없다. + +**R2 — 라벨 단일화 방향은 이미 완료. 새 권고 아님.** +baseline이 제안한 "messages를 DB `style_moods.translations`에 맞춤"은 PR #912(`6bd50568`)가 이미 집행했다. T10 가드가 재발을 **탐지**할 수는 있으나 자동 실행되지 않으므로(결론 3), "가드가 지키고 있다"고 읽으면 안 된다 — CI 배선은 R4에 포함. **재제안하지 말 것.** 대신 남은 것은 아래 R3. + +**R3 — DB 시드 이중 소스 정리 (기술 부채, 비준 비차단).** +같은 9행이 `supabase/migrations/…sql`과 `m20260610_000001_taste_onboarding.rs`에 각각 하드코딩되어 있다. 현재 내용은 완전 동일하지만, 무드 라벨을 고칠 때 **두 곳 + snapshot 픽스처 = 3곳**을 동시에 고쳐야 하고 이를 강제하는 가드가 없다. 최소 조치: 두 파일 시드 블록에 상호 참조 주석(`⚠️ 다른 시드도 함께 수정할 것`)을 달고, 이상적으로는 시드 소스를 한쪽으로 일원화한다. + +**R4 — vault↔DB 대조 가드 신설 검토 (선택).** +현재 T10은 `messages ↔ snapshot ↔ 코드`만 본다. vault는 별도 레포라 CI에서 직접 대조가 어렵다. 현실적 대안: snapshot 픽스처에 ko `description`까지 포함시키고, DB 시드 SQL을 파싱해 픽스처와 대조하는 테스트를 추가하면 **시드↔픽스처 수기 동기화 부채**가 사라진다. vault↔시드 축은 R1의 "설명문 건드리지 말 것" 규율로만 관리(수용 가능한 잔여 리스크 — vault 문서는 6주간 무변경). + +**R5 — en `description` 결손 결정.** +`translations.en`에 `name`만 있고 `description`이 없다(9/9). en 로케일은 라우팅상 지원 대상이므로(`routing.ts:4`), 결과 화면에서 en 사용자에게 무드 설명을 어떻게 보여줄지 결정 필요: (a) en 설명문 번역 추가, (b) 의도적 미노출로 명시. 현재는 미결 상태로 방치되어 있다. + +--- + +## ja 라벨 상태 — declared-but-unpopulated + 구현 불일치 + +세 층 모두에서 ja가 없거나 선언과 다르다. + +| 층 | 선언 | 실제 | +|----|------|------| +| 스키마 계약 | `translations` 폴백 `ja→en→ko` (`…tables.sql:11`, `m20260610_000001…rs:23`) | — | +| 데이터 | — | `translations`에 `ja` 키 **0/9행**. `ko`+`en`만 존재 | +| 구현 | 위 COMMENT대로면 ja 요청 시 ja → en → ko | `StyleMoods.tsx:101-102` = `translations?.[locale]?.name ?? translations?.ko?.name ?? m.key` — **`locale` → `ko` 2단 폴백, en 중간 단계 없음** | +| 라우팅 | — | `routing.ts:4` = `locales: ["ko", "en"]` — **ja 로케일 자체가 존재하지 않음**. `messages/` 디렉터리도 `en.json`/`ko.json` 2개뿐 | + +결론: ja는 **DB COMMENT 한 줄에만 존재하는 미래 의도**다. 데이터도, 로케일 라우트도, 선언대로의 폴백 구현도 없다. 실질 영향은 지금 0이지만, COMMENT가 존재하지 않는 계약을 선언하고 있어 후속 구현자를 오도할 수 있다. + +**#792(로케일) 종속:** ja 라벨 채우기는 ja 로케일 도입 자체에 선행 종속된다. #802 범위에서 할 수 있는 것은 (a) 현 상태를 declared-but-unpopulated로 기록(본 문서), (b) COMMENT의 `ja→en→ko` 문구를 실제 구현(`locale→ko`)에 맞춰 정정하거나 "ja 도입 시 목표 폴백"임을 명시하는 것뿐이다. 데이터 백필은 #792 이후. + +--- + +## 남은 갭 + +### G1. 홈 `MOOD_RAILS` 라벨 편차 — **by-design, 블로커 아님** + +`packages/web/app/[locale]/(shell)/page.tsx:50-60`은 정본 라벨과 다른 문자열을 쓴다: `casual_daily`→"Casual", `chic_edgy`→"Chic & Edgy", `retro_vintage`→"Y2K & Retro". 또한 9개 전부가 아니라 데이터 있는 무드만 렌더한다. + +이는 drift가 아니라 **기록된 의도적 결정**이다. `content-quality.ts:27-37`이 이를 명시적으로 문서화하고 drift 가드에서 제외한 이유까지 남겨두었다 — 표시 라벨을 정본 라벨과 하드 매칭하면 false drift가 난다는 것. 코드 주석(`page.tsx:47-49`)도 "홈의 다른 하드코딩 영어 섹션명과 일관되게 매력적인 표현으로"라고 의도를 밝힌다. + +**D-STYLEDNA 참고 사항으로만 기록.** 수정 대상으로 올리면 코드베이스의 기존 결정과 충돌한다. 단, "정본 라벨을 쓰는 서피스"(`StyleMoods.tsx`, Style DNA 결과/프로필)와 "큐레이팅 라벨을 쓰는 서피스"(홈 rail)가 공존한다는 사실 자체는 비준 문서에 남겨야 한다 — 나중에 "왜 홈만 다르지"가 재발 질문으로 돌아온다. + +### G2. prod DB 실값 미확인 — NOT-IN-CODE + +본 조사는 **마이그레이션 시드 파일**을 근거로 한다. prod `public.style_moods` 테이블의 실제 행이 시드와 같다는 것은 **검증되지 않았다**. 시드가 `ON CONFLICT (key) DO UPDATE`(`…tables.sql:113`)이므로 마이그레이션이 재실행되었다면 시드값으로 수렴했겠지만, 마이그레이션 이후 수동 UPDATE가 있었다면 감지할 방법이 없다(어떤 CI 가드도 live DB를 조회하지 않음 — `style-moods.snapshot.json:2`, `style-moods-drift.test.ts:12-13`). + +**본 세션에서는 prod 크리덴셜이 없고 psql을 실행하지 않았다.** 비준 담당자가 별도로 1회 스팟체크할 명령(참고용, 미실행): + +```sql +-- prod psql 1회 스팟체크 (본 문서 작성 세션에서는 미실행) +SELECT key, sort_order, is_active, + translations->'ko'->>'name' AS ko_name, + translations->'en'->>'name' AS en_name, + translations ? 'ja' AS has_ja, + translations->'en' ? 'description' AS has_en_desc, + left(translations->'ko'->>'description', 20) AS ko_desc_head +FROM public.style_moods +ORDER BY sort_order; +``` + +기대 결과: 9행, `sort_order` 1..9 조밀, `ko_name`/`en_name`이 위 대조표와 일치, `has_ja` 전부 `false`, `has_en_desc` 전부 `false`. 이와 다르면 시드 이후 out-of-band 변경이 있었다는 뜻이며 비준 근거가 무효화된다. + +### G3. `style_mood_mappings` 미대조 — 범위 밖 + +vault "태그 → 무드 매핑"(`:45-53`)과 DB `style_mood_mappings` 시드(`…tables.sql:119` 이하) 대조는 #802 질문(라벨·순서·설명·ja) 범위 밖이라 수행하지 않았다. 표기 정규화 규칙(`casual chic`→`casual-chic` 등, COMMENT `…tables.sql:19`) 때문에 vault 원문과 시드가 문자 그대로 같지 않으며, 별도 정규화 대조가 필요하다. 스코어링 정확도에 직결되므로 후속 티켓 가치가 있다. + +--- + +## 부록 — 재현 방법 + +본 문서의 프로그램 대조는 3개 소스에서 9행을 파싱해 전 필드 동등성을 검사하는 일회성 스크립트로 수행했다(리포에 커밋하지 않음 — R4에서 정식 테스트로 승격 권고). 검증 절차: + +1. `supabase/migrations/20260610120000_taste_onboarding_tables.sql`와 `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs`에서 `('key', '{json}', '[brands]', N, bool)` 패턴 9행 추출 (SQL `''` 이스케이프 해제 후 JSON 파싱) +2. vault 표(`:16-26`) 행에서 key/이름/커버리지/전속/브랜드 추출, 산문(`:33-41`)에서 ko 라벨→설명문 추출 +3. `"{ko.name} {en.name}"` == vault 이름 셀, `sort_order` == 표 행 순서, `brand_examples` == 브랜드 열, `translations.ko.description` == 산문 문자열을 각각 assert + +T10 가드 실행: `cd packages/web && bunx vitest run tests/style-moods-drift.test.ts` → 2026-07-22 기준 6/6 통과. diff --git a/docs/research/2026-07-22-803-style-dna-write-path.md b/docs/research/2026-07-22-803-style-dna-write-path.md new file mode 100644 index 000000000..3692efac9 --- /dev/null +++ b/docs/research/2026-07-22-803-style-dna-write-path.md @@ -0,0 +1,287 @@ +--- +title: "Style DNA는 온보딩하면 실제로 생기는가 — write 경로 코드 검증 (#803)" +date: 2026-07-22 +status: final +doc: snapshot +essence: aligned +owner: kiyori +tags: + - research + - style-dna + - onboarding + - api-server + - ai-server +issue: https://github.com/decodedcorp/decoded/issues/803 +map: https://github.com/decodedcorp/decoded/issues/789 +related_decisions: + - "#793 — Style DNA 무드 SSOT 화해 (9무드 분포 + drift 가드)" + - "#799 — BE 갭 quick-unblock 티어링 + contract-first" +--- + +# Style DNA는 온보딩하면 실제로 생기는가 — write 경로 코드 검증 (#803) + +**검증 기준 커밋:** `origin/dev` = **`a3d5702b`** (2026-07-22 fetch). 모든 인용은 그 시점 기준 repo-relative `파일:줄`. prod DB/API는 이 세션에서 조회하지 않았다(§7에 명령만 기재). + +> ⚠️ **FE 앵커 한정 드리프트:** 작성 20분 뒤 PR [#985](https://github.com/decodedcorp/decoded/pull/985)가 dev에 머지(`b48a7701`)되어 `style-dna/*` 컴포넌트에 익명 이벤트 호출이 추가됐다 → **§2 hop 3·5의 FE 줄번호가 현재 dev에서 밀렸다**(예: `StyleDnaAnalyzing.tsx:37` → 현재 `:38`). **서버측 결론(§1·§3·§5)은 전혀 영향받지 않는다** — Rust 경로·게이트·배치는 그대로다. FE 줄을 다시 찾을 때는 `git show a3d5702b:`로 대조할 것. + +--- + +## 1. 결론 + +### 온보딩(=`/style-dna` 빌더) 완주 시 **실제로 생기는 것** + +- **persona 절반**은 생긴다 — 단, **하드 게이트 3개를 전부 통과했을 때만**. 게이트를 하나라도 못 넘으면 요청이 4xx/5xx로 실패하고 `users.style_dna`에는 아무것도 안 쓰인다(부분 저장 없음). + - 픽 중 `posts.cody_analysis IS NOT NULL`인 것 ≥ 5 — `packages/api-server/src/domains/users/handlers.rs:168-175` + - ai-server `ExtractStyleDna` gRPC 도달 + 성공 — `packages/api-server/src/domains/users/handlers.rs:180-191` + - ai-server에 `GEMINI_API_KEY` 주입 — 비면 synthesizer 생성자가 `RuntimeError` — `packages/ai-server/src/services/raw_posts/processors/style_dna_synthesizer.py:171-179` +- 통과하면 persona는 **항상** 저장된다. 저장 호출이 `?`로 전파되므로 실패 시 요청 자체가 에러가 된다 — `packages/api-server/src/domains/users/handlers.rs:246` + +### **안 생길 수 있는 것 (조용히)** + +- **mood 절반(`primary_mood` / `mood_scores` / `secondary_moods`)은 best-effort**다. 실패해도 `tracing::info!`만 남기고 삼킨다 — `packages/api-server/src/domains/users/handlers.rs:241-243`. + mood 단계는 `posts.cody_analysis`가 아니라 **미리 계산된 `post_mood_vectors` 행**을 요구한다. 픽 전부에 벡터가 없으면 `BadRequest` → 삼켜짐 — `packages/api-server/src/domains/users/service.rs:922-932`. + → **결과: persona만 있고 mood는 없는 `users.style_dna`가 정상 경로에서 만들어진다.** +- **`onboarding_pick` 이벤트도 같은 단계에서만 쓰인다.** mood 단계가 실패하면 `style_dna_events`에 행이 하나도 안 남는다 — `packages/api-server/src/domains/users/service.rs:938-956`. +- **야간 배치는 이 상태를 절대 치유하지 못한다** (§5 참조). `onboarding_pick`이 없으면 `compute_style_dna`가 `None`이고, 일배치는 애초에 `onboarding_pick` 보유자만 선택한다. + +### 판정 + +> **"온보딩하면 Style DNA가 생긴다"는 전제는 여전히 금지 상태로 유지해야 한다.** +> 정확한 표현은 **"온보딩 완주 시 persona는 조건부로 생기고, mood는 생길 수도 안 생길 수도 있다"**. 어떤 코드 경로도 "온보딩 = DNA 완성"을 보장하지 않는다. + +베이스라인 17앵커 대비: **16 CONFIRMED(줄번호 드리프트만)**, **1 OVERTURNED(마이페이지 404 소비자 — §4)**. + +--- + +## 2. write 경로 플로우 (hop별) + +### 2.1 라이브 진입점 — 임베딩 picker (spec의 "9무드 풀-픽"이 아님) + +| # | hop | file:line | +|---|-----|-----------| +| 1 | `/style-dna` 페이지 (로그인 게이트, `robots: noindex`) | `packages/web/app/[locale]/(shell)/style-dna/page.tsx:20-27` · 게이트 `packages/web/lib/proxy/compose.ts:93-94` | +| 2 | `StyleDnaFlow` step 머신 (`intro → picks → analyzing → result`) | `packages/web/lib/components/style-dna/StyleDnaFlow.tsx:65-75` | +| 3 | `StyleDnaPicker` — 풀 = `useInfinitePosts`(recent) + 픽별 `/posts/similar` 임베딩 추천 누적. **무드 풀(`/onboarding/pool`) 미사용** | `packages/web/lib/components/style-dna/StyleDnaPicker.tsx:65-120` | +| 4 | 게이트: 5장 이상 선택해야 진행 | `packages/web/lib/stores/styleDnaBuilderStore.ts:7,47` | +| 5 | `StyleDnaAnalyzing` mount 1회 → `useBuildMyStyleDna` mutation | `packages/web/lib/components/style-dna/StyleDnaAnalyzing.tsx:37,55-76` | +| 6 | Next 프록시 `POST /api/v1/users/me/style-dna` (Vercel `maxDuration=60`, 내부 abort 55s) | `packages/web/app/api/v1/users/me/style-dna/route.ts:21,49-54` | + +**9무드 풀은 어디로 갔나:** `/api/v1/style-moods` + `/api/v1/onboarding/pool`은 살아 있지만 **홈 `StyleMoods` 섹션의 탭 표시용으로만** 소비된다 — `packages/web/lib/components/main-renewal/StyleMoods.tsx:21-34`, `packages/web/app/[locale]/(shell)/page.tsx:148`. 라우터: `packages/api-server/src/domains/onboarding/handlers.rs:14-40`. +**익명 로컬 계산 `computeLocalDna`는 prod dead code** — 프로덕션 참조는 0건이고 `packages/web/tests/styleDna.test.ts:17`(유닛)과 QA 매트릭스 노트에서만 언급된다 — 정의 `packages/web/lib/utils/styleDna.ts:28`. + +### 2.2 서버 — `POST /api/v1/users/me/style-dna` (한 요청 안에서 persona + mood 둘 다 시도) + +| # | hop | file:line | +|---|-----|-----------| +| 7 | `build_my_style_dna` 진입, `post_ids` **min 5** validate | `packages/api-server/src/domains/users/handlers.rs:160,165` · DTO `packages/api-server/src/domains/users/dto.rs:177-180` | +| 8 | **[게이트 A]** `fetch_cody_analyses` — `SELECT cody_analysis FROM posts WHERE id = ANY($1) AND cody_analysis IS NOT NULL` | `packages/api-server/src/domains/users/service.rs:120-141` | +| 9 | **[게이트 A]** `analyses.len() < 5` → 422 ValidationError | `packages/api-server/src/domains/users/handlers.rs:169-175` | +| 10 | **[게이트 B]** gRPC `ExtractStyleDna(user_id, post_ids, cody_analyses_json)` | 클라이언트 `packages/api-server/src/services/decoded_ai_grpc/client.rs:513-534` · proto `packages/ai-server/src/grpc/proto/inbound/inbound.proto:66,403-415` | +| 11 | ai-server servicer — `CodyAnalysis` 재검증 후 `style_dna_synthesizer().synthesize()` | `packages/ai-server/src/grpc/servicer/metadata_servicer.py:918-961` | +| 12 | **[게이트 C]** `StyleDnaSynthesizer.__init__` — `api_key` 비면 `RuntimeError` / 텍스트-only Gemini 호출 | `packages/ai-server/src/services/raw_posts/processors/style_dna_synthesizer.py:171-179,189-223` · DI `packages/ai-server/src/config/_container.py:386-394` | +| 13 | 응답 `error_message` 또는 빈 `style_dna_json` → `ExternalService` 에러 (요청 실패) | `packages/api-server/src/domains/users/handlers.rs:186-191` | +| 14 | persona jsonb에 api-server 메타 병합 — `source_post_ids` / `gender_affinity` / `generated_at` / `recommended_post_ids`(임베딩 top-12) | `packages/api-server/src/domains/users/handlers.rs:197-231` | +| 15 | **[step 4 — mood, best-effort]** `run_id = Uuid::new_v4()`로 `CommitStyleDnaDto` 조립 → `commit_user_style_dna` | `packages/api-server/src/domains/users/handlers.rs:237-243` | +| 16 | └ `picked_post_ids.len() < 5` → BadRequest (삼켜짐) | `packages/api-server/src/domains/users/service.rs:918-920` | +| 17 | └ **[게이트 D]** `post_mood_vectors` 조회, **전부 없으면** BadRequest (삼켜짐) | `packages/api-server/src/domains/users/service.rs:922-932` | +| 18 | └ **⇒ `style_dna_events` INSERT (`event_type='onboarding_pick'`, run_id, mood_scores=벡터 스냅샷)** — 부분 유니크 인덱스 타깃 `ON CONFLICT DO NOTHING` | `packages/api-server/src/domains/users/service.rs:938-956` | +| 19 | └ `recompute_user_dna` → `None`이면 `InternalError` (삼켜짐) | `packages/api-server/src/domains/users/service.rs:957-961` | +| 20 | └ └ 이벤트 전량 로드 → `compute_style_dna` | `packages/api-server/src/domains/onboarding/service.rs:98-121` | +| 21 | └ └ **⇒ `users.style_dna` mood 키 merge-patch** (`mood_scores`/`primary_mood`/`secondary_moods`/`source`/`computed_at`/`gender_affinity`) | `packages/api-server/src/domains/onboarding/service.rs:151-180` | +| 22 | **[step 5 — persona, hard]** `merge_style_dna(...).await?` — **`?` 전파** | `packages/api-server/src/domains/users/handlers.rs:246` · 구현 `packages/api-server/src/domains/users/service.rs:90-115` | +| 23 | 뱃지 수여 best-effort → 최신 프로필 반환 | `packages/api-server/src/domains/users/handlers.rs:249-258` | + +**핵심:** 21번(mood)과 22번(persona)은 **같은 `users.style_dna` jsonb에 서로의 키를 보존하며 merge**한다. 그래서 21이 실패해도 22는 성공한다 = persona-only DNA. + +### 2.3 `post_mood_vectors`는 누가 채우나 (게이트 D의 상류) + +| # | hop | file:line | +|---|-----|-----------| +| a | 배치 `mood_vectors` — 매일 05:30 + 부팅 후 15초 1회 | `packages/api-server/src/batch/scheduler.rs:176-206` | +| b | `style_mood_mappings`가 비면 **early return**(아무 벡터도 안 씀) | `packages/api-server/src/batch/mood_vectors.rs:78-81` | +| c | 입력 어휘: `cody_analysis.style.mood_scores` → `cody_analysis.style.mood` → `posts.style_tags` 순 폴백 | `packages/api-server/src/batch/mood_vectors.rs:16-38,44-69` | +| d | `post_mood_vectors` upsert (`OnConflict(post_id)` update) | `packages/api-server/src/batch/mood_vectors.rs:129-149` | +| e | 매핑/무드 시드는 마이그레이션에 포함 (`style_moods` 9행 + `style_mood_mappings`) | `supabase/migrations/20260610120000_taste_onboarding_tables.sql:103,119` · SeaORM 미러 `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs:109,122` | + +**⇒ `post_mood_vectors` 커버리지 = 사실상 `posts.cody_analysis`(또는 `style_tags`) 커버리지 × 배치 실행 여부.** 픽한 룩이 최근 업로드라 아직 05:30 배치를 안 탔으면 mood는 조용히 빠진다. + +### 2.4 passive recompute (배치) + +| # | hop | file:line | +|---|-----|-----------| +| f | 일배치 06:00 — `style_dna_events`에서 **`event_type='onboarding_pick'` 보유 user만 distinct 선택** | `packages/api-server/src/batch/style_dna_recompute.rs:15-22` · cron `packages/api-server/src/batch/scheduler.rs:222-233` | +| g | 큐 드레인 10분 주기(5분 오프셋) — `style_dna_recompute_queue` 행만 | `packages/api-server/src/batch/style_dna_recompute.rs:35-67` · cron `packages/api-server/src/batch/scheduler.rs:236-247` | +| h | 둘 다 `recompute_user_dna` → `compute_style_dna` | `packages/api-server/src/domains/onboarding/service.rs:98,120` | +| i | **`onboarding_pick` + `run_id` 있는 이벤트가 0건이면 `None`** (early `?` on Option) | `packages/api-server/src/domains/onboarding/scoring.rs:273-283` | +| j | like/save 백필은 `onboarding_pick`을 **절대 쓰지 않는다** (like/save만) | `packages/api-server/src/batch/style_dna_backfill.rs:12-25` | +| k | 스케줄러는 무조건 spawn (env 플래그 게이트 없음) | `packages/api-server/src/main.rs:147-153` | + +--- + +## 3. 조건·게이트 표 + +| # | 조건 | 위반 시 결과 | file:line | +|---|------|--------------|-----------| +| G0 | FE: 픽 ≥ 5 | Analyze 버튼 비활성 | `packages/web/lib/stores/styleDnaBuilderStore.ts:7,47` | +| G0' | `/style-dna` 로그인 필수 | 로그인으로 리다이렉트(redirect= 보존) | `packages/web/lib/proxy/compose.ts:93-94` | +| G1 | `post_ids.len() >= 5` | **422** ValidationErrors (`AppError::ValidationErrors` → `UNPROCESSABLE_ENTITY`, `packages/api-server/src/error.rs:81-84`) | `packages/api-server/src/domains/users/dto.rs:177-180` | +| **G2** | **`cody_analysis` 보유 픽 ≥ 5** | **422, DNA 전량 미생성** | `packages/api-server/src/domains/users/handlers.rs:169-175` | +| **G3** | **ai-server 도달 + `GEMINI_API_KEY` 세팅** | **502/5xx, DNA 전량 미생성** | `packages/api-server/src/domains/users/handlers.rs:180-191` · `packages/ai-server/src/services/raw_posts/processors/style_dna_synthesizer.py:175-178` | +| G3' | 합성 60s 내 완료 | Next 프록시 55s abort → 502 JSON | `packages/web/app/api/v1/users/me/style-dna/route.ts:21,52` | +| **G4** | **픽 중 `post_mood_vectors` 보유 ≥ 1** | **mood 전량 누락 — 에러 삼킴, 요청은 200** | `packages/api-server/src/domains/users/service.rs:929-932` ← 삼킴 `handlers.rs:241-243` | +| G5 | `style_mood_mappings` 시드 존재 | `post_mood_vectors` 0행 → G4 상시 위반 | `packages/api-server/src/batch/mood_vectors.rs:78-81` (시드는 마이그레이션에 있음: `supabase/migrations/20260610120000_taste_onboarding_tables.sql:119`) | +| G6 | `mood_vectors` 배치가 픽 대상 post를 이미 처리 | 최신 룩 픽 시 mood 누락 | `packages/api-server/src/batch/scheduler.rs:176-206` | +| G7 | persona 저장 | `?` 전파 → 요청 실패(200 안 줌) | `packages/api-server/src/domains/users/handlers.rs:246` | +| G8 | passive recompute: `onboarding_pick` 이벤트 존재 | `None` — DNA 생성/치유 **불가** | `packages/api-server/src/domains/onboarding/scoring.rs:279-283` | +| G9 | 일배치 대상 선정: `onboarding_pick` 보유 user | 미보유자는 스캔조차 안 됨 | `packages/api-server/src/batch/style_dna_recompute.rs:15-22` | +| G10 | `GET /users/me/style-dna`: `primary_mood` 존재 | 404 NotFound (persona-only도 404) | `packages/api-server/src/domains/users/service.rs:976-982` | + +--- + +## 4. Drift since baseline (앵커별 판정) + +| 베이스라인 앵커 | 판정 | 현재 위치 / 비고 | +|---|---|---| +| persona 항상 저장 (step5 `?` 전파) | **CONFIRMED** | `users/handlers.rs:246` (baseline `handlers.rs:202` → +44) | +| mood 절반 best-effort, 에러 삼킴 | **CONFIRMED** | `users/handlers.rs:241-243` | +| 라이브 온보딩 = 임베딩 `StyleDnaPicker`(/style-dna), spec의 9무드 풀-픽 아님 | **CONFIRMED** | `StyleDnaPicker.tsx:65-120` — 풀은 `useInfinitePosts` + `/posts/similar` | +| 9무드 풀은 홈 `StyleMoods` 표시용으로만 소비 | **CONFIRMED** | `StyleMoods.tsx:21-34`, `app/[locale]/(shell)/page.tsx:148` | +| `computeLocalDna` 익명경로 prod dead | **CONFIRMED** | `lib/utils/styleDna.ts:28` — 프로덕션 참조 0 (테스트/QA 노트만) | +| MIN_PICKS = 5 | **CONFIRMED** | `styleDnaBuilderStore.ts:7` (baseline `:7` 그대로) | +| write는 양쪽 (`style_dna_events` + `users.style_dna`) | **CONFIRMED** | `users/service.rs:938-956` / `onboarding/service.rs:151-180` + `users/service.rs:90-115` | +| `onboarding_pick` 유일 prod writer = build step4 | **CONFIRMED** | `users/service.rs:938-956` (baseline `service.rs:997` → −55). §4.1 전수조사 참조 | +| `post_mood_vectors` 없으면 0행 | **CONFIRMED** | `users/service.rs:922-932` | +| passive recompute는 `onboarding_pick` 없으면 None | **CONFIRMED** | `onboarding/scoring.rs:279-283` (baseline `scoring.rs:203` → +76) | +| 일배치 06:00 + 10분 드레인 | **CONFIRMED** | `scheduler.rs:222-247`, `style_dna_recompute.rs:15,35` | +| build hard 의존: `cody_analysis` ≥5, 부족 시 422 | **CONFIRMED** | `users/handlers.rs:169-175` (baseline `handlers.rs:141` → +28) | +| `GEMINI_API_KEY` 없으면 build 전량 실패 | **CONFIRMED** | `style_dna_synthesizer.py:175-178`; env 선언 `packages/ai-server/src/config/_environment.py:66`, 템플릿 `.env.backend.example:148` | +| `get_user_style_dna`는 `primary_mood` 없으면 404 | **CONFIRMED (엔드포인트 사실)** | `users/service.rs:976-982`, 핸들러 `users/handlers.rs:568-574` (baseline `:1039` → −60) | +| **404 → 마이페이지 카드 404 + 온보딩 재유도** | **⚠️ OVERTURNED (소비자 드리프트)** | 아래 §4.2 | +| build step 4의 `run_id`는 요청마다 새로 생성 | **CONFIRMED** | `users/handlers.rs:237-240` | +| like/save 백필은 DNA를 만들지 않음 | **CONFIRMED** | `style_dna_backfill.rs:12-25` + `scoring.rs:272` 주석 | + +### 4.1 `onboarding_pick` 방출 전수조사 (질문 2 — 정밀 검증) + +Rust/Python/TS/SQL 전체에서 문자열 `onboarding_pick` 검색 결과, **행을 INSERT하는 코드는 단 한 곳**: + +- **쓰기:** `packages/api-server/src/domains/users/service.rs:938-956` (호출자는 `packages/api-server/src/domains/users/handlers.rs:241` 하나뿐 — `commit_user_style_dna` 참조 전수: `handlers.rs:241` + 테스트) +- **읽기/필터만:** `packages/api-server/src/domains/onboarding/scoring.rs:237,281,287` · `packages/api-server/src/domains/onboarding/service.rs:127,142` · `packages/api-server/src/batch/style_dna_recompute.rs:18` +- **스키마/인덱스:** `supabase/migrations/20260610120000_taste_onboarding_tables.sql:44,50` · `supabase/migrations/20260611000000_style_dna_pick_unique.sql:6-8` · `supabase/migrations/20260610120200_style_dna_onboarding_pick_idempotent.sql:3-5` · `packages/api-server/migration/src/m20260610_000001_taste_onboarding.rs:56` · `packages/api-server/migration/src/m20260611_000001_style_dna_pick_unique.rs:17-19` +- **동명이인 주의:** `packages/web/lib/stores/behaviorStore.ts:32,153` 및 `packages/api-server/src/domains/events/handlers.rs:19`의 **`onboarding_picks_done`은 별개의 애널리틱스 이벤트**로, `style_dna_events`와 무관하다. + +**⇒ 결론: `onboarding_pick`은 build step 4 외 어디서도 방출되지 않는다. 별도 "온보딩 커밋" 엔드포인트는 라우터에 존재하지 않는다** (`packages/api-server/src/domains/users/handlers.rs:580-586`은 `/me/style-dna`의 POST/GET/PATCH만 노출). + +### 4.2 OVERTURNED: 404의 사용자 영향 + +`GET /api/v1/users/me/style-dna`의 404 자체는 코드 사실로 **확인**된다(`users/service.rs:976-982`). 그러나 **웹에서 이 엔드포인트를 호출하는 화면이 없다.** 마이페이지 카드는 `/users/me` 페이로드의 원시 `users.style_dna` jsonb를 prop으로 받는다: + +- `packages/web/app/[locale]/(shell)/profile/ProfileClient.tsx:198` → `` +- `packages/web/lib/components/profile/ProfileStyleDnaPanel.tsx:22-25` — 렌더 판정은 **persona 기준**(`dna.persona || dna.dominant_styles?.length`), `primary_mood` 무관 +- `packages/web/lib/components/style-dna/StyleDnaAnalyzing.tsx:60-66` — 결과 화면도 빌드 **응답**의 `style_dna`를 그대로 씀 +- `packages/web/lib/components/style-dna/StyleDnaFlow.tsx:40-51` — `?view=result`도 `useMe()`의 `style_dna` hydrate + +**⇒ 404는 CONFIRMED-but-LATENT.** persona-only DNA로도 마이페이지 Style DNA 탭은 정상 렌더된다. 실제로 아픈 곳은 §5의 두 지점이다. + +> 주: `packages/web/lib/api/generated/`는 gitignored(`.gitignore:62-64`)라 워크트리에 없다 — "generated에 훅이 없다"는 근거로 쓰지 않았고, 위 소비자 인용(positive evidence)만으로 판정했다. + +--- + +## 5. 실패 모드 — mood best-effort 삼킴이 실제로 어디서 아픈가 + +``` +픽 5+ (전부 cody_analysis 보유, 그러나 post_mood_vectors 미보유) + → G2 통과, G3 통과 → persona 합성 성공 + → step4 commit_user_style_dna → vectors.is_empty() → BadRequest + → handlers.rs:241 이 삼킴 (tracing::info! 만) + → style_dna_events 0행, users.style_dna 에 primary_mood 없음 + → 요청은 200 OK, 결과 화면은 persona 를 정상 표시 (유저는 "성공"으로 인식) +``` + +이후 사용자에게 보이는 증상: + +1. **온보딩 프롬프트가 계속 뜬다.** `packages/web/lib/components/onboarding/TasteOnboardingPrompt.tsx:26-28`은 `style_dna.primary_mood` 유무로 "DNA 보유"를 판단한다 → persona-only 유저는 영원히 미보유 취급(세션 dismiss만 가능). +2. **홈 `StyleMoods`가 개인화되지 않는다.** `packages/web/lib/components/profile/StyleDNACard.tsx:36-40`의 `parseStyleDnaV1`은 `primary_mood` **와** `mood_scores`를 둘 다 요구 → `StyleMoods.tsx:34`에서 `null` → 기본 탭(street). +3. **피드 DNA 재랭킹이 무증상 no-op이 된다.** 트렌딩 피드는 유저 `mood_scores`와 포스트 `post_mood_vectors`를 **둘 다** 요구하고(`packages/api-server/src/domains/feed/service.rs:212-224`), `blend_with_dna`(`:22-33`)는 둘 중 하나라도 없으면 `_ => base`로 **원점수를 그대로 반환**한다. 로그도 플래그도 없다 → mood 없는 유저에게 피드는 조용히 **순수 트렌딩 순서**로 돌아간다. ⚠️ "개인화 피드가 죽는다"는 과장 — 함수 doc-comment 자신이 "정렬 보정, 완전 개인화 아님"이라 밝히고, 실제 가중치 `feed_dna_mix` 값은 미확인이다. 즉 **손실 크기는 미측정, 손실이 조용하다는 사실만 확정**이다. +4. **마이페이지 404는 발생하지 않는다** (§4.2 — 소비자 없음). 다만 `GET /me/style-dna`를 나중에 배선하면 즉시 표면화된다. + +### 치유 불가 (가장 중요한 함의) + +- `compute_style_dna`는 `onboarding_pick`이 없으면 `None` — `packages/api-server/src/domains/onboarding/scoring.rs:279-283` +- 일배치는 `onboarding_pick` **보유자만** 선택 — `packages/api-server/src/batch/style_dna_recompute.rs:15-22` +- like/save 백필은 `onboarding_pick`을 만들지 않고, like/save만으로는 설계상 `None` — `packages/api-server/src/batch/style_dna_backfill.rs:12-25`, `packages/api-server/src/domains/onboarding/scoring.rs:270-272` + +**⇒ mood 단계가 한 번 실패한 유저는 어떤 배치로도 복구되지 않는다. `post_mood_vectors`를 나중에 백필해도 그 유저는 "재-온보딩"하거나 `PATCH /api/v1/users/me/style-dna`(또는 동등한 직접 merge 잡)로 mood 키를 직접 써 넣기 전까지 persona-only다.** + +> **세 번째 writer 주의(§2.2 보완).** `users.style_dna`에 쓰는 지점은 둘이 아니라 **셋**이다 — 위 두 경로 외에 `update_style_dna`(`packages/api-server/src/domains/users/service.rs:80`)가 있고, 이는 caller가 준 jsonb로 **전체 교체**한다(`UpdateStyleDnaDto`에 `#[validate]` 없음 → `packages/api-server/src/domains/users/dto.rs:169-171`, 핸들러 `handlers.rs:131-139`, `PATCH /api/v1/users/me/style-dna`). JWT의 `user.id`로 스코프된 자기 쓰기라 교차 사용자 문제는 아니지만, **`onboarding_pick` 없이 `primary_mood`를 세울 수 있어** 위 실패 모드를 전부 무력화한다. 현재 웹에는 이 PATCH를 호출하는 코드가 없어 latent이지만, **#799의 복구 설계에서 "소급 `onboarding_pick` 방출 잡"만이 유일한 선택지가 아니라는 뜻**이다 — 직접 계산·merge가 더 싼 대안일 수 있다. + +--- + +## 6. 결정 영향 (#793 · #799) + +### 6-1. mood 단계 hard-fail 승격 여부 (#793 계열) + +- **현행(삼킴)의 비용:** 조용한 데이터 손실 + 영구 미치유(§5). 관측 가능한 건 `tracing::info!` 로그뿐이라 "몇 명이 mood 없이 나갔는지" 계측이 없다. +- **hard-fail 승격의 비용:** `post_mood_vectors` 커버리지가 낮은 현 상태에서 승격하면 **온보딩 완주율이 곧바로 떨어진다**(persona는 되는데 전체 요청이 422가 됨). 즉 **G4를 hard로 올리는 건 커버리지 백필 이후에만 안전**하다. +- **권고 순서:** + 1. (즉시·무해) 삼킴을 `tracing::warn!` + 메트릭으로 승격해 실적을 계측 — `packages/api-server/src/domains/users/handlers.rs:241-243` + 2. 두 항으로 나뉜다 — 비용 등급이 다르다: + - **2a (즉시·저비용):** mood 누락 시 빌드 응답에 플래그를 실어 FE가 정직한 zero-state를 그리게 함. 서버 변경이 응답 필드 1개라 티어링 대상 아님. + - **2b (BE 계약 추가 → #799 티어 대상):** picker 풀을 `post_mood_vectors` 보유 룩으로 제한. ⚠️ `GET /posts?mood=`는 대체재가 **아니다** — 그건 `cody_analysis->'style'->'mood'` containment 필터라(`packages/api-server/src/domains/posts/service.rs:1710-1716`) `post_mood_vectors` 행 존재를 보장하지 않는다. 새 필터/엔드포인트가 필요하다. + 3. (백필 이후) G4 hard-fail 승격 검토 +- **부수 사실:** `packages/web/lib/config/feature-flags.ts:30`의 `REDESIGN_FLAGS.styleDna: false`("users.style_dna aggregation (not populated)")는 `packages/web/lib/components/profile/ProfileRightRail.tsx:72` 한 곳만 게이트하고, 마이페이지 탭/홈 개인화는 플래그를 우회해 `style_dna`를 직접 읽는다 — **플래그와 실동작 불일치**(QA 매트릭스도 동일 지적: `packages/web/qa/matrix/matrix.ts:165,546`). #793 정리 시 같이 화해시킬 것. + +### 6-2. cody / vector 백필의 선행조건화 (#799 티어링) + +- `post_mood_vectors` 백필은 **필요조건이지 충분조건이 아니다.** 기존 persona-only 유저는 `onboarding_pick` 이벤트가 없어 배치로 복구되지 않는다(§5). +- 따라서 #799 Tier 작업은 **두 개로 쪼개야** 한다: + - **T-a (데이터):** `posts.cody_analysis` 커버리지 확대 → `mood_vectors` 배치 재실행 → `post_mood_vectors` 확보. 이게 G2·G4의 공통 상류다. + - **T-b (마이그레이션):** persona-only 유저에 대해 저장된 `style_dna.source_post_ids`(`packages/api-server/src/domains/users/handlers.rs:216-219`에서 항상 기록됨)를 씨앗으로 `onboarding_pick`을 **소급 방출**하는 일회성 잡. T-a만 하고 T-b를 빼면 기존 유저는 그대로 남는다. +- 로드맵 문구는 "Style DNA 백엔드 있음"이 아니라 **"persona 파이프라인 있음 / mood 파이프라인은 데이터 커버리지 대기"**로 표기해야 한다. + +--- + +## 7. 남은 갭 (NOT-IN-CODE — 코드로는 답할 수 없음) + +아래는 **prod DB 실측이 필요한 항목**이다. 이 세션에서는 실행하지 않았다(자격증명 없음 / 실행 금지). 담당자가 별도 세션에서 확인할 것. + +**Q1. persona vs mood 격차 — 조용한 손실의 실제 규모** + +```sql +SELECT count(*) FILTER (WHERE style_dna IS NOT NULL) AS has_any_dna, + count(*) FILTER (WHERE style_dna->>'persona' IS NOT NULL) AS has_persona, + count(*) FILTER (WHERE style_dna->>'primary_mood' IS NOT NULL) AS has_mood, + count(*) FILTER (WHERE style_dna->>'persona' IS NOT NULL + AND style_dna->>'primary_mood' IS NULL) AS persona_only +FROM public.users; +``` + +**Q2. `onboarding_pick` 실적 — 이벤트가 실제로 쌓이는가** + +```sql +SELECT event_type, count(*) AS rows, count(DISTINCT user_id) AS users, max(created_at) AS last_seen +FROM public.style_dna_events GROUP BY event_type ORDER BY rows DESC; +``` + +**Q3. `post_mood_vectors` 커버리지 (G4/G6의 상류)** + +```sql +SELECT (SELECT count(*) FROM public.post_mood_vectors) AS mood_vectors, + (SELECT count(*) FROM public.posts WHERE cody_analysis IS NOT NULL) AS posts_with_cody, + (SELECT count(*) FROM public.posts) AS posts_total, + (SELECT count(*) FROM public.style_mood_mappings) AS mappings, + (SELECT count(*) FROM public.style_moods WHERE is_active) AS active_moods; +-- source 분포 (cody_analysis 기반 vs style_tags 폴백) +SELECT source, count(*) FROM public.post_mood_vectors GROUP BY source; +``` + +**Q4. 환경 변수 (파일 열람 금지 — 콘솔에서 존재 여부만 확인)** +ai-server 런타임에 `GEMINI_API_KEY`가 주입돼 있는지. 선언 위치: `packages/ai-server/src/config/_environment.py:66`(기본값 `""`), 템플릿 `.env.backend.example:148`. 비어 있으면 `style_dna_synthesizer.py:175-178`에서 컨테이너 해석 시점에 `RuntimeError` → **모든 빌드 요청이 5xx**. + +**Q5. 배치 실행 증거** +api-server 로그에서 `Starting mood vectors batch job` / `Style DNA recompute batch completed` / `No style_mood_mappings — skipping` 출현 여부 (`packages/api-server/src/batch/mood_vectors.rs:73,80`, `packages/api-server/src/batch/style_dna_recompute.rs:11,30`). + +--- + +## 8. 한 줄 요약 + +**온보딩 완주 ≠ Style DNA 생성.** persona는 3개 하드 게이트(cody_analysis ≥5 · ai-server 도달 · GEMINI_API_KEY)를 다 넘겨야 생기고, mood는 `post_mood_vectors` 유무에 따라 조용히 빠지며 한 번 빠지면 어떤 배치로도 복구되지 않는다. 로드맵/스펙에서 "온보딩하면 Style DNA가 생긴다"는 전제는 **계속 금지**하고, 대신 "persona 조건부 / mood 데이터 커버리지 대기"로 표기한다. diff --git a/docs/research/2026-07-22-806-vton-prod-token-strategy.md b/docs/research/2026-07-22-806-vton-prod-token-strategy.md new file mode 100644 index 000000000..7b8cef697 --- /dev/null +++ b/docs/research/2026-07-22-806-vton-prod-token-strategy.md @@ -0,0 +1,50 @@ +--- +title: "VTON prod 토큰 전략 재검증 (#806) — 요약 스텁" +date: 2026-07-22 +status: final +doc: snapshot +essence: aligned +owner: kiyori +tags: + - research + - vton + - stub +related_issues: + - "#806" + - "#789" +--- + +# VTON prod 토큰 전략 재검증 (#806) — 요약 스텁 + +> **본문은 팀 vault에 있다:** https://github.com/decodedcorp/decoded-docs/blob/main/Project/research/2026-07-22-vton-prod-token-strategy.md +> 이 스텁은 공개 레포용 요약이다. 환경변수 실측값, 배포 환경 구성, 오설정 시나리오 등 운영 세부는 vault 문서에만 있다. +> +> **검증 기준 커밋:** `origin/dev` = `a3d5702b`, `origin/main` = `a7783813` (2026-07-22 fetch). + +## 배경 + +이슈 #806은 원래 이렇게 물었다 — 웹의 서버리스 라우트가 Vertex AI VTON 모델을 직접 호출하는데, 그 호출에 필요한 단기 액세스 토큰을 prod가 어떻게 조달하는가. 당시 코드에는 로컬 개발 환경을 전제한 토큰 조달 경로밖에 없어 서버리스에서 실패가 예상됐다. + +## 결론 + +**원 질문은 더 이상 성립하지 않는다(moot).** + +- 문제의 전제였던 웹 서버리스 생성 라우트(`packages/web/app/api/v1/vton/route.ts`)는 **삭제됐다** — PR #988(2026-07-16 dev 머지). `origin/dev` 와 `origin/main` 모두 존재하지 않는다. +- 생성 경로는 Vercel 밖으로 이동했다: 웹은 `app/api/v1/[...path]/route.ts` 의 catch-all 프록시일 뿐이고, 게이트·큐 진입은 api-server(`packages/api-server/src/domains/vton/mod.rs`), 실제 생성은 ai-server의 상주 워커가 맡는다(비동기 job + 폴링). +- 기본 엔진은 `gpt-image-2` 다(`packages/ai-server/src/config/_environment.py`). 이 경로는 **GCP를 전혀 사용하지 않는다.** +- Vertex 엔진은 ai-server 안의 **비기본 선택지**로만 잔존하며(`packages/ai-server/src/managers/llm/adapters/vton/vertex.py`), 서버리스가 아닌 상주 프로세스에서 표준 라이브러리 기반으로 토큰을 발급·갱신한다. + +즉 원 블로커의 두 축(서버리스 실행 환경 제약 · 토큰 발급 코드 부재)이 구조적으로 모두 제거됐다. GCP 토큰 문제는 "비기본 엔진이 선택됐을 때"에 한정된 조건부 이슈로 축소됐다. + +⚠️ **단 "선택"이 반드시 명시적인 것은 아니다.** 엔진 선택 env에 **빈 값**이 주입되면 설정 기본값(`gpt-image-2`)이 아니라 Vertex로 폴백되는 경로가 코드에 있다(`packages/ai-server/src/managers/llm/adapters/vton/factory.py`). 운영자는 "빈 값 = 기본값"으로 읽기 쉬우므로, 아래 "남은 갭"의 배포 환경 엔진 설정 확인은 형식적 절차가 아니라 **실제로 필요한 점검**이다. 분기별 결과(요란한 실패 vs 조용한 구엔진 동작)는 vault 문서 참조. + +## 부수 확인 + +- **dev == main**: VTON·GCP 관련 파일에 두 브랜치 간 diff가 없다. 후속 PR #1023/#1024/#1025 는 이 조사 시점에 미머지(OPEN). +- **PR #620**(서비스 계정 기반 토큰 발급 도입)은 2026-07-09 dev 머지됨. 다만 그때 도입된 웹 측 모듈은 #988 이후 **소비자가 없다** — 정리 대상. +- **저장 경로**(`POST /api/v1/tries`)는 api-server의 VTON 도메인으로 이동했고, 클라이언트에서는 여전히 생성 성공 이후에만 호출된다(`packages/web/lib/hooks/useVtonTryOn.ts`). 저장 자체는 생성 엔진과 무관하다. +- 웹 쪽 설정 예시 파일에 구 Vertex 시절 안내가 남아 있어 정리가 필요하다. + +## 남은 갭 + +코드만으로는 "prod가 실제로 동작한다"를 확정할 수 없다. 배포 환경의 엔진 설정 확인과 라이브 스모크 1회가 남아 있다. 이번 조사는 read-only 범위로, 프로덕션 호출이나 환경 조회를 수행하지 않았다. 상세 절차는 vault 문서 참조. diff --git a/docs/research/2026-07-22-894-instrumentation-code-truthmap.md b/docs/research/2026-07-22-894-instrumentation-code-truthmap.md new file mode 100644 index 000000000..e08e7cdd3 --- /dev/null +++ b/docs/research/2026-07-22-894-instrumentation-code-truthmap.md @@ -0,0 +1,227 @@ +--- +title: "계측 서피스 코드 truth map — Meta Pixel · GA4 · 행동 이벤트 · 가입 (#894)" +date: 2026-07-22 +status: superseded (a3d5702b 스냅샷) +doc: snapshot +essence: partial +owner: kiyori +updated: 2026-07-22 +tags: + - research + - analytics + - attribution + - meta-pixel + - ga4 + - instrumentation +related_issues: + - https://github.com/decodedcorp/decoded/issues/894 + - https://github.com/decodedcorp/decoded/issues/893 + - https://github.com/decodedcorp/decoded/issues/970 + - https://github.com/decodedcorp/decoded/pull/985 + - https://github.com/decodedcorp/decoded/pull/1046 +--- + +# 계측 서피스 코드 truth map (#894) + +> **범위 경계:** 이 문서는 **레포 코드가 무엇을 하는가**만 다룬다. 운영 수치(Cloudflare/GA4/Meta 대시보드 값, prod DB 카운트)는 이 레포에 기록하지 않는다 — 레포가 PUBLIC이기 때문이며, 라이브 수치와 해석은 팀 vault 자산(`Project/research/2026-07-11-analytics-truth-map.md`)에 있다. +> +> **검증 기준 커밋:** `origin/dev` = `a3d5702b`, `origin/main` = `a7783813` (2026-07-22 fetch 기준). `main`은 머지 커밋 `6dec171b`(PR #1052)로 `dev`를 포함하므로 **main ⊇ dev** — 아래 모든 코드 사실이 두 브랜치에서 동일하게 성립한다. +> +> **방법:** repo 소스 grep/read + `git`/`gh` 출력만 사용. 이 세션은 prod 크리덴셜이 없어 GA4·Cloudflare·Meta·prod DB를 조회하지 않았다. + +> ⚠️ **SUPERSEDED — 이 문서는 `a3d5702b` 시점 스냅샷이다.** 작성 20분 뒤 PR [#985](https://github.com/decodedcorp/decoded/pull/985)가 머지(`b48a7701`, 2026-07-22T10:01:18Z)되어 아래 여러 결론이 **뒤집혔다.** 현재 `origin/dev`에는 다음이 존재한다: +> +> - `packages/web/lib/analytics/attribution.ts` — UTM 5필드 + `fbclid`/`gclid` first-touch 캡처(쿠키 `decoded_attr`, 90일) +> - `packages/web/lib/analytics/meta-pixel-events.ts` — Meta 표준 이벤트 레이어(`fbq("track", …)`) +> - `packages/web/lib/analytics/first-core-action.ts` +> - `trackAnonEvent` **프로덕션 호출부 4곳** — `StyleDnaIntro.tsx:247`(`onboarding_start`) · `StyleDnaPicker.tsx:185` · `StyleDnaAnalyzing.tsx:71`(`onboarding_result_view`) · `loginRequiredStore.ts:36`(`soft_wall_hit`) +> +> 따라서 **결론 3·4, §1의 UTM·`trackAnonEvent`·Meta 표준 전환 이벤트 행, §2의 #985 상태(OPEN·CONFLICTING → MERGED), §6의 해당 행은 더 이상 성립하지 않는다.** 또한 `b48a7701`은 **dev 전용이고 main에는 없으므로**(`git cat-file -e origin/main:packages/web/lib/analytics/attribution.ts` → 부재) 위 "main ⊇ dev"와 `dev vs main` 열의 "동일" 표기도 해당 행에서는 깨졌다. +> +> 특히 **§6 item 6은 전제만 참이고 결론이 거짓이다** — `softwall/helpers.ts`는 여전히 dead이지만 `soft_wall_hit`은 이제 `loginRequiredStore.ts:36`에서 발화한다("헬퍼 미배선 → 발화 지점 없음" 추론이 무효). 반대로 **`signup_complete`·`dna_committed` 자체 이벤트 emit 0은 dev에서도 여전히 유효**하다. +> +> 스냅샷은 의도적으로 보존한다(원 판정을 지우지 않는다) — 아래 본문은 `a3d5702b` 기준으로 읽을 것. + +## 결론 + +1. **"Meta Pixel 부재" (2026-07-16 baseline)는 뒤집혔다.** PR #1046(cocoyoon, 2026-07-19 dev 머지, 머지 커밋 `fbf47d54`)가 `packages/web/lib/analytics/meta-pixel.tsx`를 추가하고 `packages/web/app/layout.tsx:15,108`에 마운트했다. dev와 main 양쪽에 동일하게 존재한다(`git diff origin/main origin/dev` 해당 경로 = 빈 diff). +2. **다만 범위는 최소다.** 로드되는 것은 base 스니펫 + `PageView`(초기 로드) + SPA 라우트 변경 시 `PageView` 재발화뿐이다. `Lead`/`CompleteRegistration`/`Search`/`ViewContent` 같은 **표준 전환 이벤트는 코드에 없다.** 즉 Meta는 방문만 알고 전환을 모른다 → 전환 최적화·정확한 CPA는 여전히 불가. +3. **어트리뷰션 축은 그대로 비어 있다.** `utm_*`·`fbclid`·`gclid` 캡처/보존 코드가 레포 전체 0건. 광고 클릭 → 방문 → 가입을 자체 데이터로 조인할 수 없다. +4. **익명 퍼널은 여전히 transport만 존재하고 발화가 0이다.** `trackAnonEvent`는 정의만 있고 **프로덕션 호출부가 0건**(테스트·주석 제외). soft wall 헬퍼도 `proxy.ts`를 포함해 어디에서도 import되지 않는다. +5. **로그인 게이트 유지.** `useTrackEvent`의 `if (!user) return`이 그대로다 → 익명 획득 퍼널(=광고 타깃 오디언스)은 자체 이벤트 스트림에 원천적으로 안 잡힌다. +6. 따라서 첫 광고의 canonical 지표는 **하나의 숫자로 합치지 말고 소스별 계약으로 분리**해야 한다(§4). 특히 Cloudflare는 사람 수 KPI에서 제외하고 edge 이상탐지 전용으로 둔다. + +## 1. 계측 서피스 인벤토리 + +상태: **연결됨** = 코드가 실제로 발화/수집 · **스캐폴딩** = 경로·타입·엔드포인트는 있으나 호출부 0 · **부재** = 코드 자체 없음. + +| 서피스 | 상태 | file:line | dev vs main | +|---|---|---|---| +| GA4 전역 태그 (`@next/third-parties`) | 연결됨 (env-gated) | `packages/web/lib/analytics/google-analytics.tsx:4-7`, `packages/web/app/layout.tsx:14,107` | 동일 | +| GA4 커스텀 이벤트 / `sendGAEvent` / key event | **부재** | `sendGAEvent`·`gtag(` grep 0건 | 동일 | +| Vercel Web Analytics | 연결됨 (무조건) | `packages/web/app/layout.tsx:16,109` | 동일 | +| **Meta Pixel base + `PageView`** | **연결됨 (env-gated)** | `packages/web/lib/analytics/meta-pixel.tsx:7,45-78`, `packages/web/app/layout.tsx:15,108` | **동일 (둘 다 존재)** | +| Meta Pixel SPA 라우트 `PageView` | 연결됨 | `packages/web/lib/analytics/meta-pixel.tsx:23-37` | 동일 | +| Meta 표준 전환 이벤트 (`Lead`/`CompleteRegistration`/`Search`/`ViewContent`) | **부재** | `fbq(` 호출은 `meta-pixel.tsx:33`(PageView)과 인라인 스니펫 `:59-60`뿐 | 동일 | +| Meta Pixel consent 게이트 | **부재** | `meta-pixel.tsx:46`이 `PIXEL_ID` 유무만 검사; 레포에 analytics consent 배너 없음 | 동일 | +| UTM / `fbclid` / `gclid` 캡처·보존 | **부재** | `utm_source|utm_medium|utm_campaign|fbclid|gclid|ttclid` grep = `packages/**` 0건 | 동일 | +| 인증 사용자 이벤트 큐 + sendBeacon | 연결됨 | `packages/web/lib/stores/behaviorStore.ts:11-35,130-146` | 동일 | +| 로그인 게이트 (익명 no-op) | 연결됨(=차단자) | `packages/web/lib/hooks/useTrackEvent.ts:16-20` (`if (!user) return`) | 동일 | +| 인증 이벤트 ingest 프록시 | 연결됨 | `packages/web/app/api/v1/events/route.ts:20-33` (세션 없으면 401) | 동일 | +| api-server 인증 ingest (`user_id` 서버 주입) | 연결됨 | `packages/api-server/src/domains/events/handlers.rs:41-48` | 동일 | +| 익명 ingest 프록시 | **스캐폴딩** | `packages/web/app/api/v1/events/anon/route.ts:18-35` | 동일 | +| api-server 익명 화이트리스트 (4종) | **스캐폴딩** | `packages/api-server/src/domains/events/handlers.rs:16-21` | 동일 | +| `trackAnonEvent` (익명 진입점) | **스캐폴딩 / 호출부 0** | 정의 `packages/web/lib/stores/behaviorStore.ts:158-171`; 호출처 = 테스트 `packages/web/tests/behaviorStore-anon.test.ts:12`와 라우트 주석뿐 | 동일 | +| `soft_wall_hit` 발화 | **부재** | soft wall 헬퍼 `packages/web/lib/softwall/helpers.ts`가 헬퍼+테스트 외 어디에서도 import 안 됨 (`packages/web/proxy.ts` 포함) | 동일 | +| `signup_complete` 발화 | **부재** | 타입 유니온 `behaviorStore.ts:34`에만 존재. `packages/web/lib/components/auth/redesign/SignupForm.tsx`에 track 호출 0 | 동일 | +| `onboarding_start`/`_picks_done`/`_result_view` 발화 | **부재** | 타입 `behaviorStore.ts:31-33` + 화이트리스트 `:151-154`만 | 동일 | +| `dna_committed` 발화 | **부재** | 타입 `behaviorStore.ts:35`만 | 동일 | +| admin DAU 집계 | 연결됨(의미 주의) | `packages/api-server/src/domains/admin/dashboard.rs:495-497,511` | 동일 | +| robots 크롤러 정책 | 연결됨 | `packages/web/app/robots.ts:14-24` (`/api/`, `/admin/`, `/login`, `/request/` disallow) | 동일 | + +### 실제 발화가 확인되는 자체 이벤트(전부 로그인 게이트 뒤) + +| 이벤트 | call site | +|---|---| +| `post_click` | `packages/web/lib/components/explore/ExploreCardCell.tsx:44` | +| `search_query` | `packages/web/lib/components/search/SearchInput.tsx:88,110` | +| `dwell_time` | `packages/web/lib/hooks/useTrackDwellTime.ts:32` | +| `scroll_depth` | `packages/web/lib/hooks/useTrackScrollDepth.ts:29` | +| `affiliate_click` | `packages/web/lib/hooks/useAffiliateClick.ts:24` | +| `vton_showcase_cta_click` | `packages/web/lib/components/home/HeroDecodeBand.tsx:151`, `packages/web/lib/components/home/VtonShowcaseSection.tsx:47` | +| `vton_tries_exhausted` | `packages/web/lib/hooks/useVtonTryOn.ts:217` | +| `vton_upgrade_click` | `packages/web/lib/components/vton/VtonTriesExhaustedSheet.tsx:102` | +| vton share/download 계열 | `packages/web/lib/hooks/useVtonTryOn.ts:167` (동적 `eventType`) | + +> **07-11 vault 기록 대비 변화:** 홈 쇼케이스/VTON 계열 4종이 추가됐다. 단 전부 `useTrackEvent` 경유라 **비로그인에서는 발화하지 않는다** — `behaviorStore.ts:24-27`과 `HeroDecodeBand.tsx:148-149` 주석이 이 공백을 코드 안에서 스스로 명시한다(쇼케이스는 정확히 비로그인 대상 표면인데 로그인 유저 것만 남는다). +> +> `vton_demo_play`는 타입 유니온(`behaviorStore.ts:29`)에만 있고 호출부 0건이다. + +## 2. #1046 이후 무엇이 실제로 바뀌었나 (Meta Pixel의 실체) + +**PR #1046** — "feat(web): Meta 픽셀 추가 — 광고 전환 측정 (env-gated)", author cocoyoon, base `dev`, head `feat/meta-pixel`, merged `2026-07-19T22:13:41Z`, 머지 커밋 `fbf47d54`. 변경 파일 3개: `packages/web/lib/analytics/meta-pixel.tsx`(신규 +78), `packages/web/app/layout.tsx`(+2), `packages/web/.env.local.example`(+3). + +확인된 동작(`packages/web/lib/analytics/meta-pixel.tsx`): + +- **env 게이트:** `const PIXEL_ID = process.env.NEXT_PUBLIC_META_PIXEL_ID`(:7), `if (!PIXEL_ID) return null`(:46). env 미설정 환경에서는 완전 no-op — `google-analytics.tsx`와 동일 패턴. +- **로드 방식:** `next/script` `strategy="afterInteractive"`로 Meta 표준 스니펫 인라인(:50-61) → `connect.facebook.net/en_US/fbevents.js`, `fbq('init', PIXEL_ID)`, `fbq('track','PageView')`. +- **noscript 폴백:** `facebook.com/tr?...&ev=PageView&noscript=1` 1×1 이미지(:62-72). +- **SPA 라우트 변경:** `MetaPixelPageView`가 `usePathname`+`useSearchParams` 의존으로 `fbq("track","PageView")` 재발화, 초기 실행은 `isFirstRun` ref로 스킵해 **초기 로드 중복 카운트를 방지**(:23-37). `useSearchParams` de-opt은 ``로 격리(:73-75). +- **발화하는 이벤트 = `PageView` 단 하나.** 표준 전환 이벤트 매핑 없음. +- **consent 게이트 없음.** `PIXEL_ID`가 설정되는 순간 방문자 동의 여부와 무관하게 로드된다. 레포에 analytics/cookie consent 배너 코드가 없다(검색된 `consent`는 전부 `lib/decode/publishConsent` — 무관한 발행 동의). +- **env 문서화:** `packages/web/.env.local.example:46-47` `# NEXT_PUBLIC_META_PIXEL_ID=...` (주석 처리된 예시). + +**브랜치 상태:** `git log origin/main -- packages/web/lib/analytics/meta-pixel.tsx` → `fbf47d54` 1건. dev와 main 파일 diff 없음. 즉 **프로덕션 브랜치에 코드가 올라가 있다.** + +**⚠️ 코드 존재 ≠ 프로덕션 발화.** `NEXT_PUBLIC_META_PIXEL_ID`가 prod 환경에 실제로 주입돼 있는지는 이 세션에서 확인 불가(§6 라이브 확인 항목). + +### #970 / #985와의 관계 + +- **#970**(계측 배선 이슈)의 (a) 항목 중 "base pixel + PageView" **부분만** #1046이 충족했다. (a)의 표준 이벤트 매핑, (b) UTM/click-id 캡처 및 가입 시 서버측 스탬핑, (c) 익명 퍼널 4종 클라 배선, (d) `trackAnonEvent` 살리기는 **전부 미착수 상태 그대로**다. +- **PR #985**(`feat/970-meta-pixel-fe`, kiyori)는 위 나머지를 구현한 FE PR이지만 **OPEN이며 `mergeable: CONFLICTING` / `mergeStateStatus: DIRTY`**(2026-07-22 조회). 즉 **#985의 어떤 코드도 dev/main 트리에 없다** — #985 본문에 적힌 `attribution.ts`·`first-core-action.ts`·표준 이벤트·`trackAnonEvent` 배선을 현재 트리의 사실로 인용하면 안 된다. +- 충돌 원인은 두 PR이 같은 레인을 건드리기 때문이다: 둘 다 `packages/web/app/layout.tsx`와 `packages/web/lib/analytics/meta-pixel*`를 수정하며, #985는 `meta-pixel.ts` + `meta-pixel-script.tsx`로 분리한 반면 머지된 것은 단일 `meta-pixel.tsx`다. → **#985는 머지된 로더 위로 rebase/reconcile이 필요**하다. + +## 3. admin DAU가 실제로 세는 것 + +`packages/api-server/src/domains/admin/dashboard.rs:495-497`은 기간 내 `public.user_events`에서 일자별 `COUNT(DISTINCT user_id)`를 뽑아 `dau`로 노출한다(:511). 익명 row는 `user_id`가 NULL이라 제외되고, `dwell_time`·`scroll_depth` 같은 **수동 이벤트 1건만으로도 카운트된다.** + +→ 정확한 이름은 **"그날 자체 이벤트가 1건 이상 저장된 로그인 계정 수"**다. 등록자 수도, GA4 Active Users도, 코어 액션을 수행한 활성 사용자도 아니다. 대외 보고에 `DAU`라는 이름으로 쓰지 않는다. + +## 4. 퍼널 단계별 canonical 지표 권고 (소스 계약) + +한 단계당 소스는 **하나만** 둔다. 서로 다른 시스템의 숫자를 합산하거나 "일치해야 한다"고 기대하지 않는다. + +| 퍼널 단계 | canonical source | 정의 | 금지 | +|---|---|---|---| +| 광고 노출·도달 | **Meta Ads Manager** | impressions / reach를 각각 별도 유지 | reach를 방문자 수로 환산 금지 | +| 광고 클릭 | **Meta Ads Manager** | outbound click / link click + CTR | 클릭 = 방문으로 등치 금지 | +| 브라우저 랜딩 | **GA4 + 고정 UTM** | prod 호스트명 한정, 고정 UTM 스킴 범위의 sessions/users. engaged session 병기 | Cloudflare 수치와 대조해 "맞다/틀리다" 판정 금지 | +| edge 이상탐지 | **Cloudflare (진단 전용)** | host/path/content-type/UA별 requests·visitor 추세 | **사람 수 KPI로 보고 금지** — Unique Visitors 단독 인용 금지 | +| 인플랫폼 전환 신호 | **Meta Pixel** | 현재는 `PageView`만 신뢰 가능 | 전환(가입/코어액션)은 코드가 없으므로 **Meta 대시보드에서 읽지 말 것** | +| 가입 | **`public.users`** | 기간 내 생성 계정 수 − 팀·테스트 계정 제외 규칙 적용 | 유입 UTM 조인 시도 금지(현재 저장 안 함) | +| 활성(activation) | **`public.user_events` 계정 디듑 코어 액션** | 계정별 **최초 성공한 코어 액션**(첫 `search_query` 또는 첫 `post_click`) distinct 계정 수. `dwell_time`·`scroll_depth` 등 수동 이벤트 제외 | admin `dau` 그대로 쓰지 않기 | + +### "실사용자"는 한 단어로 두지 않는다 + +- **유효 광고 방문자** = 고정 UTM을 가진 GA4 engaged user/session +- **가입 사용자** = 내부/테스트 제외 후 `public.users` 신규 계정 +- **핵심 활성 사용자** = 가입 계정 중 코어 액션 1회 이상 성공한 distinct 계정 + +외부(파트너·투자자)에 숫자를 낼 때는 항상 `정의 · 기간 · timezone · 소스 · 제외 규칙`을 같이 붙인다. + +## 5. 신뢰도·오염원 (코드로 확인된 것) + +| 오염원 | 코드 근거 | 영향 | +|---|---|---| +| **익명 트래픽 자체 미포착** | `useTrackEvent.ts:18` 로그인 게이트 | 광고로 들어온 **비로그인 방문자의 행동이 자체 DB에 0건** → 획득 퍼널을 자체 데이터로 재구성 불가 | +| **어트리뷰션 단절** | UTM/click-id 캡처 0건 | 가입자 ↔ 유입 광고 조인 불가. 채널별 CPA는 Meta 인플랫폼 추정치에만 의존 | +| **전환 신호 부재** | `fbq` 표준 이벤트 0건 | Meta 최적화 알고리즘이 `PageView`만 학습 → 전환 최적화 캠페인 불가 | +| **PageView 이중 카운트 위험(방어됨)** | `meta-pixel.tsx:26-32` `isFirstRun` 스킵 | 초기 로드 중복은 코드로 방지. 단 `useSearchParams` 의존이라 **쿼리스트링만 바뀌는 내비게이션도 PageView 1건**을 만든다 → 필터/페이지네이션이 pageview를 부풀릴 수 있음 | +| **탭 단위 세션 분리** | `behaviorStore.ts` `sessionStorage` 기반 session id | 한 사람이 여러 탭 → session_id 분리. `session_id` 기준 집계는 사람 수가 아님 | +| **fire-and-forget 손실** | `behaviorStore.ts:130-146` 타이머/`visibilitychange`/`pagehide` flush, `events/anon/route.ts:32-34` catch 후 `ok:true` | 이벤트 유실이 조용히 발생 → 자체 스트림은 **하한**으로만 해석 | +| **수동 이벤트가 활성으로 오인** | `dashboard.rs:496` | 스크롤만 해도 DAU 카운트 | +| **봇/크롤러** | `robots.ts:14-24`는 `/api/`,`/admin/`,`/login`,`/request/`만 disallow; `softwall/helpers.ts:22-38` 봇 UA 허용목록은 **미배선(soft wall 자체가 호출되지 않음)** | edge/서버 레이어에 자체 봇 필터가 사실상 없음 → Cloudflare 수치에 크롤러·스캐너가 섞임. GA4/Pixel은 JS 실행 기반이라 상대적으로 덜 섞이지만 0은 아님 | +| **내부 호출·프리뷰 트래픽** | 팀/테스트 계정 제외 규칙이 코드에 없음. GA4 internal traffic filter는 코드 밖 설정 | prod 호스트명 한정 + 팀 계정 제외를 **분석 단계에서 수동 적용**해야 함 | +| **consent 미게이트** | `meta-pixel.tsx:46` — `PIXEL_ID`만 검사 | EU 대상 서빙 시 동의 없이 픽셀 로드. 데이터 신뢰도가 아니라 **컴플라이언스 리스크** | + +## 6. 배선 필요 목록 (#970 스코프와의 관계) + +| # | 항목 | #970 매핑 | 현재 상태 | +|---|---|---|---| +| 1 | Meta 표준 전환 이벤트 (`Lead`/`CompleteRegistration`/`Search`/`ViewContent`) | (a) 잔여 | 미착수 (#1046은 PageView까지만) | +| 2 | UTM 5필드 + `fbclid`/`gclid` first-touch 캡처·보존 | (b) FE | 코드 0건 | +| 3 | 가입 시점 click-id/UTM **서버측 계정 스탬핑** | (b) backend seam | 코드 0건 (이메일 확인 홉에서 클라 스토리지 유실 가능 → 서버 스탬프 필요) | +| 4 | 익명 퍼널 4종 클라 호출부 배선 (`soft_wall_hit`, `onboarding_*`) | (c) FE | 서버 인프라 존재, 호출부 0 | +| 5 | `trackAnonEvent` dead 해소 | (d) FE | 호출부 0 | +| 6 | soft wall 게이트 자체 배선 (`softwall/helpers.ts` 미사용) | (c) 전제 | 헬퍼만 존재, `proxy.ts` 미연결 → `soft_wall_hit` 발화 지점이 애초에 없음 | +| 7 | 코어 액션 activation 정의를 쿼리로 고정 | §4 | admin `dau`가 대용으로 쓰이는 중 | +| 8 | analytics consent 게이트 | #970 범위 밖 | 코드 0건 — EU 서빙 시 env 주입 **전에** 필요 | +| 9 | PR #985 reconcile (머지된 `meta-pixel.tsx` 위로 rebase) | 1·2·4·5 실행 경로 | CONFLICTING/DIRTY | + +## 7. 남은 갭 — NOT IN CODE (라이브 확인 필요, 이 세션 범위 밖) + +코드만으로 답할 수 없는 항목. 수치·결과는 vault 자산에 기록한다. + +- [ ] prod에 `NEXT_PUBLIC_META_PIXEL_ID`가 실제 주입돼 있는가 → 픽셀이 **정말 발화 중인지** 여부. (Meta Events Manager Test Events 또는 Pixel Helper로 확인) +- [ ] prod에 `NEXT_PUBLIC_GA_MEASUREMENT_ID`가 주입돼 있는가. 미설정이면 GA4는 렌더 자체가 안 된다(`google-analytics.tsx:5-6`). +- [ ] GA4 Enhanced Measurement / internal traffic filter / bot 제외 / key event 설정의 실제 운영 상태. +- [ ] Cloudflare 수치의 출처(Zone Analytics vs Web Analytics), 기간·timezone·호스트·샘플링. +- [ ] Meta 픽셀 중복 설치 여부(GTM 등 코드 밖 경로로 별도 삽입된 픽셀이 있으면 PageView 이중 카운트). +- [ ] EU 대상 광고 서빙 계획 유무 → consent 게이트 필요성 판단(제품/법무 결정). +- [ ] 팀·테스트 계정 목록 확정(가입 수 제외 규칙의 입력값). + +## 8. 근거 + +### 검증 커밋 + +`origin/dev` `a3d5702b` · `origin/main` `a7783813` (main은 `6dec171b`/PR #1052로 dev 포함). 이전 조사 기준선은 vault 자산의 `40d199da`. + +### 코드 (repo-relative) + +- `packages/web/lib/analytics/meta-pixel.tsx` — Meta Pixel 로더 (#1046 신규) +- `packages/web/lib/analytics/google-analytics.tsx` — GA4 env-gated 래퍼 +- `packages/web/app/layout.tsx:14-16,107-109` — GA4 · Meta Pixel · Vercel Analytics 마운트 +- `packages/web/.env.local.example:43-47` — `NEXT_PUBLIC_GA_MEASUREMENT_ID` · `NEXT_PUBLIC_META_PIXEL_ID` +- `packages/web/lib/hooks/useTrackEvent.ts:16-20` — 로그인 게이트 +- `packages/web/lib/stores/behaviorStore.ts` — 이벤트 타입 유니온·큐·flush·`ANON_EVENT_TYPES`·`trackAnonEvent` +- `packages/web/app/api/v1/events/route.ts` / `packages/web/app/api/v1/events/anon/route.ts` — ingest 프록시 +- `packages/api-server/src/domains/events/handlers.rs:16-21,41-48` — 익명 화이트리스트 · 인증 ingest +- `packages/api-server/src/domains/admin/dashboard.rs:495-497,511` — DAU 집계 +- `packages/web/lib/softwall/helpers.ts` — soft wall 판정(미배선) +- `packages/web/app/robots.ts` — 크롤러 정책 + +### GitHub + +- PR [#1046](https://github.com/decodedcorp/decoded/pull/1046) — merged 2026-07-19, 머지 커밋 `fbf47d54` +- PR [#985](https://github.com/decodedcorp/decoded/pull/985) — OPEN / CONFLICTING +- Issue [#970](https://github.com/decodedcorp/decoded/issues/970) — 계측 배선 스코프 +- Issue [#894](https://github.com/decodedcorp/decoded/issues/894) (본 조사) · [#893](https://github.com/decodedcorp/decoded/issues/893) (부모 맵) + +### 벤더 지표 정의 (공식 문서) + +- [Cloudflare Zone Analytics](https://developers.cloudflare.com/analytics/account-and-zone-analytics/zone-analytics/) — Free 플랜 HTTP Traffic에 crawler·threat 포함 +- [Cloudflare Web Analytics](https://developers.cloudflare.com/web-analytics/about/) — JS beacon 기반 RUM +- [Cloudflare sampling](https://developers.cloudflare.com/analytics/sampling/) — ABR 샘플링 +- [GA4 user metrics](https://support.google.com/analytics/answer/12253918?hl=en) · [sessions](https://support.google.com/analytics/answer/12798876?hl=en) · [bot 제외](https://support.google.com/analytics/answer/9888366?hl=en) · [internal traffic](https://support.google.com/analytics/answer/10104470?hl=en) +- [Next.js third-party Google Analytics](https://nextjs.org/docs/app/guides/third-party-libraries#google-analytics) +- [Meta Pixel 설치·표준 이벤트](https://developers.facebook.com/docs/meta-pixel/get-started) — base code + `fbq('track', ...)` 표준 이벤트 목록