Smart Life US

← 목록 · 2026-09-01 · 엑셀·업무 자동화

구글시트 일괄 예약 등록 방법: LockService와 setValues 한 번으로 끝내기

구글시트 일괄 예약 등록 방법: 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) 붙여넣은 뒤 할 일

  • 저장만 하면 되고, 별도 실행은 필요 없습니다.
Apps Script (JavaScript)
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_() 를 한 번 실행해 로그에 변환 결과가 올바르게 찍히는지 확인합니다.
Apps Script (JavaScript)
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줄 중 일부가 실패하도록 구성된 결과를 로그와 시트에서 확인합니다.
Apps Script (JavaScript)
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) 함수를 웹앱 버튼이나 관리용 메뉴와 연결해, 실제 운송사 예약 파일을 붙여넣고 한 번에 입고예약 자동화를 완성할 수 있습니다.