Incident Report · Root Cause → Fix → Verification · Rinda Beta

"내일 아침 10시" 예약이 5분 뒤 발송된 사고 — 원인·수정·검증 보고

2026-08-11 · 작성: Claude Code (이철희) · 대상: 건양대 세일즈 워크스페이스 건강식품/생활용품 캠페인 (8/10)

결론 — 버그를 고쳐 alpha·beta에 배포했고, 같은 사고는 다시 나지 않습니다.

1사고 경위 — 10시 예약이 17:57 발송이 되기까지

8/10(월) 저녁, 캠페인 2건을 만들어 발송 시각을 오전 10:00·10:14로 저장하고 활성화했는데, 다음날 아침이 아니라 활성화 5분 뒤(17:57·17:58)에 350명에게 즉시 발송됐습니다. DB 실측 타임라인:

쉬운 설명

알람을 "10시"에 맞췄는데 지금이 저녁 5시 52분이면, 사람은 "내일 아침 10시"라고 생각해요. 그런데 프로그램은 "오늘 10시는 지났네? 그럼 지금 당장 울리라는 뜻!"이라고 잘못 알아들었어요. 이 오해가 사고의 전부입니다.

2원인 — "지난 시각은 지금 보내라"로 해석한 코드 한 줄

범인은 아래 코드입니다. 한국시간 모드의 첫 스텝에서 설정 시각이 지나 있으면, 6분이 지났든 8시간이 지났든 무조건 "지금+5분"으로 바꿔치기했습니다. (elysia-server/src/utils/timezone.ts · calculateScheduledTime)

if (baseDay < localNow) {
  if (!skipWeekends && delayDays === 0) {
    baseDay = localNow.plus({ minutes: 5 })   // 6분 지났든 8시간 지났든 → 즉시 발송

이 코드는 원래 "12:15로 설정하고 12:21에 활성화했더니 내일로 밀리더라"는 불만을 고치려던 것인데, '방금 지남'과 '한참 지남'을 구분하는 안전장치가 없어 저녁에 "내일 아침 10시"를 세팅한 캠페인까지 즉시 발송이 됐습니다. DB의 발송 예정 시각(17:57:00·17:58:00)이 활성화+5분·초 절삭과 초 단위까지 일치해 이 경로가 원인임을 확정했습니다.

Delay day = 0 도 원인인가?

답: 네 — 사고의 필수 조건 3개 중 하나입니다. delay_days=1(날짜를 다음날로 선택)이었다면 수정 전 코드로도 정상 발송됐습니다.

단 delay_days=0("오늘 발송") 자체는 정상 기능이므로 트리거 조건이고, 본질적 결함은 "오늘 + 이미 지난 시각" 조합을 즉시 발송으로 해석한 코드입니다. 이번 유예창 수정으로 이 조합도 안전해졌습니다.

버그 분기는 아래 세 조건이 동시에 충족될 때만 실행됩니다. 하나라도 빠지면 사고가 나지 않습니다:

조건사고 캠페인의 값이 조건이 없었다면
timezone_mode = sender (한국시간 KST 모드)senderbuyer 모드는 "+1 영업일" 분기라 정상 (다음날 10:00)
delay_days = 0 (발송 날짜 = "오늘")0delay_days=1(발송 날짜를 다음날로 선택)이었다면 구 코드로도 정확히 8/11 10:00 발송 — 사고 없음
③ 설정 시각이 이미 경과10:00 설정, 17:52 활성화10시 이전 활성화였다면 당일 10:00 정상 발송

이번 캠페인은 화면에서 발송 시각(10:00)만 다음날 아침으로 입력하고 발송 날짜는 기본값 "오늘"(= delay_days 0)로 남겨둔 상태였습니다. 달력에서 다음날(8/11)을 골랐다면 delay_days=1로 저장돼 수정 전 코드로도 정상 발송됐습니다.

쉬운 설명

예약에는 "며칠 뒤(날짜)"와 "몇 시(시각)" 두 칸이 있어요. 이번엔 시각 칸만 '아침 10시'로 바꾸고 날짜 칸은 '오늘' 그대로였어요. 그래서 컴퓨터는 "오늘 10시"로 알아들었고, 오늘 10시는 이미 지났으니 "그럼 지금!"이 된 거예요. 날짜 선택을 더 명확히 하는 UI 개선은 후속 과제(7절)에 있습니다.

피해는 이번이 처음이 아니었다 (beta 90일 실측)

지표
같은 분기로 설정 시각이 무시된 실행12,659건 / 캠페인 98개
그중 설정 시각 1시간 이상 경과(= "다음날" 의도로 추정)91개 (93%)
60분 이내(= "지금 발송" 의도로 추정)7개 (7%)

3수정 — "방금 지남"과 "한참 지남"을 구분하는 60분 유예창

const elapsedMs = localNow.toMillis() - baseDay.toMillis()
if (elapsedMs <= SEND_NOW_GRACE_MS) {          // 60분 이내 = 작성 중 지나간 것
  baseDay = localNow.plus({ minutes: 5 })      //   → 즉시 발송 의도 보존
} else {                                        // 초과 = 다음날 그 시각 의도
  baseDay = baseDay.plus({ days: 1 })          //   → 다음날 같은 시:분으로 이월
}

프론트(admin)의 미러 계산(calculateSendDate·isScheduleDeferred)도 동일 규칙으로 맞춰, "지금 발송"/날짜 라벨이 실제 발송 시각과 어긋나지 않게 했습니다. buyer(바이어 현지시간) 모드와 2번째 이후 스텝은 원래 정상이라 변경하지 않았습니다.

왜 60분인가

발송 시각 기본값이 "지금+5분"이라, 메일을 20~30분 다듬고 활성화하면 시각이 살짝 지나 있는 게 정상 사용이에요. 그래서 "1시간 안에 지난 건 즉시", "그보다 오래된 건 다음날"로 나눴어요. 실제 90일 데이터에서 피해 캠페인의 93%가 1시간 이상 경과였으니, 60분 선에서 두 의도가 깨끗하게 갈립니다.

타사 10개 서비스는 어떻게 하나 (공식 문서 조사)

Instantly·Smartlead·Lemlist·Woodpecker·Apollo·Reply.io·Outreach·Salesloft·HubSpot·Mailchimp 조사 결과: 설정 시각/발송 윈도우가 지났을 때 "조용히 즉시 발송"하는 서비스는 0개였습니다. 표준은 "다음 윈도우까지 대기"(Outreach·Apollo 등 명문화)이고, Reply.io는 활성화 순간 경고 배너로 "다음 열린 날 발송"을 고지, Woodpecker는 지난 날짜 예약의 실행을 아예 차단합니다. 이번 수정으로 Rinda도 업계 표준 동작(+즉시 의도 보존 유예창)이 됐습니다.

4검증 — 4가지 방법으로 전부 통과

① 단위 테스트 36/36

신규 유예창 테스트(백엔드 8·프론트 6) + 기존 clamp·영업시간·delay 리그레션 테스트 전부 통과. 경계값(59분/61분) 포함.

② 90일 실데이터 리플레이 이상 0건

활성화 배치 285건(실행 426,892건)을 새 로직으로 재계산 — 이월되는 130개 배치 전부 설정 시:분 정확 보존, 과거 시각 산출 0건, 즉시 의도 케이스는 전부 기존 동작 유지.

③ 배포된 실코드 테스트 통과

beta 컨테이너 안에서 배포된 코드로 사고 시나리오 재실행 — "17:52 활성화+10:00 설정 → 8/11 10:00", "17:53+10:14 → 8/11 10:14", 즉시 의도 "12:21+12:15 → 12:26", buyer 모드 정상.

④ 사고 케이스 교정 확인 일치

사고 캠페인 2건의 실제 DB 값(활성화 시각·설정 시각)을 그대로 넣으면 정확히 다음날 오전 10:00·10:14가 산출됨.

5배포 경과

6수정 전후 한눈 비교

상황 (sender 모드, 첫 스텝)수정 전수정 후
아직 안 지난 시각 (09:00 활성화, 10:00 설정)당일 10:00당일 10:00 (동일)
방금 지남 (12:21 활성화, 12:15 설정)즉시(12:26)즉시(12:26) (동일)
한참 지남 (17:52 활성화, 10:00 설정)즉시 17:57다음날 10:00
2번째 스텝 이후 (D+1 이상)다음날 10:00동일 (변경 없음)
buyer 모드 전체다음 영업일 10:00동일 (변경 없음)

7남은 후속 과제 (별도 작업)

  1. 활성화 확인 모달에 서버 계산 발송 예정 시각 표시 — 현재 성공 모달은 프론트 추정치라 buyer 모드에서 거짓 안내 가능. 번역만 있고 렌더되지 않는 경고 문구(pastTimeWarning) 연결 포함.
  2. 일시정지→재개 시 시:분 파괴 정리 — 재개 로직이 시각 정보를 잃고 즉시 발송하는 유사 지뢰.
  3. 오래된 등록 재활성화 시 일괄 즉시화(clamp) 정리, 수신자별 override 맵 키 버그 수정.
  4. 장기: 발송 윈도우 모델 전환 — 첫 스텝 절대 날짜 저장 + "delay → 윈도우 게이트" 2단 구조(업계 표준).