Rinda · Frontend Deep-Dive
/leads/addCSV/Excel 파일을 업로드해 워크스페이스의 바이어(리드)로 가져오는 3단계 위저드. 그룹 선택 → 파일 업로드 → 컬럼 매핑 → SSE 파이프라인(파싱·중복제거·이메일검증·저장)으로 동작한다. 아래는 실제 컴포넌트·서비스 함수 기준 분석.
AddBuyersPage 는 쿼리파라미터를 읽어 어느 단계부터 시작할지와 워크스페이스·그룹 컨텍스트를 정한다. Step 머신은 단순 useState 3-값 유한상태기다.
| 파라미터 | 역할 |
|---|---|
groupId | 대상 그룹 ID. 있으면 그룹 선택 단계를 건너뛰고 upload 로 시작 |
wsId | 워크스페이스 ID. analyze/start 요청의 X-Workspace-Id 헤더로 전달 |
mode | new면 그룹 선택 화면이 "새 그룹 생성" 탭으로 열림 |
newGroup=1 | 그룹 선택 스킵 + 업로드 후 커밋 시점에 새 그룹 자동 생성 |
from | 뒤로가기 목적지(dashboard 등) 결정 |
// 초기 단계: groupId 또는 newGroup 이 있으면 'group' 건너뜀 const [step, setStep] = useState<Step>(groupIdParam || autoNewGroup ? "upload" : "group") const [resolvedGroupId, setResolvedGroupId] = useState(groupIdParam) const [resolvedWorkspaceId, setResolvedWsId] = useState(wsIdParam ?? currentWorkspaceId) const [file, setFile] = useState<File | null>(null) const [mapping, setMapping] = useState<ColumnMappingEntry[]>([]) // 기존 그룹에 추가 중이면 대상 그룹명을 배너로 노출 const { data: targetGroup } = useCustomerGroup(resolvedGroupId ?? "", !!resolvedGroupId)
handleGroupResolved(groupId, wsId) → 그룹·워크스페이스 확정 후 upload 로handleAnalyzed(file, mapping) → 분석된 파일·제안 매핑 저장 후 mapping 으로handleDone() → 완료 시 leadKeys.lists() + customerGroupKeys.all 쿼리 무효화handleBack() → mapping→upload→group 역순, 최상단에선 backTarget 네비게이션groupId + wsId 가 모두 있으므로 그룹 선택(Step 1)을 건너뛰고 곧장 파일 업로드부터 시작한다. 상단엔 대상 그룹명 배너가 뜬다.
바이어를 담을 고객 그룹을 정하는 단계 (groupId 없이 진입했을 때만 노출). "기존 그룹" / "새 그룹 생성" 토글로 갈린다.
useCustomerGroupsByWorkspace(workspaceId) — 워크스페이스 내 그룹 목록(리드 수 포함) 조회useCreateCustomerGroup() — "새 그룹 생성" 모드의 저장 mutationCustomerGroupForm — 그룹명·설명 입력// 기존 그룹 선택 후 Proceed onGroupResolved(selectedGroup.id, selectedGroup.workspaceId) // → 부모 handleGroupResolved → 'upload' // 새 그룹 생성 createGroup.mutate(form, { onSuccess: (g) => onGroupResolved(g.id, g.workspaceId), })
CSV/XLSX 파일을 드롭하면 즉시 백엔드에 보내 컬럼 자동 매핑 제안을 받는다. 사용자가 직접 매핑 버튼을 누르지 않아도 다음 단계로 넘어간다.
.csv / .xlsx / .xls, 단일 파일MAX_FILE_SIZE_BYTES), 0바이트 빈 파일 차단const analyzeFile = useAnalyzeFile() // → smartImportApi.analyzeFile const onDrop = useCallback((files) => { const selectedFile = files[0] // ...크기/빈파일 검증... analyzeFile.mutate( { file: selectedFile, workspaceId }, { onSuccess: (result) => { if (!result.suggestedMapping?.length) return setFileError(...) onAnalyzed(selectedFile, result.suggestedMapping) // → Step 3 }}, ) }, [analyzeFile, onAnalyzed, workspaceId])
| 호출 | 요청 | 응답 |
|---|---|---|
POST/smart-import/analyze |
FormData(file) + X-Workspace-Id 헤더 |
AnalyzeResult: columns, suggestedMapping, totalRows |
runSmartImportPipeline 단계와 1:1로 일치하도록 카피를 맞춘 것 (과거엔 실제로 안 하는 후속 기능을 약속해 혼란을 줬음).
제안된 매핑을 사람이 검수하는 단계. 좌측은 "저장 위치"(드롭다운), 우측은 내 파일의 실제 샘플값(첫 행). 저신뢰 칸은 amber 경고로 사용자에게 확인을 요청한다.
// BE 키(phone) → FE 키(phoneNumber) 정규화, 알 수 없는 필드는 'skip' const [mapping, setMapping] = useState(() => initialMapping.map((m) => { const tf = BE_TO_FE_FIELD[m.targetField] ?? m.targetField return TARGET_FIELD_LABEL_KEY[tf] ? { ...m, targetField: tf } : { ...m, targetField: "skip", confidence: 0 } })) // 식별자(회사명/웹사이트/이메일) 중 1개 이상 필수 — BE pre-flight 미러 function hasIdentifier(m) { return m.some(e => ["companyName", "websiteUrl", "email"].includes(e.targetField)) } // 미매핑이거나 신뢰도 < 0.75 → "확인 필요"(amber) function isAttentionEntry(e) { if (e.targetField === "") return true return e.targetField !== "skip" && (e.confidence ?? 0) < 0.75 }
handleStartImport() ├─ 식별자 없으면 toast.error 후 중단 ├─ createNewGroup 모드 → setGroupPickerOpen(true) // 저장 위치 모달 └─ proceedImport(groupId) ├─ parseCsvForPreview() // 자동 제외(중복) 미리보기 레코드 추출 ├─ records > 500,000행 → 미리보기 skip, 바로 import ├─ records > 0 → ExclusionPreviewModal 띄움 └─ startImportPipeline(validMapping, targetGroupId) // SSE 시작
| 호출 | 요청 (FormData) |
|---|---|
SSE POST/smart-import/start |
file, workspaceId, mapping(JSON), customerGroupId + CSRF·Workspace 헤더, credentials: include |
| SSE event | 콜백 | 의미 |
|---|---|---|
pipeline_step | onPhaseStart / onPhaseComplete | 각 단계 시작·완료 |
import_progress | onProgress | 저장 진행률 (imported/total) |
verify_progress | onProgress | 이메일 검증 진행률 |
pipeline_complete | onComplete | 최종 통계 → done |
error | onError | 실패 단계 표시 + 토스트 |
제외 로직은 두 곳에서 동작한다 — 임포트 전 "미리보기 모달"과 임포트 후 "완료 요약". 성격이 다르다: 모달은 안내·동의용(미리보기 전용), 실제 제외는 백엔드 dedup 단계가 수행한다.
| 기준 | 필터 키 | 토글 | 의미 |
|---|---|---|---|
| 같은 회사명 존재 | byCompanyName | ON/OFF | 동일 회사명 이미 있음 |
| 같은 이메일 존재 | byEmail | ON/OFF | 동일 이메일 이미 있음 |
| 이미 메일 발송한 리드 | byPastSent | ON/OFF | 과거 발송 이력 있음 |
| 활성 캠페인 등록 리드 | byActiveSequence | 🔒 강제 ON | lock 아이콘 + "강제 제외" 배지, 끌 수 없음 |
matchedCounts[reason] 표기preview.mutate({records, filters}) → POST /exclusion-preview 로 카운트 실시간 갱신willImport) 강조onConfirm(null, null) 강행!result || willImport <= 0 이면 비활성handleExclusionConfirm 은 onConfirm(result, filters) 의 인자를 무시하고 전량을 파이프라인에 넘긴다. 즉 토글을 꺼도 실제 임포트엔 영향 없음 — "이만큼 빠질 거예요"를 보여주고 동의받는 용도이며, 실제 제외는 백엔드 dedup 이 수행한다.
// StepMapping.tsx:439 — 모달 확인 핸들러 (필터값 미사용) const handleExclusionConfirm = () => { setShowExclusionModal(false) const validMapping = mapping.filter(...) startImportPipeline(validMapping, pendingGroupId) // result/filters 무시 }
records.length > EXCLUSION_PREVIEW_MAX_ROWS(=500,000행) 이면 미리보기를 건너뛰고 곧장 임포트 — 중복은 파이프라인 dedup 이 처리. xlsx 등 비-CSV 도 미리보기 레코드를 못 만들어 모달 skip.
SSE pipeline_complete 통계로 계산해 표시. 중복이어도 그룹에 새로 편입된 건은 "제외"에서 빼는 게 포인트.
| 표시 항목 | 계산식 |
|---|---|
| 추가됨 (메인) | addedToGroup + existingAddedToGroup |
| 기존 바이어 편입 | existingAddedToGroup (>0일 때만) |
| 중복 제외 | max(0, duplicates − existingAddedToGroup) |
| 이메일 오류 제외 | leadsRejectedByVerification ?? emailsVerificationFailed |
| 이메일 없어 그룹 제외 | max(0, imported − addedToGroup) |
exclusionLine)emailsVerificationFailed(이메일 개수)와 단위가 달라 leadsRejectedByVerification 우선existingAddedToGroup 으로 따로 안내 — 과거 imported(신규)만 보여줘 "0건 추가" 패닉을 유발하던 버그의 수정 흔적.
"새 업로드 리드가 어디에 어떻게 존재하느냐"에 따라 처리가 갈린다. 핵심 두 가지를 먼저 못박자.
WHERE leads.workspace_id = ? 로 그룹이 아니라 워크스페이스 전체 기존 리드와 대조한다. 어느 그룹에 있든 같은 워크스페이스면 "중복"으로 잡힌다. ② 중복 ≠ 버림. 중복이라 신규 생성은 안 해도, 대상 그룹에 없으면 그 리드의 멤버십은 대상 그룹에 새로 추가된다.
lead_contacts.contact_value (type='email'). 정규화: NFC + 소문자 + trim + 형식검증leads.website_url host. www. 제거, 소문자leads.company_name. 정규화: 악센트 제거 + 소문자 + (주)·(유)·주식회사·inc·ltd·llc·corp·gmbh 등 접미/접두 제거(주)아모레퍼시픽·(주)모다이노칩(60건)·(주) 아로마티카 같은 표기는 모두 (주)·공백이 제거돼 아모레퍼시픽 과 동일 키로 매칭된다.
| 새 업로드 리드가… | dedup 판정 | 대상 그룹 처리 | 완료화면 표기 |
|---|---|---|---|
| ① 추가하려는 그룹에 이미 있음 | 중복 (WS 전체 매칭) | 이미 멤버 → alreadyInGroupIds 로 걸러 변화 없음 |
"중복 N건 제외" ( dupExcluded) |
| ② 다른 그룹에만 있음 (같은 WS) |
중복 (WS 전체 매칭) | 대상 그룹 미소속 → addEmailBearingLeadsToGroup 로 대상 그룹에 새 편입 |
"기존 바이어 N건 추가 편입" ( existingAddedToGroup) |
| ③ 회사명만 같음 (이메일·URL 다름/없음) |
3순위 회사명 매칭 → 중복 | ① 또는 ② 규칙을 그대로 따름 | 중복 제외 또는 편입 |
| ④ 이메일만 같음 | 1순위 이메일 매칭 → 중복 (회사명 달라도 중복) |
① 또는 ② 규칙을 그대로 따름 | 중복 제외 또는 편입 |
verification_status ≠ 'failed' 인 이메일 contact 가 1개 이상 있어야 함 (그룹 = 콜드메일 발송 단위)failed → 리드는 leads(회사 리스트)엔 남지만 그룹엔 미편입 → 완료화면 "이메일 없어 그룹 제외 N건"onConflictDoNothing() — (group_id, lead_id) 유니크라 재실행해도 안전help@coupang.com(1,558 리드)·shift@h-201.com(243 리드)처럼 여러 회사가 공유하는 대표 이메일은 회사가 달라도 첫 매칭 리드와 중복 처리돼 신규 생성이 막힐 수 있다.
dedup 은 email·url·company 3기준만 판정한다(활성캠페인·과거발송 기준은 dedup 에 없음). 다만 그런 리드는 이미 WS 에 존재하므로 대개 email/company 로도 매칭돼 실질 결과는 수렴한다.
별도 페이지가 아니라 StepMapping 위에 뜨는 단일 모달이 진행률과 완료 결과를 모두 담당한다. 진행 중엔 닫기/ESC 차단.
onComplete: (data) => {
// 너무 빨리 끝나도 진행 화면이 깜빡이지 않게 최소 3초 보장
const wait = Math.max(0, MIN_IMPORT_MS - (Date.now() - importStartRef.current))
setTimeout(() => {
setImportResult(data)
setImportStatus("done")
onDone(data) // 부모: 리드·그룹 쿼리 무효화
}, wait)
}
/leads?groupId={importTargetGroupId} 로 이동