구글 앱스 스크립트 LockService 사용법: 동시 실행 덮어쓰기 방지

구글 스프레드시트에 연결된 Apps Script가 평소에는 정상적으로 작동하지만, 여러 사용자가 비슷한 시각에 버튼을 누르거나 트리거가 겹치면 번호가 중복되거나 마지막 데이터가 다른 값으로 바뀌는 경우가 있습니다.

이 문제는 반드시 오류 메시지를 표시하지는 않습니다. 두 실행이 같은 값을 읽은 뒤 각각 계산하고 다시 저장하면, 나중에 저장된 결과가 먼저 저장된 결과를 덮어쓸 수 있습니다.

LockService는 이런 공유 데이터의 읽기·수정·쓰기 구간에 한 번에 하나의 실행만 들어가도록 제한하는 Apps Script 서비스입니다. 다만 잠금 종류와 획득 방법을 잘못 선택하면 코드를 추가하고도 충돌이 그대로 남을 수 있습니다.

구글 앱스 스크립트에서 여러 실행의 공유 데이터 접근을 LockService로 제어하는 개념 이미지
LockService는 여러 실행이 공유 데이터의 읽기·수정·쓰기 구간에 동시에 진입하지 못하도록 제한합니다. 위 이미지는 동시 실행 문제를 설명하기 위한 대표 개념 이미지입니다.

이 글은 Google의 Apps Script LockService, Lock, Properties Service, SpreadsheetApp 및 서비스 할당량 공식 문서를 기준으로 작성했습니다. 제공된 코드는 설명용 예제이며 시트 이름과 데이터 구조는 실제 프로젝트에 맞게 변경해야 합니다.

LockService가 필요한 코드는 ‘읽기 → 계산 → 쓰기’ 구조입니다

단순히 여러 실행이 시작된다는 이유만으로 모든 함수에 잠금을 넣을 필요는 없습니다. 여러 실행이 동일한 공유값을 읽고, 그 값으로 새 결과를 계산한 뒤, 같은 위치에 다시 저장하는지가 핵심입니다.

충돌이 발생할 수 있는 처리 순서

1. 현재 마지막 번호를 읽음
2. 번호에 1을 더함
3. 계산한 번호를 다시 저장함

예를 들어 마지막 번호가 120일 때 실행 A와 실행 B가 거의 동시에 시작되면 두 실행 모두 120을 읽을 수 있습니다. 두 실행이 각각 121을 계산해 저장하면 제출은 두 건이지만 번호는 모두 121이 됩니다.

다음과 같은 공유값을 수정하는 코드에서 LockService를 검토할 수 있습니다.

  • 공용 접수번호·주문번호·티켓번호 증가
  • 같은 스크립트 속성의 값 읽기와 갱신
  • 마지막 행을 확인한 뒤 다음 행에 기록
  • 재고 수량을 읽고 차감한 뒤 다시 저장
  • 하나의 작업 상태를 확인한 뒤 진행 중으로 변경

반대로 각 실행이 서로 다른 파일이나 서로 다른 행만 읽고 수정하며 공유하는 계산 상태가 없다면 잠금이 필요하지 않을 수 있습니다. 불필요한 잠금은 동시에 처리할 수 있는 작업까지 순서대로 기다리게 만듭니다.

잠금 없이 공용 번호를 증가시키는 코드의 문제

아래 코드는 Script Properties에 저장된 마지막 번호를 읽고 1을 더해 다시 저장합니다.

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

  const lastNumber =
    Number(
      properties.getProperty(
        'LAST_TICKET_NUMBER'
      ) || '999'
    );

  const nextNumber = lastNumber + 1;

  properties.setProperty(
    'LAST_TICKET_NUMBER',
    String(nextNumber)
  );

  return nextNumber;
}

한 번에 하나씩 실행될 때는 1000, 1001, 1002처럼 증가합니다. 그러나 두 실행이 getProperty()를 비슷한 시점에 호출하면 같은 마지막 번호를 읽을 수 있습니다.

문제는 setProperty() 함수 자체가 고장 난 것이 아니라, 값을 읽은 시점과 다시 저장한 시점 사이에 다른 실행이 개입할 수 있다는 점입니다.

Script·Document·User 잠금 중 무엇을 선택할까?

LockService에는 범위가 다른 세 가지 잠금이 있습니다. 이름이 비슷하지만 동시에 막는 대상이 다릅니다.

잠금 동시에 막는 범위 적합한 상황
getScriptLock() 사용자와 관계없이 같은 스크립트 프로젝트의 실행 모든 사용자가 공유하는 번호·속성·시트를 수정할 때
getDocumentLock() 현재 문서별 실행 같은 스크립트가 문서별로 독립된 데이터를 수정할 때
getUserLock() 같은 사용자의 중복 실행 사용자별로 독립된 상태를 수정할 때

여러 사용자가 하나의 번호를 공유하면 Script Lock

여러 사용자가 같은 접수번호나 같은 Script Properties 값을 수정한다면 보통 getScriptLock()이 적합합니다. 서로 다른 사용자도 동시에 보호 구간을 실행하지 못하게 하기 때문입니다.

User Lock은 다른 사용자를 서로 막지 않습니다

getUserLock()은 동일한 사용자의 중복 실행만 제한합니다. 사용자 A와 사용자 B는 동시에 실행할 수 있으므로, 모든 사용자가 공유하는 번호나 재고를 보호하는 용도로는 맞지 않습니다.

Document Lock은 문서 컨텍스트가 필요합니다

getDocumentLock()은 현재 문서를 기준으로 잠급니다. 독립형 스크립트나 웹 앱처럼 포함된 문서의 컨텍스트가 없는 곳에서 호출하면 null이 반환될 수 있습니다.

하나의 스크립트가 여러 스프레드시트에 연결돼 있고 각 파일의 작업은 서로 동시에 실행해도 된다면 Document Lock을 검토할 수 있습니다. 하나의 공용 Script Properties를 모든 문서가 공유한다면 Script Lock이 더 적합합니다.

getScriptLock만 호출하면 잠금이 시작되는 것은 아닙니다

다음 코드는 잠금 객체만 가져왔을 뿐 실제 잠금을 획득하지 않았습니다.

const lock = LockService.getScriptLock();

// 아직 잠금을 획득하지 않은 상태

잠금을 실제로 획득하려면 tryLock()이나 waitLock()을 호출해야 합니다.

tryLock(timeoutInMillis)
지정한 시간 동안 잠금을 시도합니다. 획득하면 true, 시간 안에 획득하지 못하면 false를 반환합니다.
waitLock(timeoutInMillis)
지정한 시간 동안 잠금을 기다립니다. 시간 안에 획득하지 못하면 예외를 발생시킵니다.

잠금 실패 시 직접 오류 문구를 기록하거나 별도 처리를 선택하려면 tryLock()이 이해하기 쉽습니다. 예외 처리 흐름을 사용하는 코드라면 waitLock()을 사용할 수 있습니다.

tryLock으로 공용 번호를 안전하게 증가시키는 기본 코드

아래 코드는 최대 30초 동안 Script Lock을 시도하고, 잠금을 획득한 실행만 마지막 번호를 읽고 갱신합니다.

const TICKET_PROPERTY_KEY =
  'LAST_TICKET_NUMBER';

const FIRST_TICKET_NUMBER = 1000;

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

  const acquired =
    lock.tryLock(30000);

  if (!acquired) {
    throw new Error(
      '30초 안에 작업 잠금을 획득하지 못했습니다.'
    );
  }

  try {
    const properties =
      PropertiesService.getScriptProperties();

    const savedValue =
      properties.getProperty(
        TICKET_PROPERTY_KEY
      );

    const lastNumber =
      savedValue === null
        ? FIRST_TICKET_NUMBER - 1
        : Number(savedValue);

    if (
      !Number.isSafeInteger(lastNumber) ||
      lastNumber < 0
    ) {
      throw new Error(
        '저장된 마지막 번호가 올바른 정수가 아닙니다.'
      );
    }

    const nextNumber =
      lastNumber + 1;

    properties.setProperty(
      TICKET_PROPERTY_KEY,
      String(nextNumber)
    );

    return nextNumber;

  } finally {
    lock.releaseLock();
  }
}

코드의 주요 흐름은 다음과 같습니다.

  1. Script Lock 객체를 가져옵니다.
  2. tryLock(30000)으로 최대 30초 동안 획득을 시도합니다.
  3. 잠금에 실패하면 공유값을 읽지 않고 오류를 발생시킵니다.
  4. 잠금을 획득한 뒤에만 마지막 번호를 읽습니다.
  5. 다음 번호를 Script Properties에 저장합니다.
  6. finally에서 잠금을 해제합니다.
잠금을 획득한 뒤에만 releaseLock을 호출하세요
위 코드는 tryLock()false를 반환하면 try...finally 구간에 들어가지 않습니다. 따라서 획득하지 않은 잠금을 해제하려는 흐름을 피할 수 있습니다.

Google Form 제출 행에 고유 번호 기록하기

Google Form 응답이 연결된 스프레드시트라면 제출된 행의 오른쪽에 고유 번호를 기록할 수 있습니다.

아래 코드는 앞에서 만든 getNextTicketNumber_() 함수를 사용합니다.

function onFormSubmit(e) {
  if (!e || !e.range) {
    throw new Error(
      '스프레드시트 양식 제출 이벤트 정보가 없습니다.'
    );
  }

  const ticketNumber =
    getNextTicketNumber_();

  const responseRange =
    e.range;

  const ticketCell =
    responseRange.offset(
      0,
      responseRange.getNumColumns(),
      1,
      1
    );

  ticketCell.setValue(
    ticketNumber
  );
}

e.range는 이번 제출로 기록된 응답 행을 가리킵니다. offset()은 응답 범위 바로 오른쪽 셀을 선택합니다.

이 예제에서 잠금은 Google Form이 기본 응답 행을 추가하는 작업을 보호하는 것이 아닙니다. 여러 제출이 동일한 마지막 티켓 번호를 읽고 같은 번호를 만드는 문제를 방지합니다.

설치형 양식 제출 트리거 등록

  1. 응답 스프레드시트에서 확장 프로그램 → Apps Script를 엽니다.
  2. 왼쪽에서 트리거를 선택합니다.
  3. 트리거 추가를 누릅니다.
  4. 실행할 함수로 onFormSubmit을 선택합니다.
  5. 이벤트 소스에서 스프레드시트에서를 선택합니다.
  6. 이벤트 유형에서 양식 제출 시를 선택합니다.
  7. 저장하고 필요한 권한을 승인합니다.
편집기에서 onFormSubmit을 직접 실행하지 마세요
직접 실행하면 실제 양식 제출에서 전달되는 이벤트 객체 e가 없으므로 예제 코드는 오류를 발생시킵니다.

번호가 중간에 비는 것은 중복 번호와 다른 문제입니다

예제는 다음 번호를 Script Properties에 먼저 저장하고, 이후 응답 행에 기록합니다.

번호 저장 후 시트 쓰기 단계에서 오류가 발생하면 해당 번호가 사용되지 않고 다음 번호로 넘어갈 수 있습니다. 따라서 번호가 1001 다음에 1003으로 기록되는 식의 공백이 생길 가능성은 남아 있습니다.

LockService는 동시에 같은 번호를 만드는 경쟁 상태를 제한하지만, 데이터베이스의 완전한 트랜잭션이나 자동 롤백 기능을 제공하지 않습니다.

번호에 공백이 전혀 없어야 하거나 결제·회계 기록처럼 강한 거래 보장이 필요하다면 스프레드시트 기반 번호 발급만으로 충분한지 별도로 검토해야 합니다.

마지막 행을 읽고 직접 기록할 때는 전체 구간을 잠그세요

다음 행 번호를 직접 계산해 데이터를 쓰는 구조라면 getLastRow()부터 setValues()까지를 하나의 보호 구간에 넣어야 합니다.

function appendRequestSafely(values) {
  if (!Array.isArray(values)) {
    throw new TypeError(
      'values는 배열이어야 합니다.'
    );
  }

  if (values.length === 0) {
    throw new Error(
      '기록할 값이 없습니다.'
    );
  }

  const lock =
    LockService.getScriptLock();

  if (!lock.tryLock(30000)) {
    throw new Error(
      '30초 안에 쓰기 잠금을 획득하지 못했습니다.'
    );
  }

  try {
    const spreadsheet =
      SpreadsheetApp.getActiveSpreadsheet();

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

    const sheet =
      spreadsheet.getSheetByName(
        '접수내역'
      );

    if (!sheet) {
      throw new Error(
        '접수내역 시트를 찾지 못했습니다.'
      );
    }

    const nextRow =
      Math.max(
        sheet.getLastRow() + 1,
        2
      );

    sheet
      .getRange(
        nextRow,
        1,
        1,
        values.length
      )
      .setValues([values]);

    SpreadsheetApp.flush();

  } finally {
    lock.releaseLock();
  }
}

예제는 제목 행을 1행으로 가정하고 2행부터 데이터를 기록합니다. 접수내역은 실제 시트 탭 이름으로 변경해야 합니다.

이 코드에서는 다음 행을 결정하는 작업과 실제 기록 작업을 같은 잠금 안에서 실행합니다. 다음 행만 잠금 안에서 계산하고 잠금을 해제한 뒤 값을 쓰면, 다른 실행도 같은 행 번호를 선택할 수 있습니다.

SpreadsheetApp.flush는 언제 필요한가?

Apps Script는 성능을 위해 스프레드시트 변경 작업을 묶어서 처리할 수 있습니다. SpreadsheetApp.flush()는 대기 중인 변경사항을 즉시 적용합니다.

앞의 마지막 행 예제처럼 다음 실행이 직전 실행의 시트 쓰기 결과를 바로 기준으로 삼아야 한다면, 잠금을 해제하기 전에 flush()를 실행하는 방법을 사용할 수 있습니다.

다만 다음과 같이 모든 코드에 무조건 넣을 필요는 없습니다.

  • Script Properties만 변경하고 시트를 수정하지 않는 경우
  • 다음 실행이 시트의 변경 결과를 즉시 다시 읽지 않는 경우
  • 단순히 화면 갱신 속도를 높이려는 목적이 아닌 경우

flush()는 동시 실행을 차단하거나 할당량을 늘리는 명령이 아닙니다. 잠금은 LockService가 담당하고, flush는 대기 중인 스프레드시트 변경을 적용합니다.

잠금 구간에는 필요한 작업만 넣으세요

잠금을 획득한 상태에서 외부 API 요청, 이메일 발송 또는 긴 반복 작업을 수행하면 다른 실행이 그 작업이 끝날 때까지 기다려야 합니다.

잠금 밖에서 처리할 수 있는 작업

  • 입력값이 비어 있는지 확인
  • 문자열 형식 정리
  • 전송할 이메일 본문 만들기
  • 공유 데이터를 사용하지 않는 계산
  • 외부 API에 보낼 요청 객체 만들기

잠금 안에 있어야 하는 작업

  • 공유된 현재값 읽기
  • 현재값을 기준으로 다음값 계산
  • 새 값을 공유 저장소에 기록
  • 다음 실행이 즉시 읽어야 하는 시트 변경 적용

외부 API 응답을 받아야만 공유값을 갱신할 수 있다면 작업 순서를 다시 설계해야 할 수 있습니다. 잠금을 잡은 채 장시간 외부 응답을 기다리면 후속 실행이 시간 초과될 가능성이 커집니다.

잠금 획득 실패를 성공으로 처리하지 마세요

다음과 같이 잠금에 실패했는데도 함수가 계속 진행하면 LockService를 넣은 의미가 없어집니다.

const lock =
  LockService.getScriptLock();

lock.tryLock(10000);

// 획득 성공 여부를 확인하지 않고 계속 실행
updateSharedData();

tryLock()의 반환값을 확인하고 실패했다면 공유 데이터를 수정하지 않아야 합니다.

const lock =
  LockService.getScriptLock();

if (!lock.tryLock(10000)) {
  console.error(
    '쓰기 잠금을 획득하지 못했습니다.'
  );

  throw new Error(
    '요청이 겹쳐 작업을 완료하지 못했습니다.'
  );
}

자동 트리거에서는 사용자에게 즉시 알림창을 보여주기 어려울 수 있습니다. 실패한 실행을 Apps Script 실행 기록에 남기거나 별도의 오류 기록 방식으로 처리해야 합니다.

catch에서 오류를 기록만 하고 숨기지 마세요

기존 코드처럼 잠금 시간 초과를 로그에만 남기고 정상적으로 함수를 종료하면 사용자는 데이터가 저장된 것으로 오해할 수 있습니다.

오류 설명을 추가할 필요가 있다면 원래 오류를 다시 던져 실패한 실행으로 남길 수 있습니다.

try {
  runProtectedTask();

} catch (error) {
  console.error(
    '공유 데이터 처리 실패: ' +
    error.message
  );

  throw error;
}

실패한 데이터를 별도 대기 시트나 큐에 저장하는 구조가 있다면 재처리할 수 있지만, 단순히 예외를 숨기는 것만으로 누락이 해결되지는 않습니다.

LockService가 해결하지 않는 문제

Apps Script 실행 할당량
LockService는 일일 할당량, 한 번의 실행 시간 또는 사용자별 동시 실행 한도를 늘리지 않습니다.
잠금 대기 순서 보장
여러 실행이 기다릴 때 반드시 요청된 순서대로 처리된다고 가정하지 마세요. 업무상 순서가 중요하다면 별도의 생성 시각이나 고유 ID를 기록해야 합니다.
데이터베이스 트랜잭션과 롤백
여러 저장 작업 중 하나가 실패했을 때 이전 작업을 자동으로 되돌려주지 않습니다.
사용자의 직접 셀 편집
LockService는 Apps Script 코드의 보호 구간을 제어합니다. 사용자가 스프레드시트 화면에서 직접 셀을 편집하는 권한까지 잠그지 않습니다.
모든 요청의 성공 보장
지정한 시간 안에 잠금을 획득하지 못하면 요청이 실패할 수 있습니다. 실패 기록과 재시도 정책은 코드에서 별도로 정해야 합니다.

현재 코드에서 선택할 잠금과 수정 위치

여러 사용자가 하나의 접수번호를 증가시킴
getScriptLock()으로 번호 읽기와 저장 구간을 보호합니다.
한 사용자가 버튼을 여러 번 누르는 것만 막고 싶음
사용자별 상태라면 getUserLock()을 검토할 수 있습니다. 다른 사용자의 실행은 동시에 진행됩니다.
같은 스크립트가 여러 문서에서 실행됨
각 문서 작업이 독립적이라면 getDocumentLock()을 검토합니다. 독립형 스크립트나 웹 앱에서는 문서 잠금이 null일 수 있습니다.
tryLock 뒤에도 데이터가 덮어써짐
반환값을 확인하지 않고 계속 실행하는지, 마지막 행을 읽는 코드와 값을 쓰는 코드가 모두 잠금 안에 있는지 확인합니다.
잠금 시간 초과가 자주 발생함
외부 API 요청, 이메일 발송, 긴 반복문과 불필요한 Utilities.sleep()이 잠금 안에 포함됐는지 확인합니다.
동시에 실행되는 스크립트가 너무 많다는 오류가 표시됨
LockService만 추가하지 말고 중복 트리거, 반복 실행과 Apps Script 동시 실행 한도를 함께 확인합니다.

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

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

Post a Comment

다음 이전