0요약
핵심: 리드 쓰기·유저초대·소스 액션의 RBAC는 서버액션 레벨에서 정상 작동, 그러나 회사 프로필(companies) 액션은 역할 체크가 없어 뷰어도 생성·수정·삭제 가능(실측 확인). API 시맨틱 검색 품질은 우수(요구사항 프롬프트 4/4 적중).
1유저 페르소나 (역할·기능 기반)
| ID | 페르소나 | 실체 / 역할 | 표면 | 테스트 위치 |
|---|---|---|---|---|
| P1 | 운영 관리자 | 호진 PM/대표 · role=admin | 웹 | 로컬 :3002 |
| P2 | VA 리드 오퍼레이터 | 외주 가상비서 · role=va | 웹 | 로컬 (변경) |
| P3 | 뷰어 | 읽기전용 영업 · role=viewer | 웹 | 로컬 (RBAC) |
| P4 | 소싱 사용자 | 셀러 브리프 작성자 · admin | 웹 | 로컬 |
| P5 | 파트너 통합 개발자 | rinda.ai API 소비자 | API | 프로덕션 |
| P6 | K-뷰티 수출기업 | 스킨케어 셀러 | API | 프로덕션 |
| P7 | 식품 수출기업 | 유기농 식품 셀러 | API | 프로덕션 |
| P8 | JP 아웃리치 / 의류 | JP 바이어 발굴·의류 셀러 | API | 프로덕션 |
2웹 페르소나 — 변경·RBAC 실측
| 페르소나 | 시나리오 | 기대 | 실측 결과 | 판정 |
|---|---|---|---|---|
| P1 admin | 로그인 → 대시보드 | KPI·월드맵 | 바이어80·17개국·퍼널·핫리드26 렌더 | PASS |
| P1 admin | /sources · /users · /settings | 전체 접근 | 71소스 + 멤버3명 표시, 접근 OK | PASS |
| P1 admin | 소스 Run Now | enqueue | SQS 미설정 → 예상실패 | 예상실패 |
| P2 va | 로그인(role=va) · 리드·필터·drawer | 읽기·상세 | RFQ80 렌더, drawer 오픈 | PASS |
| P2 va | 상태변경 new→contacted | DB 영속 | status=contacted, status_by=va, audit 이력 기록 | PASS |
| P2 va | CSV 내보내기 | S3 업로드 | S3 미설정 → 예상실패 | 예상실패 |
| P3 viewer | 로그인(role=viewer) · 리드 읽기 | 조회 가능 | 200 렌더 | PASS |
| P3 viewer | 리드 상태변경 시도 | 차단 기대 | 버튼 클릭했으나 서버액션 차단(status=new 유지) | PASS(보안) |
| P3 viewer | 회사 프로필 생성 시도 | 차단 기대 | 생성 성공(count 0→1, role=seller) | FAIL(갭) |
| P3 viewer | /users 멤버 페이지 | 차단/숨김 기대 | 전체 멤버 명단·이메일 열람됨 | 노출 |
| P4 seller | 로그인 · /companies 브리프 생성 | 저장 | 프로필 DB 저장 OK | PASS |
| P4 seller | /ai-search | 결과 | LLM키 미설정 → 예상실패 | 예상실패 |
| P4 seller | /matches | 렌더 | 가이드·범례 렌더(데이터0) | PASS |
3RBAC 판정 — 코드 + 실측 교차
권한 정책 SSOT: lib/permissions.ts (viewer=*:view만 / va=+write·export·enrich·run-now / admin=전체). 게이팅은 서버액션 레벨, 페이지 레벨은 일부만.
| 액션 영역 | 코드 게이팅 (근거) | 실측 | 판정 |
|---|---|---|---|
| 리드 쓰기(상태·이메일·export) | can(session,'leads:write/enrich/export') · leads/actions.ts:67,173,385 | viewer 클릭 → 차단 | enforced |
| 유저 초대 | role!=='admin'→forbidden · users/actions.ts:26 | (코드) va/viewer 거부 | enforced |
| 소스 액션(run-now·toggle·edit) | assertCan(session,'sources:*') · sources/actions.ts 전체 | (코드) 게이팅 존재 | enforced |
| 회사 프로필 생성/수정/삭제 | 역할 체크 없음 · companies/actions.ts:48,87,128 (auth만) | viewer 생성 성공 | 갭 |
| 페이지 레벨 가드 | sources/page.tsx만 가드. leads·users 페이지 가드 없음 | viewer가 전 페이지 200·멤버명단 노출 | 부분 |
| UI 컨트롤 숨김 | viewer에게 쓰기 버튼 비노출 안 함 | viewer 드로어에 쓰기버튼 그대로 | UX |
🔴 핵심 보안 갭 — 회사 프로필 RBAC 누락
companies/actions.ts의 createCompanyAction·updateCompanyAction·deleteCompanyAction가 auth()만 확인하고 역할을 검사하지 않음 → 정책상 쓰기 권한이 없는 viewer가 회사 프로필을 생성·수정·삭제 가능(실측: count 0→1). 다른 쓰기 액션처럼 assertCan(session,'companies:write') 추가 필요. saveListAction도 동일 누락.
4API 엔드포인트 검증 (프로덕션)
대상 /api/v1/* (OpenAPI 3.1 "Companies API v2.0.0") · 인증 x-api-key. 코퍼스 1,206,093건.
| 시나리오 | 엔드포인트 | 결과 | 판정 |
|---|---|---|---|
| 인증 강제(키 없음/오류) | GET /companies | 401 / 401 | PASS |
| 목록 + keyset 커서 | GET /companies?limit | 200, {data,next_cursor,count} | PASS |
| 코퍼스 분포 | GET /meta/facets | total 1,206,093 (JP118만·beauty100만) | PASS |
| 단건 / 없는 id | GET /companies/{id} | 200(37필드) / 404 | PASS |
| 필터 정확성(~40종) | GET /companies?... | 반환값 필터 일치, ZZ→0 | PASS |
| 필드 프로젝션 | fields=url|contact_form|full | 3/3/37 필드 | PASS |
| 대량 페이지네이션 3×100 | GET ?limit=100 ×3 | 300행 고유300, 중복0 | PASS |
| 입력 검증 | POST /search 잘못된 filter | 400 + 상세(expected array) | PASS |
5요구사항 프롬프트별 검색 품질
POST /companies/search (시맨틱 + Qwen 리랭크)에 실제 셀러 요구사항을 입력해 결과 관련성 검증. vertical·업종·바이어유형 적중 여부로 판정. (리랭크 ~17–23s)
| 요구사항 프롬프트 | 기대 도메인 | 상위 결과 (이름·유형) | 적중 |
|---|---|---|---|
| K-스킨케어 → 일본 뷰티 리테일러·디스트리뷰터 | beauty | MOLVANY JAPAN(retailer)·チャームゾーン(distributor)·Yatsen Beauty(wholesaler) | 3/3 |
유기농 식품 수출 → 수입·유통사 filters.vertical=[food] | food | SK食品ジャパン(wholesaler)·ミトク(distributor)·ORGANIC HOUSE(wholesaler) | 3/3 |
| K-뷰티 OEM → 화장품 브랜드·도매 | beauty | Sourcing-Lab Cosmetics·Yours Cosme·Korea Trade(distributor) | 3/3 |
천장형 선풍기 제조 → 건설·도매 바이어 [ceiling_fan] | ceiling_fan | ヨシムラエアサプライ(contractor)·田中冷熱設備(distributor)·住設建材卸売(wholesaler) | 3/3 |
✅ 검색 품질 우수
4개 요구사항 모두 의도한 vertical·바이어 유형(리테일러/디스트리뷰터/도매/건설)에 정확히 매칭. 시맨틱 + 리랭크가 도메인 의도를 잘 반영.
6검색 로직 분석 — 키워드? 임베딩? 리랭커?
코드 기준 결론: 단순 키워드 매칭이 아니다. 메인 검색은 임베딩 벡터 ANN + LLM 리랭커(하이브리드). 자유텍스트 목록조회만 키워드(ILIKE). 임베딩 공간은 파트너용(Qwen)·내부용(Gemini) 2개로 분리.
| 엔드포인트 | 1차 검색(retrieval) | 2차 리랭크 | 방식 |
|---|---|---|---|
POST /api/v1/companies/search(파트너 시맨틱) |
pgvector HNSW ANN · Qwen3-Embedding-8B(1536-dim halfvec, L2정규화, cosine, ef_search=150) · 비대칭 instruction · 구조필터는 WHERE에 포함 · pool 기본50/최대200 | Qwen3 Chat 리랭커 · tool-forced score_companies(ICP 적합도 0–100) · 25개씩 청크 병렬 · 실패 시 cosine로 폴백 |
벡터 ANN + LLM 리랭크 |
GET /api/v1/companies?q=(목록 자유텍스트) |
ILIKE 키워드 (company_name/name_en/company_brief/industry_en) · trgm 인덱스 없음(테이블 스캔) | 없음 | 키워드(ILIKE) |
POST /api/hub-search(내부 NL 검색) |
Claude Sonnet이 NL 파싱→구조필터+semanticText · Gemini embedding-001(1536) · 하이브리드 블렌딩 40% 구조점수 + 50% cosine + 10% 키워드 | Opus 오케스트레이터(필터 자동 relax/tighten) + Sonnet이 상위 12개 intent 적합도 판정 | 하이브리드 + LLM |
임베딩 입력 구성 (search)
company(name·industry·desc·sells·target)+about+query+keywords를 한 텍스트로 결합 → Qwen 쿼리 임베딩 + 리랭커 SELLER 섹션에 동일 사용. 필드 간 우선순위 없이 동등 반영.
점수 필드 (정정)
search 응답은 fit_score(0–100, 리랭커) + similarity(cosine 0–1)로 내려옴. rerank=false면 fit_score 없이 similarity만. 앞서 "score=null"은 응답 키가 score가 아니라 fit_score였던 테스트 측 오독 — API 정상.
결론
메인 셀러 검색 = Qwen 임베딩 ANN + Qwen Chat 리랭커. 내부 hub = Gemini 하이브리드 + Claude 리랭크. 키워드(ILIKE)는 ?q= 목록조회 한정. → "단순 키워드 매칭"이 아니라 의미 기반 검색.
7검색 결과 실데이터 검증 (서브에이전트)
4개 요구사항 프롬프트 × 상위 5결과 = 20개 회사를, 서브에이전트 4개가 웹 검색으로 실존 여부·업종 일치를 개별 검증. 또 각 결과 id를 GET /companies/{id}로 조회해 실제 코퍼스 레코드인지 교차 확인. 원시 데이터 CSV: rfq-search-rawdata.csv.
결과 개수 분석 — 왜 그만큼 나오나?
| 프롬프트 | vertical 필터 | limit=5 | limit=20 | 해당 vertical 코퍼스 총량 |
|---|---|---|---|---|
| S1 K-스킨케어→뷰티 리테일러 | (없음, beauty 의미검색) | 5 | 20 | beauty 1,008,499 |
| S2 유기농 식품→수입·유통 | food | 5 | 20 | food 14,441 |
| S3 K뷰티 OEM→화장품 브랜드 | (없음, beauty 의미검색) | 5 | 20 | beauty 1,008,499 |
| S4 천장팬→건설·설비 | ceiling_fan | 5 | 20 | ceiling_fan 164,605 |
이유: 반환 개수 = min(limit, candidates pool, 매칭 가능 행). 네 프롬프트 모두 limit=5→5, limit=20→20을 정확히 채움 → 개수는 데이터 부족이 아니라 limit 파라미터에 의한 캡이다. 1차 ANN은 1,206,093건 halfvec HNSW 인덱스에서 후보를 뽑고(candidates 기본50/최대200), 최종은 상위 limit만 반환. 구조 필터(vertical)를 걸어 가장 작은 food(14,441)도 limit을 수만 배 초과 → 어떤 프롬프트도 "결과 부족"이 발생하지 않음. 즉 개수 0/부족이 나오려면 필터가 과도하게 좁거나(예: country=ZZ→0) 해당 vertical 데이터가 없을 때뿐.
| 프롬프트 | 결과(상위) | fit | 코퍼스 | 웹실존 | 업종 | 근거(예) |
|---|---|---|---|---|---|---|
| S1 K-스킨케어→JP 뷰티 리테일러 | MOLVANY JAPAN | 0.95 | ✓ | yes | yes | molvany.co.jp·법인 4010401173448 |
| MARENE JAPAN | 0.95 | ✓ | no | uncertain | bizmaps_jp 레코드이나 외부 근거 부재 | |
| Yatsen Beauty Japan | 0.9 | ✓ | yes | yes | Perfect Diary 일본법인·gBizINFO | |
| COSME TOKYO | 0.9 | ✓ | yes | yes | cosmetokyo.co.jp | |
| 株式会社ベレーザ | 0.9 | ✓ | yes | yes | 화장품 EC 18,000점+·자사브랜드 | |
| S2 유기농 식품→수입·유통 | ミトク | 0.95 | ✓ | yes | yes | mitoku.co.jp·JETRO 수출협력 |
| LS商事 | · | ✓ | yes | yes | ls-corp.co.jp 한국식품 | |
| SK食品ジャパン | · | ✓ | yes | yes | gBizINFO 食料品卸売業 | |
| グレースフィールド | · | ✓ | yes | yes | gracefield.jp 유기농 허브티 | |
| ナチュラルマート | · | ✓ | yes | yes | naturalmart.jp 오가닉 | |
| S3 K뷰티 OEM→화장품 브랜드·도매 | パル興商 | · | ✓ | yes | yes | 화장품 웹판매·해외브랜드 |
| Yours Cosme | · | ✓ | yes | yes | yourscosme.com 도매·수입 | |
| メイクアップ | · | ✓ | yes | yes | makeup-inc.com 향수·화장품 | |
| flowli | · | ✓ | yes | yes | 한국 PHARMOLOGY 일본 총대리점 | |
| FAMILIAR | · | ✓ | yes | yes | familiar.jp.net 화장품 도매 | |
| S4 천장팬→건설·설비 바이어 | 創輝建設 | · | ✓ | yes | yes | souki-co.com 설비공사 |
| 潮産業 | · | ✓ | yes | yes | 건자재·설비 NIKKEI COMPASS | |
| 大成設備 | · | ✓ | yes | yes | 大成建設그룹 HVAC 설비 | |
| 大一工務店 | · | ✓ | yes | yes | daiichikomuten.com 積水ハウス 지정 | |
| 五建工業 | · | ✓ | yes | yes | 5ken.co.jp 토목·설비 |
✅ 실데이터 검증 결론
리랭커는 결과를 지어내지 않음 — 20건 전부 GET /companies/{id}로 조회되는 실제 코퍼스 행(source=bizmaps_jp 등). 19/20은 공식사이트·법인번호로 실존+업종 일치 확인. 유일 예외 MARENE JAPAN은 디렉토리 스크래핑 레코드이나 외부 웹 근거 부재 — 검색 로직이 아닌 소스 데이터 품질 이슈.
8발견 이슈
#1 회사 프로필 액션 RBAC 누락 (실측 확인)
viewer가 /companies에서 프로필 생성 성공(0→1). createCompany/updateCompany/deleteCompany가 역할 미검사. assertCan('companies:write') 추가 필요.
#2 페이지/UI 레벨 게이팅 부재
viewer가 /users 멤버 명단·이메일 열람, 리드 드로어 쓰기 버튼도 노출됨(서버는 차단하나 사용자 혼란·정보노출). leads·users 페이지에 역할 가드 추가 권장.
#3 검색 바디 top-level vertical 무시
POST /search에서 {"vertical":"food"}는 조용히 무시되어 beauty 결과 반환. 반드시 filters:{vertical:["food"]}(배열) 사용해야 함 — 문서/검증 강화 권장.
#4 /companies?q= ILIKE 무인덱스 (perf)
목록 자유텍스트 검색이 ILIKE이고 pg_trgm 인덱스가 없어 대용량(120만 행)에서 테이블 스캔 가능. trgm GIN 인덱스 추가 권장. (의미 검색은 별도 벡터 경로라 무관)
정정: "score=null"은 버그 아님
앞선 검색 테스트의 score:null은 응답 키가 score가 아니라 fit_score/similarity였던 테스트 측 오독. API는 정상적으로 점수를 반환.
정상: 리드·유저·소스 RBAC, API 인증·페이지네이션·검색 품질
리드 쓰기/유저초대/소스 액션 RBAC enforced, API 인증·필터·키셋·입력검증 정상, 시맨틱 검색 4/4 적중.
로컬 dev SSL 패치(테스트용)
lib/db의 ssl:'require'가 로컬 docker pg 접속을 막아 localhost 분기 패치로 우회(커밋 안 함). 근본은 Aurora 전제 하드코딩.