Smart Life US

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

구글시트 체크인 사진 드라이브 저장 방법: Apps Script로 날짜별 정리

구글시트 체크인 사진 드라이브 저장 방법: Apps Script로 날짜별 정리

도입: 체크인 사진, 왜 매번 흩어질까요

구글시트로 입고 예약과 도착 체크인을 운영하다 보면 어느 순간 같은 고민에 부딪힙니다. 기사에게 받은 BOL·POD 사진이 이메일, 메신저, 개인 Google Drive에 제각각 흩어지고, 나중에 클레임이 생겼을 때 어느 컨테이너 사진이 어디 있는지 찾느라 시간을 많이 씁니다. 파일 이름 규칙도 사람마다 달라 검색도 잘 되지 않습니다.

체크인 사진 자동저장 흐름

이 글은 그런 상황을 줄이기 위한 실무형 해법을 다룹니다. 구글시트 체크인 화면에서 찍은 BOL·POD 사진을 Google Drive에 날짜별 폴더로 자동 저장하고, 예약 시트에 사진 링크를 함께 남기는 구글 앱스스크립트 구현 방법을 정리합니다. 앞 글인 구글시트 컨테이너 번호 체크인: 기사 도착 예약 조회 만들기에서 체크인 화면과 예약 조회를 만들고, 구글시트 체크인 GPS 거리 검증: 현장 밖 체크인 차단하기에서 GPS 검증까지 붙였다면, 이번 편은 그 위에 물류 체크인 사진 관리 구글시트 자동화를 얹는 단계입니다.

아래 코드는 실제 창고 운영에서 돌려본 구조를 기준으로 정리했으며, 시리즈 다른 편과 충돌하지 않도록 CHK_ 접두어를 사용합니다. 예약을 기록하는 시트 이름은 실명이 아니라 예약 메인 시트라는 일반 명칭으로 설명합니다.


구글시트 체크인 사진을 드라이브에 저장하는 전체 구조

구글시트 기반 체크인 화면에서 사진을 받는 구조는 대부분 비슷합니다. 웹앱 또는 사이드바에서 웹캠·휴대폰 카메라로 촬영한 이미지를 base64 문자열(예: data:image/jpeg;base64,...)로 만든 뒤, google.script.run이나 HTTP POST로 서버(구글 앱스스크립트) 쪽 함수에 넘깁니다. 이때 바로 시트에 이미지를 붙여넣으면 용량과 속도 문제가 생기므로, 보통 다음 흐름을 사용합니다.

첫째, 프런트엔드에서 base64 이미지 문자열과 컨테이너 번호(또는 예약 ID) 같은 메타데이터를 서버 함수에 전달합니다. 이 글에서는 서버 쪽 엔트리 함수를 CHK_saveImageToDrive로 두고, 실제 저장 로직은 CHK_saveImageToDriveCore_라는 내부 함수에 분리합니다. 이렇게 나누면 나중에 다른 화면에서 같은 저장 기능을 재사용하기가 수월합니다.

둘째, 서버 함수에서는 전달받은 문자열을 검증한 뒤 Utilities.base64Decode로 Blob을 만들고, Google Drive의 날짜별 폴더를 찾아가거나 새로 만든 뒤 그 안에 이미지를 파일로 저장합니다. 폴더 구조는 예를 들어 입고체크인사진/2026-08-17처럼 단순한 형태가 관리에 유리했습니다. 날짜별 폴더만 정리되어 있어도 나중에 특정 날짜의 모든 BOL·POD를 한눈에 볼 수 있습니다.

셋째, 파일 생성 후에는 파일 URL과 ID를 반환하고, 예약 메인 시트에 사진 링크를 함께 기록합니다. 실무에서는 시트 셀에 =HYPERLINK("URL","BOL")처럼 수식을 넣어도 되지만, 간단히 URL만 저장해 두고 사용자 필터 기능으로 보는 정도면 충분한 경우가 많습니다. 중요한 것은 “이 컨테이너 사진이 어디 있는지”를 한 번에 따라갈 수 있게 해 두는 것입니다.

마지막으로, 여러 담당자가 동시에 체크인을 처리할 수 있으므로 Google Apps Script의 LockService로 잠금을 사용해 동시 저장 충돌을 일으키지 않도록 합니다. 허용되지 않은 MIME 유형이나 비정상적으로 큰 파일은 DriveApp에 넘기기 전에 차단해 두면, 나중에 폴더 안에 깨진 파일이 쌓이는 일을 줄일 수 있습니다.


드라이브 설정 상수 만들기: 폴더·형식·용량 기준 고정하기

우선 사진이 어디에, 어떤 규칙으로 저장될지 설정 상수를 먼저 정리해 둡니다. 이 상수만 바꾸면 폴더 이름·용량 기준을 한 번에 조정할 수 있어 운영 변경에 유리합니다.

  1. 하는 일: 드라이브 루트 폴더 이름, 날짜 폴더 형식, 허용 용량(MB), 허용 MIME 유형, 시간대를 정의합니다.
  2. 붙여넣을 위치: 구글시트 → 확장 프로그램 → Apps Script → Code.gs 맨 위, 또는 체크인 관련 스크립트 파일 상단.
  3. 붙여넣은 뒤 할 일: 저장(⌘S 또는 Ctrl+S)만 하면 됩니다.
Apps Script (JavaScript)
const CHK_DRIVE_CONFIG = {                             // → 체크인 사진 저장 설정
  ROOT_FOLDER_NAME: '입고체크인사진',                   // → 최상위 폴더 이름
  DATE_FOLDER_FORMAT: 'YYYY-MM-DD',                    // → 날짜 폴더 형식
  MAX_IMAGE_MB: 10,                                    // → 허용 파일 최대 용량(MB)
  ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png'],     // → 허용 이미지 유형
  TIMEZONE: 'America/New_York'                         // → 시간대 고정
};                                                     // 
// → 이 상수는 다른 편 상수와 이름이 겹치지 않게 CHK_ 접두어를 씁니다.

동작 확인 방법: 아직 눈에 보이는 변화는 없습니다. 다음 단계에서 이 상수를 사용하는 폴더 생성 함수와 저장 함수가 제대로 동작하는지 함께 확인합니다.


날짜별 폴더 찾기·생성: DriveApp 날짜별 폴더 생성 방법

이제 DriveApp으로 날짜별 폴더를 자동으로 만들고 재사용하는 함수를 만듭니다. BOL·POD 사진을 날짜 기준으로 모으면, 클레임이 들어온 날짜만 열어 훑어보기가 편해집니다.

  1. 하는 일:
  • 루트 폴더(입고체크인사진)를 찾거나 없으면 생성합니다.
  • 지정한 날짜 기준으로 YYYY-MM-DD 이름의 하위 폴더를 찾거나 생성합니다.
  1. 붙여넣을 위치: CHK_DRIVE_CONFIG 바로 아래.
  2. 붙여넣은 뒤 할 일: 저장 후 테스트 함수에서 오늘 날짜 폴더가 만들어지는지 확인합니다.
Apps Script (JavaScript)
function CHK_getOrCreateRootFolder_() {                            // → 최상위 폴더 찾기/생성
  const folders = DriveApp.getFoldersByName(                        // → 같은 이름 폴더 검색
    CHK_DRIVE_CONFIG.ROOT_FOLDER_NAME                               // → 설정에서 이름 사용
  );                                                                
  if (folders.hasNext()) {                                          // → 이미 있으면
    return folders.next();                                          // → 첫 번째 폴더 사용
  }                                                                 
  return DriveApp.createFolder(                                     // → 없으면 새로 생성
    CHK_DRIVE_CONFIG.ROOT_FOLDER_NAME                               // → 같은 이름으로 생성
  );                                                                
}                                                                   

function CHK_getOrCreateDateFolder_(dateObj) {                      // → 날짜별 폴더 찾기/생성
  if (!(dateObj instanceof Date) || isNaN(dateObj)) {               // → 유효한 날짜인지 확인
    throw new Error('CHK_getOrCreateDateFolder_: 잘못된 날짜입니다.');  // → 잘못된 입력 차단
  }                                                                 
  const tz = CHK_DRIVE_CONFIG.TIMEZONE;                             // → 고정 시간대 사용
  const y = Utilities.formatDate(dateObj, tz, 'yyyy');              // → 연도 추출
  const m = Utilities.formatDate(dateObj, tz, 'MM');                // → 월 추출
  const d = Utilities.formatDate(dateObj, tz, 'dd');                // → 일 추출
  const folderName = [y, m, d].join('-');                           // → YYYY-MM-DD 조합
  const root = CHK_getOrCreateRootFolder_();                        // → 최상위 폴더 확보
  const subFolders = root.getFoldersByName(folderName);             // → 날짜 폴더 검색
  if (subFolders.hasNext()) {                                       // → 있으면
    return subFolders.next();                                       // → 그 폴더 사용
  }                                                                 
  return root.createFolder(folderName);                             // → 없으면 새로 생성
}

동작 확인 방법: 간단한 테스트 함수에서 CHK_getOrCreateDateFolder_(new Date())를 호출해 실행한 뒤, Drive에 입고체크인사진/오늘날짜 폴더가 생겼는지 확인합니다.


사진 저장 핵심 함수: base64 → Blob → 드라이브 업로드

이제 핵심인 구글시트 드라이브 사진 저장 Apps Script 부분입니다. 이 함수는 BOL·POD 사진의 base64 문자열을 받아 검증하고, 날짜별 폴더에 업로드한 뒤 정보 객체를 돌려줍니다.

  1. 하는 일:
  • data:image/jpeg;base64,... 문자열에서 MIME 유형과 본문을 분리합니다.
  • 허용 MIME 목록과 최대 용량을 검증합니다.
  • Blob을 만든 뒤 날짜 폴더에 파일을 생성합니다.
  • 파일 ID·URL·파일명·폴더명을 반환합니다.
  1. 붙여넣을 위치: 날짜 폴더 함수들 아래.
  2. 붙여넣은 뒤 할 일: 저장 후 테스트 함수로 실제 파일 생성 여부를 확인합니다.
Apps Script (JavaScript)
function CHK_saveImageToDriveCore_(base64Data, fileNameHint) {        // → 이미지 저장 핵심 함수
  if (typeof base64Data !== 'string' || base64Data.indexOf(',') < 0) { // → 문자열·형식 확인
    throw new Error('CHK_saveImageToDriveCore_: 잘못된 이미지 데이터입니다.'); // → 잘못된 입력 차단
  }                                                                    
  const parts = base64Data.split(',');                                 // → 헤더·본문 분리
  const header = parts[0];                                             // → data:...;base64 부분
  const dataPart = parts[1];                                           // → 실제 base64 본문
  const mimeMatch = header.match(/data:(.*);base64/);                  // → MIME 추출
  if (!mimeMatch) {                                                    // → 형식이 다르면
    throw new Error('CHK_saveImageToDriveCore_: MIME 형식을 읽을 수 없습니다.'); // → 예외 발생
  }                                                                    
  const mimeType = mimeMatch[1];                                       // → 이미지 MIME 유형
  if (CHK_DRIVE_CONFIG.ALLOWED_MIME_TYPES.indexOf(mimeType) === -1) {  // → 허용 목록 확인
    throw new Error('허용되지 않은 이미지 형식입니다.');                // → jpg/png 외 거부
  }                                                                    
  const blobBytes = Utilities.base64Decode(dataPart);                  // → base64 디코드
  const sizeMb = blobBytes.length / (1024 * 1024);                     // → 바이트 → MB 계산
  if (sizeMb > CHK_DRIVE_CONFIG.MAX_IMAGE_MB) {                        // → 용량 제한 검사
    throw new Error('이미지 용량이 너무 큽니다. 최대 '                   // → 사용자 메시지
      + CHK_DRIVE_CONFIG.MAX_IMAGE_MB + 'MB 입니다.');                 
  }                                                                    
  const now = new Date();                                              // → 현재 시각
  const tz = CHK_DRIVE_CONFIG.TIMEZONE;                                // → 고정 시간대
  const timeLabel = Utilities.formatDate(now, tz, 'HHmmss');           // → 시각 문자열
  const safeNameHint = fileNameHint                                   // → 파일명 힌트 정리
    ? String(fileNameHint).replace(/[^0-9A-Za-z_-]+/g, '_')            // → 특수문자 치환
    : 'NO_CONTAINER';                                                  // → 힌트 없을 때 대체
  const ext = (mimeType === 'image/png') ? '.png' : '.jpg';            // → MIME에 맞는 확장자
  const finalFileName = safeNameHint + '_' + timeLabel + ext;          // → 최종 파일명
  const folder = CHK_getOrCreateDateFolder_(now);                      // → 날짜 폴더 확보
  const blob = Utilities.newBlob(blobBytes, mimeType, finalFileName);  // → Blob 생성
  const file = folder.createFile(blob);                                // → Drive 파일 생성
  return {                                                             // → 결과 객체 반환
    id: file.getId(),                                                 // → 파일 ID
    url: file.getUrl(),                                               // → 웹 URL
    name: file.getName(),                                             // → 실제 저장 이름
    folderName: folder.getName()                                      // → 날짜 폴더 이름
  };                                                                  
}

동작 확인 방법: 뒤에서 소개하는 테스트 함수에서 실제 base64 문자열을 넣고 실행했을 때, Drive의 날짜별 폴더에 이미지 파일이 생성되고, URL로 접속했을 때 정상적으로 사진이 열리면 성공입니다.


시트 연동과 LockService: 예약 메인 시트에 링크 남기기

Drive에 파일만 만들어서는 실무에 쓰기 어렵습니다. 예약 메인 시트의 체크인 행에 사진 링크를 남겨야 나중에 컨테이너 번호만으로 해당 사진을 바로 열어볼 수 있습니다. 여러 담당자가 동시에 저장할 수 있기 때문에, LockService로 잠금을 걸어 동시 저장 충돌을 피하고, 컨테이너 번호로 행을 찾는 과정도 함께 처리합니다.

  1. 하는 일:
  • 잠금을 걸어 동시에 두 번 같은 저장이 진행되지 않게 합니다.
  • 1편 조회 결과가 알려 준 행 번호(rowIndex)를 그대로 받습니다. 컨테이너 번호로 다시 찾지 않습니다.
  • 그 행이 정말 그 컨테이너의 오늘 예약이 맞는지 한 번 더 확인합니다.
  • CHK_saveImageToDriveCore_를 호출해 사진을 저장합니다.
  • CHECKIN_PHOTOS 시트에 사진 한 장당 한 줄을 쌓고, 결과를 객체로 반환합니다.
  1. 붙여넣을 위치: 저장 핵심 함수 아래.
  2. 붙여넣은 뒤 할 일: 웹앱에서 getApptInfoByCntr() 결과의 data.rowIndex 를 그대로 넘기도록 연결합니다.

여기서 두 가지를 일부러 다르게 했습니다.

첫째, 컨테이너 번호로 행을 다시 찾지 않습니다. 번호만으로 찾으면 시트 위에서부터 처음 만나는 행이 걸립니다. 같은 컨테이너가 지난달에도 왔다면 오늘 사진이 지난달 예약에 붙습니다. 1편 조회가 이미 "오늘 예약 정확히 한 건"을 확정해 행 번호까지 돌려주므로, 그 값을 그대로 쓰는 것이 맞습니다. 다만 화면이 열려 있는 동안 행이 밀릴 수도 있어서, 저장 직전에 그 행의 컨테이너와 날짜를 한 번 더 대조합니다.

둘째, 예약 행에 사진 열을 만들지 않습니다. 예약 시트는 A~M 13열로 고정돼 있고 사진 칸이 없습니다. 칸을 하나 더 붙이면 한 예약에 여러 장(BOL·POD·파손 사진…)이 올 때 마지막 한 장만 남습니다. 사진은 CHECKIN_PHOTOS 시트에 한 장당 한 줄로 쌓습니다. 예약ID를 같이 적어 두면 나중에 행이 밀려도 어느 예약의 사진인지 잃어버리지 않습니다.

Apps Script (JavaScript)
const CHK_APPT_SHEET_NAME = 'APPT_MAIN';                        // → 예약 메인 시트 이름
const CHK_PHOTO_SHEET_NAME = 'CHECKIN_PHOTOS';                  // → 사진 로그 시트(이 편에서 새로 만든다)
// 예약 시트 열 번호는 시리즈 확정 배치 그대로다. A 날짜 B 시작시간 C 장비유형 D 도어
// E 컨테이너 F 운송사 G 고객사 H 비고 I 종료시간 J 생성일시 K 수량 L 팔레트 M 예약ID.
// **사진 열은 없다.** 그래서 아래에서 별도 시트에 쌓는다.
const CHK_APPT_COL_DATE = 1;                                    // → A: 예약일자
const CHK_APPT_COL_CONTAINER = 5;                               // → E: 컨테이너 번호
const CHK_APPT_COL_BOOKING_ID = 13;                             // → M: 예약ID
const CHK_APPT_COL_LAST = 13;                                   // → 한 번에 읽을 열 수(A~M)

// 사진 로그 시트 머리글 — 사진 한 장이 한 줄이다.
const CHK_PHOTO_HEADERS = ['기록일시', '예약일자', '컨테이너', '예약행',
                           '예약ID', '종류', '파일이름', '사진URL'];

/**
 * 사진 로그 시트를 준비한다(없으면 만들고, 비어 있으면 머리글을 넣는다).
 * 기본 1편 getOrCreateSheet_() 는 시트만 만들고 머리글은 넣지 않는다.
 */
function CHK_getPhotoSheet_() {                                 // → 사진 로그 시트 준비
  const ss = SpreadsheetApp.getActiveSpreadsheet();             // → 현재 스프레드시트
  let sheet = ss.getSheetByName(CHK_PHOTO_SHEET_NAME);          // → 있으면 가져오고
  if (!sheet) {                                                 // → 없으면
    sheet = ss.insertSheet(CHK_PHOTO_SHEET_NAME);               // → 새로 만든다
  }                                                             //
  if (sheet.getLastRow() === 0) {                               // → 아직 비어 있으면
    sheet.getRange(1, 1, 1, CHK_PHOTO_HEADERS.length)           // → 1행에
      .setValues([CHK_PHOTO_HEADERS]);                          // → 머리글을 넣는다
    sheet.setFrozenRows(1);                                     // → 머리글 고정
  }                                                             //
  return sheet;                                                 // → 시트 반환
}

/**
 * 체크인 사진 저장 — **행 번호는 1편 조회 결과(data.rowIndex)를 그대로 받는다.**
 * @param {number} apptRow 1편 getApptInfoByCntr() 가 알려 준 예약 행 번호
 * @param {string} containerNo 컨테이너 번호(대조용)
 * @param {string} base64Data data:image/... 형식 문자열
 * @param {string} photoType 'BOL' | 'POD' | '기타' 등 사진 종류
 */
function CHK_saveImageToDrive(apptRow, containerNo, base64Data, photoType) {
  const lock = LockService.getScriptLock();                     // → 스크립트 잠금 객체
  lock.waitLock(30000);                                         // → 최대 30초까지 대기
  try {
    const rowNo = Number(apptRow);                              // → 행 번호 숫자로
    if (!Number.isInteger(rowNo) || rowNo < 2) {                // → 머리글(1행) 아래여야 한다
      throw new Error('예약 행 번호가 올바르지 않습니다. 조회를 먼저 실행해 주세요.');
    }
    const cntr = String(containerNo || '').trim().toUpperCase();  // → 대문자로 통일
    if (!cntr) {                                                // → 컨테이너 번호 없음
      throw new Error('컨테이너 번호가 없습니다.');              // → 필수값 누락 차단
    }

    const ss = SpreadsheetApp.getActiveSpreadsheet();           // → 현재 스프레드시트
    const sheet = ss.getSheetByName(CHK_APPT_SHEET_NAME);       // → 예약 시트 가져오기
    if (!sheet) {                                               // → 시트 없을 때
      throw new Error('예약 메인 시트를 찾을 수 없습니다.');      // → 즉시 중단
    }
    if (rowNo > sheet.getLastRow()) {                           // → 시트 밖 행
      throw new Error('예약 행을 찾을 수 없습니다. 다시 조회해 주세요.');
    }

    // 화면이 열려 있는 동안 행이 밀렸을 수 있다. **그 행이 정말 그 컨테이너의 오늘
    // 예약인지** 저장 직전에 한 번 더 본다. 아니면 엉뚱한 예약에 사진이 붙는다.
    const row = sheet.getRange(rowNo, 1, 1, CHK_APPT_COL_LAST).getValues()[0];
    const rowCntr = String(row[CHK_APPT_COL_CONTAINER - 1] || '').trim().toUpperCase();
    if (rowCntr !== cntr) {                                     // → 다른 예약
      throw new Error('예약 정보가 바뀌었습니다. 컨테이너 번호를 다시 조회해 주세요.');
    }
    let rowYmd;                                                 // → 그 행의 예약일자
    try {                                                       // → 날짜 통일
      rowYmd = APPT_ymd_(row[CHK_APPT_COL_DATE - 1]);           // → 'YYYY-MM-DD'
    } catch (e) {                                               // → 날짜를 못 읽음
      throw new Error('예약 행의 날짜를 읽을 수 없습니다(행 ' + rowNo + '). 사무실에 문의해 주세요.');
    }
    if (rowYmd !== APPT_ymd_(new Date())) {                     // → 오늘 예약이 아님
      throw new Error('오늘 예약이 아닙니다. 컨테이너 번호를 다시 조회해 주세요.');
    }

    const result = CHK_saveImageToDriveCore_(                   // → Drive에 저장 호출
      base64Data,                                               // → 이미지 데이터
      cntr                                                      // → 파일명 힌트
    );

    // **덮어쓰지 않고 쌓는다.** 한 예약에 BOL·POD·파손 사진이 여러 장 온다.
    const photoSheet = CHK_getPhotoSheet_();                    // → 사진 로그 시트
    photoSheet.appendRow([                                      // → 한 장 = 한 줄
      new Date(),                                               // → A: 기록일시
      rowYmd,                                                   // → B: 예약일자
      cntr,                                                     // → C: 컨테이너
      rowNo,                                                    // → D: 예약행
      String(row[CHK_APPT_COL_BOOKING_ID - 1] || ''),           // → E: 예약ID
      String(photoType || '기타').trim(),                       // → F: 종류
      result.name,                                              // → G: 파일이름
      result.url                                                // → H: 사진URL
    ]);

    return {                                                    // → 웹앱으로 반환
      success: true,                                            // → 성공 여부
      fileUrl: result.url,                                      // → 파일 URL
      fileId: result.id,                                        // → 파일 ID
      fileName: result.name,                                    // → 파일 이름
      row: rowNo                                                // → 예약 행 번호
    };
  } catch (e) {
    return {                                                    // → 오류도 객체로 반환
      success: false,                                           // → 실패 표시
      message: e.message || String(e)                           // → 에러 메시지
    };
  } finally {
    lock.releaseLock();                                         // → 잠금 해제
  }
}

동작 확인 방법: 체크인 웹앱에서 같은 예약에 사진을 두 장 연속 저장한 뒤 CHECKIN_PHOTOS 시트를 봅니다. 줄이 두 개 쌓여 있고 각 줄의 사진URL이 서로 다르면 정상입니다. 한 줄만 있고 링크가 바뀌었다면 예전처럼 덮어쓰고 있는 것입니다. 링크를 클릭해 Drive 사진이 열리면 DriveApp 연동까지 정상입니다.


테스트와 오류 대응: 이미지 업로드 시 동작 확인 루틴

이미지 업로드는 입력 데이터가 길고 형식이 까다로워, 실무에서는 먼저 테스트 전용 함수를 두고 동작과 오류 메시지를 확인해 보는 것이 좋습니다. 특히 다른 개발자가 만든 프런트엔드에서 넘어오는 base64 포맷을 검증할 때 유용합니다.

  1. 하는 일:
  • 테스트용 컨테이너 번호와 base64 문자열로 CHK_saveImageToDrive를 호출합니다.
  • 결과를 로그에 출력해 성공 여부와 에러 메시지를 확인합니다.
  1. 붙여넣을 위치: CHK_saveImageToDrive 바로 아래.
  2. 붙여넣은 뒤 할 일: TEST_BASE64를 실제 base64 문자열로 교체하고 실행 로그를 확인합니다.
Apps Script (JavaScript)
function CHK_testSaveImageToDrive() {                         // → 저장 함수 테스트
  // 1편 조회를 먼저 돌려 **오늘 실제로 있는 예약의 행 번호**를 얻는다.
  // 행 번호를 손으로 찍으면 그 행이 오늘 예약인지 알 수 없어 테스트가 의미를 잃는다.
  const TEST_CONTAINER = 'TEST123456';                        // → 오늘 예약이 있는 번호로 교체
  const TEST_BASE64 = 'data:image/jpeg;base64,AAAA';          // → 실제 base64로 교체 필요

  const found = getApptInfoByCntr(TEST_CONTAINER);            // → 1편 조회 함수
  if (!found.found) {                                         // → 오늘 예약이 없으면
    Logger.log('오늘 예약을 찾지 못했습니다: ' + found.reason + ' / ' + found.message);
    return;                                                   // → 여기서 멈춘다
  }

  const result = CHK_saveImageToDrive(found.data.rowIndex,    // → 조회가 알려 준 행 번호
    TEST_CONTAINER, TEST_BASE64, 'BOL');                      // → 번호·이미지·종류
  Logger.log(JSON.stringify(result));                         // → 결과를 로그에 출력
}

동작 확인 방법: Apps Script 편집기에서 CHK_testSaveImageToDrive를 선택해 실행한 뒤, 실행 로그에서 {"success":true,...}가 찍히는지 확인합니다. 동시에 Drive에 날짜별 폴더와 파일이 생겼는지, 예약 메인 시트의 테스트 컨테이너 행에 URL이 들어갔는지도 함께 보면 전체 흐름을 한 번에 검증할 수 있습니다. 실패한 경우 success:false와 함께 반환된 message를 보고 MIME 유형·용량·컨테이너 번호 매칭 중 어느 부분에서 막혔는지 바로 파악할 수 있습니다.


실무 팁: 물류 체크인 사진을 오래 쓸 수 있게 만드는 요령

실제 창고 현장에서 BOL·POD 사진을 Google Drive와 구글시트로 관리해 보니, 코드 외에 다음과 같은 운영 습관이 특히 중요했습니다.

첫째, 폴더 구조를 최대한 단순하게 유지하는 것이 좋습니다. “날짜 → 운송사 → 컨테이너”처럼 깊게 나누면 규칙을 새로 온 사람에게 설명하기 어렵고, 중간에 사람마다 다른 서브 폴더를 만들기 시작하면 다시 찾기 힘들어집니다. 날짜 폴더 하나만 두고, 파일 이름에 컨테이너 번호와 시각을 포함해 두면 대부분의 조회 상황을 커버할 수 있었습니다.

둘째, 용량 제한과 사진 품질을 함께 설계해야 합니다. 입고 장에서 찍는 문서 사진은 너무 고해상도일 필요가 없는 경우가 많습니다. Apps Script에서 10MB 정도로 제한하고, 모바일 기기에서 “표준 화질” 정도로 촬영하도록 안내하면 업로드 오류와 저장 속도 문제를 줄일 수 있었습니다.

셋째, 사진 링크를 시트에 기록할 때는 이 글처럼 열 번호를 상수로 관리하는 것을 추천합니다. 예약 메인 시트에 열이 하나씩 늘어나는 것은 흔한 일인데, 코드 전체를 수정하는 대신 CHK_APPT_COL_PHOTO_URL 값만 바꾸면 되도록 설계해 두면 유지보수가 훨씬 수월합니다.

넷째, 구글 앱스스크립트 이미지 업로드 시트 링크를 여러 팀이 함께 쓸 때는 권한과 계정을 분리하는 것도 도움이 됩니다. 스크립트와 Drive 폴더는 공용 계정 소유로 두고, 운영 담당자는 보기·편집 권한만 부여하면 인수인계 시 계정 정리가 간단했습니다. 특히 외부 운송사·고객에게 링크를 공유하는 경우에는 회사 워크스페이스 정책에 맞게 공개 범위를 점검하는 것이 필요합니다.

마지막으로, 물류 체크인 사진 관리 구글시트를 도입할 때는 한두 개 도크에서 먼저 시범 운영해 보는 것이 좋습니다. 실제 라인에서 몇 주간 돌려 보며 사진 품질·업로드 속도·클레임 대응 시간을 함께 체크한 뒤, 기준을 조금씩 조정해 전체 현장에 확대하면 현장 반발을 줄일 수 있었습니다.


맺음말: 오늘 바로 테스트 컨테이너 하나로 시작해 보기

입고 체크인 순간에 찍힌 BOL·POD 사진은 나중에 문제가 생겼을 때 사실상 마지막 방패 역할을 합니다. 그 사진이 이메일과 메신저 사이에 흩어져 있느냐, 아니면 “예약 메인 시트 컨테이너 번호 → 사진 링크 → Drive 날짜 폴더”로 한 번에 이어지느냐에 따라 처리 시간이 크게 달라집니다.

오늘 바로 할 수 있는 행동은 간단합니다. 운영 중인 예약 메인 시트 오른쪽에 사진 URL 열을 하나 추가한 뒤, 위 코드에서 CHK_APPT_SHEET_NAMECHK_APPT_COL_PHOTO_URL 값을 실제 시트 구조에 맞게 수정하고, 테스트 컨테이너 하나를 잡아 CHK_testSaveImageToDrive를 실행해 보시기 바랍니다. 구글시트 체크인 사진 드라이브 저장 흐름이 한 번이라도 제대로 돌아가는 것을 확인하면, 이후 실제 체크인 화면과 연결해 BOL·POD 사진 자동 정리를 자연스럽게 확장해 갈 수 있습니다.