SSmart Life US

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

구글시트 대시보드 만들기 Apps Script: 창고 현황 한눈에 보는 방법

구글시트 대시보드 만들기 Apps Script: 창고 현황 한눈에 보는 방법

도입: 엑셀 탭 열다가 하루가 지나가는 문제

창고에서 실제로 일하다 보면 입고 시트, 출고 시트, 재고 집계, 적치 현황, Load 관리 시트까지 여러 파일을 동시에 열어 두고 왔다 갔다 하게 됩니다. 아침 회의 전에는 각 시트에서 숫자를 뽑아 오느라 엑셀과 구글시트 탭만 보다가, 정작 현장 점검은 뒤로 밀리기 일쑤였습니다. 이 글은 그런 상황에서 검색해서 들어온 분들이 찾는 답, 즉 구글시트 대시보드 만들기로 입고·출고·재고를 한 화면에서 자동으로 보는 방법을 정리합니다.

대시보드 자동갱신 구축

이번 편에서는 이미 운영 중인 여러 시트의 데이터를 모아 하나의 DASHBOARD 시트에 요약 숫자를 보여 주고, Apps Script 시간 기반 트리거로 30분마다 자동 갱신되도록 만드는 과정을 설명합니다. 재고와 작업량이 기준선을 넘으면 배경색으로 알림도 함께 넣어, 현장에서 바로 대응할 수 있는 수준까지 구현합니다.


구글시트 Apps Script 대시보드 설계: 어떤 숫자를 볼 것인가

대시보드는 “지금 이 순간 무엇이 중요한가”를 최소한의 숫자로 보여 주는 화면입니다. 여러 창고에서 실제로 사용해 본 경험상, 처음부터 많은 지표를 올리기보다 매일 확인하는 4~5개만 먼저 올리고 검증해 보는 것이 효과적이었습니다. 이 글에서는 다음 다섯 가지 지표를 기준 예시로 사용합니다.

  1. 오늘 입고 건수

오늘 날짜로 들어온 입고 오더나 팔레트 수를 센 값입니다. 아침에 들어올 물량 규모를 가늠하고, 오후 피크 타임에 얼마나 준비해야 할지 판단하는 기준이 됩니다.

  1. 미처리 적치 건수

재고 집계 시트에서 아직 최종 로케이션이 배정되지 않았거나, ‘적치 대기’ 상태로 남아 있는 건수를 셉니다. 이 값이 높아지면 랙 앞 통로가 막히기 시작하므로, 현장에서 바로 치워야 할 우선순위를 잡는 데 유용합니다.

  1. 로케이션별 재고 상위 10개

SKU·로케이션·재고 수량 기준으로 상위 10개를 뽑아 한눈에 보여 줍니다. 특정 SKU가 과적체 상태인지, 어느 구간에서 재고가 과하게 쌓였는지를 빠르게 파악할 수 있습니다.

  1. 오늘 출고 수량

오늘 실제로 나간 수량(팔레트 또는 박스 기준)을 합산해 보여 줍니다. 출고 팀 입장에서는 목표 대비 진행률을 확인하고, 추가 인력 투입 여부를 판단하는 기준이 됩니다.

  1. 미마감 Load 수

아직 출발 처리되지 않은 차량이나 Load 단위의 개수를 셉니다. 저녁 시간에 이 숫자가 기준선을 넘으면, 도킹 우선순위 조정이나 추가 장비 투입을 검토하는 신호로 사용할 수 있습니다.

이 다섯 지표를 DASHBOARD 시트에 표 형태로 배치하고, refreshDashboard() 함수 한 번으로 모든 숫자를 다시 계산하도록 만드는 것이 이번 글의 핵심입니다. 여기에 Apps Script 시간 기반 트리거를 설정해 30분마다 자동 갱신되도록 하면, 별도 버튼을 누르지 않아도 항상 최신 상태를 유지할 수 있습니다.

이 글의 코드는 1~7편에서 만든 시트를 그대로 읽습니다. 시트 이름은 INBOUND·OUTBOUND·STOCK·LOADS, 열 위치는 그때 정한 머리글 순서 그대로입니다. 앞 편을 따라오셨다면 고칠 것이 없고, 시트 이름이나 열 순서를 바꿔 쓰고 계신 분만 코드 맨 위 DASHBOARD_CONFIGSHEET_·COL_ 값을 자기 것에 맞추면 됩니다.


DASHBOARD 시트 구조 잡기: 사람이 먼저 보기 편하게

스크립트 작업에 들어가기 전, 대시보드 시트의 구조를 먼저 정리해 두면 이후 유지보수가 훨씬 편합니다. 실제로 운영하면서 가장 무난했던 구조는 아래와 같습니다.

  • 시트 이름: DASHBOARD
  • A열: 항목 이름
  • B열: 값(실제 숫자)
  • C열: 기준선 숫자 또는 여유 값
  • D열 이후: 기준 설명이나 단위 텍스트

예시 배치는 다음과 같습니다.

  • A2: 오늘 입고 예정 건수, B2: 값, C2: 기준선 숫자(선택)
  • A3: 미처리 적치 건수, B3: 값, C3: 기준선 숫자 (예: 50)
  • A4: 오늘 출고 수량, B4: 값, C4: 기준선 숫자(선택)
  • A5: 미마감 Load 수, B5: 값, C5: 기준선 숫자 (예: 5)
  • A7: 재고 상위 10개 제목, A8: 로케이션·모델·재고 수량 머리글, A9부터 데이터 10줄

이 행 번호는 코드에서 dashboardRows_() 한 곳에서만 계산합니다. 시트를 만드는 함수와 값을 채우는 함수가 각자 행을 계산하면 한 줄만 어긋나도 표 머리글이 데이터로 덮어써집니다. 그래서 배치를 바꾸고 싶으면 DASHBOARD_CONFIGROW_FIRST_KPI·KPI_COUNT·TOP_N 만 바꾸면 두 함수가 함께 따라옵니다. 색상 알림을 넣을 셀도 B3·B5처럼 미리 정해 두고, C3·C5에는 반드시 숫자만 입력하도록 설계하는 것이 중요합니다. 기준 설명은 D열에 “팔레트 기준”, “Load 기준”처럼 분리해 두면, 코드에서는 숫자만 다루기 때문에 오류를 줄일 수 있습니다.


1단계 — DASHBOARD 시트 자동 생성 함수

DASHBOARD 시트가 없을 때 자동으로 생성하고, 기본 라벨과 표 구조를 만들어 줍니다. 이미 DASHBOARD가 있어도 제목과 라벨은 유지하면서 값 영역은 재설정하도록 구성했습니다.

  • 붙여넣을 위치: 구글시트 → 확장 프로그램 → Apps Script → Code.gs
  • 붙여넣은 뒤 할 일: 저장 후 testCreateDashboardSheet_()를 한 번 실행해 시트를 만듭니다.
Apps Script (JavaScript)
// → 이 편에서만 쓰는 설정 모음. 시트·열 이름은 1~7편에서 만든 그대로다.
const DASHBOARD_CONFIG = {
  SHEET_DASHBOARD: 'DASHBOARD',                 // → 이 편에서 새로 만드는 시트
  SHEET_INBOUND:   'INBOUND',                   // → 1편에서 만든 입고 예정 시트
  SHEET_OUTBOUND:  'OUTBOUND',                  // → 2편에서 만든 출고 이력 시트
  SHEET_STOCK:     'STOCK',                     // → 3편에서 만든 재고 집계 시트
  SHEET_LOADS:     'LOADS',                     // → 7편에서 만든 Load 관리 시트

  COL_INBOUND:  { ETA_DATE: 4, STATUS: 6 },     // → D열 도착예정일, F열 상태
  COL_OUTBOUND: { QTY: 4, TIMESTAMP: 5 },       // → D열 수량, E열 처리시각
  COL_STOCK:    { LOCATION: 1, MODEL: 2, STOCK: 5 },  // → A열 로케이션, B열 모델, E열 재고
  COL_LOADS:    { STATUS: 4 },                  // → D열 상태

  DONE_STATUS: 'PUTAWAY_DONE',                  // → 1편이 적치 완료에 쓰는 값
  OPEN_STATUS: 'OPEN',                          // → 7편이 미마감 Load에 쓰는 값

  ROW_FIRST_KPI: 2,                             // → 지표가 시작하는 행(A2)
  KPI_COUNT: 4,                                 // → 지표 개수
  TOP_N: 10                                     // → 재고 상위 몇 개를 보여줄지
};

// 표가 시작하는 행을 여기 한 곳에서만 계산한다.
// 만드는 함수와 채우는 함수가 각자 계산하면 한 줄씩 어긋나 머리글이 지워진다.
function dashboardRows_() {                     // → 대시보드 행 배치
  const first = DASHBOARD_CONFIG.ROW_FIRST_KPI; // → 지표 첫 행
  const title = first + DASHBOARD_CONFIG.KPI_COUNT + 1;  // → 한 줄 띄우고 재고 표 제목
  return {
    kpiFirst: first,                            // → 지표 첫 행 (A2)
    stockTitle: title,                          // → '재고 상위 10개' 제목 행 (A7)
    stockHeader: title + 1,                     // → 표 머리글 행 (A8)
    stockData: title + 2                        // → 표 데이터 첫 행 (A9)
  };
}

function createDashboardSheet_() {              // → DASHBOARD 시트 만들기·초기화
  const ss = SpreadsheetApp.getActiveSpreadsheet();      // → 현재 스프레드시트
  let sheet = ss.getSheetByName(DASHBOARD_CONFIG.SHEET_DASHBOARD);  // → 기존 시트
  if (!sheet) {                                 // → 없으면
    sheet = ss.insertSheet(DASHBOARD_CONFIG.SHEET_DASHBOARD);       // → 새로 만든다
  }
  const rows = dashboardRows_();                // → 행 배치 가져오기

  sheet.getRange('A1').setValue('창고 대시보드');          // → 제목
  sheet.getRange('A1').setFontSize(16).setFontWeight('bold');       // → 제목 스타일

  const labels = [                              // → A열 지표 이름 (순서 고정)
    ['오늘 입고 예정 건수'],                    // → B2
    ['미처리 적치 건수'],                       // → B3
    ['오늘 출고 수량'],                         // → B4
    ['미마감 Load 수']                          // → B5
  ];
  sheet.getRange(rows.kpiFirst, 1, labels.length, 1).setValues(labels);  // → 라벨 쓰기
  sheet.getRange(rows.kpiFirst, 2, labels.length, 1).clearContent();     // → 값은 새로고침이 채운다

  // C열 기준값은 운영자가 손으로 넣는 값이다. 이미 숫자가 있으면 절대 지우지 않는다.
  const thRange = sheet.getRange(rows.kpiFirst, 3, labels.length, 1);    // → 기준값 범위
  const kept = thRange.getValues().map(function (r) {                    // → 한 줄씩 확인
    const n = Number(r[0]);                     // → 숫자로 바꿔 보고
    return (r[0] !== '' && Number.isFinite(n)) ? [r[0]] : [''];          // → 숫자면 유지
  });
  thRange.setValues(kept);                      // → 되돌려 쓰기

  sheet.getRange(rows.stockTitle, 1)            // → 재고 표 제목
    .setValue('재고 상위 ' + DASHBOARD_CONFIG.TOP_N + '개').setFontWeight('bold');
  sheet.getRange(rows.stockHeader, 1, 1, 3)     // → 표 머리글
    .setValues([['로케이션', '모델', '재고 수량']]).setFontWeight('bold');
  sheet.getRange(rows.stockData, 1, DASHBOARD_CONFIG.TOP_N, 3).clearContent();  // → 데이터 영역 비우기

  sheet.setColumnWidths(1, 3, 150);             // → A~C열 너비
}

function testCreateDashboardSheet_() {          // → 편집기에서 실행할 확인용 함수
  createDashboardSheet_();                      // → 시트 만들기
  Logger.log(JSON.stringify(dashboardRows_())); // → 어떤 행에 무엇이 들어갔는지 로그로 확인
}

2단계 — refreshDashboard()로 입고·출고·재고·Load 한 번에 갱신

이 함수는 각 운영 시트에서 데이터를 읽어 와서 대시보드의 숫자와 재고 상위 10개 표를 채웁니다. 여러 사용자가 동시에 스크립트를 실행해도 값이 꼬이지 않도록 LockService로 잠금을 걸어 두고, 마지막에는 반드시 lock.releaseLock()이 실행되도록 finally 블록을 사용합니다.

또한 날짜를 날짜로 읽을 수 없거나 수량 칸에 글자가 섞인 행은 집계에서 빼고 몇 행이 문제인지 Logger.log 에 남깁니다. Number('ABC')NaN 이 되고, NaN 은 한 번만 더해져도 합계 전체를 NaN 으로 만들기 때문에 반드시 걸러야 합니다.

  • 붙여넣을 위치: 위 코드 바로 아래
  • 붙여넣은 뒤 할 일: 저장 후 testRefreshDashboard_()를 실행해 숫자가 채워지는지 확인
Apps Script (JavaScript)
// 시트 값이 날짜인지 확인하고 yyyy-MM-dd 문자열로 바꾼다. 날짜가 아니면 빈 문자열.
function ymd_(v, tz) {                          // → 날짜 정규화
  if (v === '' || v === null || v === undefined) return '';   // → 빈 칸
  const d = (v instanceof Date) ? v : new Date(v);            // → 날짜로 해석
  if (isNaN(d.getTime())) return '';            // → 날짜가 아니면 빈 값
  return Utilities.formatDate(d, tz, 'yyyy-MM-dd');           // → 비교용 문자열
}

// 머리글을 뺀 데이터 행만 돌려준다. 시트가 없거나 비어 있으면 빈 배열.
function sheetRows_(ss, name) {                 // → 데이터 행 읽기
  const sh = ss.getSheetByName(name);           // → 시트 찾기
  if (!sh || sh.getLastRow() < 2) return [];    // → 없거나 머리글뿐이면 빈 배열
  return sh.getRange(2, 1, sh.getLastRow() - 1, sh.getLastColumn()).getValues();
}

function refreshDashboard() {                   // → 대시보드 전체 갱신
  const lock = LockService.getScriptLock();     // → 동시 실행 잠금
  lock.waitLock(30000);                         // → 최대 30초 대기
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();         // → 현재 스프레드시트
    const tz = ss.getSpreadsheetTimeZone();     // → 시트에 설정된 시간대
    const dash = ss.getSheetByName(DASHBOARD_CONFIG.SHEET_DASHBOARD);   // → DASHBOARD 시트
    if (!dash) {                                // → 없으면
      throw new Error('DASHBOARD 시트가 없습니다. testCreateDashboardSheet_ 를 먼저 실행하세요.');
    }
    const rows = dashboardRows_();              // → 행 배치
    const today = Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd');   // → 오늘 날짜
    const skipped = [];                         // → 건너뛴 행 기록

    // 1)·2) INBOUND — 오늘 도착 예정 건수와, 그중 아직 적치되지 않은 건수
    const ci = DASHBOARD_CONFIG.COL_INBOUND;    // → 입고 열 위치
    let inboundToday = 0;                       // → 오늘 입고 예정
    let pendingPutaway = 0;                     // → 그중 미처리 적치
    sheetRows_(ss, DASHBOARD_CONFIG.SHEET_INBOUND).forEach(function (row, i) {
      const day = ymd_(row[ci.ETA_DATE - 1], tz);              // → 도착 예정일
      if (!day) {                               // → 날짜로 못 읽으면
        skipped.push('INBOUND ' + (i + 2) + '행: ETA_DATE를 날짜로 읽을 수 없음');
        return;                                 // → 이 행은 세지 않는다
      }
      if (day !== today) return;                // → 오늘이 아니면 건너뜀
      inboundToday++;                           // → 오늘 예정 +1
      const status = String(row[ci.STATUS - 1] || '').trim();  // → 상태값
      if (status !== DASHBOARD_CONFIG.DONE_STATUS) {           // → 적치 완료가 아니면
        pendingPutaway++;                       // → 미처리 +1
      }
    });

    // 3) OUTBOUND — 오늘 처리된 출고 수량 합계
    const co = DASHBOARD_CONFIG.COL_OUTBOUND;   // → 출고 열 위치
    let outboundQty = 0;                        // → 수량 합계
    sheetRows_(ss, DASHBOARD_CONFIG.SHEET_OUTBOUND).forEach(function (row, i) {
      if (ymd_(row[co.TIMESTAMP - 1], tz) !== today) return;   // → 오늘 것만
      const qty = Number(row[co.QTY - 1]);      // → 수량을 숫자로
      if (!Number.isFinite(qty)) {              // → 글자가 섞였으면
        skipped.push('OUTBOUND ' + (i + 2) + '행: 수량이 숫자가 아님');
        return;                                 // → 합계를 NaN으로 만들지 않는다
      }
      outboundQty += qty;                       // → 합산
    });

    // 4) LOADS — 아직 마감되지 않은 Load 수
    const cl = DASHBOARD_CONFIG.COL_LOADS;      // → Load 열 위치
    let openLoads = 0;                          // → 미마감 Load
    sheetRows_(ss, DASHBOARD_CONFIG.SHEET_LOADS).forEach(function (row) {
      const status = String(row[cl.STATUS - 1] || '').trim().toUpperCase();  // → 상태
      if (status === DASHBOARD_CONFIG.OPEN_STATUS) openLoads++;              // → OPEN이면 +1
    });

    dash.getRange(rows.kpiFirst, 2, DASHBOARD_CONFIG.KPI_COUNT, 1).setValues([
      [inboundToday],                           // → B2
      [pendingPutaway],                         // → B3
      [outboundQty],                            // → B4
      [openLoads]                               // → B5
    ]);

    // 5) STOCK — 재고가 많은 순으로 상위 N개
    const cs = DASHBOARD_CONFIG.COL_STOCK;      // → 재고 열 위치
    const picked = [];                          // → 뽑아낸 행
    sheetRows_(ss, DASHBOARD_CONFIG.SHEET_STOCK).forEach(function (row, i) {
      const qty = Number(row[cs.STOCK - 1]);    // → 재고 수량
      if (!Number.isFinite(qty)) {              // → 숫자가 아니면
        skipped.push('STOCK ' + (i + 2) + '행: 재고 수량이 숫자가 아님');
        return;                                 // → 순위에서 제외
      }
      if (qty <= 0) return;                     // → 0 이하는 보여줄 필요가 없다
      picked.push({ loc: row[cs.LOCATION - 1], model: row[cs.MODEL - 1], qty: qty });
    });
    picked.sort(function (a, b) { return b.qty - a.qty; });    // → 많은 순 정렬

    const table = [];                           // → 항상 TOP_N줄을 쓴다
    for (let i = 0; i < DASHBOARD_CONFIG.TOP_N; i++) {         // → 모자라면 빈 줄로
      const r = picked[i];                      // → i번째 행
      table.push(r ? [r.loc, r.model, r.qty] : ['', '', '']);  // → 없으면 빈 줄
    }
    dash.getRange(rows.stockData, 1, DASHBOARD_CONFIG.TOP_N, 3).setValues(table);

    applyDashboardColors_(dash, rows, [inboundToday, pendingPutaway, outboundQty, openLoads]);

    if (skipped.length) {                       // → 건너뛴 행이 있으면
      Logger.log('건너뛴 행 ' + skipped.length + '건: ' + skipped.join(' / '));
    }
  } finally {
    lock.releaseLock();                         // → 성공·실패와 무관하게 잠금 해제
  }
}

function testRefreshDashboard_() {              // → 편집기에서 실행할 확인용 함수
  refreshDashboard();                           // → 갱신 실행
  const dash = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(DASHBOARD_CONFIG.SHEET_DASHBOARD);        // → DASHBOARD 시트
  const rows = dashboardRows_();                // → 행 배치
  const kpi = dash.getRange(rows.kpiFirst, 1, DASHBOARD_CONFIG.KPI_COUNT, 2).getValues();
  Logger.log(JSON.stringify(kpi));              // → 라벨과 값을 함께 로그로 출력
}

어느 시트의 어느 열을 읽는가

코드가 읽는 위치는 앞 편에서 만든 머리글 순서 그대로입니다.

  • INBOUND (1편) — CONTAINER, MODEL, QTY, ETA_DATE, PUTAWAY, STATUS

→ D열 ETA_DATE 로 오늘 것을 고르고, F열 STATUSPUTAWAY_DONE 이 아니면 미처리 적치로 셉니다.

  • OUTBOUND (2편) — ORDER_NO, LOCATION, MODEL, QTY, TIMESTAMP

→ E열 TIMESTAMP 가 오늘인 행의 D열 QTY 를 더합니다.

  • STOCK (3편) — Location, Model, InQty, OutQty, Stock

→ E열 Stock 이 큰 순서로 상위 10개를 뽑고 A·B열을 함께 보여 줍니다.

  • LOADS (7편) — LOAD_NO, DOOR, CARRIER, STATUS

→ D열 STATUSOPEN 인 행을 셉니다.

시트 이름이나 열 순서를 다르게 쓰고 계시다면 DASHBOARD_CONFIGSHEET_COL_ 숫자만 자기 시트에 맞추면 나머지 로직은 그대로 동작합니다.


3단계 — 임계치 색상 알림 함수

숫자만 보면 위험 구간이 지나가 버리기 쉽습니다. 현장에서는 색으로 경고를 주는 것이 훨씬 빠르게 반응을 이끌어 냅니다. 이 함수는 C열에 기준값을 넣어 둔 지표의 B열 셀에 배경색을 칠합니다. 기준값을 비워 두면 그 줄은 색을 칠하지 않으므로, 필요한 지표에만 기준을 넣으면 됩니다.

색은 한 값에 하나만 정해집니다. 기준의 두 배 이상이면 붉은색, 기준 이상이면 노란색, 그 아래는 흰색입니다. 같은 값에 두 규칙을 겹쳐 적용하면 나중 규칙이 앞 규칙을 덮어써 “두 배 이상”이 영원히 보이지 않게 되므로, 조건을 else if 로 이어 하나만 고르게 했습니다.

  • 붙여넣을 위치: testRefreshDashboard_() 바로 아래
  • 붙여넣은 뒤 할 일: DASHBOARD 시트 C3에 50, C5에 5처럼 기준값(숫자)을 입력하고 testRefreshDashboard_() 를 다시 실행
Apps Script (JavaScript)
// C열에 기준값이 들어 있는 지표만 색을 칠한다.
// 기준의 2배부터 붉은색, 기준부터 노란색, 그 아래는 흰색 — 한 값에는 한 색만 정해진다.
function applyDashboardColors_(dash, rows, values) {         // → 임계치 색상
  const n = DASHBOARD_CONFIG.KPI_COUNT;         // → 지표 개수
  const thresholds = dash.getRange(rows.kpiFirst, 3, n, 1).getValues();  // → C열 기준값
  const colors = [];                            // → 칠할 색 목록

  for (let i = 0; i < n; i++) {                 // → 지표마다
    const th = Number(thresholds[i][0]);        // → 기준값
    const v = Number(values[i]);                // → 현재 값
    let color = '#ffffff';                      // → 기본은 흰색
    if (Number.isFinite(th) && th > 0 && Number.isFinite(v)) {            // → 기준이 있을 때만
      if (v >= th * 2) {                        // → 기준의 두 배 이상
        color = '#f4cccc';                      // → 붉은색(즉시 대응)
      } else if (v >= th) {                     // → 기준 이상
        color = '#fff2cc';                      // → 노란색(주의)
      }
    }
    colors.push([color]);                       // → 목록에 추가
  }

  dash.getRange(rows.kpiFirst, 2, n, 1).setBackgrounds(colors);          // → 한 번에 칠하기
}

제대로 됐는지 확인:

  1. DASHBOARD 시트 C3에 50, C5에 5 입력
  2. 적치·Load 데이터를 움직여 B3·B5 값을 바꾼 뒤 testRefreshDashboard_() 실행
  3. 기준 미만일 때는 흰색, 기준 이상이면 노란/연노랑, 기준의 두 배 이상이면 붉은 계열로 바뀌면 정상입니다.

4단계 — onOpen 메뉴 추가와 시간 기반 트리거 설정

수동으로 갱신할 메뉴 버튼과, 30분마다 자동으로 갱신해 주는 시간 기반 트리거를 함께 설정하면 운영이 편해집니다. 메뉴는 팀에서 직접 눌러 쓸 수 있도록 하고, 트리거는 야간·주말에도 주기적으로 돌아가도록 구성합니다.

4-1. onOpen에서 대시보드 새로고침 메뉴 추가

시트를 열 때 상단 메뉴에 창고 도구 → 대시보드 새로고침 항목을 붙입니다. 이미 다른 편에서 onOpen을 사용 중이라면, 그 onOpen 안에 addDashboardMenu_() 호출만 추가하는 방식으로 합치면 됩니다.

Apps Script (JavaScript)
function onOpen() {                             // → 시트를 열 때 자동 실행
  const menu = SpreadsheetApp.getUi().createMenu('창고 도구');  // → 시리즈 공용 메뉴
  addDashboardMenu_(menu);                      // → 이 편 항목 붙이기
  menu.addToUi();                               // → 시트에 붙이기
}

function addDashboardMenu_(menu) {              // → 합칠 때는 이 함수만 부른다
  menu.addItem('대시보드 새로고침', 'refreshDashboard');       // → 메뉴 항목
}

4-2. Apps Script 시간 기반 트리거로 30분마다 자동 갱신

이 코드는 한 번만 실행하면 이후에는 30분마다 자동으로 refreshDashboard()를 실행하는 시간 기반 트리거를 만들어 줍니다. 기존 동일 함수 트리거가 있으면 먼저 삭제해 중복 실행을 방지합니다.

Apps Script (JavaScript)
function createDashboardTrigger_() {                     // → 트리거 생성 함수
  const funcName = 'refreshDashboard';

  // 기존 같은 함수용 시간 트리거 삭제
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(tr => {
    if (tr.getHandlerFunction() === funcName &&
        tr.getEventType() === ScriptApp.EventType.TIME_BASED) {
      ScriptApp.deleteTrigger(tr);
    }
  });

  // 30분마다 실행되는 새 트리거 생성
  ScriptApp.newTrigger(funcName)
    .timeBased()
    .everyMinutes(30)
    .create();

  Logger.log('대시보드 자동 갱신 트리거 생성 완료');
}

트리거는 “매 30분마다” 실행되지만, 구글 인프라 상황에 따라 약간 앞뒤로 흔들릴 수 있습니다. 시간대 창 안 실행·정각 미보장이며, “매시 정각·30분 정각”에 꼭 맞는 것을 보장하지 않는다는 점을 알고 사용하는 편이 좋습니다.


실무 팁: 중소 창고에서 대시보드를 운영하며 느낀 점

  1. 지표는 적을수록 현장에서 잘 본다

처음부터 입고 라인별, 고객사별, 출고 채널별 등 수십 개 지표를 올리면 아무도 다 안 봅니다. 입고·출고·적치·Load 같은 핵심 4~5개로 시작하고, 팀에서 “이 숫자도 필요하다”는 요구가 생길 때 하나씩 추가하는 편이 훨씬 잘 정착합니다.

  1. 임계치는 팀이 체감하는 수준으로 잡는다

미처리 적치 기준을 10 팔레트로 잡으면 대부분 창고는 항상 빨간색입니다. 실제 인력과 장비로 하루에 처리 가능한 양, 최근 일주일 평균 적치량을 한 번 정리한 뒤 그 값의 1.2~1.5배 수준을 C3 기준값으로 쓰면 색상 알림이 의미를 가집니다. Load도 마찬가지로 “도킹 두 개가 계속 막히기 직전” 정도를 기준으로 잡는 것이 좋습니다.

  1. 시간 기반 트리거는 30분~1시간 정도로

입출고가 분 단위로 일어나더라도, 의사결정은 보통 30분~1시간 단위로 합니다. 트리거를 5분 간격으로 두면 스크립트 일일 실행 한도에 더 빨리 걸리고, 에러 관리 부담이 크게 늘어납니다. 30분 또는 1시간 간격으로도 실무에서 충분히 쓸 만했습니다.

  1. 동시 실행과 잘못된 입력을 막는 것이 중요

회의 직전에 여러 사람이 새로고침 메뉴를 반복해서 누르다가 집계가 꼬이는 경우가 실제로 있었습니다. LockService로 동시 실행을 막아 두면 이런 문제를 상당 부분 줄일 수 있습니다. 또 수량 컬럼에 문자나 음수가 들어왔을 때 Number.isFinite()qtyVal < 0 체크로 스킵하고, Logger에 어떤 행이 스킵됐는지 남겨두면 나중에 데이터 정리할 때 큰 도움이 됩니다.

  1. 상태 코드·값은 허용 목록을 정하고 나머지는 “기록 후 스킵”

적치 상태, Load 상태처럼 텍스트 기반 컬럼은 사람이 임의로 값을 바꾸기 쉽습니다. 이 글처럼 ['적치대기', '적치 대기', 'PENDING_PUTAWAY']['OPEN', '진행중', 'IN_PROGRESS']처럼 허용 목록을 명시하고, 목록 밖의 값은 카운트에서 제외하되 Logger로만 남겨 두면, 집계 숫자는 깨끗하게 유지하면서도 현장 데이터 교육 포인트를 잡아 갈 수 있습니다.


자주 발생하는 오류와 해결 방법

  1. DASHBOARD 시트가 없습니다 에러

refreshDashboard()를 먼저 실행했거나 DASHBOARD 이름을 바꾼 경우입니다.

testCreateDashboardSheet_()를 먼저 실행한 뒤, 시트 이름이 DASHBOARD_CONFIG.DASHBOARD_SHEET_NAME와 같은지 확인합니다.

  1. B3·B5 색상이 전혀 바뀌지 않음

C3·C5에 “50 팔레트”처럼 문자+숫자 형태로 넣으면 Number() 변환이 실패해 기준이 없는 것으로 처리됩니다.

→ 기준 셀(C3·C5)에는 숫자만 넣고, “팔레트 기준” 같은 설명은 D열이나 셀 메모에 따로 적습니다.

  1. 오늘 입고·출고 값이 항상 0

날짜가 텍스트로 들어가 있거나, 국가별 포맷과 다르게 적힌 경우입니다. 이 글의 normalizeDateToYmd_()는 가능한 범위에서 문자열도 처리하지만, 완전히 자유 형식 텍스트까지는 커버하지 못합니다.

→ 날짜 열은 반드시 “날짜 형식”으로 서식을 지정하고, 사용자들이 날짜 선택 UI를 사용하게 하는 것이 좋습니다.

  1. 출고 집계가 실제보다 낮음

출고 수량 열에 공백, 기호가 섞여 있거나 음수(취소 수량 등)가 섞여 있으면 집계에서 제외됩니다. 실행 로그에 출고 수량 스킵:으로 시작하는 JSON이 보이면, 거기 기록된 행을 확인해 데이터 클리닝 기준을 다시 맞추면 됩니다.


맺음말: 오늘 해 볼 일 하나 — 테스트 함수 두 개만 돌려 보기

구글시트와 Apps Script만으로도 입고·출고·재고·Load를 한 화면에서 보는 운영용 대시보드를 충분히 만들 수 있습니다. 별도 BI 도구 없이도, 현장에서 바로 기준선을 정하고 색상 알림을 넣어 실제 의사결정에 쓰는 수준까지 올릴 수 있다는 점이 장점입니다.

오늘 시도해볼 수 있는 가장 간단한 단계는 두 가지입니다.

  1. Apps Script 편집기를 열고 testCreateDashboardSheet_()를 한 번 실행해 DASHBOARD 뼈대를 만든다.
  2. 1~7편에서 만든 INBOUND / OUTBOUND / STOCK / LOADS 시트가 그대로 있는지 확인한 뒤 testRefreshDashboard_() 를 실행해 본다.

DASHBOARD 시트에 네 가지 숫자와 재고 상위 10개 표가 자동으로 채워지는 것을 한 번 눈으로 확인해 보면, 이후 기준선과 항목을 어떻게 조정할지 감이 잡힙니다. 마지막으로 createDashboardTrigger_()까지 실행해 30분 자동 갱신을 걸어 두면, 내일부터는 아침 회의 준비 시간이 눈에 띄게 줄어드는 것을 체감하실 겁니다.