분석 리포트

답장 잘 받는 메일 — 정확성·성능 개선 종합

어드민 콘솔의 "답장 잘 받는 메일" 페이지에서 발견된 4가지 결함(쿼리 속도·답장 카운트·Sheet 표시·렌더링) 과 후속 카운트 정확성 문제까지, 베타 DB 실측 기반으로 진단·개선해 alpha/beta 양쪽 머지를 완료한 리포트.

📅 2026-05-28 👤 chlee (alpha · beta admin) 🌳 base alpha / beta 🧪 검증 beta DB ws b3e2c3bf-... (31만 outbound)

핵심 수치

쿼리 시간 (30일 / 31만 outbound)
916ms → 410ms
−55% (PR #7984)
답장 미리보기 쿼리
0.2ms
Index Scan only (PR #7988)
답장 카운트 정확성
max 1 → max 5
thread_id union 으로 multi-reply 잡힘
머지 PR (alpha + beta)
8개
#7982 ~ #7989

1. 사용자 보고 → 진단

"받은 답장 (1) 안 맞음"

한 outbound 메일에 답장이 여러 번 와도 Sheet 의 카운트가 항상 "(1)" 로 보였던 문제. 베타 DB 실측으로 원인 확인:

방식 multi-reply (≥2건) 잡힌 outbound 최대 답장 수 (max)
email_replies 매핑만 2건 / 124 2
thread_id 매칭 32건 / 124 (25%) 5
둘을 UNION 32건 (정확) 5
원인. email_replies 는 outbound 당 first reply 만 매핑하는 경향. 같은 thread 의 inbound 메일이 있어도 추가 매핑이 안 들어감. 사용자가 본 "(1)" 의 직접 원인. thread_id 매칭은 multi-reply 정확하지만 일부 케이스 매칭 누락 → 둘을 UNION 해 한 쪽에라도 잡히면 카운트.

SQL 성능 — LEFT JOIN cross-product

v1 의 emails LEFT JOIN email_repliesGROUP BY 는 한 메일에 답장 N건일 때 emails 행이 N배로 복제 + email_replies 가 workspace 필터 못 받아 전체 Seq Scan 으로 cache 오염.

기간v1 (LEFT JOIN + array_agg)v2 (단일 GROUP BY + MAX FILTER)
7일~30ms11ms
30일508~916ms410ms
90일> 1s< 1s

array_agg(... ORDER BY ...) 가 31만 행 external merge sort (디스크 27MB) 강제 → MAX(id::text) FILTER (...) + UUIDv7 시간순 prefix 활용해 sort 제거.

이메일 렌더링 일관성

초기 버전은 sanitizeEmailHtml + dangerouslySetInnerHTML 단순 호출 → email-replies / email-detail 페이지의 표준 파이프라인 (decode → DOMPurify → bracket 치환 → cid 이미지 → MIME 정리) 누락. 표준 EmailBody 컴포넌트로 교체해 다른 페이지와 동일 렌더.

Sheet 데이터 표시 누락

emailId 를 nuqs useQueryState 로만 보관 → 클릭 직후 URL push 가 비동기라 첫 render 에 stale. useState 로 분리해 즉시 fetch.

2. 머지된 PR 타임라인

3. 최종 동작

접근. app.beta.rinda.ai/admin?tab=top-reply-emails · 워크스페이스 선택 = 분석 대상 (X-Workspace-Id 헤더 기준)
  • 현재 워크스페이스 답장률 Top 10 캠페인 + 스텝 (campaign-health 와 동일 기준)
  • 4 컬럼 정렬: 발송 / 답장 / 답장률 / 마지막 답장
  • 기간 토글: 30일 기본 + 7일 / 90일
  • 시퀀스명 클릭 → /sequences/edit?id=... 캠페인 편집 페이지로 이동
  • 행 클릭 → 우측 Sheet: 발송 메일 본문 (표준 EmailBody) + 답장 미리보기 최신 3건
  • "받은 답장 (N)" 카운트가 진짜 답장 수 (multi-reply 포함). 3건 초과 시 "최신 3건 표시 / 총 N건" 보조 라벨
  • 표본 5건 미만 캠페인 제외 + empty state 안내 카피

4. PG18 최적화 노트

모든 쿼리에서 활용된 인덱스:

  • emails_workspace_sent_at_idx (workspace_id, sent_at) — 캠페인/스텝 집계의 base scan
  • emails_thread_id_idx (thread_id) — 답장 union 의 두번째 subquery
  • email_replies_original_email_id_idx (original_email_id) — 답장 union 의 첫 subquery
  • emails_pkey (id) — preview 의 emails JOIN

UUIDv7 시간순 prefix 덕분에 MAX(id::text) FILTER (...) 가 latest = max — 별도 sort 회피. analyticsDb (work_mem 256MB) 사용으로 대규모 집계 디스크 spill 방지.