B2B 글로벌 AI 세일즈 자동화 플랫폼 · v2.8.0.0 · principal-level 기술 감사
전체 건강도: B+ — 성숙한 프로덕션 SaaS로 3-layer 분리·default-deny 라우트 인증·migration integrity gate·JWT 하드닝·중앙집중 DOMPurify·구조화 로거(pino) 등 동급 스타트업 대비 상위권 규율. 발견의 대부분은 "근본 결함"이 아니라 성장 부채(god 파일·fork 후 미수거 중복)와 정책 위반(자체 CLAUDE.md 규칙을 코드가 어김)으로, 팀이 옳은 패턴을 알면서 일부 경로에서 적용을 빠뜨린 형태다.
bun test를 전혀 실행 안 함 — 831개 테스트가 머지 게이트 밖, 회귀 무방비 CriticalstoreInboundEmailInDB — SendGrid 인바운드 전체가 테스트·리뷰 불가 단위 Criticaltest:unit 추가(S) — 즉시 회귀 게이트 확보| 목적 | 수출 기업용 해외 바이어 발굴 + AI 이메일 영업 + 리드 관리 올인원(B2B SaaS) |
| 성숙도 | 프로덕션 (alpha/beta 이중 환경, docker-compose+nginx 배포, 정식 결제 Paddle/Toss) |
| 스택 | FE: React 19 + Vite + TanStack Query + Jotai + Radix + Tailwind v4 (yarn4) / BE: Elysia + Bun + Drizzle/Postgres + BullMQ/Redis (bun) / E2E: Playwright(npm) |
| 아키텍처 | BE 3-layer (routes→services→db) + 워커 플릿(BullMQ) + AI 에이전트(Mastra/LangGraph/Vercel ai). FE는 BE 미러, SSE 실시간 진행률 |
| 경로 | 설명 |
|---|---|
elysia-server/src/{routes,services,db} | 백엔드 3-layer 코어 (services 829파일이 비즈니스 로직 중심) |
elysia-server/src/{workers,lib/queue} | BullMQ 워커 플릿 + 큐 레지스트리(SSOT) |
services/buyer-search* | 바이어 탐색 — 3개 병렬 구현 공존(~65k LOC) |
admin/src/{pages,components,lib/api} | FE 페이지·UI·서버상태 훅 |
pages/lead-discovery{,-mastra} | v1↔mastra fork 중복(~3.5k LOC drift) |
.github/workflows, send-ci.sh | CI/CD + 로컬=CI 동일 검증 스크립트 |
docs/ (282파일) + 루트 4 MD | 문서 — ADR·인시던트·플랜·PDF 혼재, sprawl |
keepPreviousData를 금지하는데 핵심 대용량 경로에서 위반 잔존Sentry.init 없음(죽은 설정)yarn.lock이 gitignore된 8개월 orphanpuppeteer가 3곳 선언·버전 drift인데 src에 import 0건심각도 순 · F=Fact, J=Judgment
bun test를 전혀 실행 안 함ci:checkstoreInboundEmailInDB 단일 메서드 1,276줄 (try/catch 23개)| ID | 발견 | 위치 | 영향 | F/J |
|---|---|---|---|---|
| H1 | 비만료·결정론적 디바이스 토큰 wsId.HMAC(wsId,SECRET), per-token revoke 불가(글로벌 시크릿 회전=전체 로그아웃) | workspace-device.service.ts:29-60; recordings-device.routes.ts:79,122,159(전부 public) | 토큰 1개 유출 시 해당 워크스페이스 recordings/contact-import에 영구 쓰기 권한 | J |
| H2 | 3중 buyer-search 구현 공존: base(57f/23k)·pro(5f/3k)·vercel-ai(144f/39k). mastra 큐 배선은 빈 디렉토리(dead) | services/buyer-search*/; dead lib/queue/queues.ts:869 | 스코어링/버그픽스를 2-3곳 중복 적용→drift. ~65k LOC 유지비 | J |
| H3 | OFFSET 페이지네이션 — 대용량 leads 위반(CLAUDE.md 명시 금지, 사례 1500ms→0.147ms) | lead-search.service.ts:697,731 | 깊은 offset에서 O(offset) 스캔, 대용량 워크스페이스 성능 저하 | F |
| H4 | OFFSET on emails/sequence_enrollments(둘 다 고성장) | email-replies.service.ts:158; sequence-enrollment.service.ts:135,180,317 | 동상 — 깊은 페이지 스캔 | F |
| H5 | Sentry DSN을 config에서 읽지만 @sentry 미설치·Sentry.init 없음 → 프로덕션 에러 트래킹 부재 | config.ts:398 | 비-워커 경로(라우트/서비스) 미처리 에러 가시성 공백(Slack+pino에만 의존) | F |
| H6 | 핵심 비즈로직 테스트가 live-DB env 게이트로 기본 skip(env 없으면 조용히 통과). 24개 파일 | deals.service.test.ts:20-22 외 23 | CI에 없을 뿐 아니라 로컬에서도 env 없으면 거짓 green | F |
| H7 | 서비스 26%(215/829)·워커 9%(12/129) 테스트. 발송 핵심 워커 거의 무테스트 | src/workers/, src/services/ | 매출·발신 평판 직결 경로 회귀 무방비 | F |
| H8 | CI cost-gate denylist: 핵심 기여자 6명(+bot) push/PR은 CI 전체 skip | .github/workflows/ci.yml:30-33 | 가장 자주 머지하는 계정이 lint·typecheck·migration gate 우회 | J |
| H9 | root eslint:"*" dead dep; puppeteer 3곳 선언·24.37↔24.38 drift·src import 0건이며 full puppeteer가 deps에 위치 | package.json:23,24; elysia-server/package.json:83,165,176 | 죽은/오배치 의존성, 설치 비대·비결정성 | F/J |
| ID | 발견 | 위치 | F/J |
|---|---|---|---|
| M1 | lib/queue/queues.ts 4,636줄(87 Queue, 38개 near-identical addXxxJob 래퍼) + types.ts 2,410줄 — 머지충돌 자석 | lib/queue/queues.ts | F |
| M2 | webhook.service.ts 2,578줄 단일 클래스에 3개 도메인(ingestion/engagement/enrollment) 혼재 | services/webhook.service.ts | F |
| M3 | FE god 컴포넌트: ChatRoom.tsx 2,046줄(useEffect15/useState9/13 props), UpdateNoteForm.tsx 1,854줄 | lead-discovery-mastra/ChatRoom.tsx | J |
| M4 | FE lead-discovery v1↔mastra fork 중복, 이미 drift(ClarificationCards 292↔295 등 ~3.5k LOC) | pages/lead-discovery{,-mastra}/; router/dashboard-routes.tsx:194 | J |
| M5 | FE OFFSET+keepPreviousData — CLAUDE.md 이중 금지 위반 | SequenceEnrollmentsTable.tsx:863,1277; hooks/sequences.ts:216-237 | F |
| M6 | COUNT(DISTINCT)가 OLTP db 풀에서 실행(analyticsDb import 0). lead-fit은 쓰기경로마다 호출 | ui-events.service.ts:175; lead-fit-feedback.service.ts:236 | F |
| M7 | subscription-utils.service.ts catch→return null — DB 에러를 "구독 없음"으로 마스킹, 티어 게이팅 오판 위험 | subscription-utils.service.ts:68,98,144 | J |
| M8 | 미인증 Slack 알림 트리거(public) — 인터넷 누구나 내부 채널 flood 가능(글로벌 rate limit만 의존) | routes/discover-email.routes.ts:135-167 | J |
| M9 | 문서 sprawl: docs/ 282파일 + 루트 4 MD(CHANGELOG 141KB). ADR 2개만 번호, 형제 5개 미번호 | docs/, docs/architecture/ | F/J |
| M10 | @types/* 5개가 elysia dependencies에 위치; Biome 버전 drift; @typescript/native-preview가 "beta" 이동태그 → CI 비재현 | elysia-server/package.json:74,85,117-121 | F |
| M11 | integration 테스트가 실제 TCP 소켓 fetch(포트 경합 flaky 소지); E2E 자동 트리거 비활성(수동 dispatch만) | routes/public/__tests__/*.test.ts:80; e2e-on-alpha.yml:3-6 | F |
auth.service.ts:220 JWT expiresIn as any(보안 인접) · webhook/auth 경계 formData as any(C2 입력원) · console.log 148건 pino와 혼재 · root yarn.lock gitignore된 8개월 orphan · CLAUDE.local.md git-tracked · FE @ts-ignore 1건 · exhaustive-deps suppress 77건(email-replies 집중) · device 토큰 시크릿이 JWT_SECRET로 fallback · trial-analytics.service.ts:214 수작업 quote-escape + sql.raw(현재 admin UUID만, 비exploit)
any 35개; FE는 2,138파일 중 escape hatch 20개·deprecated useMe() 0just setup 동작, README↔code↔version 동기(v2.8.0.0)5개 테마가 발견의 대부분을 설명한다.
test:unit 실행·실패 시 차단. 원칙: 테스트는 실행돼야 자산이다.console.log 148건 일괄 치환 안 함 — 저영향, 점진 교체."beta"→고정핀만.worker.ts 2,182줄 — 위임형 레지스트리라 분리 우선순위 낮음.test:unit 실패 시 fail (C1 닫힘)| 태스크 | 위치 | 검증 |
|---|---|---|
QW1 root eslint:"*" 제거 + puppeteer 3중선언 정리 | package.json:23,24 | build green |
QW2 @types/* 5개 deps→devDeps, "beta"→고정핀, Biome 버전 정렬 | elysia-server/package.json:74,85,117-121 | CI 재현성 |
QW3 orphan root yarn.lock 정리, CLAUDE.local.md 추적 의도 확인 | .gitignore | git clean |
QW4 dead buyer-search-mastra 큐 배선 제거 | lib/queue/queues.ts:869 | grep 0 |
QW5 subscription-utils null-on-error → 에러 구분(티어 오판 차단) | :68,98,144 | 단위테스트 1건 |
| ID | 태스크 | Accept | 노력 | 리스크 | 의존 |
|---|---|---|---|---|---|
| M0-1 | CI에 test:unit 추가(양 워크스페이스 ci:check에 병합) | 일부러 깬 테스트가 PR fail | S | 낮음 | — |
| M0-2 | live-DB 게이트 테스트를 CI에 ephemeral PG로 실행(24개 skip 해제) | env 없이도 실행, green | M | 중 | M0-1 |
| M0-3 | 발송 핵심 워커 happy+failure 테스트 추가 | 각 워커 ≥2 테스트 | L | 낮음 | M0-1 |
| M0-4 | CI denylist 재검토(lint+typecheck+migration gate는 우회 불가로) | denylist 계정도 게이트 통과 강제 | S | 중 | — |
| ID | 태스크 | Accept | 노력 | 리스크 | 의존 |
|---|---|---|---|---|---|
| M1-1 | storeInboundEmailInDB(1,276줄) 책임 단위 분리 | 함수당 ≤120줄, 인바운드 테스트 green | L | 높음 | M0-1,M0-3 |
| M1-2 | 디바이스 토큰 DB기반 만료·per-token revoke 스킴 | 토큰 개별 폐기 가능 | L | 높음 | — |
| M1-3 | device 토큰 시크릿 가드+JWT 결합 분리; 미인증 Slack 트리거 per-route rate limit | 약한 시크릿 거부, rate limit 동작 | S | 낮음 | — |
| M1-4 | M7 검증 후 billing-gate 정확성 회귀 테스트 | DB 에러≠"구독없음" 테스트 | S | 낮음 | QW5 |
| ID | 태스크 | Accept | 노력 | 리스크 | 의존 |
|---|---|---|---|---|---|
| M2-1 | buyer-search canonical 확정·나머지 2 삭제(~65k→~39k LOC) | 디렉토리 1개, E2E green | XL | 높음 | M0-1, OQ1 |
| M2-2 | 대용량 OFFSET→keyset 전환 + FE 무한스크롤 | 해당 경로 .offset( 0, lint gate | L | 중 | M0-1 |
| M2-3 | COUNT(DISTINCT) analyticsDb 라우팅(특히 쓰기경로 lead-fit) | 부하테스트 spill 0 | M | 낮음 | — |
| M2-4 | Sentry(or 대체) 실연결 — 라우트/서비스 미처리 에러 캡처 | 의도적 에러가 대시보드 기록 | M | 낮음 | — |
| M2-5 | 신규 OFFSET/keepPreviousData/OLTP COUNT(DISTINCT) 차단 lint/check 스크립트 | 위반 PR fail | M | 낮음 | M2-2 |
| ID | 태스크 | 노력 |
|---|---|---|
| M3-1 | queues.ts 38 addXxxJob→typed enqueue() 헬퍼, 도메인별 파일 분리 | L |
| M3-2 | FE lead-discovery v1 retire(mastra 확정 후) — M4 중복 제거 | M |
| M3-3 | god 컴포넌트 ChatRoom/UpdateNoteForm 책임 분리 | L |
| M3-4 | webhook/auth 경계 as any 타입화, FE @ts-ignore→@ts-expect-error | S |
| M3-5 | 문서 아카이빙, ADR 번호 정합, CONTRIBUTING↔CLAUDE.md DB섹션 링크 | M |
| M3-6 | console.log 148건 점진 pino 교체, exhaustive-deps suppress 집중 리뷰 | M |
ci:check에 bun test:unit(BE)·bun test(FE) 추가. long-running/integration은 분리 유지(M0-2).| # | 질문 | 막힌 태스크 |
|---|---|---|
| OQ1 | buyer-search 3구현 중 canonical은? 트래픽이 vercel-ai로 전량 가는가, pro/구버전이 티어·실험으로 분기되는가? | M2-1 |
| OQ2 | lead-discovery v1을 retire해도 되는가? /lead-discovery-v1가 최근까지 편집됨 — 의도적 폴백인가? | M3-2 |
| OQ3 | 디바이스 토큰: 코드 주석은 "beta 가면 DB 토큰 교체" — 지금 beta 운영 중인가? 그렇다면 H1은 즉시. | M1-2 |
| OQ4 | 에러 트래킹: Sentry 도입 vs DSN config 제거(Slack+pino 유지) 중 어느 방향? | M2-4 |
| OQ5 | CI denylist(핵심 기여자 skip)의 비용 vs 안전 — migration/auth gate만이라도 강제해도 되는가? | M0-4 |
| OQ6 | 성능 타깃: keyset 전환의 현재 통증 임계(워크스페이스당 row 수)는? | M2-2 |