구글 앱스 스크립트 실행 시간 초과 해결: 작업 분할과 이어서 실행

구글 앱스 스크립트로 많은 행을 처리하거나 여러 파일과 외부 API를 순서대로 호출하면 다음 오류가 발생할 수 있습니다.

Exceeded maximum execution time
허용된 최대 실행 시간을 초과했습니다.

이 오류는 코드가 문법적으로 잘못됐다는 뜻이 아닙니다. 스크립트가 실행 방식에 적용되는 제한 시간 안에 작업을 끝내지 못해 시스템에 의해 중단된 상태입니다.

해결하려면 실행 제한을 없애려고 하기보다, 어느 작업에 시간이 사용되는지 확인하고 스프레드시트와 외부 서비스 호출 횟수를 줄여야 합니다.

최적화한 뒤에도 한 번에 끝낼 수 없는 대량 작업은 여러 실행으로 나누고 마지막 처리 위치를 저장해 다음 실행에서 이어서 처리해야 합니다.

이 글은 Google의 Apps Script 할당량, 성능 권장사항, 사용자 지정 함수, 시간 기반 트리거, Properties Service와 Lock Service 공식 문서를 기준으로 작성했습니다. 예제의 시트 이름과 데이터 열은 실제 문서 구조에 맞게 변경해야 합니다.

구글 앱스 스크립트의 긴 작업을 여러 실행으로 나누고 저장된 진행 위치에서 이어서 처리하는 대표 개념 이미지
Apps Script 실행 시간이 제한을 넘는 작업은 서비스 호출을 줄이고 여러 실행으로 나눈 뒤 진행 위치를 저장해 이어서 처리해야 합니다. 위 이미지는 장기 작업의 분할 실행 구조를 설명하기 위한 대표 개념 이미지입니다.


먼저 어떤 방식으로 실행된 코드인지 구분합니다

Apps Script는 실행 방식에 따라 제한과 사용 가능한 기능이 다릅니다. 같은 함수라도 셀 수식으로 호출했는지, 편집기에서 실행했는지, 단순 트리거로 실행했는지를 구분해야 합니다.

실행 방식 현재 공식 제한 먼저 확인할 내용
일반 스크립트 실행 실행당 6분 서비스 호출 수, 반복문, 작업 분할
스프레드시트 사용자 지정 함수 실행당 30초 셀마다 함수가 반복 호출되는지
단순 onOpen·onEdit 트리거 실행당 30초 이벤트 범위를 제한했는지
설치형·시간 기반 트리거 Apps Script 실행 제한과 트리거 할당량 적용 중복 트리거와 진행 위치 저장 여부

현재 할당량 표는 일반 스크립트의 실행 시간을 개인 계정과 Google Workspace 계정 모두 실행당 6분으로 안내합니다.

할당량과 제한은 변경될 수 있으므로 오래된 게시물에 적힌 숫자보다 Google의 현재 공식 할당량 표를 우선 확인해야 합니다.

실행 기록에서 실패한 함수와 실행 시간을 확인합니다

Apps Script 편집기 왼쪽에서 실행을 선택하면 최근 실행 기록을 확인할 수 있습니다.

다음 항목을 먼저 기록합니다.

  • 실패한 함수 이름
  • 실행을 시작한 방식
  • 실행 시간
  • 오류 메시지와 발생 위치
  • 같은 시각에 겹쳐 실행된 트리거가 있는지

함수 전체가 오래 걸린다는 사실만으로는 어느 작업을 수정해야 하는지 알기 어렵습니다. 시간이 많이 걸릴 가능성이 있는 구간의 시작과 끝에 로그를 남길 수 있습니다.

function inspectExecutionTime() {
  const startedAt =
    Date.now();

  console.log(
    '원본 읽기 시작: ' +
    new Date().toISOString()
  );

  const spreadsheet =
    SpreadsheetApp.getActiveSpreadsheet();

  const sheet =
    spreadsheet.getSheetByName('원본');

  if (!sheet) {
    throw new Error(
      '원본 시트를 찾을 수 없습니다.'
    );
  }

  const values =
    sheet.getDataRange().getValues();

  console.log(
    '원본 읽기 완료: ' +
    String(Date.now() - startedAt) +
    'ms'
  );

  const calculatedAt =
    Date.now();

  const result =
    values.map(function (row) {
      return row.map(function (value) {
        return String(value).trim();
      });
    });

  console.log(
    '배열 계산 완료: ' +
    String(Date.now() - calculatedAt) +
    'ms'
  );

  console.log(
    '전체 경과 시간: ' +
    String(Date.now() - startedAt) +
    'ms'
  );

  return result;
}

로그는 원인을 찾기 위한 진단 정보입니다. 로그를 많이 남기는 것 자체가 실행 제한을 늘리지는 않습니다.

반복문 안에서 셀을 한 개씩 읽고 쓰지 않습니다

Apps Script에서 JavaScript 배열을 계산하는 작업보다 Sheets, Drive, Gmail이나 외부 API 같은 서비스에 요청하는 작업이 오래 걸릴 수 있습니다.

다음 코드는 각 행마다 여러 번 스프레드시트 서비스를 호출합니다.

function processRowsOneByOne() {
  const sheet =
    SpreadsheetApp
      .getActiveSpreadsheet()
      .getSheetByName('원본');

  if (!sheet) {
    throw new Error(
      '원본 시트를 찾을 수 없습니다.'
    );
  }

  const lastRow =
    sheet.getLastRow();

  for (
    let rowNumber = 2;
    rowNumber <= lastRow;
    rowNumber += 1
  ) {
    const quantity =
      sheet
        .getRange(rowNumber, 2)
        .getValue();

    const unitPrice =
      sheet
        .getRange(rowNumber, 3)
        .getValue();

    const amount =
      Number(quantity) *
      Number(unitPrice);

    sheet
      .getRange(rowNumber, 4)
      .setValue(amount);
  }
}

행 하나마다 두 번 읽고 한 번 쓰기 때문에 데이터 행이 늘어날수록 Sheets 서비스 호출 수도 함께 늘어납니다.

범위를 한 번에 읽고 배열에서 계산한 뒤 한 번에 씁니다

같은 작업을 getValues()setValues()로 묶을 수 있습니다.

function processRowsInBatch() {
  const sheet =
    SpreadsheetApp
      .getActiveSpreadsheet()
      .getSheetByName('원본');

  if (!sheet) {
    throw new Error(
      '원본 시트를 찾을 수 없습니다.'
    );
  }

  const firstDataRow = 2;

  const lastRow =
    sheet.getLastRow();

  if (lastRow < firstDataRow) {
    console.log(
      '처리할 데이터가 없습니다.'
    );
    return;
  }

  const rowCount =
    lastRow - firstDataRow + 1;

  const inputValues =
    sheet
      .getRange(
        firstDataRow,
        1,
        rowCount,
        3
      )
      .getValues();

  const outputValues =
    inputValues.map(function (row) {
      const quantityValue =
        row[1];

      const unitPriceValue =
        row[2];

      const hasQuantity =
        quantityValue !== '' &&
        quantityValue !== null;

      const hasUnitPrice =
        unitPriceValue !== '' &&
        unitPriceValue !== null;

      const quantity =
        Number(quantityValue);

      const unitPrice =
        Number(unitPriceValue);

      const isValid =
        hasQuantity &&
        hasUnitPrice &&
        Number.isFinite(quantity) &&
        Number.isFinite(unitPrice);

      if (!isValid) {
        return [
          '',
          '확인 필요'
        ];
      }

      return [
        quantity * unitPrice,
        '완료'
      ];
    });

  sheet
    .getRange(
      firstDataRow,
      4,
      outputValues.length,
      2
    )
    .setValues(outputValues);
}

이 코드는 다음 순서로 처리합니다.

  1. A열부터 C열까지 필요한 데이터를 한 번에 읽습니다.
  2. JavaScript 배열에서 수량과 단가를 계산합니다.
  3. 계산 결과와 상태를 2차원 배열로 만듭니다.
  4. D열과 E열에 결과를 한 번에 기록합니다.

처리 행 수가 같더라도 스프레드시트에 요청하는 횟수를 크게 줄이는 구조입니다.

getDataRange와 전체 열을 무조건 사용하지 않습니다

getDataRange()는 데이터가 있는 것으로 인식되는 전체 범위를 가져옵니다.

과거에 값이나 서식이 입력됐던 불필요한 행과 열까지 사용 범위에 포함돼 있다면 실제 작업에 필요한 데이터보다 큰 배열을 읽을 수 있습니다.

처리에 필요한 열이 정해져 있다면 범위를 명시합니다.

const firstDataRow = 2;
const lastRow = sheet.getLastRow();

const values =
  sheet
    .getRange(
      firstDataRow,
      1,
      lastRow - firstDataRow + 1,
      5
    )
    .getValues();

A열부터 E열까지만 필요하다면 수천 개의 불필요한 열을 함께 읽지 않습니다.

마지막 행을 찾는 기준 열도 업무상 항상 값이 들어오는 주문번호나 ID 열로 결정하는 편이 적절할 수 있습니다.

중첩 반복문과 반복 검색을 줄입니다

데이터 두 목록을 비교하기 위해 모든 행을 서로 한 번씩 비교하면 데이터가 늘어날수록 반복 횟수가 빠르게 증가합니다.

다음 구조는 각 주문마다 고객 목록 전체를 다시 검색합니다.

orders.forEach(function (order) {
  customers.forEach(function (customer) {
    if (order[1] === customer[0]) {
      order[4] = customer[1];
    }
  });
});

고객 ID를 키로 사용하는 Map을 먼저 만들면 각 주문에서 고객 목록 전체를 반복하지 않아도 됩니다.

const customerNameById =
  new Map();

customers.forEach(function (customer) {
  const customerId =
    String(customer[0]);

  const customerName =
    String(customer[1]);

  customerNameById.set(
    customerId,
    customerName
  );
});

const result =
  orders.map(function (order) {
    const customerId =
      String(order[1]);

    return [
      order[0],
      customerId,
      customerNameById.get(customerId) || '',
      order[2]
    ];
  });

업무상 고유해야 하는 고객 ID가 중복될 수 있다면 마지막 값 하나만 저장하는 Map 구조가 적절한지 먼저 확인해야 합니다.

사용자 지정 함수는 셀마다 따로 호출하지 않습니다

스프레드시트 셀에 다음처럼 사용자 지정 함수를 여러 행에 복사하면 각 셀이 Apps Script 서버를 별도로 호출할 수 있습니다.

=NORMALIZE_NAME(A2)
=NORMALIZE_NAME(A3)
=NORMALIZE_NAME(A4)

사용자 지정 함수는 실행당 30초 안에 값을 반환해야 하며, 많은 셀에서 복잡한 함수를 각각 호출하면 실행 지연이 커질 수 있습니다.

범위를 한 번에 받아 같은 크기의 2차원 배열을 반환하도록 작성할 수 있습니다.

/**
 * 이름 문자열을 정리합니다.
 *
 * @param {string|Array<Array<string>>} input
 * @return {string|Array<Array<string>>}
 * @customfunction
 */
function NORMALIZE_NAME(input) {
  if (Array.isArray(input)) {
    return input.map(function (row) {
      return row.map(function (value) {
        return normalizeName_(value);
      });
    });
  }

  return normalizeName_(input);
}

function normalizeName_(value) {
  return String(
    value === null ||
    value === undefined
      ? ''
      : value
  )
    .trim()
    .replace(/\s+/g, ' ');
}

시트에서는 범위 전체를 한 번에 전달합니다.

=NORMALIZE_NAME(A2:A1000)

함수가 반환한 배열이 펼쳐질 영역은 비어 있어야 합니다.

이메일 전송이나 임의 셀 수정처럼 승인 또는 부작용이 필요한 작업은 사용자 지정 함수가 아니라 사용자 메뉴, 버튼이나 설치형 트리거로 실행해야 합니다.

onEdit에서는 수정된 범위와 관련 없는 작업을 실행하지 않습니다

사용자가 어느 셀을 수정하든 전체 시트를 다시 읽고 계산하면 작은 편집 하나에도 큰 작업이 반복됩니다.

이벤트 객체의 수정 시트와 열을 먼저 확인하고 대상이 아니면 바로 종료합니다.

function onEdit(e) {
  if (!e || !e.range) {
    return;
  }

  const editedRange =
    e.range;

  const sheet =
    editedRange.getSheet();

  if (sheet.getName() !== '업무') {
    return;
  }

  const watchedColumn = 3;

  if (
    watchedColumn <
      editedRange.getColumn() ||
    watchedColumn >
      editedRange.getLastColumn()
  ) {
    return;
  }

  processEditedRows_(editedRange);
}

단순 onEdit는 30초 제한이 있으므로 대량 파일 검색, 많은 API 호출이나 전체 시트 재계산을 직접 수행하는 진입점으로 사용하지 않습니다.

외부 API와 다른 서비스 호출을 반복하지 않습니다

UrlFetchApp.fetch(), Drive 파일 검색, Gmail 전송과 다른 Google 서비스 호출은 외부 또는 원격 서비스와 통신합니다.

반복문 안에서 같은 API 데이터를 계속 요청하는 구조를 확인합니다.

orders.forEach(function (order) {
  const response =
    UrlFetchApp.fetch(
      'https://api.example.com/config'
    );

  const config =
    JSON.parse(
      response.getContentText()
    );

  processOrder_(
    order,
    config
  );
});

모든 주문에서 같은 설정 데이터를 사용한다면 반복문 밖에서 한 번만 가져옵니다.

const response =
  UrlFetchApp.fetch(
    'https://api.example.com/config'
  );

const config =
  JSON.parse(
    response.getContentText()
  );

orders.forEach(function (order) {
  processOrder_(
    order,
    config
  );
});

실행 사이에서도 같은 결과를 재사용할 수 있는 데이터라면 Cache Service를 검토할 수 있습니다.

캐시는 지정한 시간까지 값이 반드시 유지되는 영구 저장소가 아니므로 작업 진행 위치처럼 잃어버리면 안 되는 상태에는 Properties Service를 사용합니다.

최적화해도 끝나지 않는 작업은 여러 실행으로 나눕니다

서비스 호출을 줄였는데도 전체 작업이 실행 제한 안에 끝나지 않는다면 한 번의 실행에서 모든 행을 처리하는 구조를 유지하면 안 됩니다.

작업을 다음 단위로 나눌 수 있습니다.

  • 일정한 행 수
  • 파일 개수
  • 날짜 구간
  • 페이지 번호
  • API의 다음 페이지 토큰

각 실행이 끝날 때 마지막 처리 행이나 다음 페이지 토큰을 저장하고, 시간 기반 트리거가 다음 실행을 시작하도록 구성합니다.

한 번의 실행을 공식 제한인 6분까지 모두 사용하려고 하지 말고, 진행 위치 저장과 다음 트리거 생성을 마칠 수 있도록 여유 시간을 둡니다.

행 단위 작업을 이어서 실행하는 전체 예제

다음 예제는 스프레드시트에 연결된 Apps Script 프로젝트에서 사용하도록 구성했습니다.

“원본” 시트는 다음 구조를 사용합니다.

내용
A 주문번호
B 수량
C 단가
D 계산 금액
E 처리 상태
const LONG_JOB_CONFIG =
  Object.freeze({
    sheetName: '원본',
    firstDataRow: 2,
    batchSize: 500,

    // 공식 제한값이 아니라 다음 실행을
    // 준비할 시간을 남기기 위한 예제 설정입니다.
    maxRunMs: 4 * 60 * 1000,

    continuationDelayMs:
      60 * 1000,

    handlerFunction:
      'continueLongJob',

    spreadsheetIdKey:
      'LONG_JOB_SPREADSHEET_ID',

    nextRowKey:
      'LONG_JOB_NEXT_ROW',

    statusKey:
      'LONG_JOB_STATUS',

    updatedAtKey:
      'LONG_JOB_UPDATED_AT',

    errorKey:
      'LONG_JOB_ERROR'
  });

function startLongJob() {
  const spreadsheet =
    SpreadsheetApp.getActiveSpreadsheet();

  if (!spreadsheet) {
    throw new Error(
      '현재 스프레드시트를 찾을 수 없습니다.'
    );
  }

  const sheet =
    spreadsheet.getSheetByName(
      LONG_JOB_CONFIG.sheetName
    );

  if (!sheet) {
    throw new Error(
      '원본 시트를 찾을 수 없습니다: ' +
      LONG_JOB_CONFIG.sheetName
    );
  }

  const lock =
    LockService.getScriptLock();

  if (!lock.tryLock(10000)) {
    throw new Error(
      '다른 작업이 실행 중이어서 시작하지 못했습니다.'
    );
  }

  try {
    const properties =
      PropertiesService
        .getScriptProperties();

    deleteContinuationTriggers_();

    properties.setProperties({
      [LONG_JOB_CONFIG.spreadsheetIdKey]:
        spreadsheet.getId(),

      [LONG_JOB_CONFIG.nextRowKey]:
        String(
          LONG_JOB_CONFIG.firstDataRow
        ),

      [LONG_JOB_CONFIG.statusKey]:
        'RUNNING',

      [LONG_JOB_CONFIG.updatedAtKey]:
        new Date().toISOString(),

      [LONG_JOB_CONFIG.errorKey]:
        ''
    });

  } finally {
    lock.releaseLock();
  }

  continueLongJob();
}

function continueLongJob() {
  const lock =
    LockService.getScriptLock();

  if (!lock.tryLock(10000)) {
    console.log(
      '다른 실행이 작업을 처리 중입니다.'
    );
    return;
  }

  try {
    const properties =
      PropertiesService
        .getScriptProperties();

    const status =
      properties.getProperty(
        LONG_JOB_CONFIG.statusKey
      );

    if (status !== 'RUNNING') {
      deleteContinuationTriggers_();
      return;
    }

    const spreadsheetId =
      properties.getProperty(
        LONG_JOB_CONFIG.spreadsheetIdKey
      );

    if (!spreadsheetId) {
      throw new Error(
        '이어 실행할 스프레드시트 ID가 없습니다.'
      );
    }

    const spreadsheet =
      SpreadsheetApp.openById(
        spreadsheetId
      );

    const sheet =
      spreadsheet.getSheetByName(
        LONG_JOB_CONFIG.sheetName
      );

    if (!sheet) {
      throw new Error(
        '원본 시트를 찾을 수 없습니다: ' +
        LONG_JOB_CONFIG.sheetName
      );
    }

    let nextRow =
      Number(
        properties.getProperty(
          LONG_JOB_CONFIG.nextRowKey
        ) ||
        LONG_JOB_CONFIG.firstDataRow
      );

    if (
      !Number.isInteger(nextRow) ||
      nextRow <
        LONG_JOB_CONFIG.firstDataRow
    ) {
      throw new Error(
        '저장된 다음 행 번호가 유효하지 않습니다.'
      );
    }

    const lastRow =
      sheet.getLastRow();

    const deadline =
      Date.now() +
      LONG_JOB_CONFIG.maxRunMs;

    while (
      nextRow <= lastRow &&
      Date.now() < deadline
    ) {
      const remainingRows =
        lastRow - nextRow + 1;

      const rowCount =
        Math.min(
          LONG_JOB_CONFIG.batchSize,
          remainingRows
        );

      processOrderBatch_(
        sheet,
        nextRow,
        rowCount
      );

      nextRow += rowCount;

      properties.setProperties({
        [LONG_JOB_CONFIG.nextRowKey]:
          String(nextRow),

        [LONG_JOB_CONFIG.updatedAtKey]:
          new Date().toISOString()
      });
    }

    if (nextRow > lastRow) {
      properties.setProperties({
        [LONG_JOB_CONFIG.statusKey]:
          'COMPLETED',

        [LONG_JOB_CONFIG.updatedAtKey]:
          new Date().toISOString(),

        [LONG_JOB_CONFIG.errorKey]:
          ''
      });

      properties.deleteProperty(
        LONG_JOB_CONFIG.nextRowKey
      );

      deleteContinuationTriggers_();

      console.log(
        '모든 행의 처리가 완료됐습니다.'
      );

      return;
    }

    scheduleContinuation_();

    console.log(
      '다음 실행 시작 행: ' +
      String(nextRow)
    );

  } catch (error) {
    const properties =
      PropertiesService
        .getScriptProperties();

    properties.setProperties({
      [LONG_JOB_CONFIG.statusKey]:
        'ERROR',

      [LONG_JOB_CONFIG.updatedAtKey]:
        new Date().toISOString(),

      [LONG_JOB_CONFIG.errorKey]:
        String(
          error && error.message
            ? error.message
            : error
        ).slice(0, 500)
    });

    deleteContinuationTriggers_();

    throw error;

  } finally {
    lock.releaseLock();
  }
}

function processOrderBatch_(
  sheet,
  startRow,
  rowCount
) {
  const inputValues =
    sheet
      .getRange(
        startRow,
        1,
        rowCount,
        3
      )
      .getValues();

  const outputValues =
    inputValues.map(function (row) {
      const orderId =
        row[0] === null ||
        row[0] === undefined
          ? ''
          : String(row[0]).trim();

      const quantityValue =
        row[1];

      const unitPriceValue =
        row[2];

      const hasQuantity =
        quantityValue !== '' &&
        quantityValue !== null;

      const hasUnitPrice =
        unitPriceValue !== '' &&
        unitPriceValue !== null;

      const quantity =
        Number(quantityValue);

      const unitPrice =
        Number(unitPriceValue);

      const isValid =
        orderId !== '' &&
        hasQuantity &&
        hasUnitPrice &&
        Number.isFinite(quantity) &&
        Number.isFinite(unitPrice);

      if (!isValid) {
        return [
          '',
          '확인 필요'
        ];
      }

      return [
        quantity * unitPrice,
        '완료'
      ];
    });

  sheet
    .getRange(
      startRow,
      4,
      rowCount,
      2
    )
    .setValues(outputValues);
}

function scheduleContinuation_() {
  deleteContinuationTriggers_();

  ScriptApp
    .newTrigger(
      LONG_JOB_CONFIG.handlerFunction
    )
    .timeBased()
    .after(
      LONG_JOB_CONFIG
        .continuationDelayMs
    )
    .create();
}

function deleteContinuationTriggers_() {
  const triggers =
    ScriptApp.getProjectTriggers();

  triggers.forEach(function (trigger) {
    if (
      trigger.getHandlerFunction() ===
      LONG_JOB_CONFIG.handlerFunction
    ) {
      ScriptApp.deleteTrigger(
        trigger
      );
    }
  });
}

function resumeLongJob() {
  const properties =
    PropertiesService
      .getScriptProperties();

  const spreadsheetId =
    properties.getProperty(
      LONG_JOB_CONFIG.spreadsheetIdKey
    );

  const nextRow =
    properties.getProperty(
      LONG_JOB_CONFIG.nextRowKey
    );

  if (!spreadsheetId || !nextRow) {
    throw new Error(
      '이어 실행할 저장 상태가 없습니다.'
    );
  }

  properties.setProperties({
    [LONG_JOB_CONFIG.statusKey]:
      'RUNNING',

    [LONG_JOB_CONFIG.updatedAtKey]:
      new Date().toISOString(),

    [LONG_JOB_CONFIG.errorKey]:
      ''
  });

  continueLongJob();
}

function stopLongJob() {
  const lock =
    LockService.getScriptLock();

  if (!lock.tryLock(10000)) {
    throw new Error(
      '실행 중인 작업 때문에 중지 상태를 저장하지 못했습니다.'
    );
  }

  try {
    const properties =
      PropertiesService
        .getScriptProperties();

    deleteContinuationTriggers_();

    properties.setProperties({
      [LONG_JOB_CONFIG.statusKey]:
        'STOPPED',

      [LONG_JOB_CONFIG.updatedAtKey]:
        new Date().toISOString()
    });

    properties.deleteProperty(
      LONG_JOB_CONFIG.nextRowKey
    );

    properties.deleteProperty(
      LONG_JOB_CONFIG.spreadsheetIdKey
    );

  } finally {
    lock.releaseLock();
  }
}

function printLongJobStatus() {
  const properties =
    PropertiesService
      .getScriptProperties();

  console.log({
    status:
      properties.getProperty(
        LONG_JOB_CONFIG.statusKey
      ),

    nextRow:
      properties.getProperty(
        LONG_JOB_CONFIG.nextRowKey
      ),

    updatedAt:
      properties.getProperty(
        LONG_JOB_CONFIG.updatedAtKey
      ),

    error:
      properties.getProperty(
        LONG_JOB_CONFIG.errorKey
      )
  });
}

이어 실행 코드의 처리 순서

처음 작업을 시작할 때는 startLongJob()을 실행합니다.

  1. 현재 스프레드시트 ID와 첫 처리 행을 Script Properties에 저장합니다.
  2. 기존에 남은 이어 실행 트리거를 제거합니다.
  3. 지정한 행 수만큼 데이터를 배열로 읽습니다.
  4. 계산 결과를 D열과 E열에 한 번에 기록합니다.
  5. 다음에 처리할 행 번호를 저장합니다.
  6. 예제의 실행 시간 범위 안에 작업이 끝나지 않으면 시간 기반 트리거를 하나 만듭니다.
  7. 다음 실행은 저장된 행 번호부터 작업을 계속합니다.
  8. 마지막 행까지 처리하면 상태를 COMPLETED로 바꾸고 트리거를 삭제합니다.

예제의 500행과 4분은 모든 프로젝트에 적용되는 공식 기준이 아닙니다. 한 행에서 호출하는 서비스, 외부 API 응답 시간과 데이터 크기에 따라 적절한 배치 크기와 실행 시간을 정해야 합니다.

시간 기반 트리거는 정확히 지정한 초에 실행되지 않을 수 있습니다

after()는 현재 시각에서 지정한 밀리초가 지난 이후에 실행되도록 최소 대기 시간을 설정합니다.

실제 실행은 지정한 최소 시간보다 늦을 수 있으므로 1분 뒤 트리거를 만들었다고 정확히 60초 뒤 실행된다고 가정하지 않습니다.

다음 작업이 반드시 즉시 이어져야 하는 업무라면 Apps Script 시간 기반 트리거의 실행 특성이 요구 조건에 맞는지 검토해야 합니다.

진행 위치는 Properties Service에 저장합니다

Properties Service는 문자열 형태의 키와 값을 스크립트, 사용자 또는 문서 범위로 저장할 수 있습니다.

저장소 범위 사용할 수 있는 상황
Script Properties 같은 스크립트의 사용자에게 공유 하나의 공용 작업 진행 위치
User Properties 현재 사용자별로 분리 사용자마다 독립된 작업 상태
Document Properties 특정 문서·부가기능 컨텍스트 문서 단위 설정을 저장하는 지원 환경

위 전체 예제는 하나의 공용 작업만 실행한다는 전제로 Script Properties를 사용합니다.

여러 사용자가 서로 다른 작업을 동시에 시작해야 한다면 사용자별 저장소나 고유 작업 ID를 키에 포함하는 별도 구조가 필요합니다.

중복 트리거가 쌓이지 않도록 기존 트리거를 삭제합니다

작업이 끝날 때마다 새 트리거만 추가하고 기존 트리거를 정리하지 않으면 같은 함수가 여러 번 예약될 수 있습니다.

그러면 같은 행을 동시에 처리하거나 Properties의 다음 행 번호를 서로 덮어쓸 수 있습니다.

전체 예제의 deleteContinuationTriggers_()continueLongJob을 실행하는 기존 프로젝트 트리거를 삭제한 뒤 새 트리거 하나만 만듭니다.

프로젝트에 같은 함수명을 사용하지만 다른 업무 목적의 트리거가 있다면 함수명만으로 모두 삭제하는 방식이 적절하지 않을 수 있습니다. 각 장기 작업에 고유한 이어 실행 함수명을 사용하는 편이 구분하기 쉽습니다.

LockService로 이어 실행이 겹치지 않도록 합니다

수동 실행과 시간 기반 트리거가 비슷한 시각에 시작되면 동일한 진행 상태를 동시에 읽을 수 있습니다.

전체 예제는 Script Lock을 획득한 실행 하나만 진행 위치를 읽고 수정하도록 구성했습니다.

LockService가 실행 시간을 늘려주는 것은 아닙니다. 여러 실행이 같은 상태와 시트를 동시에 변경하는 충돌을 줄이기 위한 기능입니다.

잠금을 기다리는 시간도 현재 실행 안에서 사용되므로 잠금 범위 안에 불필요한 외부 API 호출이나 긴 대기 작업을 넣지 않도록 설계를 검토해야 합니다.

같은 배치를 다시 실행해도 결과가 중복되지 않도록 설계합니다

스크립트가 결과를 기록한 직후 진행 위치를 저장하기 전에 중단되면 다음 실행이 같은 배치를 다시 처리할 가능성을 고려해야 합니다.

위 예제는 동일한 주문 행의 D열과 E열을 다시 계산해 같은 위치에 덮어쓰므로 같은 배치를 다시 실행해도 행이 새로 추가되지는 않습니다.

다음 작업은 별도의 중복 방지 구조가 필요합니다.

  • 이메일 발송
  • 결제·주문 생성 API 호출
  • 다른 시트에 새 행 추가
  • 파일 생성
  • 외부 시스템에 레코드 삽입

이러한 작업은 업무용 고유 ID, 처리 완료 표시, 멱등성 키 또는 기존 결과 조회 방식으로 이전 처리 여부를 확인한 뒤 실행해야 합니다.

Utilities.sleep으로 실행 시간을 초기화할 수 없습니다

Utilities.sleep()은 지정한 시간 동안 현재 스크립트 실행을 대기시키는 기능입니다.

기다린다고 현재 실행이 새로운 실행으로 바뀌거나 실행 시간 계산이 처음부터 다시 시작되는 것은 아닙니다.

외부 API의 짧은 재시도 간격을 맞추는 용도로 제한적으로 사용할 수는 있지만, 실행 시간 초과를 해결하기 위한 핵심 방법은 아닙니다.

긴 대기 시간이 필요하다면 현재 실행을 종료하고 시간 기반 트리거로 나중에 다시 시작하는 구조를 검토합니다.

실행 시간 초과와 서비스 할당량 오류를 구분합니다

오류 형태 의미 수정 방향
Exceeded maximum execution time 한 번의 실행이 제한 시간을 초과 서비스 호출 최소화와 작업 분할
Service invoked too many times 특정 서비스의 호출 빈도나 할당량 문제 서비스 호출 횟수와 공식 할당량 확인
HTTP 429 Too Many Requests 호출한 외부 API가 요청을 제한 Retry-After와 API별 속도 제한 확인
동시에 실행한 스크립트 수가 너무 많음 사용자 또는 스크립트의 동시 실행 제한 중복 트리거와 겹치는 실행 확인

실행 시간 초과 코드에 대기 시간을 추가하면 전체 실행 시간이 더 길어질 수 있습니다.

반대로 일일 할당량을 초과한 상태에서는 작업을 여러 배치로 나누더라도 같은 날 발생하는 전체 서비스 호출량이 줄지 않으면 문제가 계속될 수 있습니다.

실행 시간 초과 최종 점검 순서

1. 실행 방식을 확인
일반 실행, 사용자 지정 함수, 단순 트리거와 설치형 트리거를 구분합니다.
2. 실행 기록에서 실패 함수 확인
실행 시간, 오류 메시지와 겹친 트리거를 확인합니다.
3. 반복문 안의 서비스 호출 제거
셀마다 읽고 쓰는 코드를 배열 일괄 처리로 바꿉니다.
4. 읽는 범위를 축소
전체 시트보다 실제로 필요한 행과 열만 가져옵니다.
5. 반복 검색과 중첩 반복문 확인
고유 ID 조회에는 Map 같은 인덱스 구조를 검토합니다.
6. 사용자 지정 함수 호출 수 축소
셀마다 호출하지 말고 범위를 받아 배열을 반환하도록 구성합니다.
7. 같은 외부 데이터를 반복 요청하지 않음
반복문 밖에서 한 번 가져오거나 적절한 데이터는 캐시를 검토합니다.
8. 한 번에 끝나지 않으면 작업 분할
행·파일·페이지 단위로 나누고 진행 위치를 Properties에 저장합니다.
9. 이어 실행 트리거 하나만 유지
기존 이어 실행 트리거를 정리한 뒤 다음 트리거를 만듭니다.
10. 중복 실행과 중복 결과 방지
LockService와 업무용 고유 ID를 사용해 같은 작업이 겹치지 않게 합니다.

내용 확인에 사용한 Google 공식 자료

공식 문서 확인일: 2026년 7월 29일

Post a Comment

다음 이전