Haeminway haeminway
English
기술 노트로
14 분 분량

여러 사람이 한 시트에 쓰는 점검 앱: 동시성·중복·내보내기 설계

체크리포트 데모를 만들며 실제로 헤맨 지점과 코드. 공유 시트 동시 저장은 ScriptLock + 멱등 키로 막고, 셀에는 메타데이터만, 내보내기는 시간 예산으로 끊는다. 좋은 코드와 나쁜 코드를 함께 본다.

이 글은 체크리포트 데모를 만들며 실제로 헤맨 지점과 그때 쓴 코드입니다. 여러 점검자가 하나의 Google Sheet를 공유하는 점검 앱을, GAS만으로 깨지지 않게 만드는 설계를 다룹니다.

화면을 그리는 건 쉽습니다. 어려운 건 여러 명이 같은 데이터를 동시에 써도 안 깨지게 만드는 것입니다. 점검 앱에서 시간을 가장 많이 쓴 곳도 거기였습니다.

핵심

공유 시트 앱의 안정성은 화면이 아니라 저장 경로에서 결정된다. 핵심은 네 가지다 — (1) 저장을 잠그고, (2) 중복을 멱등 키로 거르고, (3) 셀에는 안전한 값만 넣고, (4) 무거운 내보내기는 시간 예산으로 끊는다.

전체 구조

브라우저 (HtmlService 프론트, 모바일 UI)
        │  google.script.run (RPC)

Google Apps Script 백엔드
  - 입력 정규화/검증 (normalize)
  - 저장/완료/삭제/출력 핸들러 (handlers)  ← LockService
  - 시트 어댑터 (sheets)                    ← 헤더 기반 read/write
  - Drive 출력 어댑터 (drive-output)        ← 시간 예산
        │  SpreadsheetApp.openById()

Google Sheets (공유 DB)
  InspectionRecords / InspectionOutputs /
  InspectionCompletions / InspectionRecordDeletes

데이터베이스는 시트 하나, 백엔드는 GAS, 화면은 HtmlService입니다. 공개 주소만 Cloudflare Pages 래퍼가 맡습니다. 서버도 DB도 따로 빌리지 않습니다.

헤맨 지점 1 — 동시에 저장하면 행이 깨진다

appendRow는 원자적이지 않습니다. 두 사용자가 같은 순간에 저장을 누르면, 같은 마지막 행을 두고 경쟁하다 한쪽이 덮이거나 번호가 겹칩니다. (이 천장은 LockService와 30 동시 실행에서 따로 정리했습니다.)

해법은 저장 구간 전체를 잠그고, 반드시 finally에서 푸는 것입니다. 락을 잡지 못하면 빠르게 실패시켜 사용자에게 다시 시도하라고 알립니다.

function saveInspection(payload) {
  const generatedAt = new Date().toISOString();
  let lock = null;
  let lockAcquired = false;

  try {
    const normalized = normalizeInspectionSavePayload_(payload);
    const spreadsheetId = getBootstrapSpreadsheetId_();

    lock = LockService.getScriptLock();
    lockAcquired = lock.tryLock(INSPECTION_SAVE_LOCK_TIMEOUT_MS); // 8000ms
    if (!lockAcquired) {
      throw makeSaveError_('LOCK_TIMEOUT', '다른 저장 작업이 진행 중입니다. 잠시 후 다시 시도해주세요.');
    }

    const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
    const sheet = ensureInspectionRecordsSheet_(spreadsheet);

    // ... 중복 확인 + 행 추가 ...

  } catch (error) {
    return buildInspectionSaveError_(error, generatedAt);
  } finally {
    if (lock && lockAcquired) {
      try { lock.releaseLock(); } catch (_) {}
    }
  }
}

헤맨 지점 2 — 재시도하면 같은 점검이 두 번 저장된다

현장은 네트워크가 불안합니다. 저장 응답이 늦으면 사용자는 다시 누릅니다. 그러면 같은 점검이 두 행으로 들어갑니다.

서버에서 시간으로 막으려 하면 실패합니다. 대신 클라이언트가 만든 멱등 키(clientIdempotencyKey)를 저장 전에 한 번 조회해서, 이미 있으면 새로 쓰지 않고 기존 결과를 그대로 돌려줍니다.

const duplicate = findInspectionRecordByIdempotencyKey_(sheet, normalized.clientIdempotencyKey);
if (duplicate) {
  // 이미 저장된 기록 → 새 행을 만들지 않고 그대로 성공 응답
  return buildInspectionSaveSuccess_(duplicate, true, generatedAt, spreadsheetId);
}

const recordId = generateInspectionRecordId_(generatedAt, normalized.clientIdempotencyKey);
const row = buildInspectionRecordValues_(normalized, recordId, generatedAt);
appendObjectByHeaders_(sheet, INSPECTION_RECORD_HEADERS, row, makeSaveError_, INSPECTION_RECORDS_SHEET_NAME);

조회는 헤더에서 clientIdempotencyKey 열 위치를 찾아 행을 훑습니다. 데모 규모에서는 단순 선형 스캔으로 충분합니다.

function findInspectionRecordByIdempotencyKey_(sheet, clientIdempotencyKey) {
  const values = sheet.getDataRange().getValues();
  if (!Array.isArray(values) || values.length < 2) return null;
  const headers = values[0].map(toText_);
  const keyIndex = headers.indexOf('clientIdempotencyKey');
  if (keyIndex < 0) return null;
  for (let i = 1; i < values.length; i += 1) {
    if (toText_(values[i][keyIndex]) === clientIdempotencyKey) {
      return { rowNumber: i + 1, /* ...스냅샷 필드... */ };
    }
  }
  return null;
}

헤맨 지점 3 — 셀에 아무 값이나 넣으면 터지거나 위험하다

시트 셀에는 세 가지 함정이 있습니다.

(1) 셀 길이 한계. 시트 셀은 5만 자에서 막힙니다. 답변 JSON이 커지면 저장이 통째로 깨집니다. 한계를 넘기 전에 미리 막습니다. (셀에 JSON·이미지를 통째로 넣으면 터진다 참고.)

function jsonCell_(value, label) {
  const text = JSON.stringify(value == null ? null : value);
  if (text.length > INSPECTION_MAX_JSON_CELL_CHARS) { // 45000
    throw makeSaveError_('VALIDATION_FAILED', `${label} 데이터가 너무 큽니다.`);
  }
  return text;
}

(2) 수식 주입. 사용자가 메모에 =, +, @, -로 시작하는 글을 적으면 시트에서 수식으로 실행됩니다. 들어가는 모든 셀 값 앞을 한 번 검사해서, 위험한 시작 문자는 작은따옴표로 무력화합니다.

function sheetSafeCell_(value) {
  if (value == null) return '';
  if (typeof value !== 'string') return value;
  const trimmed = value.trimStart();
  if (/^\d{4}-\d{1,2}$/.test(trimmed)) return `'${value}`;   // 'YYYY-M' 날짜 오인 방지
  return /^[=+\-@]/.test(trimmed) ? `'${value}` : value;     // 수식 주입 차단
}

(3) 사진 본문을 기록 행에 넣지 않는다. 사진을 data URL/base64로 점검 기록에 넣으면 (1)에 바로 걸립니다. 그래서 기록 행에는 메타데이터만 받고, 파일 본문·Drive ID가 섞여 들어오면 거부합니다.

Object.keys(photo).forEach(key => {
  if (/dataurl|base64|content|blob|fileid|driveid|url/i.test(key) || /^bytes$/i.test(key)) {
    throw makeSaveError_('PHOTO_OUTPUT_NOT_ALLOWED',
      'InspectionRecords에는 파일 본문이나 Drive ID를 저장하지 않고 metadata만 저장합니다.');
  }
});

좋은 코드 / 나쁜 코드 — 열은 이름으로 다룬다

시트를 다룰 때 가장 흔한 나쁜 코드는 열 위치를 숫자로 박는 것입니다.

// 나쁨: 열 순서가 바뀌면 조용히 엉뚱한 칸에 쓴다
sheet.appendRow([payload.id, payload.name, payload.status, '', payload.memo]);
const name = row[1]; // 누가 열 하나 끼워넣으면 전부 밀린다

대신 헤더 이름으로 행을 만들고 읽습니다. 열 순서가 바뀌거나 새 열이 생겨도 깨지지 않고, 필요한 헤더가 없으면 명시적으로 실패합니다.

// 좋음: 헤더에서 위치를 찾아 값 객체를 행으로 변환
function appendObjectByHeaders_(sheet, requiredHeaders, valuesByHeader, makeError, sheetName) {
  const headers = requireSheetHeaders_(sheet, requiredHeaders, makeError, sheetName);
  const row = headers.map(header =>
    Object.prototype.hasOwnProperty.call(valuesByHeader, header) ? valuesByHeader[header] : ''
  );
  sheet.appendRow(sheetSafeRow_(row)); // 쓰기 직전 전 셀을 sheetSafeCell_로 정리
}

스키마(헤더 목록)는 상수로 한 곳에 둡니다. 시트의 진실은 코드의 상수와 일치해야 합니다.

const INSPECTION_RECORD_HEADERS = [
  'schemaVersion', 'recordId', 'clientIdempotencyKey', 'facilityNo',
  'plantNameSnapshot', /* ... */ 'answersJson', 'photosJson',
  'clientCreatedAt', 'serverSavedAt', 'appVersion',
];

헤맨 지점 4 — 내보내기가 6분에 걸린다

PDF/PNG를 여러 장 만들면 GAS 실행 시간 한계(6분 천장)에 걸립니다. 한 번에 다 하려다 통째로 실패하면 사용자는 처음부터 다시 해야 합니다.

해법은 소프트 시간 예산을 두고, 넘으면 거기까지 저장한 뒤 “이어서”를 안내하는 것입니다. 같은 요청으로 다시 부르면 이미 저장된 출력물은 건너뜁니다(여기서도 멱등 키가 일합니다).

function enforceInspectionOutputTimeBudget_(startedAtMs, nextIndex, outputs, files) {
  if (nextIndex >= outputs.length) return;
  const elapsedMs = Math.max(0, currentTimeMillis_() - Number(startedAtMs || 0));
  if (elapsedMs <= INSPECTION_OUTPUT_SOFT_TIME_LIMIT_MS) return; // 270000ms = 4.5분
  throw makeOutputError_('TIME_BUDGET_EXCEEDED',
    '출력 저장 시간이 길어져 일부만 저장했습니다. 같은 요청으로 다시 실행하면 이어서 저장합니다.',
    { partialFiles: files.slice(), resume: { retrySamePayload: true, processedCount: nextIndex, totalCount: outputs.length } });
}

PDF 자체는 HtmlService로 만들고, 너무 크면 막습니다. (캔버스로 화면을 PNG로 뽑을 때의 모바일 함정은 모바일 화면 이미지 저장에 따로 있습니다.)

const blob = HtmlService.createHtmlOutput(html)
  .setTitle(normalized.fileName)
  .getAs(MimeType.PDF)
  .setName(normalized.fileName);
if (blob.getBytes().length > INSPECTION_OUTPUT_MAX_FILE_BYTES) { // 12MB
  throw makeOutputError_('PAYLOAD_TOO_LARGE', 'PDF는 12MB 이하여야 합니다. 사진 수나 해상도를 줄여주세요.');
}

래퍼에 임베드하려면 doGet에서 프레임 허용

공개 주소를 Cloudflare Pages 래퍼로 두고 GAS 앱을 그 안에서 띄우려면, doGet에서 프레임 노출을 허용해야 합니다. (재배포해도 주소가 안 바뀌게 하는 법은 따로 있습니다.)

function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('일반 설비 점검 모바일')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0, viewport-fit=cover')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

정리 — 가져갈 원칙

핵심
  • 저장은 락 안에서 한 묶음으로: 락 → 멱등 조회 → 쓰기, 그리고 finally에서 해제.
  • 중복은 시간이 아니라 키로 막는다: 클라이언트가 만든 멱등 키를 저장 전에 조회.
  • 셀에는 안전한 값만: 길이 제한, 수식 주입 차단, 파일 본문은 시트가 아니라 Drive로.
  • 열은 숫자가 아니라 이름으로: 헤더 기반 read/write로 스키마 변경에 강하게.
  • 무거운 작업은 시간 예산으로 끊고 재개 가능하게: 6분 천장을 설계에 미리 넣는다.

이 방식이 맞지 않는 경우

  • 하루 수천 건 이상의 동시 저장이 쌓이는 경우 — 선형 스캔과 단일 시트가 한계입니다.
  • 법정 안전 판정 자체를 시스템이 대신해야 하는 경우.
  • 완전 오프라인 + 자동 동기화가 필수인 경우.

이때는 처음부터 전용 백엔드나 외부 DB를 검토하세요. 한계 신호는 언제 GAS를 떠나야 하나에 정리해 두었습니다. GAS는 현장 기록과 보고서 흐름을 가볍게 묶는 데 강하고, 범위만 정확하면 그 일을 유지비 없이 해냅니다.

자주 묻는 질문

여러 사람이 동시에 저장할 때 행이 깨지지 않으려면 어떻게 해야 하나요?
LockService.getScriptLock()으로 저장 구간 전체를 잠그고, 락 해제는 반드시 finally 블록에 둡니다. 락을 얻지 못하면 즉시 실패시켜 사용자에게 재시도를 안내합니다.
네트워크가 끊겨서 저장 버튼을 두 번 누르면 같은 점검이 중복 저장되나요?
클라이언트가 생성한 멱등 키(clientIdempotencyKey)를 저장 전에 먼저 조회합니다. 이미 있는 키라면 새 행을 만들지 않고 기존 결과를 그대로 반환하므로 중복이 생기지 않습니다. 단, 이 조회와 쓰기는 반드시 같은 락 안에서 실행해야 합니다.
PDF 내보내기를 여러 장 처리하면 Apps Script 실행 시간 한계에 걸리는데 어떻게 대처하나요?
소프트 시간 예산(4.5분)을 두고, 초과하면 저장한 파일만 반환하고 재시도 안내를 함께 돌려줍니다. 같은 요청으로 다시 실행하면 이미 저장된 출력은 건너뛰어 이어서 처리합니다.