구글시트 일괄 예약 등록 방법: LockService와 setValues 한 번으로 끝내기
도입 — 검증은 끝났는데, 등록이 너무 느린 상황
구글시트 일괄 예약 등록 방법을 찾다 보면 “검증까지는 잘 되는데, 실제로 시트에 적재하는 단계가 너무 느리다”는 문제에 자주 부딪힙니다. 특히 입고예약처럼 하루에 수십~수백 건씩 쌓이는 업무에서는 운송사가 보내 준 예약 목록을 한 번에 붙여넣고 처리하고 싶어집니다. 앞 편인 구글시트 일괄 예약 검증 방법: 붙여넣기 한 번에 검사하기 (완성본) 에서는 붙여넣은 예약 후보를 한 줄씩 검사해, 날짜·시간·정원·컨테이너 중복을 미리 걸러내는 validateBulkSlots() 까지 구현했습니다.
실제 문제는 그 다음입니다. “통과” 표시가 난 줄만 골라 APPT_MAIN 시트에 옮겨 적어야 하는데, 행마다 appendRow() 를 호출하면 100줄만 돼도 체감 속도가 크게 떨어집니다. 동시에 두 명이 저장하면 중복 예약까지 생길 수 있습니다. 이 글에서는 검증을 통과한 줄만 다시 한 번 서버 쪽에서 확인하고, LockService 로 잠금을 건 뒤 setValues 한 번으로 예약을 일괄 등록하는 흐름을 정리합니다. 핵심은 “검증과 저장 사이의 시간차에서 생기는 오류를 어떻게 줄일 것인가”입니다.
검증 결과만 믿지 않고 서버에서 한 번 더 점검하는 이유
일괄 등록을 실제 운영에 올려 보니, 화면에서 초록색으로 “검증 통과” 표시가 떠 있다고 해서 그대로 시트에 적재하는 것은 생각보다 위험했습니다. 검증은 붙여넣기 시점의 스냅샷일 뿐이기 때문입니다. 검증 이후 저장 버튼을 누르기까지 몇 분이 걸릴 수 있고, 그 사이에 다른 사용자가 같은 컨테이너를 예약하거나, 같은 시간대에 추가 예약을 넣어 정원을 꽉 채워 버릴 수 있습니다. 검증 결과를 그대로 믿고 쓰면 “그때는 여유가 있었는데, 지금은 초과인 상태”를 반영하지 못하게 됩니다.
그래서 일괄 예약 함수 bulkBookAppts(rows) 를 설계할 때 두 가지 원칙을 뒀습니다. 첫째, 외부에서 넘어온 rows 를 다시 한 번 서버 쪽에서 검증합니다. 앞 편에서 만든 validateBulkSlots() 를 그대로 쓰되, 이 함수가 돌려준 cleaned 데이터를 기준으로만 저장하고, 검증 실패·예외는 상세 사유와 함께 failed 목록에 남깁니다. 둘째, 실제 시트에 쓰는 단계는 LockService.getScriptLock() 으로 잠금을 잡고, 그 안에서 setValues 한 번만 호출합니다. 같은 스크립트에서 단건 예약(BOOK_bookAppt)도 동일한 ScriptLock 을 쓰도록 맞추면, 어떤 경로에서 예약을 넣든 “한 번에 한 묶음씩만” 저장되도록 만들 수 있습니다.
이 구조를 쓰면 결과를 {total, success, failed} 형태로 깔끔하게 돌려줄 수 있습니다. 운영자는 “몇 줄이 성공했고, 어떤 줄이 왜 실패했는지”를 요약만 보고도 이해할 수 있고, 실패한 줄만 골라 운송사와 다시 조율하면 됩니다. 검증과 저장을 분리하되, 저장 단계에서 다시 한 번 현실 상태를 반영하는 방식이라고 이해하면 됩니다.
bulkBookAppts 설계 — 입력·출력과 처리 흐름 잡기
이 글에서 만드는 핵심 함수는 bulkBookAppts(rows) 입니다. rows 는 웹앱이나 사이드바에서 넘어오는 배열이라고 가정합니다. 각 원소는 한 예약 줄을 나타내는 객체이며, 앞 편 validateBulkSlots() 가 이해할 수 있는 형태(날짜·시간·유형·컨테이너 등 필드명)로 들어 있습니다.
처리 흐름은 다음 순서로 정리할 수 있습니다. 먼저, rows 가 배열인지·길이가 적절한지 확인해 기본적인 입력 오류를 막습니다. 이어서 한 줄씩 validateBulkSlots([raw]) 로 검증을 다시 돌리고, ok 가 아니거나 cleaned 배열이 비어 있으면 해당 줄은 실패 목록에 이유와 함께 기록합니다. 통과한 줄에 대해서만 cleaned[0] 을 받아 APPT_MAIN 시트 열 순서에 맞는 배열로 변환합니다. 이때 APPT_MAIN 열 배치는 시리즈 전체에서 고정이므로, 날짜·시간·유형·도어·컨테이너·수량·팔레트 등의 위치를 상수로 관리하는 편이 안전합니다.
그다음으로 rowsToInsert 에 변환된 행들을 모읍니다. 검증까지 마친 뒤에는 LockService 잠금을 획득하고, APPT_MAIN 시트의 마지막 행 아래에서부터 setValues(rowsToInsert) 를 한 번만 호출해 기록합니다. 마지막으로 전체 줄 수(rows.length), 실제로 저장된 줄 수(rowsToInsert.length), 실패 목록을 묶어 {total, success, failed} 객체로 반환합니다. 이렇게 딱 한 형태로 결과를 정하면, 웹앱이든 버튼이든 어디에서 호출하든 처리 로직을 공통으로 쓸 수 있습니다.
1단계 — 일괄 등록용 설정 상수 정의
먼저 구글시트 앱스스크립트 일괄등록에 필요한 설정을 상수로 모읍니다. 한 프로젝트에 여러 시리즈 코드가 함께 들어가므로, 이 글 전용 상수에는 BULK_ 접두어를 붙입니다. 시트 이름과 열 위치를 상수로 관리하면, 나중에 APPT_MAIN 구조를 일부 수정하더라도 코드 전체를 일일이 뒤질 필요가 줄어듭니다.
1) 이 코드가 하는 일
- APPT_MAIN 시트 이름, 잠금 대기 시간, 한 번에 처리할 최대 줄 수, 열 인덱스를 한 곳에 정의합니다.
2) 붙여넣을 위치
- 구글시트 → 확장 프로그램 → Apps Script → 프로젝트에서, 이 시리즈용 파일 상단 또는
Code.gs맨 위에 붙여넣습니다.
3) 붙여넣은 뒤 할 일
- 저장만 하면 되고, 별도 실행은 필요 없습니다.
const BULK_APPT_SHEET_NAME = 'APPT_MAIN'; // → 예약 시트 이름
const BULK_APPT_TZ = 'America/New_York'; // → 고정 시간대
const BULK_LOCK_WAIT_MS = 30 * 1000; // → 잠금 대기 최대 30초
const BULK_MAX_ROWS_PER_RUN = 500; // → 한 번에 처리할 최대 줄 수
// APPT_MAIN 열 인덱스(1부터 시작)
const BULK_COL_DATE = 1; // → A 열: 날짜
const BULK_COL_START = 2; // → B 열: 시작시간
const BULK_COL_TYPE = 3; // → C 열: 장비유형
const BULK_COL_DOOR = 4; // → D 열: 도어
const BULK_COL_CNTR = 5; // → E 열: 컨테이너
const BULK_COL_CARRIER = 6; // → F 열: 운송사
const BULK_COL_CLIENT = 7; // → G 열: 고객사
const BULK_COL_REMARK = 8; // → H 열: 비고
const BULK_COL_END = 9; // → I 열: 종료시간
const BULK_COL_CREATED_AT = 10; // → J 열: 생성일시
const BULK_COL_QTY = 11; // → K 열: 수량
const BULK_COL_PALLET = 12; // → L 열: 팔레트
const BULK_COL_APPT_ID = 13; // → M 열: 예약ID동작 확인 방법: 아직 눈에 보이는 변화는 없고, 이후 코드에서 BULK_... 상수를 참조해도 오류가 나지 않으면 정상입니다.
2단계 — 한 줄을 APPT_MAIN 형식으로 변환하는 도우미
다음은 검증이 끝난 한 줄을 APPT_MAIN 형식으로 바꾸는 도우미입니다. 앞 시리즈에서 이미 예약 기본 구조와 열 순서를 정했기 때문에, 여기서는 그 규칙을 그대로 따릅니다. 날짜와 시간은 앞 편에서 이미 엄격하게 검증·정규화했으므로, 이 단계에서는 단순히 배치만 맞춰 주면 됩니다.
1) 이 코드가 하는 일
validateBulkSlots()가 돌려준cleaned한 줄을 APPT_MAIN 열 순서에 맞춘 배열로 바꿉니다.
2) 붙여넣을 위치
- 위 설정 상수들 바로 아래에 같은 파일에서 이어서 붙입니다.
3) 붙여넣은 뒤 할 일
BULK_testMapRow_()를 한 번 실행해 로그에 변환 결과가 올바르게 찍히는지 확인합니다.
function BULK_mapCleanRowToApptRow_(clean) { // → 검증된 한 줄을 예약행으로
const tz = BULK_APPT_TZ; // → 시간대 상수 사용
const dateStr = clean.date; // → 'YYYY-MM-DD'
const startStr = clean.time; // → 'HH:mm'
const endStr = clean.endTime; // → 'HH:mm'
const dateObj = new Date(dateStr + 'T00:00:00'); // → 날짜 객체
const createdAt = new Date(); // → 생성 시각
const apptId = Utilities.getUuid(); // → 고유 예약ID 생성
return [
dateObj, // → A 날짜 (Date)
startStr, // → B 시작시간 (문자열)
clean.type, // → C 장비유형
clean.door || '', // → D 도어
clean.containerNo || '', // → E 컨테이너
clean.carrier || '', // → F 운송사
clean.client || '', // → G 고객사
clean.remark || '', // → H 비고
endStr, // → I 종료시간
createdAt, // → J 생성일시
clean.qty || 0, // → K 수량
clean.pallet || 0, // → L 팔레트
apptId // → M 예약ID
];
}
function BULK_testMapRow_() { // → 매핑 테스트 함수
const sample = { // → 샘플 데이터
date: '2026-08-31', // → 날짜
time: '09:00', // → 시작
endTime: '09:30', // → 종료
type: 'CONTAINER', // → 유형
door: 'D01', // → 도어
containerNo: 'TEST123', // → 컨테이너
carrier: 'CARRIER', // → 운송사
client: 'CLIENT', // → 고객사
remark: '테스트', // → 비고
qty: 1, // → 수량
pallet: 2 // → 팔레트
};
const row = BULK_mapCleanRowToApptRow_(sample); // → 매핑 호출
Logger.log(JSON.stringify(row)); // → 결과 로그
}동작 확인 방법: Apps Script 편집기에서 BULK_testMapRow_ 를 실행했을 때 실행 로그에 길이 13 배열이 찍히고, 첫 값이 날짜(Date) 객체로 표시되면 정상입니다.
3단계 — LockService 안에서 setValues 한 번으로 저장하기
이제 구글시트 LockService 예약 잠금을 활용해, 검증 통과분만 APPT_MAIN에 실제로 적재하는 bulkBookAppts(rows) 를 구현합니다. 이 함수는 웹앱·사이드바에서 바로 호출될 수 있도록 인터페이스를 단순하게 유지합니다.
1) 이 코드가 하는 일
- 입력 배열을 다시 검증하고, 통과한 줄만 모아
LockService잠금 안에서setValues한 번으로 APPT_MAIN에 추가합니다.
2) 붙여넣을 위치
- 위 도우미 함수들 바로 아래에 이어서 붙입니다.
3) 붙여넣은 뒤 할 일
BULK_testBulkBookAppts_()를 실행해, 예시 4줄 중 일부가 실패하도록 구성된 결과를 로그와 시트에서 확인합니다.
function bulkBookAppts(rows) { // → 일괄 예약 메인 함수
if (!Array.isArray(rows)) { // → 입력 형식 확인
throw new Error('rows 배열이 필요합니다'); // → 잘못된 호출 방지
}
if (rows.length === 0) { // → 빈 배열 처리
return { total: 0, success: 0, failed: [] }; // → 바로 반환
}
if (rows.length > BULK_MAX_ROWS_PER_RUN) { // → 최대 줄수 제한
throw new Error('한 번에 ' + BULK_MAX_ROWS_PER_RUN +
'줄까지만 처리합니다'); // → 과도한 요청 방지
}
const ss = SpreadsheetApp.getActive(); // → 현재 스프레드시트
const sheet = ss.getSheetByName(BULK_APPT_SHEET_NAME); // → APPT_MAIN 시트
if (!sheet) { // → 시트 존재 여부 확인
throw new Error('시트 ' + BULK_APPT_SHEET_NAME + ' 을(를) 찾을 수 없습니다');
}
const failed = []; // → 실패 목록
const rowsToInsert = []; // → 실제 저장할 행들
// 1단계: 각 행을 다시 검증합니다.
rows.forEach(function (raw, idx) { // → 각 줄 반복
try {
const v = validateBulkSlots([raw]); // → 1편 검증 재사용
if (!v.ok || !Array.isArray(v.cleaned) || v.cleaned.length === 0) {
failed.push({ // → 실패 기록
index: idx,
reason: v.message || '검증 실패' // → 이유
});
return; // → 다음 줄로
}
const clean = v.cleaned[0]; // → 정리된 한 줄
const apptRow = BULK_mapCleanRowToApptRow_(clean); // → 시트 행으로 변환
rowsToInsert.push(apptRow); // → 저장 대상에 추가
} catch (e) { // → 예외 처리
failed.push({ // → 실패 기록
index: idx,
reason: e.message || '예외 발생' // → 예외 메시지
});
}
});
if (rowsToInsert.length === 0) { // → 저장할 행이 없을 때
return { // → 검증 결과만 반환
total: rows.length,
success: 0,
failed: failed
};
}
// 2단계: 잠금 안에서 setValues 한 번으로 저장합니다.
const lock = LockService.getScriptLock(); // → 스크립트 잠금
let lockAcquired = false; // → 잠금 획득 여부
try {
lockAcquired = lock.tryLock(BULK_LOCK_WAIT_MS); // → 최대 대기 시간
if (!lockAcquired) { // → 잠금 실패
throw new Error('다른 작업이 실행 중입니다. 잠시 후 다시 시도하세요');
}
const lastRow = sheet.getLastRow(); // → 기존 마지막 행
const startRow = lastRow + 1; // → 새로 쓸 시작 행
const numRows = rowsToInsert.length; // → 추가할 행 수
const numCols = rowsToInsert[0].length; // → 열 개수
const range = sheet.getRange(startRow, 1, numRows, numCols); // → 쓰기 범위
range.setValues(rowsToInsert); // → 한 번에 기록
return { // → 요약 결과 반환
total: rows.length,
success: rowsToInsert.length,
failed: failed
};
} finally {
if (lockAcquired) { // → 잠금이 있었으면
lock.releaseLock(); // → 잠금 해제
}
}
}
function BULK_testBulkBookAppts_() { // → 일괄 등록 테스트
const sampleRows = [ // → 예시 입력 4줄
{
date: '2026-08-31',
time: '09:00',
endTime: '09:30',
type: 'CONTAINER',
door: 'D01',
containerNo: 'BULK001',
carrier: 'CARRIER1',
client: 'CLIENT1',
remark: 'OK1',
qty: 1,
pallet: 1
},
{
date: '2026-08-31',
time: '09:00',
endTime: '09:30',
type: 'CONTAINER',
door: 'D01',
containerNo: 'BULK002',
carrier: 'CARRIER2',
client: 'CLIENT2',
remark: 'OK2',
qty: 1,
pallet: 1
},
{
date: '2026-08-31', // → 일부러 오류값
time: '99:00', // → 잘못된 시간
endTime: '09:30',
type: 'CONTAINER',
door: 'D01',
containerNo: 'BULK003',
carrier: 'CARRIER3',
client: 'CLIENT3',
remark: 'BAD TIME',
qty: 1,
pallet: 1
},
{
date: '2026-08-31',
time: '09:00',
endTime: '09:30',
type: 'CONTAINER',
door: 'D01',
containerNo: 'BULK001', // → 컨테이너 중복
carrier: 'CARRIER4',
client: 'CLIENT4',
remark: 'DUP CNTR',
qty: 1,
pallet: 1
}
];
const result = bulkBookAppts(sampleRows); // → 메인 함수 호출
Logger.log(JSON.stringify(result)); // → 결과 확인
}동작 확인 방법: Apps Script 편집기에서 BULK_testBulkBookAppts_ 를 실행했을 때, APPT_MAIN 시트에 정상 데이터 2줄만 새로 추가되고 실행 로그에 {"total":4,"success":2,"failed":[...]} 형태의 결과가 찍히면 의도대로 동작한 것입니다.
실무 팁 — 동시 처리와 테스트 시나리오를 함께 설계하기
실제 창고 운영에서 구글시트 setValues 일괄처리를 돌려 보면서 느낀 점은, 기능 자체보다 “언제 어떻게 실패하도록 설계할 것인가”가 더 중요하다는 점이었습니다. 피크 시간에는 여러 사용자가 동시에 예약을 넣기 때문에, LockService 를 쓰더라도 잠금 대기 시간을 얼마로 둘지, 한 번에 몇 줄까지 허용할지에 따라 체감 품질이 크게 달라집니다. 이 글에서는 예시로 500줄·30초를 썼지만, 실제 업무량에 맞게 줄 수와 대기 시간을 보수적으로 설정하는 편이 안전합니다.
테스트 시나리오도 단순히 “모두 성공하는 경우”만 만들어서는 부족합니다. 위 테스트처럼 일부러 잘못된 시간·컨테이너 중복을 섞어 넣어야 검증·저장 결과가 의도대로 분리되는지 확인할 수 있습니다. 현장에서는 여기에 “예약 가능 일자 밖”, “허용되지 않은 장비유형”, “이미 정원이 찬 시간대” 등을 섞어 보면서 검증 함수와 일괄 등록 함수를 함께 튜닝하는 식으로 안정성을 높였습니다. 검증과 저장을 분리해 두었기 때문에, validateBulkSlots() 만 강화해도 전체 일괄 등록 흐름의 신뢰도가 같이 올라가는 구조입니다.
맺음말 — 오늘 바로 해 볼 한 가지
구글시트 앱스스크립트 일괄등록 흐름은 “붙여넣기 → 검증 → LockService 안에서 setValues 한 번으로 저장” 세 단계로 나눌 수 있습니다. 앞 편에서 검증 로직을 이미 만들었다면, 이번 글의 코드를 붙여넣는 것만으로도 대량 예약 등록의 마지막 퍼즐을 맞출 수 있습니다.
오늘 할 수 있는 가장 간단한 행동은, 이 글의 코드를 프로젝트에 그대로 붙여넣고 BULK_testBulkBookAppts_() 를 실행해 보는 것입니다. APPT_MAIN 에 테스트 예약 두 줄이 추가되고, 실패 두 줄의 사유가 로그에 깨끗하게 정리된다면 구조는 준비된 것입니다. 이후에는 이 bulkBookAppts(rows) 함수를 웹앱 버튼이나 관리용 메뉴와 연결해, 실제 운송사 예약 파일을 붙여넣고 한 번에 입고예약 자동화를 완성할 수 있습니다.