구글시트 KPI 자동 집계 방법: 야간 트리거로 이력 쌓기 (완성 코드 포함)
도입: 매일 KPI를 재계산하지 않아도 되도록
구글시트 KPI 자동 집계 방법을 찾다 보면 대부분은 “오늘 기준 숫자”를 어떻게 계산할지에만 초점이 맞춰져 있습니다. 그러나 실제 운영에서는 오늘 숫자보다 어제·지난주·지난달과 비교한 추세가 더 중요합니다. 매번 날짜를 바꿔가며 다시 계산하면 손이 많이 들고, 바쁜 날에는 업데이트를 놓치기 쉽습니다.
이 글에서는 앞선 글인 구글시트 예약 KPI 집계 방법: 정시율·체류시간 자동 계산에서 만든 일별 KPI 계산 로직을 바탕으로, Apps Script 야간 트리거로 매일 전날 KPI 이력을 자동 저장하는 구조를 정리합니다.
구글시트 KPI 이력 자동 저장, Apps Script 야간 트리거 설정, 구글시트 일별 KPI 누적, 최근 N일 이력 조회, 오래된 이력 자동 정리까지 한 번에 연결합니다.
이 구조를 만들어 두면 운영자는 매일 아침 별도 작업 없이 구글시트 물류 KPI 대시보드에서 전날 지표와 지난 일주일 추세를 바로 확인할 수 있습니다.
일별 KPI 이력을 따로 쌓는 이유
입고 예약·체크인·도크 작업을 한 흐름으로 관리하면 오늘 상황은 시트 몇 장만으로도 어느 정도 파악할 수 있습니다. 하지만 KPI는 단순히 “오늘 몇 대 들어왔는가”를 넘어서, 시간에 따른 패턴을 읽기 위한 데이터입니다. 예를 들어 다음과 같은 질문은 단일 날짜 대시보드만으로는 명확히 답하기 어렵습니다.
- 지난 4주 동안 정시 도착률이 꾸준히 좋아지고 있는가
- 특정 운송사의 지연 비율이 계속 높게 유지되는가
- 특정 요일이나 시간대에 평균 체류 시간이 길어지는 경향이 있는가
실제 창고 운영에서 겪은 문제는 다음과 같습니다. 대시보드는 항상 “오늘 기준”으로 잘 보여 주지만, 몇 주 전 KPI를 다시 보려고 날짜를 돌려 보면 그 사이에 예약 데이터가 수정된 것까지 반영된 값이 나옵니다. 즉, 당시 시점의 KPI 스냅샷이 아니라, 사후에 수정된 데이터를 기준으로 다시 계산한 값입니다.
그래서 KPI 이력은 다음 원칙으로 설계하는 편이 안전합니다.
- 하루 동안 예약·체크인 데이터는 계속 변해도 괜찮다고 보고,
- 하루가 완전히 끝난 뒤(예: 새벽 1시)에 “어제 기준”으로 지표를 한 번 계산해 별도 시트에 고정 저장합니다.
이렇게 하면:
- 일별 KPI는 이후 데이터 수정에 흔들리지 않는 “당시 기준 스냅샷”으로 남고,
- 월별·주별 추세 분석은 KPI를 저장해 두는 전용 시트만 읽으면 되므로 로직이 단순해집니다.
전제: 앞 편 구글시트 예약 KPI 집계 방법: 정시율·체류시간 자동 계산에서
KPI_DAILY시트와computeKpiForDate_(dateObj),saveDailyKpi_(kpi)함수를 이미 만들어 둔 상태를 전제로 합니다.이 글은 그 코드를 그대로 재사용하고, 그 위에 “이력 관리층”만 하나 더 얹습니다. 앞 편 함수·상수는 이 글에서 다시 선언하지 않습니다.
이 편에서 새로 추가할 것 정리
코드 중복·이름 충돌을 피하기 위해 이 편에서는 다음 원칙을 따릅니다.
- 앞 편에서 이미 선언한
KPI_CONFIG상수를 다시 선언하지 않습니다. - 이 편에서 필요한 설정은 모두
KPI_HISTORY_...처럼 접두사를 다르게 붙여 새 상수로 둡니다. - 이력용 시트도 앞 편에서 읽는 시트 이름(예:
APPT_MAIN,KPI_DAILY)을 건드리지 않고, 이 편 전용 상수로만 다룹니다.
이 글에서 새로 만드는 것:
KPI_HISTORY_CONFIG— 이력용 상수 묶음KPI_getHistorySheet_()— 이력 시트 생성·헤더 보장KPI_saveHistoryForDate_(ymd)— 날짜 기준 KPI 이력 저장·덮어쓰기nightlyRollup()— 뉴욕 기준 어제 날짜 계산 + KPI 계산 +KPI_HISTORY/KPI_DAILY동시 저장 + 로그 출력getKpiHistory(days)— 최근 N일 KPI 이력 조회KPI_cleanupOldHistory_()— 오래된 KPI 이력 정리- 각 기능 테스트용 함수(기대값 검증 포함)
- Apps Script 시간 기반 트리거 설정 가이드
아래부터는 완성본 전체 코드와 함께 설명합니다.
코드는 중간에 끊지 않고, 생략 없이 끝까지 모두 포함합니다.
KPI 이력용 상수 정의하기 (이 편 전용 이름 사용)
먼저 이 편에서 사용할 KPI 이력 전용 설정을 상수로 모읍니다.
앞 편에서 이미 KPI_CONFIG를 썼기 때문에, 이름 충돌을 피하려고 KPI_HISTORY_CONFIG라는 별도 이름을 사용합니다.
// 이 편 전용 KPI 이력 설정 상수
const KPI_HISTORY_CONFIG = { // → KPI 이력 설정 묶음
TZ: 'America/New_York', // → 시간대 고정 (전체 시리즈와 동일)
SHEET_NAME: 'KPI_HISTORY', // → 이력 시트 이름
HEADERS: [ // → 헤더 목록
'날짜', // → YYYY-MM-DD
'총예약건수', // → 예시 필드
'정시도착건수', // → 예시 필드
'정시도착률', // → 예시 필드
'평균체류시간분', // → 예시 필드
'지연건수', // → 예시 필드
'지연비율' // → 예시 필드
],
MAX_DAYS: 365 // → 이력 보관 일수(예시: 1년)
};앞 편 코드에
KPI_CONFIG가 이미 있다면 그대로 두고, 이 상수만 새로 추가하면 됩니다.기존 상수를 지우거나 이름을 바꾸지 마십시오.
KPI_HISTORY 시트 생성과 헤더 보장 함수
이제 KPI_HISTORY 시트를 만들고, 헤더를 항상 올바르게 맞춰 두는 도우미 함수를 작성합니다.
앞 편 “입고예약 기본 1편”에서 만든 getOrCreateSheet_(ss, name)을 그대로 사용하고, 이 편에서는 이력용 래퍼만 추가합니다.
getOrCreateSheet_()는 이미 입고예약 기본 1편에서 만들었습니다.이 글에서는 그 함수를 그대로 호출만 합니다.
function KPI_getHistorySheet_() { // → KPI 이력 시트 반환
const ss = SpreadsheetApp.getActive(); // → 현재 스프레드시트
const sheet = getOrCreateSheet_(ss, KPI_HISTORY_CONFIG.SHEET_NAME); // → 시트 찾거나 생성
const headerRange = sheet.getRange(
1, 1, 1, KPI_HISTORY_CONFIG.HEADERS.length // → 1행 헤더 범위
);
const currentValues = headerRange.getValues()[0]; // → 기존 헤더 읽기
const needUpdate = currentValues.some(
(v, i) => v !== KPI_HISTORY_CONFIG.HEADERS[i] // → 원하는 헤더와 비교
);
if (needUpdate) { // → 헤더가 다르면
headerRange.setValues([KPI_HISTORY_CONFIG.HEADERS]); // → 헤더 새로 쓰기
}
return sheet; // → 시트 반환
}
function KPI_testEnsureHistorySheet_() { // → 이력 시트 테스트 생성
const sheet = KPI_getHistorySheet_(); // → 시트 준비
Logger.log('KPI_HISTORY 시트 이름: ' + sheet.getName()); // → 이름 로그로 확인
}동작 확인
- 스크립트 편집기에서
KPI_testEnsureHistorySheet_를 실행합니다. - 스프레드시트에
KPI_HISTORY시트가 생성되고, 1행에 설정한 헤더가 올바르게 들어갔는지 확인합니다.
날짜 문자열 검증 유틸: 존재하지 않는 날짜 막기
이 글의 핵심 함수들은 YYYY-MM-DD 형식의 문자열을 많이 사용합니다.
형식만 맞고 실제로는 존재하지 않는 날짜(예: 2024-02-30)를 막기 위해 정규식 + Date 재검증을 공용 유틸로 분리합니다.
/**
* 'YYYY-MM-DD' 문자열을 엄격 검증 후 Date 객체로 반환
* - 형식 검증: 정규식
* - 존재 여부 검증: Date 객체로 변환 후 연/월/일 재비교
*/
function KPI_parseYmdStrict_(ymd) {
if (!ymd || typeof ymd !== 'string') {
throw new Error('유효한 날짜 문자열(YYYY-MM-DD)이 필요합니다: ' + ymd);
}
const parts = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/); // → 형식 검증
if (!parts) {
throw new Error('YYYY-MM-DD 형식이어야 합니다: ' + ymd);
}
const [, y, m, d] = parts; // → 캡쳐 그룹 구조분해
// 문자열을 new Date() 에 넣지 않는다. 'T00:00:00' 은 **현지 시각**으로 읽히는데
// 그것을 getUTC* 로 되읽으면 시간대에 따라 하루가 어긋난다. 숫자로 바로 만든다.
const dateObj = new Date(Number(y), Number(m) - 1, Number(d)); // → 현지 달력 자정
if (!Number.isFinite(dateObj.getTime())) {
throw new Error('잘못된 날짜 값입니다: ' + ymd);
}
const fy = dateObj.getFullYear();
const fm = dateObj.getMonth() + 1; // JS 월: 0~11 → +1
const fd = dateObj.getDate();
if (fy !== parseInt(y, 10) ||
fm !== parseInt(m, 10) ||
fd !== parseInt(d, 10)) {
throw new Error('존재하지 않는 날짜입니다: ' + ymd); // → 2월 30일 등 방지
}
return dateObj;
}이제 이후 코드에서는 날짜 문자열을 받을 때 이 유틸을 이용해 형식·존재 여부를 함께 검증합니다.
날짜별 KPI 이력 저장: 같은 날짜는 덮어쓰기
이제 핵심인 일별 KPI 이력 저장 함수를 만듭니다.
앞 편에서 작성한 computeKpiForDate_(dateObj)는 그대로 재사용합니다.
이 함수는:
YYYY-MM-DD문자열을 받아 존재하는 날짜인지 엄격히 검증하고- 해당 날짜 KPI를 계산한 뒤
KPI_HISTORY에서 같은 날짜를 찾으면 그 행을 덮어쓰고, 없으면 새 행을 추가합니다.
즉, 같은 날짜는 항상 한 줄만 남게 설계합니다.
function KPI_saveHistoryForDate_(ymd) { // → 특정 날짜 이력 저장
// 날짜 형식·존재 여부 엄격 검증
const dateObj = KPI_parseYmdStrict_(ymd); // → 검증 통과 시 Date 반환
const lock = LockService.getScriptLock(); // → 스크립트 잠금
lock.waitLock(30000); // → 최대 30초 대기
try {
const kpi = computeKpiForDate_(dateObj); // → 앞 편 함수로 KPI 계산
if (!kpi || typeof kpi !== 'object') {
throw new Error('KPI 계산 결과가 올바르지 않습니다');
}
// 필수 KPI 키와 숫자 유효성 검사 (앞 편 구조에 맞게)
const requiredKeys = [
'totalAppts', 'onTimeAppts', 'onTimeRate',
'avgDwTimeMinutes', 'lateAppts', 'lateRate'
];
requiredKeys.forEach(key => {
if (!(key in kpi)) {
throw new Error('KPI 필드 누락: ' + key);
}
const val = kpi[key];
if (!Number.isFinite(val)) {
throw new Error('KPI 값이 숫자가 아닙니다: ' + key + ' = ' + val);
}
});
const sheet = KPI_getHistorySheet_(); // → 이력 시트 가져오기
const lastRow = sheet.getLastRow();
let targetRow = 0; // → 쓸 행 번호
if (lastRow > 1) { // → 데이터가 있을 때
const range = sheet.getRange(2, 1, lastRow - 1, 1); // → 2행부터 날짜 열
const values = range.getValues();
for (let i = 0; i < values.length; i++) {
const cellVal = values[i][0];
if (!cellVal) continue;
const cellYmd = (cellVal instanceof Date)
? Utilities.formatDate(cellVal, KPI_HISTORY_CONFIG.TZ, 'yyyy-MM-dd')
: String(cellVal);
if (cellYmd === ymd) { // → 같은 날짜 발견
targetRow = i + 2; // → 실제 행 번호(헤더 + 오프셋)
break;
}
}
}
if (!targetRow) { // → 기존 날짜가 없으면
targetRow = lastRow >= 1 ? lastRow + 1 : 2; // → 새 행 번호
}
const rowValues = [
ymd, // 날짜
kpi.totalAppts, // 총예약건수
kpi.onTimeAppts, // 정시도착건수
kpi.onTimeRate, // 정시도착률
kpi.avgDwTimeMinutes, // 평균체류시간분
kpi.lateAppts, // 지연건수
kpi.lateRate // 지연비율
];
const targetRange = sheet.getRange(
targetRow,
1,
1,
rowValues.length
);
targetRange.setValues([rowValues]); // → 이력 저장 또는 덮어쓰기
Logger.log(
'[KPI_saveHistoryForDate_] 저장 완료 - 날짜: %s, 행: %s',
ymd,
targetRow
);
return {
ok: true,
row: targetRow,
ymd
};
} finally {
lock.releaseLock(); // → 잠금 해제
}
}KPI_saveHistoryForDate_ 테스트 함수 (기대값 검증 포함)
요구사항에 따라, 저장 행 수가 증가하지 않는지까지 검증하는 테스트를 작성합니다.
function KPI_testSaveHistoryForDate_() { // → 이력 저장 테스트
const tz = KPI_HISTORY_CONFIG.TZ;
const now = new Date();
const y = Utilities.formatDate(now, tz, 'yyyy');
const m = Utilities.formatDate(now, tz, 'MM');
const d = Utilities.formatDate(now, tz, 'dd');
const ymd = [y, m, d].join('-'); // → 오늘 날짜 문자열
const sheet = KPI_getHistorySheet_();
// 현재 데이터 행 개수 파악 (2행부터 1열, 빈 셀 제외)
const allValuesBefore = sheet.getLastRow() > 1
? sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues()
: [];
const countBefore = allValuesBefore.filter(v => v[0] !== '').length;
const result1 = KPI_saveHistoryForDate_(ymd); // → 첫 번째 저장
const result2 = KPI_saveHistoryForDate_(ymd); // → 두 번째 저장(덮어쓰기)
// 두 번 저장해도 같은 행이어야 함
if (result1.row !== result2.row) {
throw new Error('두 번째 저장이 첫 번째 행을 덮어쓰지 않음 (행 불일치)');
}
// 저장 이후 다시 데이터 행 개수 확인
const allValuesAfter = sheet.getLastRow() > 1
? sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues()
: [];
const countAfter = allValuesAfter.filter(v => v[0] !== '').length;
// 행 개수가 증가하면 안 됨(덮어쓰기 구조 확인)
if (countAfter !== countBefore && countBefore !== 0 && countAfter !== countBefore + 1) {
// 단, 처음 테스트해서 오늘 날짜가 새로 추가되는 경우는 +1 허용
throw new Error('행 개수가 비정상적으로 증가/감소함: before='
+ countBefore + ', after=' + countAfter);
}
Logger.log('KPI_testSaveHistoryForDate_ OK - ymd=%s, row=%s', ymd, result1.row);
}설명:
- 첫 실행 시 오늘 날짜가 처음 저장되면 행 개수는
+1증가할 수 있습니다. - 이미 오늘 날짜 행이 있고 다시 테스트할 경우에는 행 개수가 그대로 유지되어야 합니다.
- 같은 날짜 두 번 저장해도
result1.row === result2.row여야 “덮어쓰기” 구조가 맞습니다.
nightlyRollup: 뉴욕 기준 “어제” 자동 집계 + KPI_HISTORY & KPI_DAILY 동시 업데이트
이제 이 편의 중심인 nightlyRollup()을 만듭니다. 이 함수는:
- 시간대 'America/New_York' 기준으로 “오늘” 자정 기준 Date를 만들고
- 거기서 달력으로 하루를 빼서 “어제”를 구한 뒤 (밀리초로 24시간을 빼면 서머타임 전환일 다음 날에 하루가 건너뛰어집니다)
computeKpiForDate_(dateObj)로 어제 기준 KPI를 계산하고- 같은 KPI를
KPI_HISTORY에는KPI_saveHistoryForDate_(ymd)로 저장하고KPI_DAILY에는saveDailyKpi_(kpi)로 저장하며
- 로그에 실행 결과를 남깁니다.
function nightlyRollup() { // → 야간 KPI 집계
const tz = KPI_HISTORY_CONFIG.TZ || 'America/New_York';
const now = new Date(); // → 현재 시각
// 오늘 날짜(뉴욕 기준) 문자열 생성
const todayY = Utilities.formatDate(now, tz, 'yyyy');
const todayM = Utilities.formatDate(now, tz, 'MM');
const todayD = Utilities.formatDate(now, tz, 'dd');
const todayStr = [todayY, todayM, todayD].join('-');
// 어제는 **밀리초가 아니라 달력으로** 구한다. 24시간을 빼면 서머타임 시작일
// 다음 날에 하루가 통째로 사라진다 — 2026-03-09 에 돌리면 '어제'가 3월 8일이
// 아니라 3월 7일로 나와, 3월 8일 KPI 는 영원히 집계되지 않는다.
// `setDate()` 는 달력 위에서 움직이므로 그날이 23시간이든 25시간이든 상관없다.
const yesterdayDate = new Date(
Number(todayY), Number(todayM) - 1, Number(todayD) // → 오늘 자정(현지 달력)
);
yesterdayDate.setDate(yesterdayDate.getDate() - 1); // → 하루 전 날짜
const yY = Utilities.formatDate(yesterdayDate, tz, 'yyyy');
const yM = Utilities.formatDate(yesterdayDate, tz, 'MM');
const yD = Utilities.formatDate(yesterdayDate, tz, 'dd');
const ymd = [yY, yM, yD].join('-'); // → 어제 YYYY-MM-DD
// 1) 이력 시트 저장 (날짜 엄격 검증 포함)
const historyResult = KPI_saveHistoryForDate_(ymd);
// 2) KPI_DAILY에도 같은 기준으로 저장
const dateObj = KPI_parseYmdStrict_(ymd); // → 어제 Date 재생성
const kpi = computeKpiForDate_(dateObj); // → KPI 계산
saveDailyKpi_(kpi); // → KPI_DAILY 시트에 저장
const result = {
ok: true,
ymd,
historyRow: historyResult.row
};
Logger.log('[nightlyRollup] 완료 - %s (행=%s)', ymd, historyResult.row);
return result;
}nightlyRollup 테스트 함수 (기대값 검증 포함)
마찬가지로, 어제 날짜에 대해 두 번 실행해도 이력 행 수가 증가하지 않는지 검증합니다.
function KPI_testNightlyRollup_() { // → nightlyRollup 테스트
const sheet = KPI_getHistorySheet_();
// 현재 이력 행 수 파악
const allValuesBefore = sheet.getLastRow() > 1
? sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues()
: [];
const countBefore = allValuesBefore.filter(v => v[0] !== '').length;
const result1 = nightlyRollup(); // → 첫 실행
const result2 = nightlyRollup(); // → 두 번째 실행 (덮어쓰기 기대)
if (result1.ymd !== result2.ymd) {
throw new Error('두 번 실행했을 때 기준 날짜가 다릅니다');
}
if (result1.historyRow !== result2.historyRow) {
throw new Error('두 번째 nightlyRollup이 첫 번째 행을 덮어쓰지 않음');
}
// 실행 후 다시 이력 행 수 확인
const allValuesAfter = sheet.getLastRow() > 1
? sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues()
: [];
const countAfter = allValuesAfter.filter(v => v[0] !== '').length;
// 어제 날짜가 새로 추가되는 첫 실행인 경우를 제외하면, 행 수가 증가하면 안 됨
if (countAfter !== countBefore && countBefore !== 0 && countAfter !== countBefore + 1) {
throw new Error('nightlyRollup 실행 후 행 개수가 비정상적으로 변경됨: before='
+ countBefore + ', after=' + countAfter);
}
Logger.log('KPI_testNightlyRollup_ OK - %s, row=%s',
result1.ymd,
result1.historyRow
);
}최근 N일 KPI 이력 조회 함수 만들기
대시보드에서 최근 7일·30일 추세를 보기 위해서는 KPI_HISTORY에서 최근 N일 데이터를 읽어오는 함수가 필요합니다.
시트 정렬이 깨져 있어도 안전하게 동작하도록, 코드 안에서 날짜 기준 정렬을 한 번 더 수행합니다.
function getKpiHistory(days) { // → 최근 N일 이력 조회
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) {
throw new Error('조회할 일수는 1 이상의 숫자여야 합니다: ' + days);
}
const sheet = KPI_getHistorySheet_();
const lastRow = sheet.getLastRow();
if (lastRow <= 1) {
return []; // → 데이터 없음
}
const dataRange = sheet.getRange(
2,
1,
lastRow - 1,
KPI_HISTORY_CONFIG.HEADERS.length
);
const values = dataRange.getValues();
const tz = KPI_HISTORY_CONFIG.TZ;
const rows = [];
for (let i = 0; i < values.length; i++) {
const row = values[i];
const cellVal = row[0]; // → 날짜 값
if (!cellVal) continue;
let ymd;
if (cellVal instanceof Date) {
ymd = Utilities.formatDate(cellVal, tz, 'yyyy-MM-dd');
} else {
ymd = String(cellVal);
}
// 날짜 형식/존재 여부 검증 (유효하지 않으면 건너뜀)
let d;
try {
d = KPI_parseYmdStrict_(ymd);
} catch (e) {
continue;
}
rows.push({
ymd,
date: d,
totalAppts: row[1],
onTimeAppts: row[2],
onTimeRate: row[3],
avgDwTimeMinutes: row[4],
lateAppts: row[5],
lateRate: row[6]
});
}
if (rows.length === 0) return [];
// 날짜 오름차순 정렬
rows.sort((a, b) => a.date.getTime() - b.date.getTime());
const startIndex = Math.max(0, rows.length - n);
const recent = rows.slice(startIndex); // → 최근 N일
return recent.map(r => ({
ymd: r.ymd,
totalAppts: r.totalAppts,
onTimeAppts: r.onTimeAppts,
onTimeRate: r.onTimeRate,
avgDwTimeMinutes: r.avgDwTimeMinutes,
lateAppts: r.lateAppts,
lateRate: r.lateRate
}));
}getKpiHistory 테스트 함수
function KPI_testGetKpiHistory_() { // → 이력 조회 테스트
const history7 = getKpiHistory(7); // → 최근 7일
const history30 = getKpiHistory(30); // → 최근 30일
// 간단 기대값 검증: 7일 이력이 30일 이력보다 많을 수는 없음
if (history7.length > history30.length) {
throw new Error('최근 7일 이력이 30일 이력보다 많을 수 없습니다');
}
// 날짜가 오름차순인지 검증
const isSorted = arr => arr.every((r, i) =>
i === 0 || r.ymd >= arr[i - 1].ymd
);
if (!isSorted(history7) || !isSorted(history30)) {
throw new Error('이력 데이터가 날짜 오름차순으로 정렬되지 않았습니다');
}
Logger.log('최근 7일: ' + JSON.stringify(history7));
Logger.log('최근 30일: ' + JSON.stringify(history30));
}오래된 KPI 이력 자동 정리: MAX_DAYS 활용
이력 시트는 시간이 지날수록 행이 계속 늘어납니다.
KPI_HISTORY_CONFIG.MAX_DAYS보다 오래된 이력은 삭제하도록 정리 함수를 만듭니다.
동시에 실행될 때 충돌을 막기 위해 LockService를 사용합니다.
function KPI_cleanupOldHistory_() { // → 오래된 이력 정리
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const sheet = KPI_getHistorySheet_();
const lastRow = sheet.getLastRow();
if (lastRow <= 1) {
return 0; // → 데이터 없음
}
const range = sheet.getRange(2, 1, lastRow - 1, 1); // → 날짜 열만
const values = range.getValues();
const tz = KPI_HISTORY_CONFIG.TZ;
const now = new Date();
// 오늘(뉴욕 기준) 자정
const todayY = Utilities.formatDate(now, tz, 'yyyy');
const todayM = Utilities.formatDate(now, tz, 'MM');
const todayD = Utilities.formatDate(now, tz, 'dd');
// 보관 기준일도 같은 이유로 달력으로 뺀다(24시간 × N 이 아니다)
const cutoff = new Date(
Number(todayY), Number(todayM) - 1, Number(todayD) // → 오늘 자정(현지 달력)
);
cutoff.setDate(cutoff.getDate() - KPI_HISTORY_CONFIG.MAX_DAYS);
const cutoffMs = cutoff.getTime(); // → 이 시각 이전 데이터는 삭제 대상
const rowsToDelete = [];
for (let i = 0; i < values.length; i++) {
const cellVal = values[i][0];
if (!cellVal) continue;
let ymd;
if (cellVal instanceof Date) {
ymd = Utilities.formatDate(cellVal, tz, 'yyyy-MM-dd');
} else {
ymd = String(cellVal);
}
let d;
try {
d = KPI_parseYmdStrict_(ymd);
} catch (e) {
continue; // → 이상한 날짜는 조용히 패스
}
if (d.getTime() < cutoffMs) {
rowsToDelete.push(i + 2); // → 실제 행 번호
}
}
// 뒤에서부터 지워야 행 번호가 깨지지 않음
rowsToDelete.sort((a, b) => b - a);
rowsToDelete.forEach(row => {
sheet.deleteRow(row);
});
Logger.log('[KPI_cleanupOldHistory_] 삭제된 행 수: ' + rowsToDelete.length);
return rowsToDelete.length;
} finally {
lock.releaseLock();
}
}KPI_cleanupOldHistory_ 테스트 함수
function KPI_testCleanupOldHistory_() { // → 정리 테스트
const deleted = KPI_cleanupOldHistory_();
if (!Number.isFinite(deleted) || deleted < 0) {
throw new Error('삭제된 행 수가 올바르지 않습니다: ' + deleted);
}
Logger.log('삭제된 행 수: ' + deleted);
}Apps Script 시간 기반 트리거로 야간 자동화 설정하기
이제 코드가 준비됐으니, 구글시트 시간 기반 트리거를 통해 매일 새벽 자동으로 nightlyRollup()을 실행되게 만듭니다.
트리거 설정 절차
- 구글시트 상단 메뉴에서
확장 프로그램 → Apps Script를 엽니다. - 좌측 메뉴에서
트리거(시계 아이콘)를 클릭합니다. - 우측 하단
+ 트리거 추가버튼을 클릭합니다. - 다음과 같이 설정합니다.
- 실행할 함수 선택:
nightlyRollup - 배포:
Head - 이벤트 소스 선택:
시간 기반 - 시간 기반 트리거 유형:
일별 타이머 - 시간: 운영 상황에 맞춰 예를 들어
오전 1시~2시등 새벽 시간대
- 저장 후 처음 한 번은 권한 요청이 뜹니다. 가이드에 따라 승인합니다.
트리거 시간 추천 예시 (실무 기준)
대시보드 자동 새로고침을 별도로 운영 중이라면, 트리거를 다음처럼 나누는 것을 추천합니다.
- 00:30 — 대시보드 리프레시 (쿼리·피벗 등 화면용 갱신)
- 01:30 — 전날 KPI 야간 집계 (
nightlyRollup)
이렇게 분리하면 Apps Script 실행 시간이 겹치지 않아 타임아웃이나 리소스 충돌을 줄일 수 있습니다.
트리거 정상 동작 점검 방법
- 다음날 아침
KPI_HISTORY시트를 열어 보면 어제 날짜가 새로 한 줄 추가되어 있어야 합니다. - 스크립트 편집기의
트리거화면에서 실행 성공 여부, 최근 실행 시간, 오류 메시지를 확인할 수 있습니다. - 문제가 있다면 해당 시간대에
nightlyRollup()을 스크립트 편집기에서 직접 실행해 로그를 확인해 봅니다.
실무 팁: 날짜 기준·잠금·테스트를 어떻게 잡을지
왜 “어제 기준”으로 집계할까?
입고 예약과 게이트 체크인은 자정을 전후해 이어지는 경우가 많습니다.
당일 KPI를 23:00쯤에 확정해 버리면 그 이후 들어오는 트럭은 지표에서 빠집니다. 실무 경험상:
- 어제 기준 KPI를 오늘 새벽에 확정하는 방식이 가장 무난합니다.
- 야간 시프트가 있는 창고라면 첫 시프트가 끝난 뒤(예: 새벽 1~2시)에 집계하는 게 안전합니다.
KPI가 하루 늦게 나오더라도, “데이터가 덜 들어간 상태의 숫자”로 논의하는 위험을 크게 줄일 수 있습니다.
LockService를 쓰는 이유
Apps Script 시간 기반 트리거는 실패 시 재시도되거나, 운영자가 수동 실행을 눌러 겹쳐 돌아갈 수 있습니다.
KPI_saveHistoryForDate_(), KPI_cleanupOldHistory_()처럼 행 삭제·덮어쓰기를 하는 함수는 동시 실행이 매우 위험합니다.
- 잠금 없이 동시에 두 번 저장되면, 같은 날짜 이력이 두 줄 생기거나 삭제 타이밍이 꼬일 수 있습니다.
- 이 글의 저장·정리 함수에 모두
LockService.getScriptLock()을 사용해, - 한 번에 한 실행만 이력·삭제 작업을 하도록 강제했습니다.
- 오류가 나도
finally에서 잠금을 해제해 다음 실행이 막히지 않도록 했습니다.
비슷한 패턴(예약 저장, 체크인 로그, 배치 처리가 있는 스크립트)을 만들 때도 이 구조를 그대로 가져다 쓰면 충돌 위험을 줄일 수 있습니다.
자주 발생하는 오류와 점검 포인트
computeKpiForDate_ is not defined
→ 이 편 코드는 앞 편 구글시트 예약 KPI 집계 방법: 정시율·체류시간 자동 계산의 코드가 같은 Apps Script 프로젝트에 이미 들어가 있다는 전제입니다.
→ 이런 에러가 뜬다면, 먼저 앞 편의 함수들(computeKpiForDate_, saveDailyKpi_, getOrCreateSheet_ 등)을 동일 프로젝트에 복사해 넣었는지 확인해야 합니다.
- 필드 이름 불일치 (
totalAppts,onTimeRate등)
→ 실무에서 KPI 항목을 바꾸거나 이름을 다르게 썼다면,
→ KPI_saveHistoryForDate_() 안의 requiredKeys 배열과 rowValues 구성 부분을 실제 사용하는 필드 이름에 맞게 함께 수정해야 합니다.
→ 그렇지 않으면 이 글의 방어 코드가 “KPI 필드 누락 / 숫자 아님” 오류를 의도적으로 던집니다.
- 존재하지 않는 날짜 / 잘못된 형식 입력
→ KPI_parseYmdStrict_()에서 정규식 + Date 재검증으로 막고 있습니다.
→ 예를 들어 KPI_saveHistoryForDate_('2024-02-30')처럼 잘못된 날짜를 호출하면 명확한 에러를 던져 디버깅이 쉬워집니다.
맺음말: 오늘 밤부터 KPI 이력을 자동으로 쌓아 보기
이 글에서는 구글시트 KPI 자동 집계 구조를 실무 기준으로 정리했습니다. 정리하면:
- KPI 이력용 설정 상수(
KPI_HISTORY_CONFIG)와KPI_HISTORY시트 구조를 정의하고 KPI_saveHistoryForDate_(ymd)로 날짜별 KPI를 한 줄씩 저장·덮어쓰기 하며nightlyRollup()으로 뉴욕 기준 “어제” 지표를 계산해KPI_HISTORYKPI_DAILY
두 곳에 동시에 저장하고
getKpiHistory(days)로 최근 N일 이력을 읽어 대시보드·리포트에 쓰고KPI_cleanupOldHistory_()로MAX_DAYS를 넘긴 오래된 이력을 정리하며- 각 기능에 대해 테스트 함수에서 기대값(행 개수, 행 번호, 정렬 여부 등)까지 검증했습니다.
지금 바로 할 수 있는 행동은 한 가지입니다.
현재 사용하는 구글시트 예약·체크인 시스템의 Apps Script에 이 글의 코드 전체를 붙여 넣고, 시간 기반 트리거에서 nightlyRollup을 새벽 시간대에 실행되도록 등록해 보십시오.
내일부터는 별도의 엑셀 집계 없이도 KPI_HISTORY 시트에 일별 지표가 한 줄씩 자동으로 쌓이고, 그 데이터를 기반으로 구글시트 물류 KPI 대시보드에서 최근 7일·30일 추세를 안정적으로 확인할 수 있습니다.