Smart Life US

← 목록 · 2026-08-18 · 엑셀·업무 자동화

구글시트 체크인 예약 상태 자동 변경: Apps Script로 구현하기 — 체크인 4편

구글시트 체크인 예약 상태 자동 변경: Apps Script로 구현하기 — 체크인 4편

도입: 체크인을 했는데 예약 상태가 그대로라면

구글시트 체크인 예약 상태 자동 변경이 되지 않으면, 현장에서는 곧바로 혼선이 생깁니다. 체크인 화면에서 기사 정보와 사진까지 잘 들어가는데, 정작 어느 차량이 도착했는지는 아무 데도 남지 않는 경우가 많습니다. 도착한 차량인데도 사무실에서는 알 수 없으니, 도어 배정이나 생산·하적 계획이 뒤로 밀리기 쉽습니다. 이 글에서는 체크인이 저장되는 순간 '도착' 기록을 자동으로 남기고, 이미 완료·취소된 예약은 다시 체크인되지 않게 막는 Apps Script 구현 방법을 단계별로 정리합니다.

한 가지 먼저 정해 두겠습니다. 예약 시트에는 상태 열을 만들지 않습니다. 예약 시트는 기본 1편이 만든 A~M 13열 그대로 두고, 체크인 상태는 CHECKIN_LOG 시트에 한 줄씩 쌓습니다. 이유는 두 가지입니다. 첫째, 예약 행에 칸을 하나 더 붙이면 앞 편들의 정원 계산·도어 배정 함수가 읽는 자리가 흔들립니다. 둘째, 상태를 한 칸에 덮어쓰면 '예약 → 도착 → 완료' 로 언제 넘어갔는지가 남지 않습니다. 로그로 쌓으면 체류 시간이나 노쇼 비율을 나중에 그대로 뽑아낼 수 있습니다.

체크인 상태 자동변경 흐름

이 글은 구글시트 입고 체크인 자동화의 네 번째 편입니다. 앞 글들에서 이미 ① 기사님이 컨테이너 번호로 예약을 찾는 체크인 웹앱 화면, ② GPS로 사이트 바깥 체크인을 막는 검증, ③ 체크인 사진을 Google Drive와 체크인 사진용 시트에 저장하는 기능까지 만들었습니다. 이번 편에서는 이 흐름의 마지막 단계인 예약 상태 자동 변경과 체크인 시각 기록을 완성합니다.

이 시리즈는 예약 관리로 시작했습니다. 지금처럼 예약·지연·체크인·도크 배정·상태 기록이 이어진 형태가 처음부터 그려져 있었던 것은 아닙니다. 각 기능은 책상에서 상상해 붙인 것이 아니라, 앞 단계를 실제로 쓰면서 드러난 다음 문제를 풀며 늘어난 결과입니다.

예약표를 만들고 나니 실제 도착이 안 맞았고, 도착을 기록하기 시작하니 도어 배정과 이어야 했고, 도어를 배정하니 기사에게 전달할 방법이 필요했습니다. 이 편의 상태 기록도 같은 순서로 나왔습니다. 체크인은 저장되는데 그 예약이 지금 어느 단계인지 물어볼 곳이 없었습니다.


체크인 후 예약 상태 자동화가 필요한 이유

입고 예약을 구글시트로 관리할 때 가장 먼저 떠올리는 방법은 예약 시트에 상태 칸 하나를 만들어 담당자가 수동으로 '도착', '완료' 같은 값을 입력하는 방식입니다. 초기에는 단순하고 편해 보이지만, 일정 규모를 넘어서면 여러 문제가 드러납니다.

운영 현장에서 자주 있었던 사례는 다음과 같습니다. 기사님이 체크인 웹앱에서 이미 체크인을 마쳤는데, 사무실 담당자가 그 사실을 몰라 기존 예약 상태를 그대로 두거나 엉뚱한 상태로 바꿔 버리는 경우입니다. 같은 차량에 도어가 이중 배정되거나, 이미 처리된 예약이 다시 '도착'으로 돌려지는 일이 발생했습니다. 원인은 간단합니다. 체크인 저장과 예약 상태 변경을 서로 다른 시점, 서로 다른 사람 손으로 처리했기 때문입니다.

그래서 이번 편에서는 다음과 같은 원칙으로 설계를 합니다.

  1. 체크인 저장과 예약 상태 변경을 하나의 함수에서 한 번에 처리합니다.
  2. 어떤 상태에서 어떤 상태로만 바뀔 수 있는지 상태 전환 표로 제한합니다.
  3. 이미 완료·취소된 예약을 다시 도착으로 바꾸는 등 허용되지 않는 전환은 코드 차원에서 거부합니다.

이 구조를 적용하면 체크인과 동시에 상태 기록이 자동으로 남고, 예약 시트는 앞 편들이 읽던 모습 그대로 유지됩니다. 나중에 체류 시간이나 노쇼 비율 같은 지표를 뽑을 때도 CHECKIN_LOG 한 시트만 보면 됩니다.


체크인 저장과 상태 변경을 한 번에: saveCheckInData()

이번 편의 중심은 웹앱에서 호출하는 saveCheckInData() 함수입니다. 이 함수 안에서 잠금 → 입력값 검증 → 예약 조회 → 상태·체크인 시각 갱신 → 결과 반환까지를 한 번에 처리합니다. 이렇게 구성하면 중간 단계에서 일부만 저장되는 상황을 줄일 수 있습니다.

이 함수는 앞 편들이 만든 두 함수를 그 편이 정한 반환 규격 그대로 사용합니다. 규격을 잘못 읽으면 오류 없이 undefined 가 흘러들어 가므로, 필드 이름을 그대로 옮겨 적습니다.

  • CHK2_validateCheckinData_(payload)GPS 거리 검증 편의 입력 검증. { ok, message, cleaned } 를 돌려줍니다. error·data 가 아닙니다.
  • getApptInfoByCntr(containerNo)컨테이너 번호 체크인 편의 오늘 예약 조회. { found, reason, message, data } 를 돌려주고, 행 번호는 data.rowIndex 에 들어 있습니다.
  • 사진까지 함께 받는다면 체크인 사진 드라이브 저장 편CHK_saveImageToDrive() 을 같은 data.rowIndex 로 부르면 됩니다.

아래 코드를 구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 아래에 붙여 넣습니다. 붙여 넣은 뒤에는 저장(⌘S 또는 Ctrl+S) 후, testSaveCheckInData_()를 한 번 실행해 권한을 승인합니다.

이 코드는 체크인 요청(payload)을 받아 검증하고, 잠금을 걸고, 체크인 성공 시 예약 상태·체크인 시각을 갱신해 결과를 돌려줍니다.

Apps Script (JavaScript)
// → 체크인 전체 흐름을 처리하는 설정
// 예약 시트는 **기본 1편이 만든 A~M 13열 그대로**다. 여기에 상태 열을 새로 붙이지 않는다.
// A 예약일자 B 시작시간 C 장비유형 D 도어 E 컨테이너 F 운송사 G 고객사 H 비고
// I 종료시간 J 생성일시 K 수량 L 팔레트 M 예약ID — 상태 칸은 없다.
// 체크인 상태는 **CHECKIN_LOG 시트에 한 줄씩 쌓는다.** 예약 행을 덮어쓰지 않으므로
// 정원 계산·도어 배정 같은 앞 편 함수가 읽는 값이 하나도 바뀌지 않고,
// '누가 언제 어떤 상태로 바꿨는지'가 그대로 남아 나중에 되짚어 볼 수 있다.
const CHK_CONFIG = {                                     // → 체크인 관련 상수 모음
  LOCK_TIMEOUT_MS: 30000,                                // → 잠금 대기 최대 30초
  APPT_SHEET_NAME: 'APPT_MAIN',                          // → 예약 시트 이름
  CHECKIN_SHEET_NAME: 'CHECKIN_LOG',                     // → 체크인 기록 시트(이 편에서 만든다)
  APPT_COL_DATE: 1,                                      // → A: 예약일자
  APPT_COL_CONTAINER: 5,                                 // → E: 컨테이너 번호
  APPT_COL_BOOKING_ID: 13,                               // → M: 예약ID
  APPT_COL_LAST: 13,                                     // → 한 번에 읽을 열 수(A~M)
  TIMEZONE: 'America/New_York'                           // → 고정 시간대
};                                                       //

// 체크인 기록 시트 머리글 — 상태 변경 한 번이 한 줄이다.
const CHK_LOG_HEADERS = ['기록일시', '예약일자', '컨테이너', '예약행',
                         '예약ID', '이전상태', '상태', '비고'];
const CHK_LOG_COL_BOOKING_ID = 5;                        // → E: 예약ID(현재 상태를 되짚는 열쇠)
const CHK_LOG_COL_STATUS = 7;                            // → G: 상태

// → 허용되는 상태 전환 정의
const CHK_STATUS_TRANSITIONS = {                         // → 상태 전환 규칙 표
  '': ['도착'],                                          // → 비어 있으면 처음 체크인 시 '도착'
  '예약': ['도착'],                                      // → 예약 → 도착
  '도착': ['도착'],                                      // → 이미 도착이면 그대로 허용(시간만 갱신 가능)
  '진행중': [],                                          // → 진행중이면 체크인 변경 불가
  '완료': [],                                            // → 완료된 예약은 더 이상 체크인 불가
  '취소': []                                             // → 취소된 예약은 체크인 불가
};                                                       // 

// → 체크인 데이터 전체를 저장하고 상태를 바꾸는 함수
function saveCheckInData(payload) {                      // → 웹앱에서 호출하는 진입점
  const lock = LockService.getScriptLock();              // → 스크립트 잠금 객체
  const acquired = lock.tryLock(CHK_CONFIG.LOCK_TIMEOUT_MS); // → 잠금 시도
  if (!acquired) {                                       // → 잠금 실패 시
    return {                                             // → 오류 결과 반환
      ok: false,
      error: '다른 사용자가 처리 중입니다. 잠시 후 다시 시도하세요.'
    };
  }                                                      // 
  try {                                                  // → 잠금 안에서 작업
    // 2편 검증 함수의 반환 규격은 { ok, message, cleaned } 다. error·data 가 아니다.
    const validated = CHK2_validateCheckinData_(payload); // → 2편 검증 함수 호출
    if (!validated.ok) {                                 // → 검증 실패 시
      return {                                           // → 오류 반환
        ok: false,
        error: validated.message || '체크인 데이터가 올바르지 않습니다.'
      };
    }                                                    // 

    const containerNo = validated.cleaned.containerNo;   // → 정제된 컨테이너 번호
    // 1편 조회 함수의 반환 규격은 { found, reason, message, data } 다.
    // 행 번호는 data.rowIndex 에 들어 있다 — 최상위에 rowIndex 는 없다.
    const found = getApptInfoByCntr(containerNo);        // → 오늘 예약 조회(1편 함수)
    if (!found.found) {                                  // → 오늘 예약을 못 찾았을 때
      return {                                           // → 사유까지 그대로 전달
        ok: false,
        reason: found.reason,
        error: found.message
      };
    }                                                    // 

    // → 체크인 기록을 남긴다(예약 행은 건드리지 않는다)
    const rec = CHK_recordCheckin_(found.data.rowIndex,  // → 1편이 알려 준 행 번호
      containerNo, '도착');                              // → 목표 상태
    if (!rec.ok) {                                       // → 상태 전환 실패 시
      return {                                           // → 오류 반환
        ok: false,
        error: rec.error
      };
    }                                                    // 

    return {                                             // → 최종 성공 결과
      ok: true,
      apptRow: found.data.rowIndex,                      // → 예약 행 번호
      prevStatus: rec.prevStatus,                        // → 직전 상태
      newStatus: rec.newStatus,                          // → 새 상태
      checkinAt: rec.checkinAt                           // → 체크인 시각 문자열
    };
  } finally {                                            // → 예외 여부와 상관없이
    lock.releaseLock();                                  // → 잠금 해제
  }                                                      // 
}                                                        // 

// → 수동 테스트용 함수(샘플 payload로 실행)
function testSaveCheckInData_() {                        // → 코드 변경 후 검증용
  const sample = {                                       // → 예시 체크인 데이터
    containerNo: 'TEST1234567',                          // → 테스트 컨테이너 번호
    driverName: '홍길동',                                // → 기사 이름 예시
    lat: 0,                                              // → 좌표 예시
    lng: 0,                                              // → 좌표 예시
    photoDataUrl: null                                   // → 이번 편에서는 사진 생략
  };                                                     // 
  const result = saveCheckInData(sample);                // → 실제 함수 호출
  Logger.log(JSON.stringify(result));                    // → 결과를 로그로 확인
}                                                        // 

제대로 됐는지 확인하는 법: Apps Script 편집기에서 testSaveCheckInData_()를 실행한 뒤, 실행 로그에 { "ok": ... } 형태의 JSON이 찍히면 기본 흐름은 정상입니다. 에러 메시지가 나온다면 그 내용을 보고 시트 이름이나 테스트 컨테이너 번호를 점검합니다.


상태 전환 규칙 표 만들기: CHK_transitionStatus_()

상태를 아무 값으로나 덮어쓰게 두면 데이터가 빠르게 뒤엉깁니다. 어떤 담당자는 '도착', 다른 사람은 'ARRIVED'처럼 영문을 쓰고, 또 누군가는 '입차'처럼 자체 용어를 쓸 수 있습니다. 심지어 이미 '완료'된 예약이 실수로 다시 '도착'으로 돌아가는 일도 생깁니다. 이런 혼선을 막으려면 상태 전환 규칙을 표로 선언해 두고, 그 표가 허용하는 경우에만 상태를 바꾸도록 제한해야 합니다.

이를 담당하는 함수가 CHK_transitionStatus_()입니다. 위에서 정의한 CHK_STATUS_TRANSITIONS 객체를 사용해 현재 상태에서 허용되는 다음 상태 목록을 조회합니다. 함수는 현재 상태와 목표 상태를 받아, 허용되는 전환이면 성공, 아니면 실패와 이유를 반환합니다.

아래 코드를 같은 Code.gs 파일에서 saveCheckInData 아래에 붙여 넣습니다. 저장 후에는 CHK_testTransitionStatus_()를 실행해 로그를 확인합니다.

Apps Script (JavaScript)
// → 상태 전환이 가능한지 검사하는 도우미
function CHK_transitionStatus_(currentStatus, targetStatus) { // → 상태 전환 검사
  const cur = (currentStatus || '').toString().trim();   // → 현재 상태 문자열 정리
  const next = (targetStatus || '').toString().trim();   // → 목표 상태 문자열 정리

  if (!next) {                                           // → 목표 상태가 비었으면
    return { ok: false, error: '목표 상태가 비어 있습니다.' }; // → 오류 반환
  }                                                      // 

  const allowedNext = CHK_STATUS_TRANSITIONS[cur];       // → 허용 목록 조회
  if (!allowedNext) {                                    // → 정의되지 않은 현재 상태
    return {                                             // → 전환 거부
      ok: false,
      error: '현재 상태에서 전환 규칙이 정의되지 않았습니다: ' + cur
    };
  }                                                      // 

  if (allowedNext.indexOf(next) === -1) {                // → 허용되지 않는 목표 상태
    return {                                             // → 전환 거부
      ok: false,
      error: Utilities.formatString(                     // → 상세 메시지 작성
        '상태를 "%s"에서 "%s"(으)로 바꿀 수 없습니다.',  // → 오류 문구 템플릿
        cur || '(빈 상태)',                               // → 현재 상태 표현
        next                                             // → 목표 상태 표현
      )
    };
  }                                                      // 

  return { ok: true };                                   // → 전환 허용
}                                                        // 

// → 상태 전환 규칙을 확인하는 테스트 함수
function CHK_testTransitionStatus_() {                   // → 수동 테스트용
  const cases = [                                        // → 테스트 시나리오 배열
    { cur: '', next: '도착' },                           // → 처음 → 도착
    { cur: '예약', next: '도착' },                       // → 예약 → 도착
    { cur: '완료', next: '도착' }                        // → 완료 → 도착(거부 기대)
  ];                                                     // 
  cases.forEach(function(c) {                            // → 각 케이스 반복
    const r = CHK_transitionStatus_(c.cur, c.next);      // → 함수 호출
    Logger.log('%s -> %s : %s',                          // → 결과 로그
      c.cur, c.next, JSON.stringify(r));                 // → 상태/응답 출력
  });                                                    // 
}                                                        // 

제대로 됐는지 확인하는 법: CHK_testTransitionStatus_()를 실행한 뒤 로그에서 '' -> 도착'예약 -> 도착'의 결과가 { "ok": true }, '완료 -> 도착'의 결과가 { "ok": false, ... }로 나오는지 확인합니다. 이렇게 상태 전환 규칙을 코드로 고정해 두면, 나중에 현장 기준이 바뀌어도 이 표만 수정해 일관되게 반영할 수 있습니다.


체크인 기록 남기기: CHK_recordCheckin_()

이제 실제로 상태를 기록하는 함수를 만듭니다. 예약 시트는 읽기만 하고 쓰지 않습니다. 흐름은 이렇습니다.

  1. 1편 조회가 알려 준 행 번호를 받습니다. 컨테이너 번호로 다시 찾지 않습니다.
  2. 화면이 열려 있는 동안 행이 밀렸을 수 있으니, 그 행의 컨테이너와 날짜를 한 번 더 대조합니다. (3편 사진 저장과 같은 방식입니다.)
  3. 그 행의 M열 예약ID를 읽습니다. 행 번호는 밀릴 수 있지만 예약ID는 그대로라, 이것이 상태를 되짚는 열쇠가 됩니다.
  4. CHECKIN_LOG 에서 그 예약ID의 가장 마지막 줄을 찾아 현재 상태를 읽습니다.
  5. 허용되는 전환인지 확인한 뒤, 로그에 새 줄을 한 줄 붙입니다.

구현 시 핵심은 다음과 같습니다.

  • 예약 정보에는 최소한 대상 행 번호(rowIndex)가 포함되어 있어야 합니다.
  • 현재 상태를 읽어 CHK_transitionStatus_()로 전환 가능 여부를 확인합니다.
  • 전환이 허용되지 않으면 시트에 아무 값도 쓰지 않고 이유를 돌려줍니다.
  • 체크인 시각은 Date 객체로 쓰고, 표시 형식만 사람이 읽기 좋은 형태로 지정합니다.

아래 코드를 CHK_transitionStatus_ 아래에 이어 붙여 넣습니다. 저장 후 CHK_testRecordCheckin_()로 동작을 검증합니다.

Apps Script (JavaScript)
// → 체크인 기록 시트를 준비한다(없으면 만들고, 비어 있으면 머리글을 넣는다)
function CHK_getLogSheet_() {                            // → 체크인 로그 시트
  const ss = SpreadsheetApp.getActive();                 // → 현재 스프레드시트
  let sheet = ss.getSheetByName(CHK_CONFIG.CHECKIN_SHEET_NAME); // → 있으면 그대로
  if (!sheet) {                                          // → 없으면
    sheet = ss.insertSheet(CHK_CONFIG.CHECKIN_SHEET_NAME); // → 새로 만든다
  }                                                      //
  if (sheet.getLastRow() === 0) {                        // → 아직 비어 있으면
    sheet.getRange(1, 1, 1, CHK_LOG_HEADERS.length)      // → 1행에
      .setValues([CHK_LOG_HEADERS]);                     // → 머리글을 넣는다
    sheet.setFrozenRows(1);                              // → 머리글 고정
  }                                                      //
  return sheet;                                          // → 시트 반환
}                                                        //

// → 그 예약의 **현재 상태**는 로그의 가장 마지막 줄이다
function CHK_getCurrentStatus_(bookingId) {              // → 현재 상태 조회
  const sheet = CHK_getLogSheet_();                      // → 로그 시트
  const last = sheet.getLastRow();                       // → 마지막 행
  if (last < 2 || !bookingId) {                          // → 기록이 없거나 열쇠가 없으면
    return '';                                           // → 아직 체크인 전
  }                                                      //
  const rows = sheet.getRange(2, 1, last - 1,            // → 머리글 아래 전부
    CHK_LOG_HEADERS.length).getValues();                 // → 한 번에 읽는다
  for (let i = rows.length - 1; i >= 0; i--) {           // → **뒤에서부터** 찾는다
    const id = String(rows[i][CHK_LOG_COL_BOOKING_ID - 1] || '').trim();
    if (id && id === bookingId) {                        // → 같은 예약이면
      return String(rows[i][CHK_LOG_COL_STATUS - 1] || '').trim(); // → 그게 현재 상태
    }                                                    //
  }                                                      //
  return '';                                             // → 이 예약은 기록이 없다
}                                                        //

// → 체크인 기록 한 줄을 남긴다. **예약 시트는 읽기만 하고 쓰지 않는다.**
function CHK_recordCheckin_(apptRow, containerNo, targetStatus) {
  const rowNo = Number(apptRow);                         // → 행 번호 숫자로
  if (!Number.isInteger(rowNo) || rowNo < 2) {           // → 머리글 아래여야 한다
    return { ok: false, error: '예약 행 번호가 올바르지 않습니다.' };
  }                                                      //
  const cntr = String(containerNo || '').trim().toUpperCase(); // → 대문자로 통일
  const ss = SpreadsheetApp.getActive();                 // → 현재 스프레드시트
  const sheet = ss.getSheetByName(CHK_CONFIG.APPT_SHEET_NAME); // → 예약 시트
  if (!sheet || rowNo > sheet.getLastRow()) {            // → 시트·행이 없을 때
    return { ok: false, error: '예약 행을 찾을 수 없습니다. 다시 조회해 주세요.' };
  }                                                      //

  // 조회한 뒤 행이 밀렸을 수 있다. **그 행이 정말 그 컨테이너의 오늘 예약인지**
  // 기록 직전에 한 번 더 본다(3편 사진 저장과 같은 방식).
  const row = sheet.getRange(rowNo, 1, 1, CHK_CONFIG.APPT_COL_LAST).getValues()[0];
  const rowCntr = String(row[CHK_CONFIG.APPT_COL_CONTAINER - 1] || '').trim().toUpperCase();
  if (rowCntr !== cntr) {                                // → 다른 예약
    return { ok: false, error: '예약 정보가 바뀌었습니다. 다시 조회해 주세요.' };
  }                                                      //
  let rowYmd;                                            // → 그 행의 예약일자
  try {                                                  // → 날짜 통일
    rowYmd = APPT_ymd_(row[CHK_CONFIG.APPT_COL_DATE - 1]); // → 'YYYY-MM-DD'
  } catch (e) {                                          // → 날짜를 못 읽음
    return { ok: false, error: '예약 행의 날짜를 읽을 수 없습니다(행 ' + rowNo + ').' };
  }                                                      //
  if (rowYmd !== APPT_ymd_(new Date())) {                // → 오늘 예약이 아님
    return { ok: false, error: '오늘 예약이 아닙니다. 다시 조회해 주세요.' };
  }                                                      //

  const bookingId = String(row[CHK_CONFIG.APPT_COL_BOOKING_ID - 1] || '').trim();
  if (!bookingId) {                                      // → 예약ID가 비어 있으면
    return { ok: false, error: '예약ID가 비어 있어 체크인을 기록할 수 없습니다(행 ' + rowNo + ').' };
  }                                                      //

  const cur = CHK_getCurrentStatus_(bookingId);          // → 로그에서 현재 상태
  const can = CHK_transitionStatus_(cur, targetStatus);  // → 전환 가능 여부
  if (!can.ok) {                                         // → 허용되지 않는 전환
    return { ok: false, error: can.error };              // → 오류 반환
  }                                                      //

  const now = new Date();                                // → 현재 시각
  const logSheet = CHK_getLogSheet_();                   // → 로그 시트
  logSheet.appendRow([                                   // → 상태 변경 한 번 = 한 줄
    now,                                                 // → A: 기록일시
    rowYmd,                                              // → B: 예약일자
    cntr,                                                // → C: 컨테이너
    rowNo,                                               // → D: 예약행
    bookingId,                                           // → E: 예약ID
    cur,                                                 // → F: 이전상태
    targetStatus,                                        // → G: 상태
    ''                                                   // → H: 비고
  ]);                                                    //
  logSheet.getRange(logSheet.getLastRow(), 1)            // → 기록일시 칸
    .setNumberFormat('yyyy-MM-dd HH:mm');                // → 표시 형식

  return {                                               // → 결과 반환
    ok: true,
    prevStatus: cur,                                     // → 직전 상태
    newStatus: targetStatus,                             // → 새 상태
    checkinAt: Utilities.formatDate(now,                 // → 화면에 그대로 쓸 문자열
      CHK_CONFIG.TIMEZONE, 'yyyy-MM-dd HH:mm')
  };                                                     //
}                                                        //

// → 체크인 기록 동작을 검증하는 테스트 함수
function CHK_testRecordCheckin_() {                      // → 수동 테스트용
  // 1편 조회를 먼저 돌려 **오늘 실제로 있는 예약의 행 번호**를 얻는다.
  const TEST_CONTAINER = 'TEST1234567';                  // → 오늘 예약이 있는 번호로 교체
  const found = getApptInfoByCntr(TEST_CONTAINER);       // → 1편 조회 함수
  if (!found.found) {                                    // → 오늘 예약이 없으면
    Logger.log('오늘 예약을 찾지 못했습니다: ' + found.reason + ' / ' + found.message);
    return;                                              // → 여기서 멈춘다
  }                                                      //
  const r = CHK_recordCheckin_(found.data.rowIndex,      // → 조회가 알려 준 행 번호
    TEST_CONTAINER, '도착');                             // → 목표 상태
  Logger.log(JSON.stringify(r));                         // → 결과 확인
  Logger.log('한 번 더: ' + JSON.stringify(               // → 같은 예약 두 번째 실행
    CHK_recordCheckin_(found.data.rowIndex, TEST_CONTAINER, '도착')));
}                                                        //

제대로 됐는지 확인하는 법: 오늘 날짜로 테스트 예약을 한 건 넣어 두고 CHK_testRecordCheckin_()을 실행합니다. CHECKIN_LOG 시트가 자동으로 생기고 머리글 아래에 이전상태가 비어 있고 상태가 '도착'인 줄이 하나 생기면 성공입니다.

이 테스트는 같은 예약을 연달아 두 번 기록합니다. 두 번째 로그에는 이전상태가 '도착'인 줄이 하나 더 붙어야 합니다 — 전환 규칙에서 '도착 → 도착'을 허용해 두었기 때문입니다. 반대로 CHECKIN_LOG 에 그 예약ID로 상태가 '완료'인 줄을 손으로 하나 넣어 둔 뒤 다시 실행하면, 새 줄이 생기지 않고 실행 로그에 { "ok": false, "error": ... }가 찍혀야 올바르게 동작하는 것입니다.


실무 팁: 동시 저장, 열 위치, 테스트에서 자주 막히는 부분

이 정도 구조만 갖추고도 구글시트 체크인 자동화는 상당 부분 안정됩니다. 다만 실제 적용 과정에서는 몇 가지 반복되는 실수가 있습니다. 경험상 자주 막히는 지점들을 정리하면 다음과 같습니다.

첫째, LockService를 빼먹거나 잠금 시간을 너무 짧게 잡는 문제가 많습니다. 체크인 웹앱을 여러 단말에서 동시에 사용할 경우, 같은 예약에 대한 체크인 요청이 거의 동시에 두 번 들어올 수 있습니다. 잠금 없이 상태를 쓰면 마지막에 도착한 요청이 앞서 처리된 결과를 덮어씁니다. 이 글처럼 tryLockfinally를 사용하는 패턴을 지켜야 동시 저장 충돌을 상당 부분 예방할 수 있습니다.

둘째, 예약 시트에 상태 칸을 임의로 만들어 붙이는 경우가 있습니다. 이 시리즈의 예약 시트는 A~M 13열로 고정돼 있고, 정원 계산·도어 배정 함수가 그 자리를 기준으로 읽습니다. 칸을 하나 더 붙이면 당장은 문제가 없어 보여도, 나중에 열을 옮기거나 지우는 순간 앞 편 함수들이 통째로 어긋납니다. 상태는 이 글처럼 CHECKIN_LOG 에 쌓고, 예약ID로 이어 붙이는 편이 안전합니다.

셋째, 테스트 데이터와 실제 시트 내용이 맞지 않아 예약을 못 찾는 경우가 잦습니다. testSaveCheckInData_()CHK_testRecordCheckin_()에 들어 있는 'TEST1234567'은 단순 예시이므로, 오늘 날짜로 실제 예약이 들어 있는 컨테이너 번호로 교체해야 합니다. 1편 조회 함수가 '오늘 예약'만 찾기 때문에, 어제 날짜로 넣어 둔 테스트 데이터로는 아무리 실행해도 NOT_FOUND_TODAY 만 나옵니다.

마지막으로, 완료·취소 상태에서의 재체크인 정책을 사전에 정리해 두는 것이 중요합니다. 이 글의 기본 코드는 완료·취소된 예약에 대해서는 체크인을 거부합니다. 현장 사정상 예외적으로 허용해야 한다면, 먼저 운영 기준을 문서로 정리한 뒤 CHK_STATUS_TRANSITIONS에 허용 목록을 추가하고, 그에 맞춰 안내 문구와 교육을 함께 진행하는 편이 좋습니다. 코드만 바꾸고 사람 기준을 바꾸지 않으면 오히려 혼란이 커집니다.


맺음말

이번 글에서는 구글시트 체크인 자동화 흐름의 마지막 조각, 즉 체크인과 동시에 '도착' 기록을 남기는 Apps Script 구현을 마무리했습니다. 핵심은 세 가지입니다. saveCheckInData() 한 함수 안에서 잠금·검증·예약 조회·기록을 모두 처리하고, CHK_STATUS_TRANSITIONSCHK_transitionStatus_()로 허용되지 않는 상태 변경을 사전에 차단하며, 상태를 예약 행에 덮어쓰지 않고 CHECKIN_LOG 에 쌓아 앞 편들이 읽는 예약 시트를 그대로 지키는 구조입니다.

지금 바로 할 수 있는 행동은 한 가지입니다. 오늘 날짜로 테스트 예약을 한 건 넣어 두고 CHK_testRecordCheckin_()을 실행해 보시기 바랍니다. CHECKIN_LOG 시트가 자동으로 만들어지고 '도착' 한 줄이 쌓이는 것을 확인하면, 웹앱과 연결했을 때 어떻게 동작할지 바로 감이 오실 것입니다. 예약 시트는 한 칸도 바뀌지 않는다는 점도 함께 확인해 보세요 — 앞 편의 정원 계산과 도어 배정이 그대로 도는 이유가 그것입니다.