Smart Life US

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

구글시트 입고 예약 중복 방지 방법: LockService로 안전하게 저장하기

구글시트 입고 예약 중복 방지 방법: LockService로 안전하게 저장하기

도입: 같은 시간대에 예약이 두 건 들어올 때 생기는 문제

구글시트로 입고 예약 시스템을 만들다 보면 일정 수준까지는 잘 돌아가다가, 인원이 늘고 시간대가 겹치기 시작하는 순간부터 데이터가 어지러워지기 쉽습니다. 특히 많이 검색하는 구글시트 입고 예약 중복 방지 방법은 실제 운영 단계에서 거의 반드시 필요한 주제입니다. 이 글은 앞에서 기본 구조를 만든 뒤, 마지막 단계인 “실제 저장 시 중복·정원 초과를 어떻게 막을 것인가”에 초점을 맞춥니다.

LockService 예약 저장 흐름

지금 읽고 있는 글은 한 물류센터에서 실제로 쓰던 설계를 바탕으로 정리한 구글시트 입고 예약 시스템 만들기 — 예약 등록 4편에 해당합니다. 앞의 1~구글시트 예약 용량 계산 방법: 시간대별 남은 자리 자동 표시에서 이미 다음 기능까지 구현되어 있다고 가정합니다.

  • 점심시간·야간 등 운영시간 차단
  • 고객사별 예약 가능 기간 계산
  • 시간대·도어 기준 남은 용량 계산

이제 남은 일은 웹앱이나 사이드바에서 들어온 예약 한 건을 Apps Script의 LockService로 잠근 상태에서 검증→중복 체크→정원 재확인→저장까지 일관되게 처리하는 것입니다. 이 글에서는 그 전체 흐름과 필요한 코드를 단계별로 정리합니다.


예약 저장에서 꼭 막아야 하는 두 가지: 중복과 정원 초과

입고 예약을 시트 한 줄로만 보면 단순해 보이지만, 실제 운영에서 반드시 막아야 하는 문제는 두 가지로 정리할 수 있습니다.

첫째는 컨테이너(또는 트레일러) 번호 중복 예약입니다. 같은 번호로 같은 날짜에 예약이 두 건 잡히면, 현장에서는 어느 예약을 기준으로 움직여야 할지 혼란이 생깁니다. 운송사가 시간 변경을 요청했다가 이전 예약을 제때 취소하지 않는 경우, 담당자가 바쁜 와중에 새 예약만 추가하고 기존 건을 지우지 않으면서 이런 중복이 쌓입니다. 결국 야드에서는 실 컨테이너는 하나인데 배차·도어 배정이 두 개로 나뉘는 상황이 발생합니다.

둘째는 도어·야드 슬롯 정원 초과입니다. 앞 편에서 “이 시간대에 몇 대까지 받을 수 있는지”는 이미 계산해 두었다고 해도, 두 사람이 거의 동시에 마지막 한 자리를 보고 동시에 저장을 누르면 문제가 생깁니다. 계산은 각각 ‘남은 1자리’를 보고 끝났는데, 저장은 동시에 들어가서 시트에는 2건이 쌓이는 구조입니다. 이때는 화면의 남은 자리가 아니라, 저장 직전 “실제 시트에 쌓인 수량”을 다시 보고 결정해야 합니다.

이 두 가지는 사람의 주의만으로는 막기 어렵습니다. 그래서 앱스스크립트 중복 예약 막기 로직정원 초과 방지 코드를 함께 두고, 무엇보다도 저장 구간 전체를 LockService로 보호하는 것이 중요합니다. 그렇게 해야 “거의 동시에 저장”하는 상황에서도 결과가 일관되게 유지됩니다.


LockService를 사용하는 이유와 기본 구조

구글 Apps Script에서 동시성 문제를 막기 위해 제공하는 기능이 LockService입니다. 여기서는 별도의 키를 쓰는 방식이 아니라, 스크립트 전체에 하나만 존재하는 ScriptLock을 사용합니다. 즉 “이 스크립트 프로젝트 안에서 예약 저장을 한 번에 한 사람만 실행하도록” 만드는 방식입니다.

입고 예약 저장 함수의 기본 구조는 다음과 같습니다.

  1. LockService.getScriptLock()으로 스크립트 잠금을 가져옵니다.
  2. waitLock(…)으로 잠금을 확보할 때까지 일정 시간(예: 5초) 대기합니다.
  3. 잠금이 잡힌 뒤에만
  • 필수값·형식을 검증하고
  • 운영시간·예약 가능 기간을 다시 확인하고
  • 같은 컨테이너 예약이 이미 있는지 조회한 뒤
  • 이 시점 기준 정원을 재계산해 남은 자리가 있는지 확인합니다.
  • 통과하면 실제로 시트에 한 줄을 추가합니다.
  1. try에서 어떤 오류가 나도 finally에서 잠금을 해제합니다.

여기서 중요한 부분은 정원 재확인과 저장까지를 잠금 안에서 한 번에 처리한다는 점입니다. 정원 계산을 밖에서 해 둔 뒤, 안에서는 저장만 하면 동시에 두 건이 들어갈 여지가 남습니다. 또한 오래된 예약을 보관 시트로 옮기고 지우는 정리 작업 역시 “읽고→붙여넣고→지우는” 순서 전체를 잠금으로 감싸야 중복 보관·중복 삭제를 예방할 수 있습니다.


예약 저장 메인 함수: BOOK_bookAppt

이제 실제 코드를 단계별로 나눠 보겠습니다. 시리즈의 다른 코드와 합쳐질 것을 고려해 모든 상수·함수 이름에는 BOOK_ 접두어를 붙였습니다. 시트는 3편과 같은 APPT_MAIN 을 그대로 씁니다. 3편의 사용량 함수가 그 시트의 A(날짜)·B(시간)·C(장비) 열을 읽기 때문에, 여기서 시트나 열을 바꾸면 그 함수가 아무것도 못 세고 0을 돌려줍니다. 0은 '자리가 남았다'로 읽혀 정원 초과 예약을 그대로 통과시킵니다. 보관용으로는 APPT_ARCHIVE 시트를 새로 만듭니다.

1단계 — 예약 저장 메인 함수 골격 만들기

이 코드는 웹앱·사이드바에서 전달받은 예약 데이터를 한 건 저장하는 메인 함수입니다.

붙여넣을 위치: 구글시트 상단 메뉴 → 확장 프로그램 → Apps Script → 예약 관련 스크립트가 들어 있는 프로젝트의 코드 파일 맨 아래.

붙여넣은 뒤 할 일: 저장한 뒤 BOOK_testBookAppt_ 함수를 에디터에서 한 번 실행해 성공·실패 메시지를 로그로 확인합니다.

Apps Script (JavaScript)
// 여기만 본인 시스템에 맞게 바꾸세요
// 시트와 A~C 열은 **등록 3편과 똑같이** 쓴다. 3편의 사용량 함수가 A(날짜)·B(시간)·C(장비)를
// 읽기 때문에, 여기서 이름이나 순서를 바꾸면 그 함수가 아무것도 못 세고 0을 돌려준다.
// 0이 나오면 '자리가 남았다'로 읽혀 정원 초과 예약을 그대로 통과시킨다.
const BOOK_ARCHIVE_SHEET_NAME = 'APPT_ARCHIVE';         // → 예약 보관 시트 이름

const BOOK_APPT_COLS = {                                // → 예약 시트 열 번호(1부터)
  DATE: 1,          // A: 예약일자      ← 3편과 동일
  TIME: 2,          // B: 시작 시간     ← 3편과 동일
  TYPE: 3,          // C: 장비 유형     ← 3편과 동일
  DOOR: 4,          // D: 도어/야드 슬롯
  CONTAINER: 5,     // E: 컨테이너 번호
  CARRIER: 6,       // F: 운송사
  CLIENT: 7,        // G: 고객사
  REMARK: 8,        // H: 비고
  END_TIME: 9,      // I: 종료 시간
  CREATED: 10,      // J: 생성일시
  QTY: 11,          // K: 수량
  PALLET: 12,       // L: 팔레트 수
  BOOKING_ID: 13    // M: 예약 ID (보관을 다시 실행해도 겹치지 않게 하는 열쇠)
};                                                      // →

/**
 * 예약 한 건을 저장하는 메인 함수
 * @param {Object} data 웹앱에서 넘어오는 예약 정보
 * @returns {Object} 저장 결과 및 메시지
 */
function BOOK_bookAppt(data) {                          // → 예약 저장 진입점
  const lock = LockService.getScriptLock();             // → 스크립트 전체 잠금
  let locked = false;                                   // → 잠금 성공 여부 표시
  try {
    lock.waitLock(5000);                                // → 최대 5초까지 대기
    locked = true;                                      // → 잠금 성공 표시

    const validated = BOOK_validateBookingInput_(data); // → 필수값·형식 검사
    BOOK_validateBusinessRules_(validated);             // → 운영시간·기간 검사
    BOOK_checkDuplicateContainer_(validated);           // → 컨테이너 중복 검사
    BOOK_checkCapacityAndSave_(validated);              // → 정원 재확인 후 저장

    return {                                            // → 성공 응답
      success: true,                                    // → 성공 여부
      message: '예약이 정상적으로 저장되었습니다.'        // → 사용자 메시지
    };
  } catch (e) {
    return {                                            // → 실패 응답
      success: false,                                   // → 실패 표시
      message: e.message || '예약 저장 중 오류가 발생했습니다.' // → 오류 메시지
    };
  } finally {
    if (locked) {                                       // → 잠금이 잡힌 경우만
      lock.releaseLock();                               // → 잠금 해제
    }
  }
}

/**
 * 테스트용 — **설정을 읽어 실제로 예약 가능한 날짜·시각**을 찾는다
 * @returns {Object|null} {ymd, time, endTime} 또는 null
 */
function BOOK_findTestSlot_() {                         // → 예약 가능한 슬롯 찾기
  const window = getBookingWindowForClient();           // → [{ymd, label, isToday}, ...]
  if (!window || window.length === 0) {                 // → 예약 가능일이 없으면
    return null;                                        // → 찾지 못함
  }
  for (let i = 0; i < window.length; i++) {             // → 가능일을 하루씩
    const ymd = window[i].ymd;                          // → 날짜 문자열
    const dp = ymd.split('-');                          // → 연·월·일
    const info = getDateBlockInfo(                      // → 그 날의 운영 정보
      new Date(Number(dp[0]), Number(dp[1]) - 1, Number(dp[2])));
    if (info.holidayType === 'FULL' || info.holidayType === 'CLOSED'
        || !info.open || !info.close) {                 // → 쉬는 날이면
      continue;                                         // → 다음 날로
    }
    const openMin = BOOK_timeToMinutes_(info.open);     // → 여는 시각(분)
    const closeMin = BOOK_timeToMinutes_(info.close);   // → 닫는 시각(분)
    const blocks = info.blocks || [];                   // → 차단 구간
    for (let m = openMin; m + 30 <= closeMin; m += 30) { // → 30분 단위로 훑기
      const start = BOOK_minutesToTime_(m);             // → 시작 'HH:mm'
      const end = BOOK_minutesToTime_(m + 30);          // → 종료 'HH:mm'
      let hit = false;                                  // → 차단과 겹치는가
      for (let b = 0; b < blocks.length; b++) {         // → 구간마다
        if (compareTime_(start, blocks[b].end) < 0
            && compareTime_(end, blocks[b].start) > 0) { // → 겹치면
          hit = true;                                   // → 이 시각은 안 된다
          break;
        }
      }
      if (!hit) {                                       // → 겹치지 않으면
        return { ymd: ymd, time: start, endTime: end }; // → 이 슬롯을 쓴다
      }
    }
  }
  return null;                                          // → 끝내 못 찾음
}

/**
 * 분(정수)을 'HH:mm' 으로 되돌린다
 */
function BOOK_minutesToTime_(min) {                     // → 분 → 시각 문자열
  const h = Math.floor(min / 60);                       // → 시
  const m = min % 60;                                   // → 분
  return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
}

/**
 * 간단 테스트용 함수
 */
function BOOK_testBookAppt_() {                         // → 테스트 전용 함수
  // 날짜·시각을 고정으로 적어 두면 그날이 휴일이거나 그 시각이 운영시간 밖일 때
  // **코드가 멀쩡한데도 테스트가 실패한다.** 설정을 읽어 실제 가능한 슬롯을 찾는다.
  const slot = BOOK_findTestSlot_();                    // → 예약 가능한 슬롯
  if (!slot) {                                          // → 못 찾으면
    Logger.log('예약 가능한 날짜·시각이 없습니다. CALENDAR_CONFIG·HOLIDAYS 설정을 먼저 확인하세요.');
    return;                                             // → 테스트 중단
  }
  const dummy = {                                       // → 예시 데이터
    date: slot.ymd,                                     // → 예약 가능한 날짜
    time: slot.time,                                    // → 가능한 시작 시각
    endTime: slot.endTime,                              // → 그 30분 뒤
    type: 'CONTAINER',                                  // → 장비 유형(3편 허용 목록)
    door: 'D01',                                        // → 도어 또는 슬롯
    containerNo: 'TEST1234567',                         // → 컨테이너 번호
    carrier: '테스트 운송사',                             // → 운송사명
    client: '테스트 고객',                               // → 고객사명
    remark: '테스트 예약입니다.',                        // → 비고
    qty: '10',                                          // → 수량(문자열 가능)
    pallet: '2'                                         // → 팔레트 수
  };
  const result = BOOK_bookAppt(dummy);                  // → 예약 저장 실행
  Logger.log(slot.ymd + ' ' + slot.time + '~' + slot.endTime + ' → ' +
             JSON.stringify(result));                   // → 결과 로그 출력
}

제대로 됐는지 확인하는 법: 에디터에서 BOOK_testBookAppt_를 실행했을 때 실행 로그에 {"success":true로 시작하는 메시지가 찍히고, APPT_MAIN 시트에 테스트 데이터 한 줄이 추가되면 기본 골격은 정상입니다.


입력값 검증: 필수값·형식·숫자까지 한 번에 체크하기

예약 시스템을 실제로 돌려 보면, 입력 누락과 잘못된 형식이 의외로 자주 나옵니다. 날짜 포맷이 제각각이거나, 시간 칸에 글자가 섞여 있거나, 수량 칸에 “많이” 같은 메모가 들어오는 식입니다. 이런 값이 그대로 저장되면 용량 계산 과정에서 NaN이 섞여 전체 합계가 망가질 수 있습니다.

아래 코드는 다음을 모두 확인합니다.

  • 날짜·시작 시간·종료 시간·유형·도어·컨테이너 번호 필수
  • 날짜·시간 형식(YYYY-MM-DD, HH:MM)
  • 예약 유형·도어 형태 기본 허용 목록(예시)
  • 수량·팔레트 수가 숫자인지, NaN이 아닌지

2단계 — 입력값 검증 함수

이 코드는 예약 데이터의 필수값·형식·숫자 유효성을 확인하고 정리된 객체를 반환합니다.

붙여넣을 위치: 방금 만든 BOOK_bookAppt 함수 아래.

붙여넣은 뒤 할 일: BOOK_testBookAppt_의 날짜·시간·수량을 일부러 잘못 넣어보고 오류 메시지를 확인합니다.

Apps Script (JavaScript)
/**
 * 입력값 검증 — 형식만 보지 않고 **실제로 있는 날짜·시각인지**까지 본다
 * @param {Object} data 화면에서 넘어온 원본
 * @returns {Object} 정리된 예약 데이터
 */
function BOOK_validateBookingInput_(data) {               // → 입력 검증 함수
  if (!data) {                                            // → 전체 객체 검사
    throw new Error('예약 데이터가 전달되지 않았습니다.'); // → 오류 메시지
  }

  const type = String(data.type || '').trim().toUpperCase();  // → 장비 유형(대문자)
  const door = String(data.door || '').trim();            // → 도어/슬롯
  // 컨테이너 번호는 저장 전에 **대문자·공백 정리**로 통일한다. 그러지 않으면
  // 'test1234567' 과 'TEST1234567' 이 서로 다른 컨테이너로 보여 중복 검사가 뚫린다.
  const containerNo = String(data.containerNo || '').trim().toUpperCase();
  const carrier = String(data.carrier || '').trim();      // → 운송사명
  const client = String(data.client || '').trim();        // → 고객사명
  const remark = String(data.remark || '').trim();        // → 비고

  if (!door) {                                            // → 도어/슬롯 체크
    throw new Error('도어(또는 야드 슬롯)는 필수 항목입니다.'); // → 안내 메시지
  }
  if (!containerNo) {                                     // → 컨테이너 체크
    throw new Error('컨테이너(또는 트레일러) 번호는 필수 항목입니다.'); // → 안내
  }

  // 날짜·시간은 기본 5편 도우미로 검증한다. 정규식만 쓰면 `2026-02-31`·`99:99` 가
  // 그대로 통과한다 — 있지도 않은 날짜로 예약이 잡힌다.
  let date;
  let time;
  let endTime;
  try {
    date = APPT_ymd_(String(data.date || '').trim());     // → 실재하는 날짜인지까지 검사
  } catch (e) {
    throw new Error('예약일자가 올바르지 않습니다: ' + e.message);
  }
  try {
    time = APPT_hm_(String(data.time || '').trim());      // → 00~23시, 00~59분
  } catch (e) {
    throw new Error('시작 시간이 올바르지 않습니다: ' + e.message);
  }
  try {
    endTime = APPT_hm_(String(data.endTime || '').trim()); // → 종료 시간
  } catch (e) {
    throw new Error('종료 시간이 올바르지 않습니다: ' + e.message);
  }
  // 끝나는 시각이 시작보다 빠르거나 같으면 예약이 성립하지 않는다.
  if (compareTime_(endTime, time) <= 0) {                 // → 1편의 시간 비교 도우미
    throw new Error('종료 시간은 시작 시간보다 뒤여야 합니다.');
  }

  // 장비 유형은 **3편이 정한 허용 목록**을 그대로 쓴다. 여기서 따로 목록을 만들면
  // 3편 정원 함수가 모르는 값이 들어와 그 예약이 통째로 집계에서 빠진다.
  if (!BOOK_CAPACITY_CONFIG.VALID_TYPES.includes(type)) { // → 허용 목록 확인
    throw new Error('알 수 없는 장비 유형입니다: ' + type +
                    ' (허용: ' + BOOK_CAPACITY_CONFIG.VALID_TYPES.join(', ') + ')');
  }

  if (!/^D\d{2}$/.test(door)) {                           // → 도어 패턴(D01 형식)
    throw new Error('도어 형식이 올바르지 않습니다.');     // → 안내 메시지
  }

  const qty = BOOK_toCount_(data.qty, '수량');            // → 0 이상 정수 또는 null
  const pallet = BOOK_toCount_(data.pallet, '팔레트 수'); // → 0 이상 정수 또는 null

  return {                                                // → 정리된 값 반환
    date: date,                                           // → 날짜 'YYYY-MM-DD'
    time: time,                                           // → 시작 'HH:mm'
    endTime: endTime,                                     // → 종료 'HH:mm'
    type: type,                                           // → 장비 유형(대문자)
    door: door,                                           // → 도어/슬롯
    containerNo: containerNo,                             // → 컨테이너(대문자)
    carrier: carrier,                                     // → 운송사
    client: client,                                       // → 고객사
    remark: remark,                                       // → 비고
    qty: qty,                                             // → 수량
    pallet: pallet                                        // → 팔레트 수
  };
}

/**
 * 수량류 입력을 0 이상의 정수로만 받는다 (빈 값은 null)
 */
function BOOK_toCount_(raw, label) {                      // → 개수 변환 도우미
  if (raw === undefined || raw === null || raw === '') {  // → 값이 없으면
    return null;                                          // → 비워 둔다
  }
  const n = Number(raw);                                  // → 숫자 변환
  if (!Number.isSafeInteger(n) || n < 0) {                // → 정수·음수 검사
    throw new Error(label + '은(는) 0 이상의 정수로 입력해야 합니다: ' + raw);
  }
  return n;                                               // → 정수 반환
}

제대로 됐는지 확인하는 법: BOOK_testBookAppt_endTime을 빈 문자열로 바꾸거나 qty: '열 개'처럼 문자로 넣은 뒤 실행했을 때, success:false와 함께 “종료 시간은 필수 항목입니다.”, “수량은 숫자로 입력해야 합니다.” 같은 구체적인 메시지가 반환되면 정상입니다.


운영 규칙·중복·정원까지 검증하고 저장하기

입력값이 통과했다고 해서 바로 저장하면, 여전히 운영 규칙 위반·중복·정원 초과 문제가 남습니다. 이 부분이 실제 운영과 직결되는 핵심입니다.

  • 운영 규칙: 고객사별 예약 가능 기간, 요일·시간대 차단
  • 중복: 같은 날짜·컨테이너(필요 시 고객사까지) 중복 예약 방지
  • 정원: 이 시점 기준 마지막 자리인지 다시 계산 후 append

3단계 — 운영 규칙 재확인 함수

이 코드는 앞 편에서 만든 예약 가능 기간·운영시간 함수를 호출해 비즈니스 규칙을 검사합니다.

붙여넣을 위치: BOOK_validateBookingInput_ 아래.

붙여넣은 뒤 할 일: 앞 편에서 설정한 마감일을 넘어가는 날짜를 넣고 테스트합니다.

Apps Script (JavaScript)
/**
 * 운영 규칙 검사 — 앞 편 함수를 **그 편이 정한 규격 그대로** 부른다
 * @param {Object} booking 검증된 예약 데이터
 */
function BOOK_validateBusinessRules_(booking) {                   // → 규칙 검사
  // ① 예약 가능 기간 — 2편 `getBookingWindow_()` 는 {startDate, endDate} 를 돌려준다.
  //    배열을 돌려주는 `getBookingWindowForClient()` 와 헷갈리면 안 된다.
  const win = getBookingWindow_();                                // → 기간 정보
  const tz = Session.getScriptTimeZone();                         // → 스크립트 시간대
  const minYmd = Utilities.formatDate(win.startDate, tz, 'yyyy-MM-dd'); // → 시작 가능일
  const maxYmd = Utilities.formatDate(win.endDate, tz, 'yyyy-MM-dd');   // → 마지막 가능일
  if (booking.date < minYmd || booking.date > maxYmd) {           // → 기간 밖 검사
    throw new Error('예약 가능 기간(' + minYmd + ' ~ ' + maxYmd + ') 밖의 날짜입니다.');
  }

  // ② 휴일·운영시간 — 1편 `getDateBlockInfo(Date)` 가 그 날의 open/close/차단구간을 준다.
  const dp = booking.date.split('-');                             // → 연·월·일 분리
  const dateObj = new Date(Number(dp[0]), Number(dp[1]) - 1, Number(dp[2])); // → 로컬 Date
  const info = getDateBlockInfo(dateObj);                         // → 그 날의 운영 정보
  // `PARTIAL`(부분 휴무)은 **그날 운영시간이 다르다**는 뜻이지 쉬는 날이 아니다.
  // `isHoliday` 만 보고 막으면 단축 운영일 예약이 전부 거부된다.
  if (info.holidayType === 'FULL' || info.holidayType === 'CLOSED'
      || !info.open || !info.close) {                              // → 종일 휴무만 거부
    throw new Error(booking.date + ' 은 예약을 받지 않는 날입니다.');
  }

  // ③ 운영시간 안인가 — 1편 `isTimeInRange_(time, start, end)` 는 **인자 세 개**다.
  if (!isTimeInRange_(booking.time, info.open, info.close)) {     // → 시작 시각
    throw new Error('시작 시간이 운영시간(' + info.open + '~' + info.close + ') 밖입니다.');
  }
  // 1편 `isTimeInRange_` 는 **종료를 포함하지 않는다**(`mt >= ms && mt < me`).
  // 그래서 08:00~17:00 운영에서 16:30~17:00 예약의 '종료 17:00' 이 거부된다.
  // 시작은 그 함수를 그대로 쓰고, 종료만 '폐점 시각까지 허용'으로 따로 본다.
  if (compareTime_(booking.endTime, info.open) <= 0
      || compareTime_(booking.endTime, info.close) > 0) {          // → 종료 시각
    throw new Error('종료 시간이 운영시간(' + info.open + '~' + info.close + ') 밖입니다.');
  }

  // ④ 점심·정비 같은 차단 구간에 걸치는지
  const blocks = info.blocks || [];                               // → 차단 구간 목록
  for (let i = 0; i < blocks.length; i++) {                       // → 구간마다 검사
    const b = blocks[i];                                          // → 한 구간
    const overlap = compareTime_(booking.time, b.end) < 0
      && compareTime_(booking.endTime, b.start) > 0;              // → 구간과 겹치는가
    if (overlap) {                                                // → 겹치면 거부
      throw new Error('차단된 시간대입니다(' + b.start + '~' + b.end +
                      (b.reason ? ', ' + b.reason : '') + ').');
    }
  }
}

제대로 됐는지 확인하는 법: 고객사별로 허용 기간 밖의 날짜를 BOOK_testBookAppt_에 넣고 실행했을 때 “예약 가능 기간을 벗어난 날짜입니다.”라는 메시지가 나오면 원하는 대로 동작합니다. 운영시간 설정 방식은 앞 글인

구글시트 예약 운영시간 차단 설정: 구글시트 예약 시스템 만들기에서 자세히 설명했습니다.

4단계 — 컨테이너 중복 예약 검사

이 코드는 같은 날짜·컨테이너 번호가 이미 예약되어 있는지 APPT 시트를 전수 조회해 확인합니다. 필요하면 고객사·유형·상태 컬럼을 조건에 추가해도 됩니다.

붙여넣을 위치: BOOK_validateBusinessRules_ 아래.

붙여넣은 뒤 할 일: APPT_MAIN 시트에 같은 날짜·컨테이너로 한 줄 미리 넣어 두고 테스트합니다.

Apps Script (JavaScript)
/**
 * 같은 날짜에 같은 컨테이너가 이미 예약되어 있는지 검사
 * @param {Object} booking 검증된 예약 데이터
 */
function BOOK_checkDuplicateContainer_(booking) {                 // → 중복 검사
  const sheet = SpreadsheetApp.getActive()                        // → 3편과 같은 시트
    .getSheetByName(BOOK_USAGE_CONFIG.APPT_SHEET_NAME);           // → APPT_MAIN
  if (!sheet) {                                                   // → 시트 없음
    throw new Error('예약 시트를 찾을 수 없습니다.');              // → 안내 메시지
  }
  const lastRow = sheet.getLastRow();                             // → 마지막 행
  if (lastRow < 2) {                                              // → 헤더만 있는 경우
    return;                                                       // → 중복 없음
  }
  const width = BOOK_APPT_COLS.BOOKING_ID;                        // → M열까지 읽는다
  const values = sheet.getRange(2, 1, lastRow - 1, width).getValues(); // → 데이터 범위
  const tz = Session.getScriptTimeZone();                         // → 시간대

  for (let i = 0; i < values.length; i++) {                       // → 행 반복
    const row = values[i];                                        // → 한 행 데이터
    const rowDate = row[BOOK_APPT_COLS.DATE - 1];                 // → A열 날짜
    // 저장할 때와 **같은 방식으로** 다듬어 비교한다. 한쪽만 대문자로 만들면
    // 'test1234567' 이 다른 컨테이너로 보여 중복이 그대로 들어간다.
    const rowContainer = String(row[BOOK_APPT_COLS.CONTAINER - 1] || '')
      .trim().toUpperCase();                                      // → E열 컨테이너
    if (!rowDate || !rowContainer) {                              // → 빈 행 건너뜀
      continue;
    }
    const rowDateStr = (rowDate instanceof Date)
      ? Utilities.formatDate(rowDate, tz, 'yyyy-MM-dd')           // → 날짜 포맷
      : String(rowDate).trim();                                   // → 문자열 변환
    if (rowDateStr === booking.date && rowContainer === booking.containerNo) {
      throw new Error('이미 같은 날짜에 동일 컨테이너 예약이 존재합니다. (행 ' +
                      (i + 2) + ')');                             // → 안내 메시지
    }
  }
}

제대로 됐는지 확인하는 법: APPT_MAIN 시트에 오늘 이후 예약 가능한 날짜와 컨테이너 TEST1234567로 한 줄 넣은 상태에서(테스트 함수가 쓰는 날짜와 같아야 합니다) BOOK_testBookAppt_를 실행했을 때, 반환값이 success:false이고 메시지가 “이미 같은 날짜에 동일 컨테이너 예약이 존재합니다.”라면 중복 방지 로직이 제대로 적용된 것입니다.

5단계 — 정원 재확인 후 실제로 저장하기

이 코드는 LockService 잠금 안에서 현재 사용량을 다시 계산해 정원 초과 여부를 확인한 뒤, 안전할 때만 한 줄을 append합니다. 앞 편에서 구현해 둔 용량 관련 함수들을 그대로 사용합니다.

붙여넣을 위치: BOOK_checkDuplicateContainer_ 아래.

붙여넣은 뒤 할 일: 허용 대수가 1인 시간·도어에서 두 번 연속 저장을 시도해 봅니다.

Apps Script (JavaScript)
/**
 * 정원 재확인 후 예약 시트에 한 줄 저장
 * @param {Object} booking 검증된 예약 데이터
 */
function BOOK_checkCapacityAndSave_(booking) {                    // → 정원 확인+저장
  const sheet = SpreadsheetApp.getActive()                        // → 3편과 같은 시트
    .getSheetByName(BOOK_USAGE_CONFIG.APPT_SHEET_NAME);           // → APPT_MAIN
  if (!sheet) {                                                   // → 시트 없음
    throw new Error('예약 시트를 찾을 수 없습니다.');              // → 안내 메시지
  }

  // 3편이 만든 함수는 **Date 두 개와 장비 유형**을 받아 남은 자리를 통째로 돌려준다.
  // 정원과 사용량을 따로 물어볼 필요가 없다 — 그 함수 하나가 둘 다 계산한다.
  const dp = booking.date.split('-');                             // → 연·월·일
  const tp = booking.time.split(':');                             // → 시·분
  const slotDate = new Date(Number(dp[0]), Number(dp[1]) - 1, Number(dp[2])); // → 날짜
  const slotTime = new Date(1899, 11, 30, Number(tp[0]), Number(tp[1]));      // → 시각
  const slot = BOOK_getSeqUsageForSlotCore_(slotDate, slotTime, booking.type);

  if (slot.remaining <= 0) {                                      // → 남은 자리 없음
    throw new Error('해당 시간대(' + slot.time + ' / ' + booking.type +
                    ')의 예약 정원이 모두 찼습니다. 정원 ' + slot.capacity +
                    ', 사용 ' + slot.used);                       // → 안내
  }

  const nextRow = sheet.getLastRow() + 1;                         // → 새로 쓸 행 번호
  const rowValues = [];                                           // → 한 행 데이터
  rowValues[BOOK_APPT_COLS.DATE - 1] = slotDate;                  // A: 날짜(진짜 Date)
  rowValues[BOOK_APPT_COLS.TIME - 1] = slotTime;                  // B: 시작 시간(Date)
  rowValues[BOOK_APPT_COLS.TYPE - 1] = booking.type;              // C: 장비 유형
  rowValues[BOOK_APPT_COLS.DOOR - 1] = booking.door;              // D: 도어/슬롯
  rowValues[BOOK_APPT_COLS.CONTAINER - 1] = booking.containerNo;  // E: 컨테이너
  rowValues[BOOK_APPT_COLS.CARRIER - 1] = booking.carrier;        // F: 운송사
  rowValues[BOOK_APPT_COLS.CLIENT - 1] = booking.client;          // G: 고객사
  rowValues[BOOK_APPT_COLS.REMARK - 1] = booking.remark;          // H: 비고
  rowValues[BOOK_APPT_COLS.END_TIME - 1] = booking.endTime;       // I: 종료 시간
  rowValues[BOOK_APPT_COLS.CREATED - 1] = new Date();             // J: 생성일시
  rowValues[BOOK_APPT_COLS.QTY - 1] = booking.qty;                // K: 수량
  rowValues[BOOK_APPT_COLS.PALLET - 1] = booking.pallet;          // L: 팔레트 수
  rowValues[BOOK_APPT_COLS.BOOKING_ID - 1] = Utilities.getUuid(); // M: 예약 ID(고유)
  for (let i = 0; i < rowValues.length; i++) {                    // → 빈 칸 메우기
    if (rowValues[i] === undefined || rowValues[i] === null) {    // → 구멍이 있으면
      rowValues[i] = '';                                          // → 빈 문자열로
    }
  }

  sheet.getRange(nextRow, 1, 1, rowValues.length).setValues([rowValues]); // → 한 번에 쓰기
  sheet.getRange(nextRow, BOOK_APPT_COLS.DATE).setNumberFormat('yyyy-MM-dd'); // → 날짜 서식
  sheet.getRange(nextRow, BOOK_APPT_COLS.TIME).setNumberFormat('HH:mm');      // → 시간 서식
}

제대로 됐는지 확인하는 법: 특정 시간·도어의 허용 대수를 1로 설정해 둔 뒤, 같은 시간·도어로 BOOK_testBookAppt_의 컨테이너 번호만 바꿔 두 번 연속 실행했을 때 첫 번째는 성공, 두 번째는 “정원이 모두 찼습니다.” 오류가 나며 시트에는 한 줄만 추가되어 있으면 LockService 안 정원 재확인이 올바르게 작동하고 있는 것입니다. 용량 계산 구조는

구글시트 예약 용량 계산 방법: 시간대별 남은 자리 자동 표시에서 자세히 설명한 내용과 맞닿아 있습니다.


예약 정리용 보관 함수: 오래된 줄을 안전하게 옮기기

운영을 하다 보면 예약 시트가 수천 행씩 쌓입니다. 그대로 두면 필터·피벗이 느려지기 때문에 일정 기준(예: 완료 날짜 기준 N일 경과)을 넘긴 예약은 보관 시트로 옮기는 작업이 필요합니다. 이때도 여러 사람이 동시에 정리 버튼을 누를 수 있다는 점을 감안해야 합니다.

아래 함수는 다음 순서로 동작합니다.

  1. ScriptLock을 잡습니다.
  2. 보관 대상 행에 ARCHIVED 표시를 먼저 남긴 뒤 APPT_ARCHIVE 시트에 붙여넣습니다.
  3. 원본 시트에서는 해당 행들을 삭제합니다(아래에서부터 지우기).
  4. finally에서 잠금을 해제합니다.

6단계 — 오래된 예약 보관 함수

이 코드는 APPT_MAIN 시트의 오래된 예약을 APPT_ARCHIVE 시트로 옮기고 원본에서 삭제합니다. 옮기기 전에 원본에 ARCHIVED 표시를 먼저 남기므로, 중간에 멈춰도 다시 실행할 때 같은 줄이 두 번 복사되지 않습니다.

붙여넣을 위치: 위 함수들 아래.

붙여넣은 뒤 할 일: 테스트용으로 보관 기준 날짜를 오늘보다 이후로 잡고 실행해 움직임을 확인합니다.

Apps Script (JavaScript)
/**
 * 오래된 예약을 보관 시트로 옮긴다 — **몇 번을 다시 실행해도 안전하게**
 * @returns {number} 이번에 옮긴 줄 수
 */
function BOOK_archiveOldAppts_() {                               // → 보관 함수
  const lock = LockService.getScriptLock();                      // → 스크립트 잠금
  let locked = false;                                            // → 잠금 여부
  try {
    lock.waitLock(5000);                                         // → 최대 5초 대기
    locked = true;                                               // → 잠금 성공
    const ss = SpreadsheetApp.getActive();                       // → 현재 스프레드시트
    const main = ss.getSheetByName(BOOK_USAGE_CONFIG.APPT_SHEET_NAME);  // → 메인 시트
    const archive = ss.getSheetByName(BOOK_ARCHIVE_SHEET_NAME);  // → 보관 시트
    if (!main || !archive) {                                     // → 시트 존재 확인
      throw new Error('예약 또는 보관 시트를 찾을 수 없습니다.');   // → 안내 메시지
    }
    const lastRow = main.getLastRow();                           // → 메인 마지막 행
    if (lastRow < 2) {                                           // → 데이터 없음
      return 0;                                                  // → 옮긴 줄 수
    }
    const width = Math.max(main.getLastColumn(), BOOK_APPT_COLS.BOOKING_ID);
    const values = main.getRange(2, 1, lastRow - 1, width).getValues();  // → 전체 데이터

    // 보관 시트에 이미 들어간 예약 ID 를 먼저 모은다. **판단 기준은 표시가 아니라
    // '보관 시트에 그 ID 가 실제로 있는가' 다.** 표시를 먼저 남기는 방식은,
    // 표시만 하고 복사 전에 멈추면 그 줄이 영영 안 옮겨진다.
    const archived = {};                                         // → 이미 보관된 ID
    const aLast = archive.getLastRow();                          // → 보관 시트 마지막 행
    if (aLast >= 2) {                                            // → 데이터가 있으면
      const aIds = archive.getRange(2, BOOK_APPT_COLS.BOOKING_ID,
                                    aLast - 1, 1).getValues();   // → ID 열만 읽기
      for (let k = 0; k < aIds.length; k++) {                    // → 한 줄씩
        const id = String(aIds[k][0] || '').trim();              // → ID 값
        if (id) {                                                // → 비어 있지 않으면
          archived[id] = true;                                   // → 있는 것으로 표시
        }
      }
    }

    const today = new Date();                                    // → 오늘 날짜
    const cutoff = new Date(today.getFullYear(), today.getMonth(),
                            today.getDate() - 30);               // → 30일 전 기준일
    const toCopy = [];                                           // → 보관 시트에 넣을 행
    const toDelete = [];                                         // → 원본에서 지울 행 번호

    for (let i = 0; i < values.length; i++) {                    // → 행 반복
      const row = values[i];                                     // → 한 행 데이터
      const dateCell = row[BOOK_APPT_COLS.DATE - 1];             // → A열 날짜
      if (!(dateCell instanceof Date)) {                         // → 날짜가 아니면
        continue;                                                // → 판단할 수 없다
      }
      const d = new Date(dateCell.getFullYear(), dateCell.getMonth(),
                         dateCell.getDate());                    // → 시각 제거
      if (d > cutoff) {                                          // → 아직 최근이면
        continue;                                                // → 그대로 둔다
      }
      let id = String(row[BOOK_APPT_COLS.BOOKING_ID - 1] || '').trim(); // → 예약 ID
      if (!id) {                                                 // → 옛 데이터라 ID 가 없으면
        id = Utilities.getUuid();                                // → 지금 만들어 주고
        main.getRange(i + 2, BOOK_APPT_COLS.BOOKING_ID).setValue(id); // → 원본에 적어 둔다
        row[BOOK_APPT_COLS.BOOKING_ID - 1] = id;                 // → 복사본에도 반영
      }
      if (!archived[id]) {                                       // → 아직 보관 안 된 것만
        toCopy.push(row);                                        // → 복사 대상
      }
      toDelete.push(i + 2);                                      // → 원본에서는 어차피 지운다
    }

    if (!toDelete.length) {                                      // → 대상이 없으면
      return 0;                                                  // → 끝
    }
    // 순서: ①복사 ②확인 ③삭제. 복사 뒤 삭제 전에 멈춰도, 다음 실행에서 그 ID 는
    // 이미 보관 시트에 있으므로 **복사는 건너뛰고 삭제만** 한다. 몇 번을 돌려도 같다.
    if (toCopy.length) {                                         // → 새로 옮길 것이 있으면
      const start = archive.getLastRow() + 1;                    // → 보관 시작 행
      archive.getRange(start, 1, toCopy.length, toCopy[0].length)
        .setValues(toCopy);                                      // → 보관 시트에 쓰기
      SpreadsheetApp.flush();                                    // → 복사를 먼저 확정
    }
    toDelete.sort(function (a, b) { return b - a; });            // → 아래에서 위로
    toDelete.forEach(function (rowIndex) {                       // → 각 행 삭제
      main.deleteRow(rowIndex);                                  // → 행 삭제
    });
    return toCopy.length;                                        // → 이번에 옮긴 줄 수
  } finally {
    if (locked) {                                                // → 잠금이 잡혔으면
      lock.releaseLock();                                        // → 해제
    }
  }
}

제대로 됐는지 확인하는 법: APPT_MAIN 시트에 예약일자가 두세 달 전인 테스트 데이터를 몇 줄 넣고 BOOK_archiveOldAppts_를 실행했을 때, 그 줄들이 APPT_ARCHIVE 시트로 옮겨지고 메인 시트에서는 사라지면 정상입니다. 한 번 더 실행해도 보관 시트에 같은 줄이 다시 쌓이지 않아야 합니다. 두 사람이 동시에 이 함수를 실행해도 ScriptLock 때문에 한 번에 한 사람만 처리하게 됩니다.


실무 팁: LockService와 중복 방지 코드를 운영에 녹이는 방법

위 코드만으로도 기본적인 구글시트 LockService 예약 저장 구조는 갖춰지지만, 실제 현장에 적용하면서 느낀 몇 가지 포인트를 정리합니다.

첫째, 에러 메시지를 사용자 기준으로 작성합니다. 코드 안에서는 중복·정원 초과·LockService 대기 등 원인을 구분하고 있지만, 화면을 보는 사람에게는 “왜 저장이 안 되는지”만 중요합니다. 예를 들어 LockService 대기 시간 초과가 발생할 수 있다는 것을 알고 있다면, 일반 사용자에게는 “잠시 후 다시 시도해 주세요. 같은 시간대 다른 예약이 처리 중입니다.”처럼 안내하는 것이 좋습니다.

둘째, 허용 목록과 형식을 최대한 초기에 강제합니다. 유형(type)·도어(door) 같은 값은 글자 하나만 달라져도 집계 과정에서 따로 집계될 수 있습니다. 이 글에서는 FCL, LTL 같은 예시 허용 목록과 D01 형식 검사를 넣었지만, 실제로는 센터별 표준에 맞추어 목록을 늘리거나, 시트 데이터 유효성 검사와 함께 사용하는 편이 좋습니다.

셋째, 수량·팔레트 수는 숫자 변환과 NaN 검사를 항상 세트로 둡니다. 현장에서 실수로 “10+5” 같은 문자열을 입력했을 경우, Number('10+5')NaN이 되고, 이 값이 한 번 합계에 들어가면 전체 합계가 NaN이 됩니다. 지금 단계에서 Number.isFinite로 막아 두면 이후 정리·통계 함수에서도 안전하게 계산할 수 있습니다.

넷째, LockService는 저장·보관처럼 읽기와 쓰기가 섞인 처리에만 적용하는 것이 좋습니다. 예약 현황을 조회하는 대시보드는 잠그지 않고, 저장과 정리 함수에만 잠금을 사용해야 전체 시스템 반응 속도가 유지됩니다. 이 글에서 보관 함수에도 ScriptLock을 적용한 이유가 바로 이 부분입니다. 읽고→옮기고→지우는 작업은 작은 시간 차이로도 중복이 발생하기 때문입니다.

다섯째, 테스트 시나리오를 명확히 정해 두면 유지보수에 도움이 됩니다. 이 글에서 제안한 것처럼,

  • 이미 존재하는 컨테이너 번호로 저장 시도 → 중복 에러
  • 허용 기간 밖 날짜로 저장 시도 → 기간 에러
  • 허용 대수 1인 시간대에서 두 번 연속 저장 시도 → 한 건만 성공

같은 3~4개의 테스트를 정기적으로 돌려 보면, 규칙이나 시트 구조를 바꾸는 과정에서 문제가 생겼는지 빠르게 확인할 수 있습니다.


맺음말: 마지막 한 자리를 일부러 두 번 예약해 보십시오

여기까지 구현하면, 구글시트로 만든 입고 예약 시스템에서 가장 빈번한 문제인 중복 예약과 정원 초과를 코드 수준에서 제어할 수 있습니다. LockService로 예약 저장 구간을 잠그고, 그 안에서 입력 검증→운영 규칙 확인→컨테이너 중복 검사→정원 재확인→저장까지 한 흐름으로 처리하면, 여러 사람이 동시에 같은 시간대를 바라보는 상황에서도 데이터 일관성이 유지됩니다.

지금 바로 해 볼 수 있는 행동은 하나입니다. 허용 대수가 1인 시간·도어를 하나 정한 뒤, 테스트 컨테이너 번호 두 개로 동시에 저장을 시도해 보십시오. 한 건만 성공하고 나머지는 “정원이 모두 찼습니다.” 혹은 중복 메시지로 거절된다면, 구글시트 입고 예약 중복 방지 방법이 LockService와 함께 제대로 자리 잡은 것입니다.