Smart Life US

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

구글시트 일괄 예약 검증 방법: 붙여넣기 한 번에 검사하기 (완성본)

구글시트 일괄 예약 검증 방법: 붙여넣기 한 번에 검사하기 (완성본)

도입: 몇 번째 줄이 왜 안 되는지 한눈에 보고 싶을 때

구글시트에 예약을 여러 건 붙여넣다 보면, 어느 줄이 왜 안 되는지 찾다가 시간을 다 쓰는 경우가 많습니다. 특히 엑셀에서 복사해 온 입고 예약 목록을 한 번에 넣으려다가, 날짜 형식 하나 잘못된 줄 때문에 전체가 막히면 현장에서 바로 불만이 나옵니다. “3행이 문제인데 1~50행을 다시 손으로 보라”는 식의 안내는 실무에서는 거의 작동하지 않습니다.

일괄 예약 검증 흐름

이 글에서는 이 문제를 정면으로 다룹니다. 구글시트 일괄 예약 검증 방법을 Apps Script로 구현해, 여러 줄을 한 번에 붙여넣었을 때 줄마다 오류를 검사하고 “몇 행이 왜 안 되는지”를 바로 보여 주는 구조를 만드는 방법을 정리합니다. 엑셀에서 복사한 입고 예약 목록을 메모리에만 올려 두고, 실제 시트에 저장하기 전에 한 번에 검증하는 레이어를 만드는 것이 목표입니다.

현장에서 받은 예약 엑셀 파일을 통째로 붙여넣어야 할 때, 한 건씩 폼으로 입력하는 방식만으로는 계획 변경을 따라가기 어렵습니다. 잘못된 예약이 그대로 들어가면 도크와 인력 계획이 엉키고, 반대로 한 줄 오류 때문에 멀쩡한 줄까지 전부 다시 입력해야 하는 상황도 피하고 싶습니다. 그래서 “붙여넣고 나서 저장하기 전에, 시스템이 먼저 한 번 걸러 주는 구조”를 별도로 설계했습니다.

이번 편에서는 특히 다음 부분까지 구현합니다.

  • 엑셀에서 복사한 여러 줄을 메모리 상의 rows 배열로 받아
  • validateBulkSlots(rows) 함수가
  • 필수값 누락
  • 날짜·시간 형식
  • 운영 시간 범위
  • 정원(용량) 초과 여부
  • 이번에 붙여넣은 줄들끼리의 컨테이너 중복

을 줄 단위로 검사하고,

  • “몇 번째 줄이 왜 안 되는지”를 { ok, errors[], cleanedRows[] } 형태로 돌려주는 구조를 만듭니다.

실제 시트에 저장하고 LockService로 동시성을 제어하는 부분은 다음 글에서 다루고, 이번 글은 검증 레이어만 완결하는 데 집중합니다.


붙여넣기용 설정: 무엇을 어떻게 검증할지 먼저 정하기

일괄 검증을 만들기 전에 먼저 정해야 할 것이 있습니다. 한 줄이라도 오류가 있으면 전체를 막을지, 통과 가능한 줄만 저장할지에 대한 정책입니다. 물류센터마다 철학이 다르지만, 실제로 돌려 본 기준으로는 다음처럼 단계를 나누는 것이 현실적이었습니다.

  1. 검증 단계에서는
  • 모든 줄을 끝까지 다 읽고
  • 각 줄이 통과인지, 어떤 오류가 있는지를 리스트로 돌려줍니다.

이 단계에서는 아직 아무것도 시트에 쓰지 않습니다. 운영자는 이 결과를 보고 “에러 있는 줄을 고쳐서 다시 전체를 넣을지, 일단 통과된 줄만 먼저 반영할지”를 결정할 수 있습니다.

  1. 저장 단계에서는
  • LockService로 잠근 뒤
  • 정원 계산과 중복 체크를 다시 한 번 확인하고
  • 예약 본문 시트에 실제로 행을 추가합니다.

여기에서야 비로소 “한 줄이라도 실패하면 전체 롤백할지”, “통과한 줄만 부분 저장할지”를 분기합니다.

이 구조를 코드로 옮기기 위해, 일괄 검증에 필요한 열 위치와 정책 값을 하나의 설정 객체로 모읍니다. 다른 갈래 코드와 이름이 겹치지 않게 하기 위해 BULK_ 접두어를 붙입니다. 예약 본문 시트의 실제 이름은 운영 환경마다 다를 수 있으므로, 여기서는 튜토리얼용 가상 명칭으로만 씁니다.

여기서 주의할 점이 하나 있습니다. 장비유형 허용 목록은 앞 편에서 쓰는 BOOK_CAPACITY_CONFIG.VALID_TYPES 기준을 그대로 따라야 합니다. 이 목록은 ['CONTAINER', 'TRAILER'] 두 가지뿐이고, 이 글에서 임의로 다른 값을 넣어 새 허용 목록을 만들면 앞 편의 정원 계산과 집계가 어긋나 버립니다. 그래서 이 글의 설정에서도 허용 목록은 그대로 유지합니다.

관련 용량·예약 로직은 다음 글들을 그대로 사용합니다.

해당 편에서 이미 만든 BOOK_getCapacityForType_(), BOOK_getSeqUsageForSlotCore_() 등의 규격을 이 글에서도 그대로 따릅니다.


1단계 — 일괄 검증 설정 상수 정의

이 코드는 일괄 검증에서 쓸 붙여넣기 열 매핑과 정책 값을 정의합니다.

붙여넣는 위치: 구글시트 → 확장 프로그램 → Apps Script → Code.gs (또는 기존 파일 맨 위쪽)

붙여넣은 뒤 할 일: 저장 후 별도 실행은 필요 없습니다.

Apps Script (JavaScript)
// 일괄 검증 설정입니다. 필요하면 여기만 본인 환경에 맞게 바꾸세요.
const BULK_CONFIG = {
  COLUMN_MAP: {                  // 붙여넣기 열 매핑 (0부터 시작, 배열 인덱스 기준)
    date: 0,                     // 0번째: 날짜
    startTime: 1,                // 1번째: 시작시간
    endTime: 2,                  // 2번째: 종료시간
    type: 3,                     // 3번째: 장비유형
    door: 4,                     // 4번째: 도어
    containerNo: 5,              // 5번째: 컨테이너번호
    carrier: 6,                  // 6번째: 운송사
    client: 7,                   // 7번째: 고객사
    remark: 8,                   // 8번째: 비고
    qty: 9,                      // 9번째: 수량
    pallet: 10                   // 10번째: 팔레트수
  },

  // 한 줄이라도 오류가 있으면 "저장" 단계에서 전체를 막을지 여부.
  // 검증 단계에서는 이 값과 상관없이 모든 줄을 끝까지 검사해서 오류 목록을 돌려줍니다.
  REQUIRE_ALL_OR_NOTHING: true,

  // 장비유형 허용 목록 — 앞 편에서 사용하는 BOOK_CAPACITY_CONFIG.VALID_TYPES 와 동일해야 합니다.
  // ['CONTAINER', 'TRAILER'] 외의 값을 추가 허용 목록으로 쓰지 마세요.
  VALID_TYPES: ['CONTAINER', 'TRAILER']
};

여기서는 예약 본문 시트 이름을 별도 상수로 두지 않았습니다. 이미 기본 시리즈에서 APPT_MAIN 시트를 기준으로 용량·예약 계산을 하기 때문에, 실제 저장 단계에서도 그 시트를 사용합니다. 이 편은 검증 전용 레이어이므로, 어디에 저장할지는 다음 편의 “일괄 저장” 함수에서 APPT_MAIN 을 기준으로 다루겠습니다.

제대로 됐는지 확인하는 법: 코드 저장 시 오류가 나지 않으면 정상입니다. 이후 함수에서 BULK_CONFIG 를 문제없이 참조할 수 있습니다.


핵심: validateBulkSlots로 줄마다 오류 이유 돌려받기

이제 이번 글의 핵심 함수인 validateBulkSlots(rows) 를 만듭니다. 이 함수는 외부에서 2차원 배열을 받아 검증만 수행하고, 실제로 시트에 쓰지는 않습니다.

이 함수의 역할은 다음과 같습니다.

  1. rows 배열(2차원, 각 행이 한 예약)을 받아
  2. 각 행별로
  • 필수값 유무
  • 날짜·시간 형식
  • 휴무일 여부·운영 시간 범위
  • 정원(용량) 초과 여부
  • 이번에 붙여넣은 줄끼리의 컨테이너 중복

을 검사합니다.

  1. 최종 결과를 { ok, errors[], cleanedRows[] } 형태로 돌려줍니다.
  • ok: 전체가 통과했는지 여부 (errors.length === 0 기준)
  • errors: { rowIndex, message } 배열
  • cleanedRows: 검증을 통과한 줄만 정리한 배열 (다음 편에서 실제 저장에 사용)

이때 이미 앞 편들에서 만든 헬퍼 함수들을 그대로 가져와 쓰는 것이 중요합니다. 예를 들어:

  • APPT_ymd_(value)'YYYY-MM-DD' 문자열을 돌려줍니다.
  • 실재하지 않는 날짜·시각만 있는 값·1 미만 숫자는 예외를 던집니다.
  • 빈 셀·빈 문자열도 예외를 던집니다.
  • 정의는 입고예약 기본 5편에 이미 있습니다.
  • BOOK_timeToMinutes_(value)'HH:mm' 형식의 시간을 분(정수)로 바꿉니다.
  • 형식이 틀리면 예외가 아니라 null 을 돌려줍니다.
  • 정의는 입고예약 등록 1편에 있습니다.
  • getDateBlockInfo(date) → 해당 날짜의 운영·휴무 정보를 {date, isHoliday, holidayType, open, close, blocks[]} 형태로 돌려줍니다.
  • 정의와 사용 예시는 역시 입고예약 등록 1편을 참고합니다.
  • BOOK_getSeqUsageForSlotCore_(dateObj, timeObj, type){time, equipType, used, capacity, remaining} 형태로 현재 사용량과 남은 자리를 계산합니다.
  • 정의는 입고예약 등록 3편에 있습니다.

이 규격을 다르게 가정하면 코드가 조용히 틀어지므로, 이미 발행된 앞 편 글의 정의와 정확히 일치하는지 반드시 확인해야 합니다. 이 글에서는 위 함수들을 다시 정의하지 않고, “앞 편에서 만든 함수를 그대로 쓴다”는 전제만 둡니다.


2단계 — validateBulkSlots 구현

이 코드는 붙여넣은 예약 줄들을 한 번에 검증하고, 줄별 오류 사유를 모은 뒤 통과한 줄만 정제해 반환합니다.

붙여넣는 위치: 구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 아래

붙여넣은 뒤 할 일: 저장 후, 아래에서 만드는 BULK_testValidateSlots_() 테스트 함수를 실행합니다.

Apps Script (JavaScript)
/**
 * 여러 줄 예약을 한 번에 검증합니다.
 * @param {Array<Array<*>>} rows 2차원 배열(각 행이 한 예약)
 * @return {{ok: boolean, errors: Array<{rowIndex: number|null, message: string}>, cleanedRows: Array<Object>}}
 */
function validateBulkSlots(rows) {
  const result = { ok: true, errors: [], cleanedRows: [] };

  if (!Array.isArray(rows) || rows.length === 0) {
    result.ok = false;
    result.errors.push({
      rowIndex: null,
      message: '검증할 행이 없습니다.'
    });
    return result;
  }

  const seenContainers = new Set();       // 이번 붙여넣기 안에서만 중복 체크
  const col = BULK_CONFIG.COLUMN_MAP;

  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    const displayRow = i + 1;             // 사용자에게 보여줄 줄 번호(1부터)

    try {
      if (!row || row.length === 0) {
        // 완전히 빈 행은 건너뜁니다. APPT_ymd_ 가 빈 값을 던지기 때문입니다.
        continue;
      }

      // 1) 원시 값 꺼내기
      const rawDate = row[col.date];
      const rawStartTime = row[col.startTime];
      const rawEndTime = row[col.endTime];
      const rawType = row[col.type];
      const rawDoor = row[col.door];
      const rawCntr = row[col.containerNo];
      const rawCarrier = row[col.carrier];
      const rawClient = row[col.client];
      const rawRemark = row[col.remark];
      const rawQty = row[col.qty];
      const rawPallet = row[col.pallet];

      // 2) 필수값 검사
      if (!rawDate) {
        throw new Error(displayRow + '행: 날짜 없음');
      }
      if (!rawStartTime) {
        throw new Error(displayRow + '행: 시작 시간 없음');
      }
      if (!rawEndTime) {
        throw new Error(displayRow + '행: 종료 시간 없음');
      }
      if (!rawType) {
        throw new Error(displayRow + '행: 장비유형 없음');
      }
      if (!rawCntr) {
        throw new Error(displayRow + '행: 컨테이너 번호 없음');
      }

      // 3) 날짜·시간 형식 정리
      // 앞 편 [입고예약 기본 5편] 의 APPT_ymd_ 를 그대로 사용합니다.
      const ymd = APPT_ymd_(rawDate);   // 'YYYY-MM-DD' 문자열
      if (!ymd) {
        // APPT_ymd_ 는 잘못된 날짜일 때 예외를 던지므로 이 분기는 거의 오지 않습니다.
        throw new Error(displayRow + '행: 날짜 형식이 잘못되었습니다');
      }

      const startTimeStr = String(rawStartTime).trim();
      const endTimeStr = String(rawEndTime).trim();

      // 앞 편 [입고예약 등록 1편] 의 BOOK_timeToMinutes_ 를 그대로 사용합니다.
      const startMin = BOOK_timeToMinutes_(startTimeStr);
      if (!Number.isFinite(startMin)) {
        throw new Error(displayRow + '행: 시작 시간 형식이 잘못되었습니다');
      }

      const endMin = BOOK_timeToMinutes_(endTimeStr);
      if (!Number.isFinite(endMin)) {
        throw new Error(displayRow + '행: 종료 시간 형식이 잘못되었습니다');
      }

      if (endMin <= startMin) {
        throw new Error(displayRow + '행: 종료 시간이 시작 시간보다 이후여야 합니다');
      }

      // 4) 운영일·운영시간 검사
      // 앞 편 [입고예약 등록 1편] 에서 만든 getDateBlockInfo 를 그대로 사용합니다.
      const blockInfo = getDateBlockInfo(ymd);
      if (!blockInfo) {
        throw new Error(displayRow + '행: 운영정보가 없는 날짜입니다');
      }

      // FULL / CLOSED 는 휴무, PARTIAL 은 예약 가능
      if (blockInfo.holidayType === 'FULL' || blockInfo.holidayType === 'CLOSED') {
        throw new Error(displayRow + '행: 휴무일에는 예약할 수 없습니다');
      }
      if (!blockInfo.open || !blockInfo.close) {
        throw new Error(displayRow + '행: 운영시간이 설정되지 않은 날짜입니다');
      }

      const openMin = BOOK_timeToMinutes_(blockInfo.open);
      const closeMin = BOOK_timeToMinutes_(blockInfo.close);
      if (!Number.isFinite(openMin) || !Number.isFinite(closeMin)) {
        throw new Error(displayRow + '행: 운영시간 설정이 잘못되었습니다');
      }

      // 시작·종료 모두 운영시간 안에 있어야 합니다.
      if (startMin < openMin || startMin >= closeMin) {
        throw new Error(displayRow + '행: 시작 시간이 운영시간 밖입니다');
      }
      if (endMin <= openMin || endMin > closeMin) {
        throw new Error(displayRow + '행: 종료 시간이 운영시간 밖입니다');
      }

      // 5) 장비유형 허용 목록 검사
      const typeKey = String(rawType).trim().toUpperCase();
      if (!BULK_CONFIG.VALID_TYPES.includes(typeKey)) {
        throw new Error(displayRow + '행: 허용되지 않은 장비유형입니다');
      }

      // 6) 정원(용량) 검사
      // 시간대 버그를 피하기 위해 'YYYY-MM-DD' 를 직접 year, month, day 로 쪼갭니다.
      const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
      if (!m) {
        throw new Error(displayRow + '행: 날짜 파싱 오류입니다');
      }
      const year = Number(m[1]);
      const month = Number(m[2]); // 1~12
      const day = Number(m[3]);

      const dateObj = new Date(year, month - 1, day); // 로컬 기준 자정
      // BOOK_getSeqUsageForSlotCore_ 는 "슬롯 단위" 기준 시각만 필요하므로 시작 시각 기준으로 계산합니다.
      const timeObj = new Date(year, month - 1, day, Math.floor(startMin / 60), startMin % 60);

      const usage = BOOK_getSeqUsageForSlotCore_(dateObj, timeObj, typeKey);
      if (!usage || !Number.isFinite(usage.remaining)) {
        throw new Error(displayRow + '행: 용량 정보를 불러오지 못했습니다');
      }
      if (usage.remaining <= 0) {
        throw new Error(displayRow + '행: 정원 초과 슬롯입니다');
      }

      // 7) 이번 붙여넣기 내 중복 컨테이너 검사
      const keyCntr = String(rawCntr).trim().toUpperCase();
      if (seenContainers.has(keyCntr)) {
        throw new Error(displayRow + '행: 붙여넣은 목록 안에서 컨테이너 중복입니다');
      }
      seenContainers.add(keyCntr);

      // 8) 수량·팔레트 유효성 검사
      const qtyNum = (rawQty === '' || rawQty == null) ? 1 : Number(rawQty);
      if (!Number.isFinite(qtyNum) || qtyNum <= 0) {
        throw new Error(displayRow + '행: 수량이 잘못되었습니다');
      }

      const palletNum = (rawPallet === '' || rawPallet == null) ? 0 : Number(rawPallet);
      if (!Number.isFinite(palletNum) || palletNum < 0) {
        throw new Error(displayRow + '행: 팔레트 수가 잘못되었습니다');
      }

      // 9) 통과한 행을 정리해서 저장
      result.cleanedRows.push({
        // 날짜·시간·유형·컨테이너는 검증/정규화된 값으로, 나머지는 트리밍해서 저장합니다.
        date: ymd,
        startTime: startTimeStr,
        endTime: endTimeStr,
        type: typeKey,
        door: rawDoor ? String(rawDoor).trim() : '',
        containerNo: keyCntr,
        carrier: rawCarrier ? String(rawCarrier).trim() : '',
        client: rawClient ? String(rawClient).trim() : '',
        remark: rawRemark ? String(rawRemark).trim() : '',
        qty: qtyNum,
        pallet: palletNum
      });

    } catch (e) {
      // 행 단위 오류만 잡아서 누적합니다. 다른 행 검사는 계속 진행합니다.
      result.ok = false;
      result.errors.push({
        rowIndex: displayRow,
        message: e && e.message ? e.message : String(e)
      });

      // REQUIRE_ALL_OR_NOTHING 가 true 라도, "검증" 단계에서는
      // 어디가 어떻게 잘못됐는지 모두 보여주는 것이 목적이므로
      // 여기서 반복을 멈추지 않습니다. 실제 "저장" 단계에서만 전체 차단 여부를 쓸 예정입니다.
    }
  }

  result.ok = result.errors.length === 0;
  return result;
}

제대로 됐는지 확인하는 법: 아래 테스트 함수를 실행했을 때, 의도적으로 넣은 잘못된 값들이 정확히 “n행: …” 형식의 메시지로 errors 배열에 나오고, 정상이면 cleanedRows 에만 쌓이는지 확인합니다.


3단계 — 테스트 함수로 예외 케이스까지 눈으로 확인하기

검증 로직은 눈으로 읽을 때보다 실제 데이터를 넣어 보면서 확인하는 편이 훨씬 안전합니다. 특히 수량에 문자를 넣는 경우처럼 NaN 이 섞이는 케이스는 테스트를 하지 않으면 운영 중에야 드러납니다. 그래서 의도적으로 잘못된 값을 섞은 예제 배열을 만들어 validateBulkSlots 를 한 번에 돌려 보는 것이 좋습니다.

여기서는 다음 여섯 가지 케이스를 한 번에 넣어 보겠습니다.

  1. 1행: 정상
  2. 2행: 시작 시간 비움 → “2행: 시작 시간 없음”
  3. 3행: 종료 시간 형식 오류 → “3행: 종료 시간 형식이 잘못되었습니다”
  4. 4행: 종료 시간이 시작보다 앞섬 → “4행: 종료 시간이 시작 시간보다 이후여야 합니다”
  5. 5행: 허용되지 않은 장비유형 → “5행: 허용되지 않은 장비유형입니다”
  6. 6행: 수량에 'ABC' → “6행: 수량이 잘못되었습니다”

이 정도만 해도 필수값·시간 형식·시간 순서·허용 목록·수량 형식 검사가 제대로 작동하는지 바로 확인할 수 있습니다.

붙여넣는 위치: validateBulkSlots 함수 바로 아래

붙여넣은 뒤 할 일: Apps Script 편집기 상단의 실행 함수 선택 메뉴에서 BULK_testValidateSlots_ 를 선택해 실행합니다.

Apps Script (JavaScript)
/**
 * validateBulkSlots 동작을 테스트합니다.
 * - 1행: 정상
 * - 2행: 시작 시간 누락 → "시작 시간 없음"
 * - 3행: 종료 시간 형식 오류 → "종료 시간 형식이 잘못되었습니다"
 * - 4행: 종료 시간이 시작보다 앞섬 → "종료 시간이 시작 시간보다 이후여야 합니다"
 * - 5행: 잘못된 장비유형 → "허용되지 않은 장비유형입니다"
 * - 6행: 수량 문자 → "수량이 잘못되었습니다"
 */
function BULK_testValidateSlots_() {
  const sampleRows = [
    // 날짜        시작     종료     유형        도어  컨테이너  운송사     고객사    비고  수량   팔레트
    ['2026-08-30', '09:00', '10:00', 'CONTAINER', 'D1', 'C001',  'CARRIER1', 'CLIENT1', '', 1,    0],     // 1행 정상
    ['2026-08-30', '',       '11:00', 'CONTAINER', 'D2', 'C002',  'CARRIER2', 'CLIENT2', '', 1,    0],     // 2행 시작 시간 비움
    ['2026-08-30', '11:00',  'aa:bb', 'CONTAINER', 'D3', 'C003',  'CARRIER3', 'CLIENT3', '', 1,    0],     // 3행 종료 시간 형식 오류
    ['2026-08-30', '13:00',  '12:00', 'CONTAINER', 'D4', 'C004',  'CARRIER4', 'CLIENT4', '', 1,    0],     // 4행 종료<시작
    ['2026-08-30', '14:00',  '15:00', 'XYZ',       'D5', 'C005',  'CARRIER5', 'CLIENT5', '', 1,    0],     // 5행 잘못된 유형
    ['2026-08-30', '16:00',  '17:00', 'CONTAINER', 'D6', 'C006',  'CARRIER6', 'CLIENT6', '', 'ABC', 0]     // 6행 수량 문자
  ];

  const result = validateBulkSlots(sampleRows);
  Logger.log(JSON.stringify(result, null, 2));

  if (result.ok) {
    Logger.log('전체 통과했습니다. 통과 행 수: ' + result.cleanedRows.length);
  } else {
    Logger.log('오류 행 수: ' + result.errors.length);
    result.errors.forEach(function (err) {
      Logger.log(err.rowIndex + '행 오류: ' + err.message);
    });
  }
}

제대로 됐는지 확인하는 법: 실행 후 실행 로그를 열었을 때, 최소한 다음과 비슷한 메시지가 보이면 정상입니다.

  • 2행 오류: 2행: 시작 시간 없음
  • 3행 오류: 3행: 종료 시간 형식이 잘못되었습니다
  • 4행 오류: 4행: 종료 시간이 시작 시간보다 이후여야 합니다
  • 5행 오류: 5행: 허용되지 않은 장비유형입니다
  • 6행 오류: 6행: 수량이 잘못되었습니다

또한 cleanedRows 에는 1행(정상 케이스)만 포함되어 있어야 합니다.


실무에서 써 본 일괄 검증 설계 팁

실제 현장에서 이 구조를 돌려 보며 얻은 교훈 몇 가지를 정리하면 다음과 같습니다.

  1. 검증과 저장을 반드시 분리하는 것이 좋습니다.

한 함수 안에서 동시에 검증·저장·롤백까지 처리하려고 하면, 어디에서 막혔는지가 운영자에게 보이지 않습니다. 지금 만든 validateBulkSlots 처럼 “메모리 상에서만 검사하고 결과를 돌려주는 함수”를 두고, 별도 함수에서 LockService로 잠근 뒤 실제 저장을 하는 구조가 설명하기도 쉽고, 장애 분석에도 유리했습니다.

  1. 정원 계산과 중복 검사는 검증 단계와 저장 단계 모두에서 한 번씩 보는 편이 안전했습니다.

검증 시점과 저장 시점 사이에 다른 사용자가 같은 슬롯에 예약을 넣을 수 있기 때문입니다. 검증 단계에서는 “지금 이 데이터가 상식적으로 말이 되는지”를 확인하고, 저장 단계에서는 “지금 시점에 실제로 자리가 남아 있는지”를 다시 확인하는 식입니다. 정원 계산 그 자체는 [입고예약 등록 3편]·[4편] 에서 이미 만든 BOOK_getSeqUsageForSlotCore_()·BOOK_checkCapacityAndSave_() 로 통일해 두고, 이 편에서는 그 함수를 그대로 호출만 합니다.

  1. 에러 메시지는 가능하면 줄 번호 + 한국어 사유 조합으로 통일하는 것을 권장합니다.

"5행: 휴무일에는 예약할 수 없습니다" 같이 남겨 두면, 현장 담당자가 스크린샷 한 장으로도 어느 줄을 어떻게 고쳐야 하는지 바로 알 수 있습니다. INVALID_TIME 같은 추상적인 코드는 디버깅에는 편할 수 있지만, 실제 운영자에게는 설명이 한 번 더 필요한 표현입니다.


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

일괄 검증 코드를 처음 붙였을 때 자주 나왔던 오류와 해결 방법을 몇 가지 정리합니다.

  1. APPT_ymd_ is not defined 오류

이 함수는 앞 갈래에서 이미 만든 날짜 정규화 헬퍼입니다. 이 프로젝트에 아직 해당 코드를 붙여 넣지 않았거나, 함수 이름을 다른 파일에서 바꾼 경우에 이런 오류가 납니다.

  • [입고예약 기본 5편] 글의 APPT_ymd_ 정의를 현재 프로젝트에도 그대로 넣었는지,
  • 이름을 변경하지 않았는지

를 확인하면 해결됩니다.

  1. 모든 날짜에서 “휴무일에는 예약할 수 없습니다”가 뜨는 경우

대부분은 getDateBlockInfo() 가 기대하는 'YYYY-MM-DD' 형식과 다른 값을 받아서, 휴무일 기본값으로 처리되었을 가능성이 큽니다.

  • 이 글의 코드처럼 APPT_ymd_(rawDate) 결과를 그대로 넘기는지,
  • 중간에 다른 포맷으로 바꾸고 있지는 않은지

를 확인해야 합니다. 테스트 시에는 실제로 운영일로 설정된 날짜를 사용하는 것도 중요합니다.

  1. 수량이나 팔레트 칸에 ABC 같은 문자를 넣었을 때 통과하는 문제

예전에는 단순히 Number(rawQty) <= 0 만 검사해 NaN 이 조용히 통과하는 문제가 생길 수 있습니다. 지금 코드에서는

Apps Script (JavaScript)
   !Number.isFinite(qtyNum) || qtyNum <= 0

조건을 먼저 보도록 해, 문자·공백·혼합 형식을 모두 “잘못된 수량”으로 잡도록 했습니다. 테스트 함수에 이미 문자를 넣어 두었으니, 이 부분이 제대로 잡히는지 실행 로그로 확인해 보시면 됩니다.

  1. 시간대 관련 미묘한 버그

자주 보던 패턴이 new Date(ymd + 'T00:00:00') 같이 문자열에 T00:00:00 을 붙이는 방식입니다. 이 경우 브라우저나 실행 환경에 따라 UTC 기준으로 해석되면서 하루가 어긋나는 문제가 생길 수 있습니다. 이 글에서는 반드시

Apps Script (JavaScript)
   const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
   const dateObj = new Date(year, month - 1, day);
   const timeObj = new Date(year, month - 1, day, hour, minute);

같이 연·월·일을 숫자로 쪼개서 new Date(year, month-1, day, ...) 생성자를 쓰는 방식으로 통일했습니다.


정리

이번 글에서는 구글시트 일괄 예약 검증 방법의 첫 단계로, 엑셀에서 복사해 온 여러 예약 행을 구글시트에 붙여넣기 전에 Apps Script에서 한 번에 검증하는 validateBulkSlots(rows) 구조를 완성했습니다.

  • 필수값
  • 날짜·시간 형식
  • 시작·종료 시간 관계
  • 운영 시간
  • 정원(용량)
  • 붙여넣기 내 컨테이너 중복
  • 수량·팔레트 유효성

까지 줄 단위로 확인하고, “몇 번째 줄이 왜 안 되는지”를 바로 보여 주는 뼈대를 갖춘 셈입니다.

지금 당장 할 수 있는 행동은 한 가지입니다.

  1. 위의 BULK_CONFIG, validateBulkSlots, BULK_testValidateSlots_ 세 블록을 자신의 Apps Script 프로젝트에 그대로 붙여넣고
  2. 테스트 함수를 실행해
  3. 실행 로그에서
  • “2행: 시작 시간 없음”
  • “3행: 종료 시간 형식이 잘못되었습니다”
  • “4행: 종료 시간이 시작 시간보다 이후여야 합니다”
  • “5행: 허용되지 않은 장비유형입니다”
  • “6행: 수량이 잘못되었습니다”

같은 메시지가 기대한 대로 보이는지 확인해 보세요.

여기까지 통과했다면, 그다음 단계인 “통과한 줄만 예약 본문 시트(APPT_MAIN)에 실제로 저장하는 일괄 등록 함수”를 붙일 준비가 된 것입니다. 이 저장 단계와 LockService 적용, 실제 예약 본문 시트와의 연결은 다음 글에서 이어서 다루겠습니다.