네이버 답장 스레드 연결 이슈 분석

허동연님 문의 후속 분석 — "네이버 회신이 이전 기록과 연결 안 되는 케이스" 데이터 추적

분석 일자: 2026-05-27 분석 대상: Beta DB · 최근 60일 · 네이버 회신 182건 분석자: 이철희
Smoking Gun

코드 버그 발견 — 두 번째 회신이 첫 번째 회신을 덮어씁니다

같은 outbound 메일에 대해 회신이 2번 이상 오면, 두 번째 회신이 첫 번째 회신의 email_replies 매핑을 덮어쓰며 사라지게 만듭니다. 결과: 첫 회신은 inbox UI 에서 "이전 기록"으로 보이지 않습니다.

elysia-server/src/services/unipile-reply.service.ts:218-258

1. 네이버 회신 전체 분포 (최근 60일)

분류 건수 비율 설명
정상 매핑 173 95.1% In-Reply-To 헤더 매칭 성공
매핑 손실 7 3.8% In-Reply-To 있고 outbound 도 DB에 있는데 매핑이 사라짐 ← 버그
In-Reply-To 없음 2 1.1% 사용자가 "새 메일"로 답장한 경우 (헤더 자체 없음)

네이버는 RFC 5322 표준을 잘 준수합니다 (173/182 = 95% 에서 In-Reply-To 정상 전송). 문제는 헤더가 아니라 서버 매핑 코드에 있습니다.

2. 매핑 손실 7건 — 모두 같은 패턴

7건 모두 in_reply_to 가 outbound message_id 와 정확히 일치합니다. 즉, 우리 코드가 매칭만 했다면 100% 연결될 수 있는 케이스. 그러나 매핑 행이 사라졌습니다.

회신자 (네이버) 매핑 잃은 회신 시각 덮어쓴 회신 시각 시간차
yumore_25@naver.com 4-15 18:52:12 4-15 18:52:54 42초 후
mybestlife4u@naver.com 5-08 02:15:06 5-08 02:16:01 55초 후
gounjae@naver.com 4-24 04:47:01 4-24 04:48:25 84초 후
thedajeon@naver.com 4-26 11:41:44 4-29 06:24:25 2.8일 후
eowiz@naver.com 4-21 01:51:03 5-15 03:52:34 24일 후

나머지 2건은 outbound 가 DB 에 없는 케이스 (다른 워크스페이스에서 발송했거나, BCC 로 받은 메일에 답장한 경우 등).

3. 실제 사례 — mybestlife4u@naver.com 스레드 추적

한 스레드에 6개 메일이 모두 정상 저장되어 있지만, UI에는 첫 회신이 안 보입니다.

2026-04-28 17:01 · 📤 OUT 1
(광고) 주식회사 브레인케어플러스 해외 영업 관련하여 제안드립니다 (시퀀스 1단계)
2026-04-30 14:30 · 📤 OUT 2
...한 번 더 연락드립니다 (시퀀스 2단계)
2026-05-07 05:38 · 📤 OUT 3
...K-Food 해외 수요 관련 (시퀀스 3단계)
2026-05-08 02:15:06 · 📥 IN 1 매핑 손실
RE: ... 해외 영업 관련하여 제안드립니다 (mybestlife4u@naver.com 첫 답장)
2026-05-08 02:16:01 · 📥 IN 2 매핑 성공
RE: ... 해외 영업 관련하여 제안드립니다 (동일 사용자 두 번째 답장, 55초 후)
2026-05-15 06:01 · 📤 OUT 4 (수동 회신)
Re: ... 해외 영업 관련하여 제안드립니다 (직원이 답장)

관찰: 6개 메일 모두 동일 thread_id ( <010c019dd50982ba-1022d4c9-...amazonses.com> ) 로 잘 묶여 있음. 즉 스레드 수준은 정상이지만, email_replies 매핑 수준에서 IN 1 이 사라짐. 허동연님이 보고한 "이전 기록 안 보임" 의 정확한 원인.

4. 버그 근원 — 잘못된 UPSERT 로직

unipile-reply.service.ts:214-258 코드 버그

// 5. Create or update email_replies record
const existingReplyResults = await db
  .select({ id: emailReplies.id })
  .from(emailReplies)
  .where(eq(emailReplies.originalEmailId, originalEmail.id))  // ← 원본별 1행만 검색
  .limit(1)

const existingReply = existingReplyResults[0]

if (existingReply) {
  // Update existing reply with the LATEST reply email
  const [updated] = await db
    .update(emailReplies)
    .set({
      replyEmailId: inboundEmail.id,    // ← 새 inbound id로 덮어쓰기!
      intent: null,
      sentiment: null,
    })
    .where(eq(emailReplies.id, existingReply.id))
  emailReply = updated
} else {
  // Create new email_replies record
  await db.insert(emailReplies).values({ ... })
}
설계 의도
원본 outbound 1건 ↔ reply 1건의 1:1 관계로 모델링
현실
실제 사용자는 같은 메일에 2회+ 답장하는 케이스가 흔함 (42초~24일 간격)
결과
두 번째 회신이 들어오면 첫 회신의 매핑이 사라짐 → inbox 에서 안 보임
다른 경로
unipile-webhook-handler.service.ts:279 (mail_received) 와 email-event.service.ts:346 (saveInboundReply) 는 INSERT 만 함. 그러나 email.replied 이벤트가 따로 와서 위 코드가 실행되면 덮어씀.

5. 추가 발견 — Sequence Step 별 thread 분리

1명의 lead 가 여러 thread 로 분산되는 패턴 구조적 이슈

네이버 lead 22,187명 (20%) 이 2~3개 thread 로 분산되어 있음:

thread 분산 정도 lead 수 비율
1 thread (정상) 90,464 80.3%
2-3 threads 22,187 19.7%
4-6 threads 12 ~0%
7+ threads 3 ~0%

원인: Sequence step 마다 새 outbound 가 발송되며, 발송 시 새 message_id + 새 thread_id 가 생성됨. 같은 lead 라도 step 별로 다른 thread.

영향: Lead 가 step 3 에 답장하면 step 1~2 의 outbound 는 inbox 에서 "같은 대화"로 묶이지 않음 — UI 가 thread_id 단위라면.

완화책: Sequence step 발송 시 첫 step 의 thread_id 를 이어받기 (이미 일부 코드는 그렇게 되어있지만 메일 클라이언트별로 차이). 또는 inbox UI 가 lead_id 단위로 그룹화하도록 변경.

6. 4가지 추적 실패 패턴 종합

패턴 네이버 영향 원인 해결
A. 멀티-회신 덮어쓰기 5건 / 60일 unipile-reply.service.ts UPDATE 로직 UPDATE → INSERT 변경 + dedupe by (original, reply_email_id)
B. outbound 없는 회신 2건 / 60일 BCC, 외부 발송에 답장한 케이스 현재 보류 (사용자가 잘못 보낸 케이스)
C. 새 메일로 답장 2건 / 60일 In-Reply-To 헤더 자체 없음 from_email + Subject fuzzy 매칭 fallback
D. Sequence step thread 분리 22K lead 영향 step 별 새 thread_id 생성 inbox UI 를 lead_id 단위 그룹으로 변경

7. 즉시 적용 가능한 백필 + 패치

① 매핑 손실 7건 즉시 복구 (백필)

INSERT INTO email_replies (workspace_id, original_email_id, reply_email_id, is_read)
SELECT
  out.workspace_id, out.id, inb.id, false
FROM emails inb
JOIN emails out
  ON out.message_id = inb.in_reply_to
  AND out.direction = 'outbound'
WHERE inb.direction = 'inbound'
  AND inb.in_reply_to IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM email_replies r
    WHERE r.original_email_id = out.id
      AND r.reply_email_id = inb.id
  );

email_replies 에 (original, reply) unique 가 없다면 모든 회신을 복구. 단, email_replies.original_email_id 에만 의존하는 UI 가 있으면 1행만 노출되므로 ②번 패치 동반 필수.

unipile-reply.service.ts UPSERT → INSERT 로 변경

// AFTER
const existingResults = await db
  .select({ id: emailReplies.id })
  .from(emailReplies)
  .where(and(
    eq(emailReplies.originalEmailId, originalEmail.id),
    eq(emailReplies.replyEmailId, inboundEmail.id),  // ← reply_email_id 까지 매칭
  ))
  .limit(1)

if (existingResults[0]) {
  // 정말 중복 webhook (같은 reply 두 번 처리) — skip
  emailReply = existingResults[0]
} else {
  // 새 회신 — 무조건 INSERT
  const [inserted] = await db
    .insert(emailReplies)
    .values({ ... })
    .returning({ id: emailReplies.id })
  emailReply = inserted
}

③ Inbox UI 가 1:N 회신을 다 노출하는지 확인

email_replies 가 N 행 있을 때 UI 가 모두 표시하는지 검증. 만약 UI 가 SELECT ... FROM email_replies WHERE original_email_id = ? LIMIT 1 같은 단일 행 조회를 한다면 ②번 패치 후에도 효과 없음.

8. 결론