자동응답이 받은 답장함을 어지럽히는 문제를 발송 단계(예방) · 분류 단계(정확도) · 조회 단계(필터) 세 층위에서 끊어내는 설계안. 현행 코드 근거와 변경 지점, 우선순위까지 정리.
세 요청은 같은 증상("자동응답이 너무 많이 보인다")의 서로 다른 층위다. 하나만 고치면 나머지가 새어 나온다 — 세 층을 함께 막는다.
neutral로 흘리지 않게 분류·신호 융합| # | 기능 | 핵심 변경 | 레이어 | 난이도 | 가치/리스크 |
|---|---|---|---|---|---|
| 1 | 국가별 영업일 발송 하드룰 | Redis Lua + 공휴일 캘린더로 슬롯 예약 시 비영업일을 다음 영업일로 이동 | 발송 워커 | L (높음) | 가치 高 / 리스크 中 (발송 타이밍 회귀 주의) |
| 2 | 자동응답 중립 오분류 개선 | OOO 프롬프트 강화 + isAutoReply 휴리스틱 ↔ intent 융합 보정 |
AI 분류 | S~M (낮음) | 가치 中 / 리스크 低 (가장 빠른 ROI) |
| 3 | 자동응답 표시/숨김 필터 | autoReply 쿼리 파라미터 + 사이드바 토글 |
조회 (FE+BE) | M (중간) | 가치 中 / 리스크 低 (②와 결합 시 효과 극대) |
isAutoReply + intent out_of_office)는 ③ 필터의 판정 기준이자 ①의 효과 측정 지표가 된다. "자동응답"의 정의를 한 곳에서 통일(SSOT)하고 세 기능이 이를 공유한다.
FE는 URL params만 상태 SSOT(Jotai 미사용), BE는 스레드 기반 keyset 무한스크롤. 분류는 AI 8-intent → 화면은 3-bucket으로 축약.
replied-emails + :emailIduseRepliedEmailsInfiniteGET /emails/search-repliedsearchRepliedThreads → :1090 applyEmailRepliesFilterConditionsemails.isAutoReplyINTENT_BUCKET_OF (8→3, neutral 숨김)emails.isAutoReply)과 AI intent(out_of_office)가 서로 모른다. 휴리스틱이 자동응답으로 잡아도 LLM이 neutral을 주면 화면 bucket에서 사라지고(②), 필터로도 걸러낼 수 없다(③). ①로 유입을 줄여도 남은 건 여전히 새어 나온다.
수신자 국가의 주말·공휴일에는 발송하지 않는다. 비영업일 발송이 부재중/휴가 자동응답을 대량 유발하므로, 유입을 원천에서 줄인다.
현재 발송 스케줄러는 provider별 간격(throttle)과 일/월 한도만 본다. 수신자 현지 시각이 토요일이든 공휴일이든 발송된다 → 부재중 자동응답이 즉시 회신되어 받은 답장함에 쌓인다.
| 자산 | 위치 | 상태 |
|---|---|---|
| 영업일/영업시간 유틸 | utils/timezone.ts:91 calculateScheduledTime · skipToNextBusinessDay · clampToBusinessHours | 존재 (주말만, 공휴일 미반영) |
| BusinessCalendar 스켈레톤 | modules/email-automation/domain.ts:80 isBusinessOpen · nextBusinessSlot | holidays Set 비어있음 |
| 캘린더 어댑터 | services/business-calendar.adapter.ts | 공휴일 소스 미연결 |
| 수신자 국가/타임존 | db/schema/leads.ts:104 country · :108 timezone | 컬럼 존재 |
| 슬롯 예약 Lua (예약 시점) | lib/queue/sequence-email-scheduler.ts:91 RESERVE_NEXT_SLOT_LUA | 원자적 예약 already |
| 발송 직전 게이팅 | workers/.../steps/pre-check.ts:45 · send-email.ts:613 | 가드 삽입 지점 |
timezone.ts의 영업일 계산 + BusinessCalendar 스켈레톤이 이미 있다. 빠진 건 ⓐ 공휴일 데이터 소스, ⓑ 예약 Lua에 비영업일 이동 로직, ⓒ Lead 국가→캘린더 연결 세 가지뿐.
holidays:{ISO2}:{year}에 캐싱(TTL 1년). 미지원 국가는 주말 규칙만 적용(graceful fallback).RESERVE_NEXT_SLOT_LUA 예약 직후, 계산된 baseTimeMs를 수신자 타임존 기준 날짜로 환산해 주말/공휴일이면 다음 영업일 09:00로 이동. Redis 안에서 원자적으로 처리 → 워커 다중에서도 race-free.country/timezone을 인자로 전달. 발송 직전 send-email.ts:613에서 최종 재확인(2차 가드).-- KEYS[1]=throttle slot key ARGV: baseMs, tz offset(min), holiday set key, openHour local base = tonumber(ARGV[1]) local localDay = day_of_week(base, ARGV[2]) -- 0=일 .. 6=토 local dateStr = ymd(base, ARGV[2]) -- "2026-06-10" while localDay == 0 or localDay == 6 or redis.call('SISMEMBER', ARGV[3], dateStr) == 1 do base = next_day_at(base, ARGV[2], tonumber(ARGV[4])) -- 익일 09:00 현지 localDay = day_of_week(base, ARGV[2]); dateStr = ymd(base, ARGV[2]) end redis.call('SET', KEYS[1], base) return base
services/holiday-calendar.service.ts — 공휴일 fetch + Redis 캐싱 + 동기화 cronlib/queue/sequence-email-scheduler.ts — Lua에 비영업일 이동, reserveNextSlot(country, tz) 시그니처 확장utils/timezone.ts — skipToNextBusinessDay에 holidays 인자 추가(주말만 → 주말+공휴일)workers/.../steps/pre-check.ts · send-email.ts — Lead country/tz 전달country/timezone 결측 리드 다수 → 워크스페이스 기본 캘린더 fallback 정책 필요.copywriting-ux.md 금지어 회피("워밍업"·"보호 모드" X → "현지 영업일에 맞춰 예약됨" 류).명백한 자동응답인데 neutral로 분류돼 화면 bucket·필터에서 누락되는 문제. 프롬프트 강화와 신호 융합으로 근본 해결(특정 케이스 패치 지양).
| 원인 | 코드 근거 | 증상 |
|---|---|---|
| OOO 정의가 빈약 | ai-classification.service.ts:135 — "Automated out-of-office or vacation reply" 한 줄뿐 | 한국어 부재중/연차/휴가 자동회신, 다국어 OOO를 놓침 → neutral |
| 애매하면 neutral fallback | :174 isReplyIntent(parsed.intent) ? … : "neutral" | 확신 낮은 자동응답이 전부 중립으로 흡수 |
| 휴리스틱과 미연결 | auto-reply-detection.ts → emails.isAutoReply (intent와 별개 저장) | 헤더로 자동응답 확정해도 intent는 보정 안 됨 |
out_of_office 정의에 다국어 예시(부재중/연차/휴가/자동 회신, "currently away", "返信は", "out of office until")와 규칙 추가: "톤이 중립이어도 명백히 자동 생성된 메일이면 out_of_office로 분류". 근본 일반화이지 입력별 패치 아님.isAutoReply === true이고 intent가 neutral|null이면 out_of_office로 보정. 헤더라는 결정적(deterministic) 신호가 확률적 LLM 출력보다 우선. → ②의 누락 + ③의 필터 정확도를 동시에 끌어올림.reply-classifier-analytics에 기록해 프롬프트 개선 효과를 정량 측정.// ai-classification.service.ts — parse 직후 신호 융합 if (email.isAutoReply && (intent === "neutral" || intent == null)) { intent = "out_of_office" // 결정적 헤더 신호 우선 reasoning += " [overridden by header heuristic]" }
services/ai-classification.service.ts — 프롬프트 §INTENT 4번 강화 + parse 후 융합 로직email.isAutoReply에 접근 가능한지(아니면 인자로 주입)__tests__/ai-classification-*.test.ts에 다국어 OOO + 융합 케이스 추가받은 답장함에서 자동응답을 숨기고/보기/자동응답만 보기로 토글. 현재는 intent 양성 선택만 가능하고 "제외" 옵션이 없다.
필터는 intentFilter === bucket 양성 선택뿐(email-search.service.ts:1102). auto_reply bucket을 골라야만 out_of_office가 보이고, 반대로 "자동응답 빼고 보기"는 불가능. emails.isAutoReply 컬럼은 저장돼 있으나 쿼리 필터로 노출되지 않는다 — applyEmailRepliesFilterConditions에 isAutoReply 분기 자체가 없음.
| 값 | 의미 | SQL 조건 (자동응답 = OR 융합) |
|---|---|---|
include | 전체 (기본) | 조건 없음 |
exclude | 자동응답 숨기기 | NOT (emails.isAutoReply OR intent = 'out_of_office') |
only | 자동응답만 | emails.isAutoReply OR intent = 'out_of_office' |
isAutoReply)과 AI intent(out_of_office)를 OR로 묶어 "자동응답"을 판정. ②의 융합 보정이 들어가면 두 신호가 대체로 수렴하지만, OR로 둬야 경계 케이스도 안전하게 잡힌다.
autoReply: t.Optional(t.Union(['include','exclude','only'])) 추가isAutoReply는 emails 컬럼이라 emailReplies join 불필요. fast-path/subquery 양쪽 모두에 적용되도록 inboundConditions 레벨에서 처리(현재 needsEmailRepliesJoin 분기 밖).autoReply URL param 추가 (SSOT 일관)locales/*.csv 6개 언어.email-search.service.ts:354의 needsEmailRepliesJoin은 intent/sentiment/tags 필터 시에만 emailReplies를 join한다. isAutoReply 필터는 join 없이도 가능하므로, 두 경로(join 있는 subquery · 없는 fast-path) 모두에서 조건이 적용되도록 배치해야 누락이 없다. intent='out_of_office' OR 절을 쓰면 join이 필요해지니, 성능을 위해 isAutoReply 단일 컬럼 우선 + intent는 ②의 융합으로 수렴시키는 방식을 권장.
WSJF(가치/소요) 기준. ②를 먼저(저위험·③의 전제) → ③(②의 정확도 활용) → ①(별도 영역·고가치·고비용).
| 순서 | 기능 | 성공 기준 (검증) | 의존 |
|---|---|---|---|
| 1 | ② 분류 정확도 | 다국어 OOO 테스트 통과 · isAutoReply인데 neutral인 케이스 0건(융합 후) | 없음 |
| 2 | ③ 조회 필터 | "자동응답 숨기기" on → 자동응답 0건 노출 · 두 쿼리 경로 모두 적용 | ② (자동응답 정의 SSOT) |
| 3 | ① 영업일 하드룰 | 비영업일 예약 → 다음 영업일 09:00 이동 · provider throttle 회귀 없음 · 자동응답 유입률 ↓ 측정 | 독립 (병렬 착수 가능) |
isAutoReply 유입률을 reply-classifier-analytics로 비교. 세 기능이 하나의 피드백 루프를 이룬다.