Rinda · Frontend Deep-Dive

바이어 업로드 마법사 /leads/add
단계별 코드 흐름

CSV/Excel 파일을 업로드해 워크스페이스의 바이어(리드)로 가져오는 3단계 위저드. 그룹 선택 → 파일 업로드 → 컬럼 매핑 → SSE 파이프라인(파싱·중복제거·이메일검증·저장)으로 동작한다. 아래는 실제 컴포넌트·서비스 함수 기준 분석.

라우트 정의
router/dashboard-routes.tsx:173
컨테이너 컴포넌트
pages/leads/AddBuyersPage.tsx
상태 관리
useState (Step 머신)
백엔드 엔드포인트
/api/v1/smart-import/*
0
진입
URL 파라미터 분기
1
그룹 선택
StepGroupSelect
2
파일 업로드
StepUpload · analyze
3
컬럼 매핑
StepMapping · start
4
진행·완료
ImportStatusDialog
0

진입 — URL 파라미터로 시작 단계 결정

AddBuyersPage.tsx
L19–87

AddBuyersPage 는 쿼리파라미터를 읽어 어느 단계부터 시작할지워크스페이스·그룹 컨텍스트를 정한다. Step 머신은 단순 useState 3-값 유한상태기다.

URL 파라미터

파라미터역할
groupId대상 그룹 ID. 있으면 그룹 선택 단계를 건너뛰고 upload 로 시작
wsId워크스페이스 ID. analyze/start 요청의 X-Workspace-Id 헤더로 전달
modenew면 그룹 선택 화면이 "새 그룹 생성" 탭으로 열림
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)을 건너뛰고 곧장 파일 업로드부터 시작한다. 상단엔 대상 그룹명 배너가 뜬다.
1

그룹 선택 — 기존 그룹 or 새 그룹

add-buyers/StepGroupSelect.tsx

바이어를 담을 고객 그룹을 정하는 단계 (groupId 없이 진입했을 때만 노출). "기존 그룹" / "새 그룹 생성" 토글로 갈린다.

데이터 훅

  • useCustomerGroupsByWorkspace(workspaceId) — 워크스페이스 내 그룹 목록(리드 수 포함) 조회
  • useCreateCustomerGroup() — "새 그룹 생성" 모드의 저장 mutation

화면 구성

  • 기존 그룹 모드: Popover 드롭다운 — 각 항목에 그룹명 + "(n건)" 리드 수 표시
  • 새 그룹 모드: CustomerGroupForm — 그룹명·설명 입력
  • Back / Proceed 버튼

전환 트리거

// 기존 그룹 선택 후 Proceed
onGroupResolved(selectedGroup.id, selectedGroup.workspaceId)   // → 부모 handleGroupResolved → 'upload'

// 새 그룹 생성
createGroup.mutate(form, {
  onSuccess: (g) => onGroupResolved(g.id, g.workspaceId),
})
2

파일 업로드 — 드롭존 + AI 컬럼 분석

add-buyers/StepUpload.tsx

CSV/XLSX 파일을 드롭하면 즉시 백엔드에 보내 컬럼 자동 매핑 제안을 받는다. 사용자가 직접 매핑 버튼을 누르지 않아도 다음 단계로 넘어간다.

검증 & 제약 (onDrop)

  • 허용 형식: .csv / .xlsx / .xls, 단일 파일
  • 최대 크기 50MB (MAX_FILE_SIZE_BYTES), 0바이트 빈 파일 차단
  • UTF-8 BOM 템플릿 다운로드 제공 (CSV/XLSX) — 한글 깨짐 방지

파일 분석 (mutation)

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로 일치하도록 카피를 맞춘 것 (과거엔 실제로 안 하는 후속 기능을 약속해 혼란을 줬음).
3

컬럼 매핑 — 확인 후 임포트 시작

add-buyers/StepMapping.tsx

제안된 매핑을 사람이 검수하는 단계. 좌측은 "저장 위치"(드롭다운), 우측은 내 파일의 실제 샘플값(첫 행). 저신뢰 칸은 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
}

검증 게이트

  • 차단(red): 식별자(회사명·웹사이트·이메일) 0개 → 업로드 버튼 비활성 + 에러 토스트
  • 경고(blue, 비차단): 식별자는 있으나 이메일 미매핑 → 보강 필요 안내
  • 주의(amber): 신뢰도 낮은 칸 개수 노출, 차단하진 않음

"업로드 시작" 핸들러 체인

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 이벤트 → 콜백 매핑 (services/smart-import.ts)

SSE event콜백의미
pipeline_steponPhaseStart / onPhaseComplete각 단계 시작·완료
import_progressonProgress저장 진행률 (imported/total)
verify_progressonProgress이메일 검증 진행률
pipeline_completeonComplete최종 통계 → done
erroronError실패 단계 표시 + 토스트

심층 — 제외 필터 & 제외 결과

ExclusionPreviewModal.tsx
exclusion-preview.ts

제외 로직은 두 곳에서 동작한다 — 임포트 "미리보기 모달"과 임포트 "완료 요약". 성격이 다르다: 모달은 안내·동의용(미리보기 전용), 실제 제외는 백엔드 dedup 단계가 수행한다.

A. 임포트 전 — 4가지 제외 기준 (ExclusionPreviewModal)

기준필터 키토글의미
같은 회사명 존재byCompanyNameON/OFF동일 회사명 이미 있음
같은 이메일 존재byEmailON/OFF동일 이메일 이미 있음
이미 메일 발송한 리드byPastSentON/OFF과거 발송 이력 있음
활성 캠페인 등록 리드byActiveSequence🔒 강제 ONlock 아이콘 + "강제 제외" 배지, 끌 수 없음

표시 방식

  • 각 섹션 우측에 매칭 건수 matchedCounts[reason] 표기
  • 토글 변경 → preview.mutate({records, filters})POST /exclusion-preview 로 카운트 실시간 갱신
  • 하단에 "제외 후 등록 예정: N건"(willImport) 강조
  • 미리보기 실패 시 빨간 에러 박스 + "그대로 진행" 버튼 → onConfirm(null, null) 강행
  • 등록 버튼은 !result || willImport <= 0 이면 비활성
핵심 함정 — 모달은 미리보기 전용. handleExclusionConfirmonConfirm(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.

B. 임포트 후 — 완료 요약 결과 (ImportStatusDialog)

SSE pipeline_complete 통계로 계산해 표시. 중복이어도 그룹에 새로 편입된 건은 "제외"에서 빼는 게 포인트.

표시 항목계산식
추가됨 (메인)addedToGroup + existingAddedToGroup
기존 바이어 편입existingAddedToGroup (>0일 때만)
중복 제외max(0, duplicates − existingAddedToGroup)
이메일 오류 제외leadsRejectedByVerification ?? emailsVerificationFailed
이메일 없어 그룹 제외max(0, imported − addedToGroup)
  • 제외 라인: 중복+이메일 둘 다 / 중복만 / 이메일만 — 3분기 텍스트(exclusionLine)
  • 이메일 없어 제외: 리드는 바이어 목록엔 저장됐지만 이메일이 없어 그룹엔 미편입 → 별도 라인
  • 단위 주의: 이메일 오류는 리드(행) 수 기준 — emailsVerificationFailed(이메일 개수)와 단위가 달라 leadsRejectedByVerification 우선
중복이어도 그룹 편입 성공 건은 existingAddedToGroup 으로 따로 안내 — 과거 imported(신규)만 보여줘 "0건 추가" 패닉을 유발하던 버그의 수정 흔적.
DB

케이스별 처리 — beta 실데이터 (린다세일즈)

smart-import.service.ts
dedup-normalize.ts · lead-ingestion.service.ts

"새 업로드 리드가 어디에 어떻게 존재하느냐"에 따라 처리가 갈린다. 핵심 두 가지를 먼저 못박자.

① 매칭 스코프 = 워크스페이스 전체. dedup 은 WHERE leads.workspace_id = ?그룹이 아니라 워크스페이스 전체 기존 리드와 대조한다. 어느 그룹에 있든 같은 워크스페이스면 "중복"으로 잡힌다.  ② 중복 ≠ 버림. 중복이라 신규 생성은 안 해도, 대상 그룹에 없으면 그 리드의 멤버십은 대상 그룹에 새로 추가된다.

중복 판정 우선순위 & 정규화 (dedup-normalize.ts)

  • 1순위 이메일lead_contacts.contact_value (type='email'). 정규화: NFC + 소문자 + trim + 형식검증
  • 2순위 웹사이트leads.website_url host. www. 제거, 소문자
  • 3순위 회사명leads.company_name. 정규화: 악센트 제거 + 소문자 + (주)·(유)·주식회사·inc·ltd·llc·corp·gmbh 등 접미/접두 제거
  • 위 순서로 하나라도 맞으면 즉시 중복 확정(이후 순위 미검사). 파일 내부 자가중복도 같은 키로 제외
회사명 정규화 효과(실데이터): 린다세일즈의 (주)아모레퍼시픽·(주)모다이노칩(60건)·(주) 아로마티카 같은 표기는 모두 (주)·공백이 제거돼 아모레퍼시픽 과 동일 키로 매칭된다.

네 가지 케이스 → 처리 결과

새 업로드 리드가…dedup 판정대상 그룹 처리완료화면 표기
① 추가하려는 그룹에 이미 있음 중복 (WS 전체 매칭) 이미 멤버 → alreadyInGroupIds 로 걸러 변화 없음 "중복 N건 제외"
(dupExcluded)
② 다른 그룹에만 있음
(같은 WS)
중복 (WS 전체 매칭) 대상 그룹 미소속 → addEmailBearingLeadsToGroup대상 그룹에 새 편입 "기존 바이어 N건 추가 편입"
(existingAddedToGroup)
③ 회사명만 같음
(이메일·URL 다름/없음)
3순위 회사명 매칭 → 중복 ① 또는 ② 규칙을 그대로 따름 중복 제외 또는 편입
④ 이메일만 같음 1순위 이메일 매칭 → 중복
(회사명 달라도 중복)
① 또는 ② 규칙을 그대로 따름 중복 제외 또는 편입

그룹 편입의 추가 게이트 — "발송 가능한 이메일"만 (lead-ingestion.service.ts)

  • 중복이든 신규든, 그룹 멤버가 되려면 verification_status ≠ 'failed' 인 이메일 contact 가 1개 이상 있어야 함 (그룹 = 콜드메일 발송 단위)
  • 이메일이 아예 없거나 전부 failed → 리드는 leads(회사 리스트)엔 남지만 그룹엔 미편입 → 완료화면 "이메일 없어 그룹 제외 N건"
  • INSERT 는 onConflictDoNothing() — (group_id, lead_id) 유니크라 재실행해도 안전

린다세일즈 실측 (workspace b3e2c3bf…)

665,389
전체 리드
154개 그룹
18%
다중 그룹 소속 리드
2개 12.3만 · 3~5개 1.2만 · 6+ 218
failed 2건
email contact 70만 중
legacy 66.4만 · verified 2.6만
케이스 ②가 흔하다. 린다세일즈는 리드의 18%(13.5만건)가 2개 이상 그룹에 중복 소속. "국내 뷰티(디비365)" 39,580명을 다른 그룹에 다시 올리면 → 전부 중복 판정되지만 failed 가 0건이라 거의 전량 대상 그룹에 "추가 편입"된다(신규 0 · 편입 ~39,569).
실무 함정 — 공용 이메일. 이메일이 1순위 키라, 린다세일즈에서 help@coupang.com(1,558 리드)·shift@h-201.com(243 리드)처럼 여러 회사가 공유하는 대표 이메일은 회사가 달라도 첫 매칭 리드와 중복 처리돼 신규 생성이 막힐 수 있다.

미리보기(4기준) vs 실제 임포트(3기준) 불일치

미리보기 모달은 active_sequence·past_sent까지 4기준으로 "제외 예정"을 보여주지만, 실제 임포트 dedupemail·url·company 3기준만 판정한다(활성캠페인·과거발송 기준은 dedup 에 없음). 다만 그런 리드는 이미 WS 에 존재하므로 대개 email/company 로도 매칭돼 실질 결과는 수렴한다.
4

진행 + 완료 — 통합 모달

add-buyers/ImportStatusDialog.tsx

별도 페이지가 아니라 StepMapping 위에 뜨는 단일 모달이 진행률과 완료 결과를 모두 담당한다. 진행 중엔 닫기/ESC 차단.

4단계 파이프라인 (백엔드 실제 단계)

1. parsing
파일 읽기
validRows · emptyRowsSkipped
2. dedup
중복 제거
unique · duplicates
3. verifying
이메일 검증
verified · failed
4. importing
리드 저장
importedCount

완료 처리

onComplete: (data) => {
  // 너무 빨리 끝나도 진행 화면이 깜빡이지 않게 최소 3초 보장
  const wait = Math.max(0, MIN_IMPORT_MS - (Date.now() - importStartRef.current))
  setTimeout(() => {
    setImportResult(data)
    setImportStatus("done")
    onDone(data)        // 부모: 리드·그룹 쿼리 무효화
  }, wait)
}

완료 화면 결과 요약

  • n건 추가됨 (신규 + 기존 바이어 그룹 편입)
  • 기존 바이어 m건 새 그룹 추가 편입 / 중복 k건 제외 / 이메일 오류 j건 제외
  • "바이어 보기" → /leads?groupId={importTargetGroupId} 로 이동
완료 시 TanStack Query 캐시 무효화로 리드 목록·그룹 카운트가 자동 갱신된다 — 사용자가 새로고침할 필요 없음.