Smart Life US

← 목록 · 2026-08-30 · 엑셀·업무 자동화

구글시트 지연 알림 자동 발송 설정: 입고 예약 KPI 5편

구글시트 지연 알림 자동 발송 설정: 입고 예약 KPI 5편

입고 차량이 늦었는데 아무도 모르면, 현장에서는 계획이 바로 꼬입니다. 이번 글에서는 구글시트 지연 알림 자동 발송 설정과 함께, 내일 입고 예약을 미리 정리해 보내는 이메일 자동화를 다룹니다. 구글시트와 Apps Script만으로 지연 차량 알림 이메일과 내일 예약 미리보기 발송까지 묶어 두면, 관리자는 메일함만 봐도 어느 시간대에 대응이 필요한지 바로 파악할 수 있습니다.

지연 알림 자동화 설정

이 글은 실제 물류센터에서 사용 중인 구글시트 입고 예약 시스템의 KPI·리포트 시리즈 5편입니다. 앞선 네 편에서는 일별 KPI를 계산하고, 야간 트리거로 이력을 쌓고, 운송사 점수를 만들고, 주간 리포트를 자동 메일로 보내는 부분까지 구성했습니다. 이번 편에서는 그 연장선으로, “지금 문제가 되는 예약”과 “내일 준비해야 할 예약”을 자동으로 알려 주는 부분을 붙입니다.


지연 알림과 내일 미리보기가 필요한 이유

예약 시스템을 쓰다 보면, 숫자로 정리된 KPI만으로는 당일 운영에 바로 도움이 되지 않는 순간이 있습니다. KPI는 주로 “어제까지”의 결과를 보여 주기 때문입니다. 현장에서 중요한 것은 지금 이 시간 기준으로 늦은 차량이 무엇인지, 내일 어느 시간대에 물량이 몰리는지입니다.

제가 맡고 있는 물류센터에서도 처음에는 정시율, 평균 체류시간 같은 지표만 보고 있었습니다. 하지만 입고 예약이 조금만 밀리면 도크와 인력 일정이 한꺼번에 흔들렸고, 관리자가 그 사실을 알게 되는 시점은 이미 너무 늦은 경우가 많았습니다. 그래서 “지연이 발생한 시점”에 가까운 타이밍으로 알림을 보내고, 다음날 예약을 미리 요약해 전달하는 흐름이 필요했습니다.

중요한 기준은 단순히 “정시/지각”을 색으로 표시하는 수준이 아니라, 실제 체크인 기록과 예약 시간을 비교해 지금 기준으로 대응해야 하는 건만 골라내는 것입니다. 이번 글의 코드는 그 역할을 할 수 있도록 설계해 두었고, 각 창고 상황에 맞게 체크인 판정 부분만 손보면 그대로 적용할 수 있습니다.


알림 설계를 위한 기본 규칙과 시트 구조

이번 편에서는 구글시트 주간 리포트 자동 메일 발송: KPI 4편 코드 포함 (감사 수정 반영 완성본)들에서 이미 만들어 둔 시트와 함수를 그대로 활용합니다. 특히 예약 본문 시트인 APPT_MAIN, KPI 이력 시트인 KPI_HISTORY, 알림 수신자를 모아 둔 KPI_RECIPIENTS 시트를 사용합니다. 시트 구조는 시리즈 전체에서 이미 확정되어 있으므로, 새로 정의하지 않고 그 전제를 그대로 따릅니다.

지연 알림과 내일 예약 미리보기를 구현하면서 정한 기본 규칙은 다음과 같습니다.

첫째, 예약 기준 시각과 도착 여부 판단입니다. 예약 시각은 APPT_MAIN 시트의 날짜·시각 열을 그대로 사용합니다. 도착 여부는 체크인 갈래에서 이미 남겨 둔 로그를 기준으로 “이 예약이 실제로 체크인됐는가”만 판정하는 보조 함수를 둡니다. 이 글에서는 KPI_isApptCheckedIn_(apptId)라는 자리만 마련해 두고, 실제 구현은 각자가 사용하는 체크인 로그 구조에 맞춰 채우게 되어 있습니다.

둘째, 유예시간(Grace Period)을 명시적으로 적용합니다. 도로 사정 등을 감안해 예약 시각이 지났다고 바로 지연으로 보지는 않고, 설정해 둔 유예시간(예시로 30분)을 더한 뒤 예약 시각 + 유예시간 < 현재 시각인 경우만 지연으로 간주합니다. 이번 글의 코드는 이 값을 상수로 관리하며, 필요하면 설정 시트에서 읽어오는 방식으로 바꾸면 됩니다.

셋째, 같은 예약을 반복해서 알리지 않습니다. APPT_MAIN의 여유 열 하나를 “지연알림 발송 여부” 용도로 지정해, 메일 발송이 성공한 예약에는 Y를 적어 둡니다. 지연 예약을 찾을 때는 이 플래그가 비어 있는 건만 대상으로 삼아, 같은 예약에 대해 여러 번 메일이 가지 않도록 했습니다.

마지막으로, 이메일은 예약별이 아니라 묶음으로 보냅니다. 지연 차량이 여러 대여도 한 통의 메일로 시간순 리스트를 보내고, 내일 예약 미리보기도 하루치 전체를 정리한 메일 한 통으로 보내도록 설계했습니다. 메일이 너무 자주 오면 현장에서 무시되기 쉽기 때문에, “필요할 때 한 번에 정리된 정보”를 목표로 했습니다.


1단계 — 알림용 설정 상수 정의

먼저 이 편에서만 쓸 설정 값을 모아 두는 객체를 하나 정의합니다. 시리즈 규칙에 맞춰 KPI_ 접두어를 쓰고, 이미 있는 다른 CONFIG 류와 이름이 겹치지 않도록 합니다. 여기에는 지연 판정 유예시간, 시트 이름, 메일 제목, 플래그 열 번호 등을 모읍니다.

  1. 이 코드가 하는 일
  • KPI 관련 알림에 필요한 상수(시간대, 시트 이름, 유예시간, 메일 제목, 표시 열 번호)를 한곳에 모읍니다.
  1. 붙여넣을 위치
  • 구글시트 → 확장 프로그램 → Apps Script → 기존 KPI 코드가 있는 프로젝트의 Code.gs 상단, 다른 KPI_ 상수들 바로 아래에 붙여넣습니다.
  1. 붙여넣은 뒤 할 일
  • 저장(⌘S/CTRL+S) 후 별도 실행은 필요 없습니다. 이후 함수에서 이 상수를 그대로 사용합니다.
Apps Script (JavaScript)
const KPI_ALERT_CONFIG = {                             // → KPI 알림 전용 설정
  TIMEZONE: 'America/New_York',                        // → 시스템 공통 시간대
  LATE_GRACE_MINUTES: 30,                              // → 지연 판단 유예시간(분)
  MAIN_SHEET_NAME: 'APPT_MAIN',                        // → 예약 본문 시트
  RECIPIENT_SHEET_NAME: 'KPI_RECIPIENTS',              // → 수신자 시트
  LATE_ALERT_SUBJECT: '[입고] 지연 예약 알림',        // → 지연 메일 제목
  TOMORROW_PREVIEW_SUBJECT: '[입고] 내일 예약 미리보기', // → 미리보기 제목
  LATE_ALERT_SENT_COLUMN: 14,                          // → APPT_MAIN의 '지연알림' 표시 열 번호(예시)
  ID_COLUMN: 13                                        // → APPT_MAIN 예약ID 열 번호(고정: M열)
};                                                     //

제대로 됐는지 확인하는 법: Apps Script 편집기에서 이 상수 정의 줄에 빨간 오류 표시가 없으면 정상입니다. 시트에서 실제로 14열이 비어 있는지도 함께 확인합니다.


2단계 — 수신자 목록 로드와 공통 메일 발송 도우미

지연 알림과 내일 예약 미리보기 메일 모두 KPI_RECIPIENTS 시트에 적힌 사람들에게 보냅니다. 이 시트는 최소한 이메일·이름·활성 여부 세 열을 가진다고 가정합니다.

  1. 이 코드가 하는 일
  • KPI_RECIPIENTS 시트에서 이메일 주소와 활성 여부를 읽어 유효한 수신자 배열을 돌려줍니다.
  • Gmail 발송을 감싸는 도우미를 만들어, 제목·본문만 넘기면 메일을 보낼 수 있게 합니다.
  1. 붙여넣을 위치
  • 앞 단계 KPI_ALERT_CONFIG 바로 아래에 붙여넣습니다.
  1. 붙여넣은 뒤 할 일
  • 나중에 테스트 함수에서 KPI_loadAlertRecipients_() 결과를 로그로 확인합니다.
Apps Script (JavaScript)
function KPI_loadAlertRecipients_() {                         // → 알림 수신자 목록 로드
  const ss = SpreadsheetApp.getActive();                     // → 현재 스프레드시트
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.RECIPIENT_SHEET_NAME); // → 수신자 시트
  if (!sheet) {                                              // → 시트 없을 때
    throw new Error('KPI_RECIPIENTS 시트를 찾을 수 없습니다.'); // → 오류 던지기
  }                                                          
  const lastRow = sheet.getLastRow();                        // → 마지막 행 번호
  if (lastRow < 2) {                                         // → 데이터 없음
    return [];                                               // → 빈 배열 반환
  }                                                          
  const range = sheet.getRange(2, 1, lastRow - 1, 3);        // → 헤더 제외 데이터 범위
  const values = range.getValues();                          // → 값 읽기
  const recipients = [];                                     // → 결과 배열
  values.forEach((row, idx) => {                             // → 각 행 반복
    const email = String(row[0] || '').trim();               // → 이메일
    const name = String(row[1] || '').trim();                // → 이름(옵션)
    const active = String(row[2] || '').trim().toUpperCase();// → 활성 여부
    if (!email) {                                            // → 이메일 없음
      return;                                                // → 건너뛰기
    }                                                        
    if (active !== 'Y' && active !== 'YES' && active !== '1') { // → 비활성
      return;                                                // → 건너뛰기
    }                                                        
    recipients.push({ email, name: name || email });         // → 수신자 추가
  });                                                        
  return recipients;                                         // → 배열 반환
}                                                            

function KPI_sendEmail_(subject, body) {                     // → 메일 발송 도우미
  const recipients = KPI_loadAlertRecipients_();             // → 수신자 로드
  if (!recipients.length) {                                  // → 아무도 없을 때
    return { sent: 0, message: '활성 수신자가 없습니다.' };  // → 결과만 반환
  }                                                          
  const toList = recipients.map(r => r.email).join(',');     // → 주소 문자열
  GmailApp.sendEmail(toList, subject, body, {                 // → 메일 발송
    name: '입고 예약 알림봇'                                 // → 발신자 이름
  });                                                        
  return { sent: recipients.length, message: 'OK' };         // → 발송 결과
}                                                            

제대로 됐는지 확인하는 법: KPI_RECIPIENTS 시트에 테스트 이메일과 Y를 입력한 뒤, 뒤에서 만드는 테스트 함수에서 KPI_loadAlertRecipients_() 호출 결과의 길이가 1 이상으로 찍히면 정상입니다.


3단계 — 지연 예약을 찾는 핵심 로직

이제 예약 시트에서 “현재 기준으로 늦었는데 체크인 기록이 없고, 아직 지연 알림을 보내지 않은 예약”만 골라내는 함수가 필요합니다. 이 함수는 지연 알림 메인 함수가 사용할 핵심 로직입니다.

실제 운영 시스템에서는 체크인 로그 시트(CHECKIN_LOG) 등에서 예약 ID를 조회해 체크인 여부를 판단합니다. 여기서는 그 자리를 KPI_isApptCheckedIn_(apptId)로 비워 두고, 예시 구현으로는 항상 false를 돌려줍니다. 독자께서는 자신의 체크인 구조에 맞게 이 부분만 교체하시면 됩니다.

  1. 이 코드가 하는 일
  • APPT_MAIN에서 예약 날짜·시간·예약 ID·지연알림 플래그를 읽습니다.
  • 예약 시각 + 유예시간 < 지금 이면서, 체크인되지 않았고, 아직 플래그가 비어 있는 행만 골라 배열로 돌려줍니다.
  1. 붙여넣을 위치
  • 앞 단계 코드 바로 아래에 붙여넣습니다.
  1. 붙여넣은 뒤 할 일
  • 뒤에서 만드는 KPI_testFindLateAppts_() 함수로 결과를 로그에 찍어 확인합니다.
Apps Script (JavaScript)
function KPI_isApptCheckedIn_(apptId) {                       // → 예약 체크인 여부 판정(예시)
  if (!apptId) {                                              // → 예약ID 없음
    return false;                                             // → 미체크인 처리
  }                                                           
  // 실제 구현에서는 CHECKIN_LOG 등에서 예약ID를 조회합니다.  // → 로그 조회 필요
  return false;                                               // → 예시: 항상 미체크인
}                                                            

function KPI_findLateAppts_() {                               // → 지연 예약 목록 조회
  const ss = SpreadsheetApp.getActive();                      // → 현재 파일
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → 예약 시트
  if (!sheet) {                                               // → 시트 없을 때
    throw new Error('APPT_MAIN 시트를 찾을 수 없습니다.');     // → 오류
  }                                                           
  const lastRow = sheet.getLastRow();                         // → 마지막 행
  if (lastRow < 2) {                                          // → 데이터 없음
    return [];                                                // → 빈 배열
  }                                                           
  const now = new Date();                                     // → 현재 시각
  const values = sheet.getRange(2, 1, lastRow - 1, KPI_ALERT_CONFIG.LATE_ALERT_SENT_COLUMN).getValues(); // → 데이터
  const lateAppts = [];                                       // → 결과 배열

  values.forEach((row, idx) => {                              // → 행 반복
    const rowIndex = idx + 2;                                 // → 실제 행 번호
    const dateVal = row[0];                                   // → A: 날짜
    const timeVal = row[1];                                   // → B: 시작시간
    const type = String(row[2] || '').trim();                 // → C: 장비유형
    const door = String(row[3] || '').trim();                 // → D: 도어
    const container = String(row[4] || '').trim();            // → E: 컨테이너
    const carrier = String(row[5] || '').trim();              // → F: 운송사
    const client = String(row[6] || '').trim();               // → G: 고객사
    const remark = String(row[7] || '').trim();               // → H: 비고
    const endTimeVal = row[8];                                // → I: 종료시간
    const createdAt = row[9];                                 // → J: 생성일시
    const qty = row[10];                                      // → K: 수량
    const pallet = row[11];                                   // → L: 팔레트
    const apptId = String(row[12] || '').trim();              // → M: 예약ID
    const alertSentFlag = row[13];                            // → N(예시): 지연알림 플래그

    if (!dateVal || !timeVal || !apptId) {                    // → 필수값 없음
      return;                                                 // → 건너뛰기
    }                                                         
    if (alertSentFlag === 'Y') {                              // → 이미 알림 발송
      return;                                                 // → 건너뛰기
    }                                                         
    if (KPI_isApptCheckedIn_(apptId)) {                       // → 이미 체크인
      return;                                                 // → 건너뛰기
    }                                                         
    const dateObj = new Date(dateVal);                        // → 날짜 객체
    const timeObj = new Date(timeVal);                        // → 시각 객체
    if (isNaN(dateObj.getTime()) || isNaN(timeObj.getTime())) { // → 값 오류
      return;                                                 // → 건너뛰기
    }                                                         
    const scheduled = new Date(dateObj);                      // → 예약시각 복제
    scheduled.setHours(timeObj.getHours(), timeObj.getMinutes(), 0, 0); // → 시·분 넣기
    const graceMillis = KPI_ALERT_CONFIG.LATE_GRACE_MINUTES * 60 * 1000; // → 유예 ms
    const deadline = new Date(scheduled.getTime() + graceMillis); // → 지연 기준 시각
    if (deadline.getTime() >= now.getTime()) {                // → 아직 유예시간 내
      return;                                                 // → 건너뛰기
    }                                                         

    lateAppts.push({                                          // → 지연 예약 추가
      rowIndex,
      apptId,
      date: dateObj,
      time: timeObj,
      type,
      door,
      container,
      carrier,
      client,
      remark,
      qty,
      pallet
    });                                                       
  });                                                         

  return lateAppts;                                           // → 지연 예약 목록 반환
}                                                             

제대로 됐는지 확인하는 법: APPT_MAIN에 과거 시간의 테스트 예약을 한 줄 만들고, 뒤에서 설명하는 KPI_testFindLateAppts_()를 실행해 로그에 지연 예약이 1건 이상 찍히는지 확인합니다.


4단계 — 지연 알림 메일 생성 및 발송

이제 실제로 지연 알림 메일을 보내는 sendLateAlerts() 함수를 만듭니다. 이 함수는 지연 예약을 모아 한 통의 메일을 보내고, 발송 성공 시 해당 예약 행에 “지연알림 발송” 표시를 남깁니다. 여러 사용자가 동시에 실행하더라도 중복 발송이 일어나지 않도록 LockService로 잠금도 걸어 줍니다.

  1. 이 코드가 하는 일
  • 지연 예약 목록을 시간순으로 정렬해 텍스트 표 형태의 메일 본문을 만들고, 한 통의 이메일로 발송합니다.
  • 발송이 성공한 경우에만 각 예약 행의 플래그 열에 Y를 기록합니다.
  1. 붙여넣을 위치
  • KPI_findLateAppts_() 아래에 붙여넣습니다.
  1. 붙여넣은 뒤 할 일
  • 테스트 함수 KPI_testSendLateAlerts_()에서 이 함수를 직접 호출해 결과를 확인합니다.
Apps Script (JavaScript)
function sendLateAlerts() {                                  // → 지연 알림 메인 함수
  const lock = LockService.getScriptLock();                  // → 스크립트 잠금 객체
  lock.waitLock(30000);                                      // → 최대 30초 대기
  try {                                                      // → 잠금 안에서 실행
    const lateAppts = KPI_findLateAppts_();                  // → 지연 예약 조회
    if (!lateAppts.length) {                                 // → 지연 없음
      return { sent: 0, message: '지연 예약이 없습니다.' };  // → 종료
    }                                                        

    lateAppts.sort((a, b) => {                               // → 시간순 정렬
      const t1 = a.date.getTime() +                          // → a 날짜 기준
        (a.time.getHours() * 60 + a.time.getMinutes()) * 60000; // → 시·분 더하기
      const t2 = b.date.getTime() +                          // → b 날짜 기준
        (b.time.getHours() * 60 + b.time.getMinutes()) * 60000; //
      return t1 - t2;                                        // → 오름차순
    });                                                      

    let bodyLines = [];                                      // → 메일 본문 줄
    bodyLines.push('다음 입고 예약이 설정된 유예시간을 넘겨 아직 도착하지 않았습니다.'); // → 안내 문구
    bodyLines.push('');                                      // → 빈 줄
    bodyLines.push('날짜\t시간\t운송사\t컨테이너\t도어\t고객사\t유형\t수량\t팔레트\t비고'); // → 헤더
    lateAppts.forEach(item => {                              // → 각 예약 줄 추가
      const y = Utilities.formatDate(item.date, KPI_ALERT_CONFIG.TIMEZONE, 'yyyy-MM-dd'); // → 날짜
      const hm = Utilities.formatDate(item.time, KPI_ALERT_CONFIG.TIMEZONE, 'HH:mm');     // → 시각
      bodyLines.push([
        y,
        hm,
        item.carrier || '',
        item.container || '',
        item.door || '',
        item.client || '',
        item.type || '',
        item.qty || '',
        item.pallet || '',
        item.remark || ''
      ].join('\t'));                                         // → 탭 구분
    });                                                      
    bodyLines.push('');                                      // → 빈 줄
    bodyLines.push('※ 이 메일은 구글시트 입고 예약 KPI 자동화에서 발송되었습니다.'); // → 꼬리말

    const subject = KPI_ALERT_CONFIG.LATE_ALERT_SUBJECT;     // → 메일 제목
    const body = bodyLines.join('\n');                       // → 본문 문자열
    const sendResult = KPI_sendEmail_(subject, body);        // → 메일 발송

    if (sendResult.sent > 0) {                               // → 발송 성공 시
      const ss = SpreadsheetApp.getActive();                 // → 현재 파일
      const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → 예약 시트
      lateAppts.forEach(item => {                            // → 각 예약에 표시
        sheet.getRange(item.rowIndex, KPI_ALERT_CONFIG.LATE_ALERT_SENT_COLUMN).setValue('Y'); // → 알림 플래그
      });                                                    
    }                                                        

    return {                                                 // → 실행 결과
      sent: sendResult.sent,
      lateCount: lateAppts.length,
      message: sendResult.message
    };                                                       
  } finally {                                                
    lock.releaseLock();                                      // → 잠금 해제
  }                                                          
}                                                             

제대로 됐는지 확인하는 법: 과거 시각의 테스트 예약을 만들어 둔 상태에서 sendLateAlerts()를 실행했을 때 메일이 한 통 오고, 해당 예약 행의 지연알림 열에 Y가 찍히며, 같은 데이터를 두 번째 실행했을 때는 메일이 오지 않으면 성공입니다.


5단계 — 내일 예약 미리보기 메일 자동화

지연 알림이 “지금 문제가 된 예약”을 알려 준다면, 내일 예약 미리보기는 “내일 준비해야 할 예약”을 요약해 줍니다. 이 함수는 APPT_MAIN에서 내일 날짜의 예약만 시간순으로 묶어 운송사·컨테이너·도어 정보를 포함한 간단한 목록 메일을 만듭니다.

  1. 이 코드가 하는 일
  • 내일 날짜의 예약만 필터링해 시간순으로 정리한 뒤 한 통의 이메일로 발송합니다.
  • 내일 예약이 없으면 아무 메일도 보내지 않습니다.
  1. 붙여넣을 위치
  • sendLateAlerts() 아래에 붙여넣습니다.
  1. 붙여넣은 뒤 할 일
  • 내일 날짜 예약을 한두 줄 만들어 둔 뒤 직접 실행해 메일을 확인합니다.
Apps Script (JavaScript)
function sendTomorrowPreview() {                             // → 내일 예약 미리보기 메인
  const ss = SpreadsheetApp.getActive();                     // → 현재 파일
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → 예약 시트
  if (!sheet) {                                              // → 시트 없음
    throw new Error('APPT_MAIN 시트를 찾을 수 없습니다.');   // → 오류
  }                                                          
  const lastRow = sheet.getLastRow();                        // → 마지막 행
  if (lastRow < 2) {                                         // → 데이터 없음
    return { sent: 0, message: '예약 데이터가 없습니다.' };   // → 종료
  }                                                          

  const now = new Date();                                    // → 현재
  const tz = KPI_ALERT_CONFIG.TIMEZONE;                      // → 시간대
  const tomorrow = new Date(now);                            // → 오늘 복제
  tomorrow.setDate(tomorrow.getDate() + 1);                  // → 내일로 이동
  const tomorrowYmd = Utilities.formatDate(tomorrow, tz, 'yyyy-MM-dd'); // → 내일 YMD

  const values = sheet.getRange(2, 1, lastRow - 1, KPI_ALERT_CONFIG.ID_COLUMN).getValues(); // → 날짜~예약ID
  const appts = [];                                          // → 내일 예약 배열

  values.forEach((row, idx) => {                             // → 행 반복
    const rowIndex = idx + 2;                                // → 실제 행
    const dateVal = row[0];                                  // → A: 날짜
    const timeVal = row[1];                                  // → B: 시작시간
    const type = String(row[2] || '').trim();                // → C: 장비유형
    const door = String(row[3] || '').trim();                // → D: 도어
    const container = String(row[4] || '').trim();           // → E: 컨테이너
    const carrier = String(row[5] || '').trim();             // → F: 운송사
    const client = String(row[6] || '').trim();              // → G: 고객사
    const remark = String(row[7] || '').trim();              // → H: 비고
    const apptId = String(row[12] || '').trim();             // → M: 예약ID

    if (!dateVal || !timeVal || !apptId) {                   // → 필수값 없음
      return;                                                // → 건너뛰기
    }                                                        
    const dateObj = new Date(dateVal);                       // → 날짜 객체
    if (isNaN(dateObj.getTime())) {                          // → 날짜 오류
      return;                                                // → 건너뛰기
    }                                                        
    const ymd = Utilities.formatDate(dateObj, tz, 'yyyy-MM-dd'); // → YMD
    if (ymd !== tomorrowYmd) {                               // → 내일 아님
      return;                                                // → 건너뛰기
    }                                                        
    const timeObj = new Date(timeVal);                       // → 시각 객체
    if (isNaN(timeObj.getTime())) {                          // → 시각 오류
      return;                                                // → 건너뛰기
    }                                                        

    appts.push({                                             // → 내일 예약 추가
      rowIndex,
      apptId,
      date: dateObj,
      time: timeObj,
      type,
      door,
      container,
      carrier,
      client,
      remark
    });                                                      
  });                                                         

  if (!appts.length) {                                       // → 내일 예약 없음
    return { sent: 0, message: '내일 예약이 없습니다.' };   // → 종료
  }                                                          

  appts.sort((a, b) => {                                     // → 시간순 정렬
    const t1 = a.time.getHours() * 60 + a.time.getMinutes(); // → a 분
    const t2 = b.time.getHours() * 60 + b.time.getMinutes(); // → b 분
    return t1 - t2;                                          // → 오름차순
  });                                                        

  let bodyLines = [];                                        // → 본문 줄
  bodyLines.push('내일 입고 예약 현황입니다. 시간순으로 정리했습니다.'); // → 안내
  bodyLines.push('');                                        // → 빈 줄
  bodyLines.push('시간\t운송사\t컨테이너\t도어\t고객사\t유형\t비고'); // → 헤더
  appts.forEach(item => {                                    // → 각 예약 줄
    const hm = Utilities.formatDate(item.time, tz, 'HH:mm'); // → 시각
    bodyLines.push([
      hm,
      item.carrier || '',
      item.container || '',
      item.door || '',
      item.client || '',
      item.type || '',
      item.remark || ''
    ].join('\t'));                                           // → 탭으로 결합
  });                                                        
  bodyLines.push('');                                        // → 빈 줄
  bodyLines.push('※ 이 메일은 구글시트 입고 예약 KPI 자동화에서 발송되었습니다.'); // → 꼬리말

  const subject = KPI_ALERT_CONFIG.TOMORROW_PREVIEW_SUBJECT; // → 제목
  const body = bodyLines.join('\n');                         // → 본문
  const sendResult = KPI_sendEmail_(subject, body);          // → 메일 발송

  return {                                                   // → 결과 반환
    sent: sendResult.sent,
    apptCount: appts.length,
    message: sendResult.message
  };                                                         
}                                                             

제대로 됐는지 확인하는 법: APPT_MAIN에 내일 날짜로 테스트 예약을 두세 건 넣은 뒤 sendTomorrowPreview()를 실행했을 때, 시간순으로 정리된 예약 목록이 메일 한 통으로 도착하면 정상입니다. 내일 예약이 없으면 메일이 오지 않아야 합니다.


실무 팁: 트리거 설정과 테스트, 운영에서의 활용

코드는 끝났지만, 실제로 쓰려면 트리거와 테스트 절차를 정리해 두는 것이 중요합니다. 실제 운영에서 확인한 포인트를 기준으로 몇 가지를 정리합니다.

첫째, 트리거 주기와 시간대를 의도적으로 정해야 합니다. 지연 알림은 너무 자주 돌면 불필요한 메일이 늘어나고, 너무 드물게 돌면 대응이 늦어집니다. 현장에서는 30분 단위 또는 1시간 단위로 sendLateAlerts()를 시간 기반 트리거에 걸어 두는 방식이 부담이 적었습니다. 반대로 내일 미리보기는 하루에 한 번, 인력 계획 회의를 하기 전 시간(예를 들어 오후 3시)에 sendTomorrowPreview()를 돌리면 충분했습니다.

둘째, 유예시간(LATE_GRACE_MINUTES)은 현장 패턴에 맞게 조정해야 합니다. 처음에는 30분으로 시작해, 특정 노선이나 고객의 도착 패턴을 보며 늘리거나 줄이는 식으로 조정했습니다. 지연 알림이 너무 자주 오는 구간이 있다면 그 노선에 한해 유예시간을 따로 가질지, 아니면 예약 시간대를 재조정할지 논의하는 근거로도 쓸 수 있습니다.

셋째, 알림 수신자는 실제로 조정 권한이 있는 사람 위주로 좁게 설정하는 것이 좋습니다. KPI_RECIPIENTS 시트에 전 직원 이메일을 넣기보다는, 도크 배정·게이트 운영·인력 스케줄링 담당자를 중심으로 구성하고, 필요하면 이들이 받은 내용을 팀 내부 채널로 재공유하는 흐름이 부담이 덜했습니다. 수신자가 많을수록 메일 무시 비율도 함께 올라가기 때문입니다.

넷째, 테스트는 항상 작은 데이터로부터 시작합니다.

  • 지연 알림의 경우, 지금보다 1~2시간 전으로 설정한 테스트 예약 한두 줄과 수신자 한 명만 둔 상태에서 sendLateAlerts()를 수동 실행해 동작을 확인합니다.
  • 내일 미리보기 역시 내일 날짜의 예약 두세 줄을 만들고 sendTomorrowPreview()를 수동 실행해 본 뒤, 원하는 형식인지 확인한 다음에야 트리거를 거는 것이 좋습니다.

이 흐름까지 갖추고 나면, 앞 편에서 만든 KPI 일별 계산·이력·운송사 리포트와 함께 구글시트 입고 예약 알림 자동화 전체가 하나의 체계로 묶입니다. KPI는 “어제까지의 결과”, 지연 알림과 내일 미리보기는 “지금과 내일의 대응”을 담당하는 식으로 역할을 나누어 두면, 시트를 일일이 열지 않고도 메일과 KPI 대시보드만으로 운영 우선순위를 정할 수 있습니다.


맺음말

지연 알림과 내일 예약 미리보기는 입고 예약 KPI 자동화의 마지막 조각에 가깝습니다. 구글시트와 Apps Script만으로도 지연 입고 알림 자동 발송내일 예약 미리보기 이메일까지 구현해 두면, 관리자는 굳이 시트를 찾아다니지 않고 메일함만으로도 오늘과 내일의 위험 구간을 확인할 수 있습니다.

바로 시작해 보고 싶다면, 오늘은 다음 한 가지만 해 보시기 바랍니다.

KPI_RECIPIENTS 시트에 본인 이메일 한 줄을 만들고, 이 글의 KPI_ALERT_CONFIG·KPI_loadAlertRecipients_()·KPI_findLateAppts_()·sendLateAlerts() 코드를 붙여넣은 뒤, 과거 시각의 테스트 예약 한 줄을 만들어 sendLateAlerts()를 직접 실행해 보시기 바랍니다. 한 번 실제로 메일이 오는 경험을 해 보면, 이후 유예시간 조정과 트리거 세팅은 훨씬 수월하게 이어갈 수 있습니다.