구글시트 예약 용량 계산 방법: 시간대별 남은 자리 자동 표시
도입: 같은 시간에 몇 대까지 받을 수 있을지 보이십니까
창고 입고 예약을 직접 운영하다 보면 가장 답답한 부분은 “같은 시간대에 몇 대까지 받을 수 있는지”가 눈에 잘 보이지 않는다는 점입니다. 담당자마다 감으로 판단하다 보면 어떤 날은 도크가 비어 있고, 어떤 날은 특정 시간에 트럭이 몰려 현장이 마비되는 일이 반복됩니다. 이 글은 그 문제를 구글시트 예약 용량 계산 방법으로 풀어, 시간대별 남은 자리를 자동으로 표시하는 방법을 다룹니다.
이번 글은 구글시트 입고 예약 시스템, 예약 등록 3편입니다. 구글시트 예약 가능 기간 계산하기: 오늘부터 며칠까지 자동 설정에서 예약 운영 시간대와 고객사별 예약 가능 날짜 범위를 계산하는 기본 구조를 만들었습니다. 여기에서는 그 위에 시간대별 예약 정원과 실제 사용량을 계산하고, 남은 자리(정원 − 사용량)를 코드로 구하는 부분에 집중합니다.
설명하는 예시는 구글시트와 Apps Script를 이용해 구글시트 시간대별 예약 관리, Apps Script 예약 슬롯 용량 계산, 구글시트 예약 남은 자리 표시까지 실제 운영에서 사용 가능한 수준으로 구성한 것입니다. 저장·중복·정리 트리거는 범위를 넘기 때문에 이 편에서는 계산 로직까지만 다루고, 예약 저장 시 Lock 적용 패턴은 개념 위주로 짚고 넘어갑니다.
시간대별 예약 용량을 왜 코드로 계산해야 하는가
실무에서 가장 먼저 떠올리는 방법은 간단합니다. 예약 시트에서 특정 시간대를 필터로 걸고, COUNTA 같은 수식으로 건수를 세어 정원과 비교하는 방식입니다. 초기 물량이 적을 때는 이렇게 시작해도 운영에 큰 문제는 없습니다. 그러나 예약이 쌓이고 참여 회사와 담당자가 늘어나면 수식 기반 방식의 한계가 금방 드러납니다.
- 속도 문제
하루 수십 건을 넘어 수백 건 수준이 되면, 시트 곳곳에 들어간 수식과 필터가 전체 문서를 느리게 만듭니다. 특히 여러 사람이 동시에 같은 구글시트를 열어 두면, 필터 변경과 수식 재계산 때문에 화면이 묶이는 현상이 자주 발생합니다. 예약 담당자는 대개 전화 응대와 동시에 시트를 조작하기 때문에, 몇 초만 딜레이가 생겨도 체감 스트레스가 큽니다.
- 장비 종류별 정원 관리의 어려움
현장에서는 컨테이너, 트레일러, 소형차 등 장비 타입마다 처리 시간과 도크 점유 패턴이 다릅니다. 그래서 “08:00에 컨테이너 2대, 트레일러 4대까지”처럼 장비별 정원을 다르게 설정해야 정상적인 구글시트 입고 예약 시스템이 됩니다. 이를 단순 수식으로 풀면 열이 매우 복잡해지고, 새로운 장비 타입이 추가될 때마다 수식을 고치는 작업이 반복됩니다.
이 때문에 이 시리즈에서는 Apps Script로 예약 슬롯 용량을 계산하는 별도 함수를 두고, 그 결과만 시트나 웹앱에 표시하는 구조를 사용합니다. 핵심은 getValues()로 예약 시트를 한 번에 읽어 메모리에서 시간대별 사용량을 계산한 뒤, 필요한 시간·장비 조합에 대해 남은 자리를 빠르게 돌려주는 것입니다. 이 구조를 쓰면 데이터가 수천 건으로 늘어나도, 구글시트 예약 남은 자리 표시 속도는 안정적으로 유지됩니다.
설정에서 장비별 시간당 정원을 읽어 오는 구조
시간대별 예약 용량 계산의 출발점은 “정원”입니다. 정원을 코드 안에 직접 숫자로 적어 넣으면, 현장 상황이 변할 때마다 개발자가 코드를 수정해야 합니다. 실무에서는 이것이 유지보수 비용으로 바로 이어집니다. 그래서 이 시리즈에서는 SETTINGS 시트에 예약 관련 설정을 한데 모으고, 장비 종류별 시간당 정원도 거기에 저장해 두는 방식을 씁니다.
예를 들어 컨테이너는 시간당 2대, 트레일러는 시간당 4대, 추가로 향후 소형차 타입을 도입할 수 있다면 설정 시트에 BOOK_CAPACITY_CONTAINER, BOOK_CAPACITY_TRAILER 같은 형태로 값을 넣어 둡니다. 예약 등록 화면에서는 장비 타입을 필수 선택값으로 두고, Apps Script 쪽에서는 그 장비 타입을 기준으로 정원 값을 찾습니다.
또 한 가지 중요한 점은 다른 코드와 충돌하지 않도록 이름 규칙을 잡는 것입니다. 이 예약 등록 갈래에서는 상수와 함수 이름에 BOOK_ 접두어를 붙여 두었습니다. 이렇게 하면 같은 프로젝트 안에 다른 자동화(예: 재고 관리, 적재 계획)가 들어와도 상수 이름이 부딪혀 오류가 나는 일을 피할 수 있습니다. 앞 편에서 이미 만들어 둔 loadSettings_() 함수는 재사용하되, 이 편에서는 그 결과 중 예약 용량 관련 키만 골라 쓰는 구조로 설계합니다.
이제 SETTINGS 시트에서 장비 타입별 기본 정원을 읽어 오는 코드부터 살펴보겠습니다.
1단계 — 장비 타입별 기본 정원 읽기 함수
이 코드는 장비 종류(컨테이너, 트레일러 등)에 따라 예약 슬롯 정원을 SETTINGS 시트에서 읽어 옵니다.
- 붙여넣을 위치: 구글시트 → 확장 프로그램 → Apps Script → 기존 프로젝트의
Code.gs맨 아래 - 사전 조건: 같은 프로젝트에
loadSettings_()함수와 SETTINGS 시트가 이미 구성되어 있어야 합니다. - 붙여넣은 뒤 할 일: 저장(⌘S/CTRL+S) 후
BOOK_testGetCapacityForType_()함수를 실행해 실행 로그를 확인합니다.
// ===== 1단계: 장비 타입별 정원 설정 읽기 =====
// 여기만 본인 환경에 맞게 바꾸세요
const BOOK_CAPACITY_CONFIG = { // → 예약 용량 설정 모음
SETTING_KEY_PREFIX: 'BOOK_CAPACITY_', // → SETTINGS 키 접두어
DEFAULT_PER_HOUR: 2, // → 설정 없을 때 기본 정원
VALID_TYPES: ['CONTAINER', 'TRAILER'] // → 허용 장비 종류
}; // →
/**
* 장비별 시간당 정원 조회
* @param {string} equipType 장비 종류 (예: 'CONTAINER', 'TRAILER')
* @return {number} 시간당 정원
*/
function BOOK_getCapacityForType_(equipType) { // → 장비별 시간당 정원 조회
if (!equipType) { // → 장비 값이 비었는지 확인
throw new Error('장비 종류가 비어 있습니다'); // → 오류로 중단
} // →
const upperType = String(equipType).toUpperCase(); // → 대문자로 정규화
if (!BOOK_CAPACITY_CONFIG.VALID_TYPES.includes(upperType)) { // → 허용 목록 확인
throw new Error('허용되지 않는 장비 종류: ' + equipType); // → 잘못된 값 거부
} // →
const settings = loadSettings_(); // → 앞 편 SETTINGS 로딩 재사용
const key = BOOK_CAPACITY_CONFIG.SETTING_KEY_PREFIX + upperType; // → SETTINGS 키 이름
const raw = settings[key]; // → 해당 키 값 읽기
if (raw === undefined || raw === '') { // → 값이 없으면
return BOOK_CAPACITY_CONFIG.DEFAULT_PER_HOUR; // → 기본 정원 반환
} // →
const capacity = Number(raw); // → 숫자로 변환
// 정원은 '몇 대'라는 건수다. 2.5 는 통과시키면 안 된다 — isFinite 만 보면 통과한다.
if (!Number.isSafeInteger(capacity) || capacity <= 0) { // → 1 이상의 정수만 허용
throw new Error('정원은 1 이상의 정수여야 합니다: ' + raw); // → 설정 오류 알림
} // →
return capacity; // → 정원 반환
} // →
/**
* BOOK_getCapacityForType_ 동작 확인용 테스트 함수
*/
function BOOK_testGetCapacityForType_() { // → 테스트용 함수
const types = BOOK_CAPACITY_CONFIG.VALID_TYPES; // → 허용 장비 목록
const result = {}; // → 결과 저장 객체
types.forEach(function(t) { // → 각 장비에 대해 반복
result[t] = BOOK_getCapacityForType_(t); // → 정원 조회
}); // →
Logger.log(JSON.stringify(result)); // → 결과를 로그에 출력
} // →동작 확인 방법
Apps Script 편집기에서 BOOK_testGetCapacityForType_를 선택해 실행한 뒤, 실행 로그에 { "CONTAINER": 2, "TRAILER": 4 }와 같은 JSON 형식의 결과가 나오면 정상입니다. SETTINGS에 값이 없으면 DEFAULT_PER_HOUR 값이 그대로 표시됩니다. 설정에 ABC, -1, 0처럼 잘못된 값을 넣으면 해당 키에서 오류를 던져 원인을 빨리 찾을 수 있습니다.
2단계 — 날짜·시간대별 실제 사용량을 한 번에 계산하기
정원을 준비했다면 이제 “이미 예약된 수량”을 계산해야 합니다. 구글시트 시간대별 예약 관리에서 성능이 갈리는 부분이 바로 여기입니다. 행마다 개별 getValue() 호출을 하거나, 시간대마다 필터를 바꿔 가며 COUNTA로 세면 초반에는 잘 돌아가지만, 일정 건수를 넘기면 체감 속도가 크게 떨어집니다.
실무에서 안정적으로 쓰려면 “해당 날짜에 속한 예약 행 전체를 getValues() 한 번으로 읽고, 자바스크립트 배열 처리로 시간대별 사용량을 모두 세기” 방식이 좋습니다. 이렇게 하면 네트워크 왕복이 한 번으로 끝나, 데이터가 수천 건이어도 연산 대부분이 메모리에서 빠르게 이루어집니다.
이 글에서는 APPT_MAIN 시트에 예약 데이터가 쌓이고,
- A열: 예약 날짜 (
Date형식) - B열: 예약 시간 (
Date형식, 시·분만 사용) - C열: 장비 타입(문자열, 예:
CONTAINER,TRAILER)
이 들어 있다고 가정합니다. 날짜와 시간은 각각 이미 프로젝트에 존재한다고 가정하는 APPT_ymd_(), APPT_hm_() 함수로 문자열(yyyymmdd, HH:MM)로 통일해 비교합니다. 이 함수들은 이전 편에서 정의된 내용 그대로 사용하고, 이 글에서는 다시 정의하지 않습니다.
아래 코드는 “특정 날짜에 대해 시간·장비 조합별 예약 건수를 모두 세어 객체로 돌려주는” 핵심 함수입니다. 허용 장비 목록에 없는 값은 조용히 건너뛰고, 집계 결과에도 포함하지 않습니다. 실무에서 잘못된 장비 코드가 들어온 경우에는 이 행들이 실제 정원 계산에 영향을 주지 않도록 하기 위한 의도입니다. 다만, 허용 목록 밖의 값이 집계에서 제외된다는 점을 알고 있어야 합니다.
2단계 코드 — 특정 날짜의 시간대별 예약 사용량 계산
- 붙여넣을 위치: 같은 Apps Script 프로젝트의
Code.gs, 앞 1단계 코드 바로 아래 - 붙여넣은 뒤 할 일: 저장 후
BOOK_testGetTimeUsageForDateCore_()를 실행해 실행 로그로 결과를 확인합니다.
// ===== 2단계: 날짜별 시간·장비 사용량 집계 =====
// 여기만 본인 시트 구조에 맞게 바꾸세요
const BOOK_USAGE_CONFIG = { // → 사용량 계산 설정
APPT_SHEET_NAME: 'APPT_MAIN', // → 예약 메인 시트 이름
COL_DATE: 1, // → 날짜 열 번호 (A=1)
COL_TIME: 2, // → 시간 열 번호 (B=2)
COL_EQUIP_TYPE: 3 // → 장비 종류 열 번호 (C=3)
}; // →
/**
* 특정 날짜에 대해 시간대·장비별 예약 사용량을 계산
* @param {Date} targetDate 기준 날짜(Date 객체)
* @return {Object} {"HH:MM|장비": 사용건수} 형태의 객체
*/
function BOOK_getTimeUsageForDateCore_(targetDate) { // → 날짜별 시간대 사용량 계산
if (!(targetDate instanceof Date)) { // → 날짜 타입 확인
throw new Error('Date 객체를 넘겨야 합니다'); // → 잘못된 입력 거부
} // →
const sheet = SpreadsheetApp // →
.getActive() // → 현재 스프레드시트
.getSheetByName(BOOK_USAGE_CONFIG.APPT_SHEET_NAME); // → 예약 시트 가져오기
if (!sheet) { // → 시트 유무 확인
throw new Error('예약 시트를 찾을 수 없습니다'); // → 설정 오류
} // →
const lastRow = sheet.getLastRow(); // → 마지막 데이터 행 번호
if (lastRow < 2) { // → 헤더만 있는 경우
return {}; // → 빈 결과 반환
} // →
const height = lastRow - 1; // → 데이터 행 개수
// 필요한 열까지만 읽도록 width 조정 (실무에 맞게 최소화 권장)
const width = BOOK_USAGE_CONFIG.COL_EQUIP_TYPE; // → 읽을 열 개수
const range = sheet.getRange(2, 1, height, width); // → 2행부터 마지막 행까지
const values = range.getValues(); // → 한 번에 읽기
const ymdTarget = APPT_ymd_(targetDate); // → 기준 날짜 문자열
const usage = {}; // → 결과 저장용 객체
const badRows = []; // → 셀 수 없었던 행 번호
values.forEach(function(row, i) { // → 각 행 반복
const rowNumber = i + 2; // → 실제 시트 행 번호
const rowDate = row[BOOK_USAGE_CONFIG.COL_DATE - 1]; // → 날짜 셀
const rowTime = row[BOOK_USAGE_CONFIG.COL_TIME - 1]; // → 시간 셀
const equipType = row[BOOK_USAGE_CONFIG.COL_EQUIP_TYPE - 1]; // → 장비 종류 셀
if (!rowDate && !rowTime && !equipType) { // → 세 칸이 모두 빈 행
return; // → 진짜 빈 줄만 건너뛴다
} // →
// 여기서부터는 **조용히 넘기지 않는다.** 못 센 행은 사용량에서 빠지고,
// 그러면 '자리가 남았다'로 계산돼 정원 초과 예약을 그대로 허용한다.
if (!(rowDate instanceof Date) || !(rowTime instanceof Date)) { // → 텍스트로 저장된 행
badRows.push(rowNumber); // → 기록해 두고
return; // → 이 행은 셀 수 없다
} // →
const upperType = String(equipType).toUpperCase(); // → 장비 대문자로
if (!BOOK_CAPACITY_CONFIG.VALID_TYPES.includes(upperType)) { // → 허용 목록 밖 장비
badRows.push(rowNumber); // → 이것도 못 센 행이다
return; // →
} // →
const ymd = APPT_ymd_(rowDate); // → 행의 날짜 문자열
if (ymd !== ymdTarget) { // → 다른 날짜면
return; // → 건너뜀 (정상)
} // →
const hm = APPT_hm_(rowTime); // → HH:MM 문자열
const key = hm + '|' + upperType; // → 시간+장비 조합 키
if (!usage[key]) { // → 처음 등장하면
usage[key] = 0; // → 0으로 초기화
} // →
usage[key] += 1; // → 예약 1건 추가
}); // →
if (badRows.length > 0) { // → 못 센 행이 하나라도 있으면
throw new Error( // → 숫자를 믿을 수 없으므로 멈춘다
'셀 수 없는 예약 행이 있습니다(행 ' + badRows.slice(0, 10).join(', ') + '). ' +
'날짜·시간이 텍스트로 저장됐거나 장비 종류가 허용 목록 밖입니다. ' +
'이 행을 빼고 세면 자리가 남은 것으로 계산돼 정원 초과 예약을 허용하게 됩니다. ' +
'기본 5편의 APPT_normalizeDateFormats() 로 정리한 뒤 다시 실행하세요.'
); // →
} // →
return usage; // → 시간·장비별 사용량 반환
} // →
/**
* BOOK_getTimeUsageForDateCore_ 동작 확인용 테스트 함수
*/
function BOOK_testGetTimeUsageForDateCore_() { // → 테스트용 함수
const today = new Date(); // → 오늘 날짜
const usage = BOOK_getTimeUsageForDateCore_(today); // → 사용량 계산
Logger.log(JSON.stringify(usage)); // → 결과 로그 출력
} // →동작 확인 방법
APPT_MAIN 시트에 오늘 날짜로 여러 시간·장비 조합의 예약을 입력한 뒤 BOOK_testGetTimeUsageForDateCore_를 실행하면, 실행 로그에 {"08:00|CONTAINER":2,"09:00|TRAILER":1} 같은 형식으로 결과가 찍혀야 합니다. 마지막 데이터 행까지 정확히 집계되는지, 허용 목록 밖 장비 타입은 결과에서 빠지는지 함께 확인합니다.
3단계 — 특정 슬롯 남은 자리를 계산하고 정원 초과를 막는 법
이제 시간·장비별 사용량까지 계산했으니, 실제로 예약을 받을 때 “지금 이 슬롯에 남은 자리가 몇 개인지”를 알아야 합니다. 구글시트 예약 남은 자리 표시는 결국 정원 - 이미 예약된 수량 계산입니다. 여기에서 주의할 점은 두 가지입니다.
- 남은 자리는 음수가 되지 않도록 처리
실무에서는 일시적으로 정원을 넘겨 받은 슬롯이 있을 수 있지만, 화면에 음수 잔여를 보여 주면 담당자와 고객 모두 혼란을 느낍니다. 잔여 표시 자체는 Math.max(정원 − 사용량, 0) 형태로 0에서 멈추도록 하는 편이 안전합니다.
- 동시 접속 시 정원 초과 방지
여러 담당자가 동시에 같은 시간대 마지막 자리를 시도하는 상황이 있습니다. 남은 자리를 계산하는 것만으로는 정원 초과를 막을 수 없습니다. 저장 직전의 스냅샷과 실제 저장 시점 사이에 다른 예약이 하나 더 들어올 수 있기 때문입니다. 이 문제는 예약 저장 로직에서 LockService를 이용해
잠금 → 최신 남은 자리 재계산 → 저장 또는 거부 → 잠금 해제
순서를 지키는 것으로 해결합니다. (실제 Lock 코드는 4단계에서 개념 예제로 보여 줍니다.)
아래 함수는 특정 날짜·시간·장비 타입에 대해 사용량과 정원, 남은 자리를 한 번에 계산해 주는 코어입니다. 이 결과를 웹앱이나 사이드바, 혹은 별도 시트에 표시하면, 시간대별 예약 관리가 훨씬 명확해집니다.
3단계 코드 — 슬롯 단위 남은 자리 계산 함수
- 붙여넣을 위치: 같은 Apps Script 프로젝트의
Code.gs, 2단계 코드 바로 밑 - 붙여넣은 뒤 할 일: 저장 후
BOOK_testGetSeqUsageForSlotCore_()를 실행합니다.
// ===== 3단계: 개별 슬롯(시간+장비) 남은 자리 계산 =====
/**
* 한 슬롯(날짜+시간+장비)의 사용량·정원·남은 자리 계산
* @param {Date} slotDate 예약 날짜(Date, 날짜 부분만 사용)
* @param {Date} slotTime 예약 시간(Date, 시간·분 부분 사용)
* @param {string} equipType 장비 종류
* @return {Object} {time, equipType, used, capacity, remaining}
*/
function BOOK_getSeqUsageForSlotCore_(slotDate, slotTime, equipType) { // → 한 슬롯 사용·남은 자리 계산
if (!(slotDate instanceof Date) || !(slotTime instanceof Date)) { // → 날짜·시간 타입 확인
throw new Error('날짜와 시간은 Date 타입이어야 합니다'); // → 잘못된 입력 거부
} // →
const upperType = String(equipType).toUpperCase(); // → 장비 대문자
if (!BOOK_CAPACITY_CONFIG.VALID_TYPES.includes(upperType)) { // → 허용 장비 확인
throw new Error('허용되지 않는 장비 종류: ' + equipType); // → 오류 알림
} // →
const usageByKey = BOOK_getTimeUsageForDateCore_(slotDate); // → 해당 날짜 전체 사용량
const hm = APPT_hm_(slotTime); // → HH:MM 문자열
const key = hm + '|' + upperType; // → 시간+장비 키
const used = usageByKey[key] || 0; // → 이미 사용된 수량
const capacity = BOOK_getCapacityForType_(upperType); // → 정원 조회
const remaining = Math.max(capacity - used, 0); // → 남은 자리 (음수 방지)
return { // → 결과 객체 반환
time: hm, // → 시간 문자열
equipType: upperType, // → 장비 종류
used: used, // → 사용 수량
capacity: capacity, // → 정원
remaining: remaining // → 남은 자리
}; // →
} // →
/**
* BOOK_getSeqUsageForSlotCore_ 동작 확인용 테스트 함수
*/
function BOOK_testGetSeqUsageForSlotCore_() { // → 테스트용 함수
const today = new Date(); // → 오늘 날짜
const time = new Date( // → 08:00 시간 객체
today.getFullYear(), // →
today.getMonth(), // →
today.getDate(), // →
8, 0, 0 // →
); // →
const info = BOOK_getSeqUsageForSlotCore_(today, time, 'CONTAINER'); // → 08:00 컨테이너 정보
Logger.log(JSON.stringify(info)); // → 결과 로그 출력
} // →동작 확인 방법
APPT_MAIN 시트에 오늘 날짜 08:00, 장비 CONTAINER 예약을 여러 건 입력하고 BOOK_testGetSeqUsageForSlotCore_를 실행합니다. 실행 로그의 used 값이 실제 예약 건수와 같고, remaining 값이 정원 − 사용량으로 계산되며 최소 0에서 멈춘다면 의도한 대로 동작하는 것입니다. 같은 시간대에 정원만큼 예약을 채워 놓고 다시 실행했을 때, remaining이 0으로 나오는지도 함께 확인합니다.
4단계 — 예약 저장 시 Lock과 함께 적용하는 패턴 (개념 예제)
앞의 1~3단계는 “계산”에 집중한 코드입니다. 실제 운영에서는 예약 등록을 저장하는 함수가 있고, 그 안에서 “마지막으로 한 번 더 남은 자리를 확인”해야 정원 초과를 막을 수 있습니다. 여기서는 전체 저장 로직을 전부 구현하지 않고, 핵심 구조만 보여 주는 샘플을 제시합니다. 이 샘플은 실제 프로젝트에 맞게 시트 이름·열 구조를 변경해 사용하면 됩니다.
4단계 코드 — LockService를 이용한 저장 패턴 예제
- 붙여넣을 위치: 같은
Code.gs, 3단계 코드 아래 - 주의: 아래 함수 이름·시트명·열 구조는 예시입니다. 실제 프로젝트의 예약 저장 함수에 이 패턴을 녹여 쓰면 됩니다.
// ===== 4단계: 정원 초과 방지를 위한 Lock 적용 패턴 (예시) =====
/**
* 예시: 단일 예약 저장 로직 (정원 체크 + Lock)
* 실제 환경에 맞게 시트명·열 구조 등을 수정해서 사용하세요.
* @param {Object} payload {date, time, equipType, shipper, memo ...}
* @return {Object} {ok: boolean, message: string}
*/
function BOOK_saveSingleBooking_(payload) {
// 1) 입력 검증 (간단 예시)
if (!payload || !payload.date || !payload.time || !payload.equipType) {
return { ok: false, message: '필수 입력값이 누락되었습니다.' };
}
// '2026-08-03' 을 new Date() 에 그대로 넣으면 UTC 자정으로 읽혀 하루가 밀리고,
// '09:00' 은 아예 Invalid Date 가 된다. 기본 5편 도우미로 검증한 뒤 직접 만든다.
let slotDate;
let slotTime;
try {
const ymdText = APPT_ymd_(payload.date); // 형식·실재 날짜까지 검증
const hmText = APPT_hm_(payload.time); // '09:00' 도 안전하게 읽는다
const dp = ymdText.split('-');
const tp = hmText.split(':');
slotDate = new Date(Number(dp[0]), Number(dp[1]) - 1, Number(dp[2]));
slotTime = new Date(1899, 11, 30, Number(tp[0]), Number(tp[1]));
} catch (e) {
return { ok: false, message: '날짜·시간 형식이 올바르지 않습니다: ' + e.message };
}
const equipType = String(payload.equipType).toUpperCase();
// 2) 스크립트 잠금 획득
const lock = LockService.getScriptLock();
try {
lock.waitLock(30 * 1000); // 최대 30초 대기 (실무 환경에 맞게 조정)
// 3) 잠금이 잡힌 상태에서 최신 남은 자리 계산
const slotInfo = BOOK_getSeqUsageForSlotCore_(slotDate, slotTime, equipType);
if (slotInfo.remaining <= 0) {
return {
ok: false,
message: Utilities.formatString(
'[정원 초과] %s %s %s 시간대는 정원이 이미 가득 찼습니다.',
APPT_ymd_(slotDate),
slotInfo.time,
equipType
)
};
}
// 4) 남은 자리가 1개 이상이면 실제 저장
const sheet = SpreadsheetApp
.getActive()
.getSheetByName(BOOK_USAGE_CONFIG.APPT_SHEET_NAME);
if (!sheet) {
return { ok: false, message: '예약 시트를 찾을 수 없습니다.' };
}
// 예시: 마지막 행 뒤에 예약 한 줄 추가 (열 구조는 프로젝트별로 맞추세요)
const lastRow = sheet.getLastRow();
const newRow = lastRow + 1;
// A: 날짜, B: 시간, C: 장비, D: 화주, E: 메모 라는 가정
// 열마다 따로 쓰면 중간에 오류가 났을 때 **반만 채워진 행**이 남는다.
// 한 줄을 배열로 만들어 setValues 한 번에 쓴다.
const rowValues = [
slotDate, // A: 날짜
slotTime, // B: 시간
equipType, // C: 장비
payload.shipper || '', // D: 화주
payload.memo || '' // E: 메모
];
sheet.getRange(newRow, 1, 1, rowValues.length).setValues([rowValues]);
sheet.getRange(newRow, BOOK_USAGE_CONFIG.COL_DATE).setNumberFormat('yyyy-MM-dd');
sheet.getRange(newRow, BOOK_USAGE_CONFIG.COL_TIME).setNumberFormat('HH:mm');
return {
ok: true,
message: Utilities.formatString(
'%s %s %s 예약이 저장되었습니다. (남은 자리: %d → %d)',
APPT_ymd_(slotDate),
slotInfo.time,
equipType,
slotInfo.remaining,
slotInfo.remaining - 1
)
};
} catch (e) {
// 개발자/운영자 확인용 로그
console.error('BOOK_saveSingleBooking_ 오류:', e);
return { ok: false, message: '예약 저장 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.' };
} finally {
// 5) 잠금 해제
try {
lock.releaseLock();
} catch (e) {
// releaseLock 실패는 보통 치명적이지 않으므로 조용히 로그만 남김
console.warn('Lock release 실패:', e);
}
}
}위 코드는 예시일 뿐이고, 실제 운영 중인 프로젝트에서는 다음 요소들을 추가로 맞춰야 합니다.
- 예약 시트의 정확한 열 구조(A~Z 중 어떤 열에 어떤 데이터가 있는지)
- 중복 예약 방지(같은 화주·PO 번호로 같은 시간대에 또 저장하려 할 때 차단 등)
- 웹앱/사이드바에서 호출할 수 있도록 public 함수로 래핑하는 부분
다만 LockService를 어떻게 물려야 하는지, 그리고 BOOK_getSeqUsageForSlotCore_() 결과를 어떻게 활용하는지 이해하기에는 충분한 기본 구조입니다.
5단계 — 실무 팁: 잠금·성능·에러 처리까지 같이 설계하기
구글시트 예약 용량 계산 코드를 한 번 붙여 넣었다고 해서 바로 현장에 안착되지는 않습니다. 실제 창고·허브 환경에 적용하면서 도움이 되었던 운영 팁을 정리하면 다음과 같습니다.
- 예약 저장 함수에는 반드시 LockService를 적용
이 글에서 예시로 보여 준 BOOK_saveSingleBooking_()처럼, 실제 운영 코드에서는 다음과 같은 순서를 기본 패턴으로 잡는 것이 좋습니다.
1) const lock = LockService.getScriptLock(); lock.waitLock(30000);으로 잠금 확보
2) 잠금이 잡힌 상태에서 BOOK_getSeqUsageForSlotCore_()로 최신 남은 자리 재계산
3) remaining이 1 이상이면 새 행을 추가하고, 0이면 사용자에게 정원 초과 메시지 반환
4) finally에서 lock.releaseLock();으로 항상 잠금 해제
이 패턴을 지키면 여러 담당자가 동시에 같은 시간대 마지막 자리를 시도해도 정원이 깨지지 않습니다.
getValues()범위를 실제 사용하는 열까지만 줄이기
예제에서는 설명을 위해 열 크기를 정해 두었지만, 현장에서는 필요한 열까지만 읽어야 성능이 가장 잘 나옵니다. 예약 시트 구조가 확정되면 BOOK_USAGE_CONFIG.COL_DATE, COL_TIME, COL_EQUIP_TYPE를 다시 한 번 점검해 불필요한 열 읽기를 줄이는 것이 좋습니다. 예약 데이터가 수천·수만 건 단위로 쌓이는 환경일수록 이 최적화가 체감됩니다.
- 오류 메시지는 로그용과 사용자 안내용을 분리
코드 안의 throw new Error('허용되지 않는 장비 종류…')는 개발자·운영 담당자가 원인을 빠르게 찾도록 돕는 용도입니다. 실제 사용자에게는 “장비 선택을 다시 확인해 주세요”처럼 조금 더 친절한 안내를 띄우는 편이 좋습니다.
- Apps Script 로그 / Stackdriver 로그: 디버깅용 상세 메시지
- 웹앱/사이드바 UI: 사용자가 이해하기 쉬운 짧은 안내 문구
- 테스트 함수는 운영 이후에도 유지
이 글에서 만든 세 가지 테스트 함수
BOOK_testGetCapacityForType_BOOK_testGetTimeUsageForDateCore_BOOK_testGetSeqUsageForSlotCore_
는 운영에 들어간 뒤에도 그대로 남겨 두는 것을 권장합니다. 문제 신고가 들어왔을 때, 오늘 날짜 기준 정원·사용량 상태를 빠르게 확인할 수 있는 진단 도구 역할을 하기 때문입니다. 테스트 함수 이름에 BOOK_ 접두어를 유지하면 같은 프로젝트의 다른 코드와도 충돌하지 않습니다.
맺음말
구글시트 예약 용량 계산 방법을 Apps Script로 구현하면, “같은 시간에 몇 대까지 받을 수 있는지”와 “지금 남은 자리가 몇 개인지”를 담당자 모두가 같은 기준으로 볼 수 있습니다. 이 글에서는
- SETTINGS 시트에서 장비별 정원을 읽어 오는 함수
getValues()한 번으로 날짜별 시간대 사용량을 계산하는 함수- 두 값을 합쳐 특정 슬롯의 남은 자리를 구하는 함수
- 실제 저장 시
LockService로 정원 초과를 막는 예시 패턴
까지 한 흐름으로 정리했습니다.
지금 바로 할 수 있는 행동은, 운영 중인 예약 시트에서 날짜·시간·장비 열 위치를 먼저 확인한 뒤, 위 코드에서 시트 이름과 열 번호만 본인 환경에 맞게 수정해 붙여 넣고 세 개의 테스트 함수를 차례대로 실행해 보는 것입니다. 이 과정에서 정원·사용량·남은 자리 숫자가 실제 예약 상황과 맞게 떨어지기 시작하면, 이후에는 웹앱 화면이나 시트 UI에 이 값을 연결해 현장 담당자에게 “시간대별 남은 자리”를 명확하게 보여 줄 수 있습니다.