RINDA · Code Audit

리드 Import 코드 구조 감사
— 현행 경로 vs 데드(고아) 코드

바이어 리스트 업로드 기능은 현재 두 개의 병렬 구현이 공존합니다. 하나(smart-import)는 모든 진입 동선이 연결된 현행 경로, 다른 하나(lead-import)는 화면 진입점이 사라진 고아 코드입니다. 본 문서는 두 구조와 공유 의존, 그리고 삭제 시 함정을 정리합니다.

작성: Claude (Opus 4.8) 기준일: 2026-06-25 대상: admin / elysia-server 계기: 스페로네 "16명 → 4 리스트" 신고

0한눈에

같은 "바이어 업로드" 기능이 두 벌. 사용자가 실제 닿는 건 하나뿐.

현행 smart-import

/leads/add → /api/v1/smart-import/*

  • 사이드바·LeadsPage 버튼·앱투어·홈 등 진입점 다수
  • AI 컬럼 자동매핑 + 사용자 수정
  • 중복을 import_jobs.unresolvedData로 보존(복구 가능)
  • 워크스페이스당 1실행 락 + job 추적

고아 lead-import

/lead-import → /api/v1/admin/lead-import/*

  • 메뉴·버튼·navigate 진입점 전무 (URL 직타만)
  • 영문 헤더 고정(매핑 UI 없음)
  • 중복은 결과 목록에만 표시 후 폐기
  • 단, GDPR 동의근거 수집은 이쪽에만 존재
핵심 결론 /lead-import 흐름은 화면상 데드코드지만, 서비스 파일 일부(parseUploadedFile·bulkImportLeads·verifyImportEmails)는 현행 smart-import이 빌려 쓰므로 파일 통삭제 불가. 게다가 GDPR 동의 입증이 이 경로에만 있어 단순 청소가 아닌 제품/컴플라이언스 의사결정 사안.

1현행 구조 — smart-import (/leads/add)

3단계 마법사(그룹 → 업로드 → 매핑) + SSE 파이프라인.

프론트엔드

파일역할
pages/leads/AddBuyersPage.tsx오케스트레이터. group→upload→mapping state machine. URL의 groupId/wsId/mode/newGroup 파라미터 해석
add-buyers/StepGroupSelect.tsx기존 그룹 선택 / 새 그룹 생성 (발송 단위 결정)
add-buyers/StepUpload.tsxdropzone(50MB·확장자 검증) → useAnalyzeFile 호출. 템플릿 CSV/XLSX 다운로드
add-buyers/StepMapping.tsxAI 매핑 표시·수정 → 제외 미리보기 → startImportPipeline(SSE 콜백)
add-buyers/StepExclusion.tsx · ImportStatusDialog.tsx제외 행 확인 모달 / 진행·완료 표시
lib/api/services/smart-import.ts · hooks/smart-import.tsanalyzeFile, startPipeline(SSE), useUnresolvedRows

백엔드

위치역할
routes/smart-import.routes.ts4 endpoint: POST /analyze · POST /start(SSE) · GET /:jobId · GET /:jobId/unresolved
services/smart-import.service.ts (868줄)analyzeFileColumns · deduplicateRecords · runSmartImportPipelineLocked · mapRecordToLeadData
utils/dedup-normalize.tsemail/url/회사명 정규화 (lead-import과 공유)
lib/import-lock.ts · db/schema/import-jobs.ts워크스페이스 동시실행 락 / job·unresolved 저장

2업로드 파이프라인 — 단계별

FE 액션과 BE 처리의 짝. SSE로 단계가 실시간 스트리밍됩니다.

// FE: /leads/add
StepGroupSelect ─▶ StepUpload ─▶ StepMapping ──(SSE)──▶ 완료 모달
                       │              │
                POST /analyze   POST /start
                       ▼              ▼
// BE: runSmartImportPipelineLocked()  [워크스페이스당 1실행 락]
 0 preflight  식별자(회사명/URL/이메일) ≥1 매핑 필수, 아니면 400
 1 parse      파싱 + 빈 행 제거 → emptyRowsSkipped
 2 dedup      email → url → 회사명 매칭 → {unique, duplicates}
              └ duplicates 를 import_jobs.unresolvedData 에 저장
 2.5 verify   MillionVerifier — undeliverable·risky(≤20) 이메일 제거
 3 import     bulkImportLeads(unique) → 그룹 멤버 추가
 3.5 group    DB매칭 중복(matchedLeadId)을 그룹에 편입
 ✓ complete   imported / duplicates / addedToGroup / existingAddedToGroup ...
1
파싱 & 빈 행 제거 service:503-532
매핑된 컬럼이 전부 빈 행 제거. CSV 끝 빈 줄·엑셀 trailing row 카운트 오염 방지.
2
중복 제거 deduplicateRecords · service:252-392
우선순위 이메일 → URL host → 회사명. 각 단계 DB매칭 + 파일내매칭. 매칭 시 duplicates로 빼고 continue. ← 스페로네 이슈 발생 지점.
2.5
이메일 검증 verifyImportEmails · service:582-672
MillionVerifier로 배달불가·위험 이메일 거절. 이메일이 전부 거절된 리드는 import 제외.
3
적재 + 그룹 편입 service:674-744
unique만 bulkImportLeads로 생성. DB매칭 중복(matchedLeadId 보유)은 기존 리드를 그룹에 새로 편입.

3데드(고아) 구조 — lead-import (/lead-import)

서버에 마운트돼 있고 라우트도 등록돼 있지만, 화면에서 도달할 길이 없습니다.

진입점 부재 근거 코드 전수 검색 결과 navigate("/lead-import") · <Link to="/lead-import"> · 사이드바 메뉴 항목 0건. 존재하는 참조는 라우트 등록(dashboard-admin-routes.tsx:198), 브레드크럼 맵(DashboardLayout.tsx:607), 권한 맵(permission/constants.ts:60)뿐 — 모두 "도달 시 표시"용일 뿐 진입 동선이 아님.
레이어파일 / 심볼상태
FE 페이지pages/lead-import/index.tsx, LeadImportAttestationSection.tsx고아
FE APIlib/api/hooks/lead-import.ts, services/lead-import.ts고아
FE 라우트dashboard-admin-routes.tsx:198, lazy-imports.ts:88등록만
BE 라우트routes/lead-import.routes.ts (upload·sheet-names·preview)고아
BE 서비스(전용)importLeadsStream, importLeadsBatch, analyzeLeadPreviewWithAI, getSheetNames, validateFileExtension, validateNonEmptyData, importSingleLead, checkDuplicatelead-import 전용

* "전용" = lead-import.routes.ts 외부에서 호출 0건 (정적 검색 기준).

4공유 의존 — 통삭제를 막는 지점

lead-import.service.ts반(半)생존. 아래 함수들은 현행 경로가 의존하므로 살아 있음.

함수현행 사용처상태
parseUploadedFilesmart-import · web-extraction.routes · web-extraction-file.service공유
bulkImportLeadssmart-import.service공유
verifyImportEmailssmart-import.service공유
함의 lead-import.service.ts를 지우려면 위 3함수를 먼저 중립 모듈(예: services/import-helpers.ts)로 추출 → 그 다음에야 전용 함수·라우트·페이지 제거 가능.

5근본 원인 — 회사명 dedup (스페로네 케이스)

두 경로가 같은 dedup 로직을 공유. 16명 업로드가 4 리스트로 줄어든 메커니즘.

업로드 파일의 website_url이 비어 있어 URL 매칭이 꺼지고, 이메일은 전부 달라 통과 → 정규화 회사명이 판정축이 됨. normalizeCompanyName이 악센트·접미사(Inc/Ltd…)·부호를 제거하므로 표기가 달라도 같은 키로 수렴.

업로드 회사명정규화 키인원결과
Gap Inc.gap8기존 5월 리드와 충돌 → 0 생성, contact 미병합
Banana Republic / Gap Inc.banana republic / gap61 생성, 5 탈락
Banana Republic Factory / Gap Inc.banana republic factory / gap11 생성
Gap Inc. / Banana Republicgap inc / banana republic11 생성

→ 신규 3 + 기존 Gap 1 = 그룹 멤버 4 ("4개 리스트"). beta DB(스페로네_0430)에서 실제 재현 확인됨.

설계 충돌 리드는 다중 contact 구조인데, 회사명 매칭은 contact를 기존 리드에 병합하지 않고 행 전체를 폐기(URL 매칭은 병합). 같은 회사 다수 담당자(ABM) 시나리오와 정면 충돌. 단 smart-import은 폐기분을 unresolvedData에 남겨 수동 복구 가능.

7권장 정리 순서

1
의사결정 — 동의 입증
smart-import에 동의근거 수집을 넣을지(A), 불필요로 결론낼지(B). 법무/제품 확인.
2
공유 함수 추출
parseUploadedFile·bulkImportLeads·verifyImportEmailsservices/import-helpers.ts로 이동, import 갱신.
3
고아 코드 제거
FE 페이지·hooks·services·라우트 등록 + BE lead-import.routes.ts + 전용 서비스 함수 8종 삭제. app.ts.use(leadImportRoutes) 제거.
4
dedup 개선(별건, 우선순위 높음)
회사명 매칭 시 폐기 대신 기존 리드에 contact 병합(URL 매칭과 대칭). 스페로네 13명 복구 임포트 포함. E2E 추가.
현행 진입점 연결됨 고아 도달 불가 공유 현행이 의존 주의 함정