콘텐츠로 바로가기

AI

작업노트

  • 활용 가이드
  • 도구 비교
  • 문제 해결
  • 이슈 해설
  • 소개
활용 가이드
Gemini, 엑셀·시트, 자동화, 중급

구글 시트 AI 자동화 2편: 배치 처리, 중복 방지, 매일 자동 실행까지 직접 돌려 확인했습니다

2026.10.30

작성자 박근모 · 운영자 소개 보기

파란 바늘이 달린 작은 아날로그 알람 시계가 빈 종이 사각형 격자 옆에 놓인 책상 위 사진

코드를 실제로 돌려 보니 모델의 영어 설명문이 분류 칸에 그대로 들어오는 문제가 나왔습니다. 응답을 허용 목록으로 검증하고 생각 기능을 끄는 두 줄로 고쳤고, 고친 코드로 다시 확인했습니다.

결론부터

1. 6줄을 실제 Gemini API로 분류시켰더니 1줄에 “THOUGHT: The user wants me to classify…” 라는 설명문이 셀에 그대로 기록됐습니다.

2. 허용된 분류어 5개 중 하나가 아니면 오류로 처리하고 생각 기능을 끄자, 같은 6줄이 모두 정상으로 나왔습니다.

3. 한 번에 처리할 줄 수 제한, 중복 실행 방지(락), 하루 한 번 트리거까지 로그와 화면으로 확인했습니다. 다만 정해진 시간에 저절로 실행되는 것은 기다려 보지 않았습니다.

어떻게 확인했나

새 스프레드시트에 문의 10줄(빈 줄 2개와 이미 분류된 줄 1개 포함)을 만들고 Apps Script를 붙였습니다. 2026년 10월 19일에 아래 순서로 확인했습니다.

  1. 가짜 AI 호출(mock)로 배치, 건너뛰기, 오류 기록 로직을 먼저 확인했습니다. 한 번에 3줄만 처리하게 설정했습니다.
  2. 실제 Gemini API를 Apps Script의 URL 호출로 불러 6줄을 분류했습니다.
  3. 락이 동시 실행을 막는지, 트리거가 중복 없이 한 개만 만들어지는지 확인했습니다.

시트의 =AI() 함수도 같은 5줄에 호출해 봤고, 이 계정에서는 배송, 환불, 기타, 결제, 교환이 순서대로 나왔습니다. 이 글의 코드와는 별개의 방법입니다.

1. 가짜 AI로 배치 처리 확인

한 번에 3줄까지만 처리하게 하고 세 번 연달아 실행했습니다. 오류 줄은 일부러 하나 만들었습니다.

실행결과 로그
1회차row 2 OK → 배송, row 4 OK → 환불, row 6 ERROR → HTTP 429(가짜), “배치 한도 도달, 나머지는 다음 실행”
2회차row 8, 9, 10을 처리. 요약: 성공 3, 오류 0, 빈줄 건너뜀 2, 기존값 건너뜀 4
3회차요약: 성공 0, 오류 0, 빈줄 건너뜀 2, 기존값 건너뜀 7
  • 이미 채워진 줄(“기타(기존값)”)과 빈 줄은 건드리지 않았습니다.
  • 오류 줄에는 “ERROR: …”가 기록돼서 다음 실행에서 건너뜁니다. 다시 시도하려면 아래 retryErrors로 비웁니다. 이 함수는 실행해 보지 않았습니다.

2. 실제 호출에서 나온 문제

실제 호출 1차에서 6줄 중 1줄(“사이즈 교환 가능한가요?”)의 분류 칸에 영어 설명문이 기록됐습니다. 모델이 답 전에 생각한 내용(thought)까지 응답에 같이 담겨 와서, 코드가 그것을 정답으로 저장한 것입니다.

실제 Gemini 호출 중 9번 행에 모델의 영어 설명문이 그대로 기록된 Apps Script 실행 로그
수정 전 실행 로그. 9번 행에 분류어 대신 영어 설명문이 들어갔습니다.

고친 곳은 세 가지입니다.

  • thinkingBudget: 0과 temperature: 0으로 생각 기능을 끄고 답을 고정합니다.
  • 응답에서 thought 파트를 제외하고 본문만 합칩니다.
  • 결과가 허용된 분류어 5개 중 하나가 아니면 셀에 쓰지 않고 BAD_OUTPUT 오류로 처리합니다.
수정한 코드로 다시 실행해 세 줄이 정상 분류된 Apps Script 실행 로그
수정 후 실행 로그. 세 줄 모두 분류어 한 단어만 기록됐습니다.

수정한 코드로 다시 돌린 6줄은 배송, 환불, 기타, 결제, 교환, 배송으로 모두 정상이었고 BAD_OUTPUT은 한 번도 나오지 않았습니다. 호출 한 번에 약 0.5~1초가 걸렸습니다. 실제 HTTP 오류(키 오류, 한도 초과)는 일부러 일으켜 보지 않았습니다.

3. 중복 실행 방지(락)

  • 같은 실행 안에서 락을 잡은 채 함수를 부르면 안쪽 락도 성공합니다(재진입). 그래서 이 방식으로는 중복 방지를 검증할 수 없었습니다.
  • 실행 두 개를 동시에 돌렸습니다. 한쪽이 락을 40초 잡은 동안 다른 쪽 classifyBatch를 실행하자, 5초 뒤 “다른 실행이 진행 중이라 종료”가 로그에 찍히고 끝났습니다.

4. 매일 실행 트리거

createDailyTrigger를 두 번 실행했습니다. 첫 번째는 “삭제한 트리거: 0개” 뒤에 생성했고, 두 번째는 “삭제한 트리거: 1개” 뒤에 다시 만들었습니다. 트리거 화면에는 시간 기반 classifyBatch가 한 개만 있었습니다.

시간 기반 트리거가 한 개만 보이는 Apps Script 트리거 화면
createDailyTrigger를 두 번 실행한 뒤의 트리거 화면. 한 개만 남았습니다.

확인 뒤 deleteTriggers로 지웠고, 화면에 “트리거 0개 표시 중”이 나오는 것까지 봤습니다. 정해진 시각에 실제로 실행되는지는 기다리지 않아서 확인하지 못했습니다.

권한 승인에서 막히는 곳

  1. 처음 실행하면 “승인 필요” 창이 뜨고, “권한 검토” 다음에 “Google에서 확인하지 않은 앱” 경고가 나옵니다. “고급”을 눌러 프로젝트로 이동한 뒤 “모두 선택”, “계속”을 누릅니다. 본인이 만든 스크립트일 때만 하세요.
  2. 코드 맨 위에 @OnlyCurrentDoc 주석이 없으면 “모든 Google Sheets 스프레드시트 확인, 수정, 생성, 삭제” 권한을 요구했습니다. 주석을 넣고 다시 승인하자 “이 스프레드시트만”으로 줄었습니다.

검증한 코드

아래 코드는 위에서 확인한 구조 그대로입니다. 시트 이름(“시트1”), A열에 문의, B열에 분류가 들어가는 구성이고, MODEL_NAME은 해당 서비스 문서에서 확인한 모델 이름으로 바꾸세요. 실행 전 프로젝트 설정의 스크립트 속성에 GEMINI_API_KEY를 추가해야 합니다. 이 글에 실린 정리본에서 직접 실행하지 않은 함수는 retryErrors와 listTriggers 두 개이며, listTriggers는 뺐습니다.

/**
 * @OnlyCurrentDoc
 */
const SHEET_NAME = '시트1';
const BATCH_SIZE = 20;                 // 한 번에 처리할 줄 수
const MAX_RUNTIME_MS = 5 * 60 * 1000;  // 실행 시간 제한(6분) 전에 스스로 멈추는 시간

const LABELS = ['배송', '환불', '교환', '결제', '기타'];

function classify_(text) {
  const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!key) throw new Error('NO_KEY: 스크립트 속성에 GEMINI_API_KEY가 없습니다');
  const url = 'https://generativelanguage.googleapis.com/v1beta/models/MODEL_NAME:generateContent';
  const payload = {
    contents: [{ parts: [{ text:
      '다음 고객 문의를 배송, 환불, 교환, 결제, 기타 중 하나로만 분류하세요. 단어 하나만 답하세요.\n\n' + text }] }],
    generationConfig: { temperature: 0, thinkingConfig: { thinkingBudget: 0 } }
  };
  const res = UrlFetchApp.fetch(url, {
    method: 'post', contentType: 'application/json',
    headers: { 'x-goog-api-key': key },
    payload: JSON.stringify(payload), muteHttpExceptions: true
  });
  const code = res.getResponseCode();
  if (code !== 200) throw new Error('HTTP ' + code + ': ' + res.getContentText().slice(0, 150));
  const json = JSON.parse(res.getContentText());
  if (!json.candidates || !json.candidates[0].content) {
    throw new Error('NO_CANDIDATE: ' + res.getContentText().slice(0, 150));
  }
  const raw = json.candidates[0].content.parts
    .filter(function (p) { return !p.thought; })
    .map(function (p) { return p.text || ''; })
    .join('').trim();
  if (LABELS.indexOf(raw) < 0) throw new Error('BAD_OUTPUT: ' + raw.slice(0, 60));
  return raw;
}

function classifyBatch() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) { Logger.log('다른 실행이 진행 중이라 종료'); return; }
  const start = Date.now();
  try {
    const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
    const last = sheet.getLastRow();
    if (last < 2) { Logger.log('데이터 없음'); return; }
    const values = sheet.getRange(2, 1, last - 1, 2).getValues();
    let ok = 0, err = 0, emptyRows = 0, filled = 0, attempts = 0;
    for (let i = 0; i < values.length; i++) {
      const row = i + 2;
      const q = String(values[i][0]).trim();
      if (!q) { emptyRows++; continue; }
      if (String(values[i][1]) !== '') { filled++; continue; }
      if (attempts >= BATCH_SIZE) { Logger.log('배치 한도 도달, 나머지는 다음 실행'); break; }
      if (Date.now() - start > MAX_RUNTIME_MS) { Logger.log('실행 시간 한도 도달, 중단'); break; }
      attempts++;
      try {
        const result = classify_(q);
        sheet.getRange(row, 2).setValue(result);
        ok++;
        Logger.log('row ' + row + ' OK -> ' + result);
      } catch (e) {
        sheet.getRange(row, 2).setValue('ERROR: ' + e.message);
        err++;
        Logger.log('row ' + row + ' ERROR -> ' + e.message);
      }
    }
    Logger.log('요약: 성공 ' + ok + ', 오류 ' + err + ', 빈줄 건너뜀 ' + emptyRows + ', 기존값 건너뜀 ' + filled);
  } finally {
    lock.releaseLock();
  }
}

function retryErrors() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const last = sheet.getLastRow();
  if (last < 2) return;
  const range = sheet.getRange(2, 2, last - 1, 1);
  const v = range.getValues();
  let n = 0;
  for (let i = 0; i < v.length; i++) {
    if (String(v[i][0]).indexOf('ERROR') === 0) { v[i][0] = ''; n++; }
  }
  range.setValues(v);
  Logger.log('재시도 대기로 전환: ' + n + '줄');
}

function createDailyTrigger() {
  deleteTriggers();
  const t = ScriptApp.newTrigger('classifyBatch').timeBased().everyDays(1).atHour(9).create();
  Logger.log('트리거 생성: ' + t.getHandlerFunction());
}

function deleteTriggers() {
  let n = 0;
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'classifyBatch') { ScriptApp.deleteTrigger(t); n++; }
  });
  Logger.log('삭제한 트리거: ' + n + '개');
}

쓰는 순서

  1. 시트 A열에 문의, B열은 비워 둡니다. 이름, 연락처 같은 개인정보가 든 문의는 보내기 전에 서비스의 데이터 이용 정책을 확인하세요.
  2. 위 코드를 붙여 넣고 스크립트 속성에 키를 넣은 뒤, 처음에는 BATCH_SIZE를 3~5로 줄여 한 번 실행합니다.
  3. B열에 분류어 한 단어만 들어갔는지 눈으로 확인하고, ERROR로 시작하는 줄을 봅니다.
  4. 문제없으면 BATCH_SIZE를 늘리고 createDailyTrigger를 한 번 실행합니다. 중복 걱정 없이 여러 번 눌러도 트리거는 한 개로 유지됩니다.
  5. 트리거가 필요 없어지면 deleteTriggers를 실행하세요.

다음 단계

  • AI 자동화가 틀리는 지점: 함정 7개를 심은 고객 문의 12건으로 확인했습니다
  • 내 업무용 AI 평가셋 만들기: 정답 있는 샘플 50건으로 채점하는 법
  • 구글 시트에서 반복 업무 하나를 AI로 자동화하기 (Apps Script 최소 예제)

출처

  • Installable Triggers | Apps Script | Google for Developers
  • Lock Service | Apps Script | Google for Developers
  • Gemini thinking | Google AI for Developers

이 글 공유하기:

  • X에 공유 (새 창에서 열림) X
  • Facebook으로 공유하기 (새 창에서 열림) Facebook

이것이 좋아요:

좋아하기 로드 중…
Gemini 엑셀·시트 자동화 중급
이전 글 AI 구독 vs API: 월 사용량으로 총비용을 직접 계산해 봤습니다
다음 글 회사에서 AI 쓰기 전 점검표: ChatGPT·Gemini·Claude 설정 화면을 직접 열어 확인했습니다

답글 남기기 응답 취소

AI

작업노트

블로그 소개

개인정보처리방침

문의

AI를 쓰고, 비교하고, 일을 바꾸는 기록.

© 2026 AI 작업노트

%d