Incident Report · Root Cause → Fix → Verification · Rinda Beta
"내일 아침 10시" 예약이 5분 뒤 발송된 사고 — 원인·수정·검증 보고
결론 — 버그를 고쳐 alpha·beta에 배포했고, 같은 사고는 다시 나지 않습니다.
- 무슨 일: "내일 아침 10시"로 예약한 캠페인 2건이 활성화 5분 뒤 350명에게 즉시 발송됐습니다.
- 왜 (의도 ≠ 인식): 사용자는 "내일 아침 10시"를 의도했지만, 발송 날짜가 기본값 "오늘(0일 후)"로 남아 있어 시스템은 "오늘 아침 10시"로 인식했습니다. 오늘 10시는 이미 지났으니 코드가 이를 "지금 보내라"로 해석 — 이 두 단계 오해가 사고의 전부입니다.
- 발생 조건: ① 한국시간 모드 ② 발송 날짜 "오늘"(delay 0) ③ 설정 시각 경과 — 셋이 겹칠 때만 터집니다.
- 어떻게 고침: 지난 지 60분 이내면 즉시 발송(원래 의도 보존), 그보다 오래됐으면 다음날 같은 시각으로 보냅니다.
- 어떻게 확인: 단위테스트 36건 + 90일 실데이터 42만 건 리플레이 + 배포된 서버 안 실행 테스트, 전부 통과.
1사고 경위 — 10시 예약이 17:57 발송이 되기까지
8/10(월) 저녁, 캠페인 2건을 만들어 발송 시각을 오전 10:00·10:14로 저장하고 활성화했는데, 다음날 아침이 아니라 활성화 5분 뒤(17:57·17:58)에 350명에게 즉시 발송됐습니다. DB 실측 타임라인:
- 8/10 17:51~17:52 캠페인 2건 생성, 스텝에
scheduled_hour=10(오전 10시) 저장 - 8/10 17:52~17:53 활성화 — 641명 등록
- 8/10 17:57·17:58 버그 전원 발송 예정 시각이 활성화+5분으로 계산됨 (10시 설정 무시)
- 8/10 17:57~18:27 350건 발송 완료 (159+191)
알람을 "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 모드) | sender | buyer 모드는 "+1 영업일" 분기라 정상 (다음날 10:00) |
② delay_days = 0 (발송 날짜 = "오늘") | 0 | delay_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번째 이후 스텝은 원래 정상이라 변경하지 않았습니다.
발송 시각 기본값이 "지금+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가지 방법으로 전부 통과
신규 유예창 테스트(백엔드 8·프론트 6) + 기존 clamp·영업시간·delay 리그레션 테스트 전부 통과. 경계값(59분/61분) 포함.
활성화 배치 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배포 경과
- PR #11064 (alpha) · #11066 (beta 체리픽) — 유예창 본 수정, CI 통과 후 머지
- 1차 배포가 admin 이미지 lint(신규 테스트 파일 스타일 3건)로 실패 → #11068 / #11070으로 정리 후 재배포 성공
- 배포 파이프라인이 실패한 커밋의 워커 재빌드를 유실하는 특성 발견 → #11072 / #11073 워커 재빌드 트리거로 워커까지 반영
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남은 후속 과제 (별도 작업)
- 활성화 확인 모달에 서버 계산 발송 예정 시각 표시 — 현재 성공 모달은 프론트 추정치라 buyer 모드에서 거짓 안내 가능. 번역만 있고 렌더되지 않는 경고 문구(
pastTimeWarning) 연결 포함. - 일시정지→재개 시 시:분 파괴 정리 — 재개 로직이 시각 정보를 잃고 즉시 발송하는 유사 지뢰.
- 오래된 등록 재활성화 시 일괄 즉시화(clamp) 정리, 수신자별 override 맵 키 버그 수정.
- 장기: 발송 윈도우 모델 전환 — 첫 스텝 절대 날짜 저장 + "delay → 윈도우 게이트" 2단 구조(업계 표준).