AI 실험도 해볼 겸 부모님이 매번 수작업/반복적으로 하던 서류 작업을 자동화해보았다.
매 주문마다 서류 4종(사진대지·품질보증서·품질보증및사후봉사증서·보조서류)에 똑같은 이름/주소/기종명을 손으로 채우는 게 너무 비효율이라, Apps Script 하나로 끝내봤다.
사실은 Claude Cowork로 하려고 했으나, 토큰이나 앞으로 관리 등을 생각했을 때 그냥 구글 내에서 수행하는 것으로 변경.
무엇을 자동화했나
- 주문자리스트 시트에서 완료여부가 비어있는 행을 찾고, 주문자정보를 확인한다.
- 같은 이름의 주문자 폴더에서 현장 사진을 읽어
- Gemini Vision으로 사진을 전경/기대로 분류 + 규격명·제조번호·제품번호 OCR
- 양식 폴더의 Google Docs 템플릿을 복사해 {{플레이스홀더}} 치환 + 사진 삽입
- 끝나면 시트의 완료여부를 완료로 업데이트
Drive 구조
Agent (루트)
├─ 주문자리스트 (Sheet)
├─ 양식/ ← 템플릿 4종 (Google Docs)
│ ├─ 사진대지_양식
│ ├─ 품질보증서_양식
│ ├─ 품질보증및사후봉사증서양식
│ └─ 보조서류_양식
└─ 강단/ ← 주문자 폴더 (이름 = 구매자명)
├─ IMG_전경.jpg
├─ IMG_기대.jpg
└─ (스크립트가 완성본 4종을 여기에 생성)
템플릿 설계 규칙
템플릿 안 채워야 할 값은 {{필드명}} 형태. 필드명이 시트 컬럼명과 일치하면 바로 매핑, 이름이 다르면 CONFIG에 선언만 해주면 된다 사진에서 읽어야 하는 값(규격명·제조번호·제품번호)은 Vision이 담당.
사전 준비
- Google AI Studio에서 Gemini API 키 발급
- Apps Script 프로젝트 → 프로젝트 설정 → 스크립트 속성에 추가
- Property: GEMINI_API_KEY
- Value: AIza...
- 실행 권한: Drive, Documents, Spreadsheet, UrlFetch
전체 코드
아래 코드를 Code.gs 하나에 그대로 붙여넣는다. CONFIG 상단의 ID 3개(시트/양식폴더/루트폴더)만 본인 환경에 맞게 수정.
/**
* 농기계 주문자 서류 자동 생성 스크립트 (Google Apps Script)
* AI: Google Gemini (Vision + 검수)
*/
// ============================================================
// CONFIG
// ============================================================
const CONFIG = {
SHEET_ID: '주문자리스트_시트_ID',
TEMPLATE_FOLDER_ID: '양식_폴더_ID',
ROOT_FOLDER_ID: '루트_폴더_ID',
SHEET_TAB_NAME: '',
NAME_COLUMN: '구매자명',
STATUS_COLUMN: '완료여부',
DONE_VALUE: '완료',
// 503/과부하 시 앞에서부터 순서대로 폴백
GEMINI_MODELS: ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash-002'],
GEMINI_API_BASE: 'https://generativelanguage.googleapis.com/v1beta/models',
GEMINI_MAX_TOKENS: 2048,
GEMINI_MAX_RETRIES: 4,
GEMINI_BASE_DELAY_MS: 1500,
USE_GEMINI_VISION: true,
USE_GEMINI_VALIDATION: true,
// {{플레이스홀더}} → 시트 컬럼
SHEET_FIELD_MAPPING: {
'구매자명': '구매자명',
'주소': '주소',
'연락처': '연락처',
'생년월일': '생년월일',
'계좌번호': '계좌번호',
'단가': '단가',
'기종명': '사업명'
},
PHOTO_EXTRACTED_FIELDS: ['규격명', '제조번호', '제품번호'],
PHOTO_LABELS: { 전경: '전체전경', 기대: '기대번호' },
PHOTO_WIDTH: 180
};
// ============================================================
// ENTRY POINTS
// ============================================================
function runAll() {
const orders = getIncompleteOrders_();
Logger.log('미완료 주문: ' + orders.length + '건');
const summary = [];
orders.forEach(order => {
try {
processOrder_(order);
summary.push({ name: order.data[CONFIG.NAME_COLUMN], status: 'OK' });
} catch (err) {
Logger.log('[ERROR] ' + order.data[CONFIG.NAME_COLUMN] + ': ' + err);
summary.push({ name: order.data[CONFIG.NAME_COLUMN], status: 'ERR: ' + err.message });
}
});
summary.forEach(s => Logger.log('• ' + s.name + ' → ' + s.status));
return summary;
}
function runOne(name) {
const orders = getIncompleteOrders_();
const target = orders.find(o => String(o.data[CONFIG.NAME_COLUMN]).trim() === String(name).trim());
if (!target) throw new Error('미완료 주문에 해당 이름이 없습니다: ' + name);
processOrder_(target);
}
function verifySetup() {
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
Logger.log('✓ 시트: ' + ss.getName());
const tpl = DriveApp.getFolderById(CONFIG.TEMPLATE_FOLDER_ID);
Logger.log('✓ 양식: ' + tpl.getName() + ' (템플릿 ' + getTemplates_().length + '개)');
const root = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
Logger.log('✓ 루트: ' + root.getName());
const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
Logger.log(key ? '✓ GEMINI_API_KEY 설정됨' : '✗ GEMINI_API_KEY 없음');
Logger.log('✓ 미완료 주문 ' + getIncompleteOrders_().length + '건');
}
// ============================================================
// CORE PIPELINE
// ============================================================
function processOrder_(order) {
const name = String(order.data[CONFIG.NAME_COLUMN]).trim();
Logger.log('\n=== 처리 시작: ' + name + ' ===');
let step = '시작';
try {
step = '주문자 폴더';
const ordererFolder = driveRetry_(() => findOrdererFolder_(name), step);
if (!ordererFolder) throw new Error('주문자 폴더 없음: ' + name);
step = '사진 수집';
const photos = driveRetry_(() => collectPhotos_(ordererFolder), step);
step = 'Vision';
let visionResult = { extracted: {}, classified: { 전경: [], 기대: [] } };
if (photos.length > 0 && CONFIG.USE_GEMINI_VISION) {
try { visionResult = extractPhotoInfoWithGemini_(photos, order.data); }
catch (e) {
Logger.log(' ⚠ Vision 실패, 파일명 폴백: ' + e.message);
visionResult.classified = classifyPhotosByName_(photos);
}
} else if (photos.length > 0) {
visionResult.classified = classifyPhotosByName_(photos);
}
const placeholders = buildPlaceholderMap_(order.data, visionResult.extracted);
step = '템플릿 처리';
const templates = driveRetry_(() => getTemplates_(), step);
const createdDocs = [];
templates.forEach(template => {
step = '복사(' + template.getName() + ')';
const copy = driveRetry_(() => copyTemplateToOrdererFolder_(template, ordererFolder, name), step);
step = '치환(' + copy.getName() + ')';
driveRetry_(() => { replacePlaceholders_(copy, placeholders); return true; }, step);
if (copy.getName().indexOf('사진대지') > -1) {
step = '사진 삽입(' + copy.getName() + ')';
driveRetry_(() => { insertPhotosIntoPhotoDoc_(copy, visionResult.classified); return true; }, step);
}
createdDocs.push(copy);
Logger.log(' ✓ ' + copy.getName());
});
if (CONFIG.USE_GEMINI_VALIDATION) {
createdDocs.forEach(doc => {
try {
const issues = validateFinalDocWithGemini_(doc);
if (issues && issues.length) Logger.log(' ⚠ 이슈(' + doc.getName() + '): ' + issues);
} catch (e) { Logger.log(' 검수 건너뜀: ' + e.message); }
});
}
markOrderComplete_(order.rowIndex);
Logger.log('=== 완료: ' + name + ' ===');
} catch (e) {
throw new Error('[' + step + '] ' + e.message);
}
}
// ============================================================
// SHEET I/O
// ============================================================
function getIncompleteOrders_() {
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
const sheet = CONFIG.SHEET_TAB_NAME ? ss.getSheetByName(CONFIG.SHEET_TAB_NAME) : ss.getSheets()[0];
const values = sheet.getDataRange().getValues();
if (values.length < 2) return [];
const headers = values[0].map(h => String(h).trim());
const statusIdx = headers.indexOf(CONFIG.STATUS_COLUMN);
const orders = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
if (String(row[statusIdx] || '').trim() === CONFIG.DONE_VALUE) continue;
const data = {};
headers.forEach((h, j) => { data[h] = row[j]; });
if (!String(data[CONFIG.NAME_COLUMN] || '').trim()) continue;
orders.push({ rowIndex: i + 1, headers, data });
}
return orders;
}
function markOrderComplete_(rowIndex) {
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
const sheet = CONFIG.SHEET_TAB_NAME ? ss.getSheetByName(CONFIG.SHEET_TAB_NAME) : ss.getSheets()[0];
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const statusIdx = headers.indexOf(CONFIG.STATUS_COLUMN);
sheet.getRange(rowIndex, statusIdx + 1).setValue(CONFIG.DONE_VALUE);
}
// ============================================================
// DRIVE HELPERS
// ============================================================
function findOrdererFolder_(name) {
const root = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
const it = root.getFoldersByName(name);
return it.hasNext() ? it.next() : null;
}
function collectPhotos_(folder) {
const photos = [];
[MimeType.JPEG, MimeType.PNG, 'image/jpg', 'image/webp', 'image/heic'].forEach(mime => {
const it = folder.getFilesByType(mime);
while (it.hasNext()) photos.push(it.next());
});
return photos;
}
function getTemplates_() {
const folder = DriveApp.getFolderById(CONFIG.TEMPLATE_FOLDER_ID);
const it = folder.getFilesByType(MimeType.GOOGLE_DOCS);
const docs = [];
while (it.hasNext()) docs.push(it.next());
return docs;
}
function copyTemplateToOrdererFolder_(template, ordererFolder, name) {
const copyName = name + '_' + template.getName().replace(/_양식$/, '') + '_양식';
const existing = ordererFolder.getFilesByName(copyName);
while (existing.hasNext()) { try { existing.next().setTrashed(true); } catch(e){} }
const copy = template.makeCopy(copyName, ordererFolder);
Utilities.sleep(600); // Drive 인덱싱 대기
return copy;
}
function driveRetry_(fn, stepLabel) {
const maxRetries = 4;
const baseMs = 800;
let lastErr;
for (let i = 0; i <= maxRetries; i++) {
try { return fn(); } catch (e) {
lastErr = e;
const msg = String(e.message || e);
const retryable = /Service error|rateLimit|backendError|timeout|Internal error|temporarily unavailable/i.test(msg);
if (!retryable || i === maxRetries) break;
const wait = baseMs * Math.pow(2, i) + Math.floor(Math.random() * 300);
Logger.log(' ⟳ Drive 재시도(' + stepLabel + ') ' + wait + 'ms');
Utilities.sleep(wait);
}
}
throw lastErr;
}
// ============================================================
// PLACEHOLDER MAPPING
// ============================================================
function buildPlaceholderMap_(rowData, extracted) {
const map = {};
Object.keys(CONFIG.SHEET_FIELD_MAPPING).forEach(ph => {
map[ph] = formatValue_(ph, rowData[CONFIG.SHEET_FIELD_MAPPING[ph]]);
});
CONFIG.PHOTO_EXTRACTED_FIELDS.forEach(f => { map[f] = (extracted && extracted[f]) ? String(extracted[f]) : ''; });
map['날짜'] = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
return map;
}
function formatValue_(ph, val) {
if (val == null || val === '') return '';
const s = String(val).trim();
if (ph === '생년월일' && /^\d{8}$/.test(s)) return s.slice(0,4)+'-'+s.slice(4,6)+'-'+s.slice(6,8);
if (ph === '단가' && /^\d+$/.test(s)) return Math.round(Number(s)/1000).toLocaleString('ko-KR');
return s;
}
function replacePlaceholders_(file, map) {
const doc = DocumentApp.openById(file.getId());
const body = doc.getBody();
Object.keys(map).forEach(k => {
body.replaceText('\\{\\{' + escapeRegex_(k) + '\\}\\}', map[k] || '');
});
doc.saveAndClose();
}
function escapeRegex_(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
// ============================================================
// PHOTO INSERTION
// ============================================================
function insertPhotosIntoPhotoDoc_(file, classified) {
const doc = DocumentApp.openById(file.getId());
doc.getBody().getTables().forEach(table => {
for (let r = 0; r < table.getNumRows(); r++) {
const row = table.getRow(r);
if (row.getNumCells() === 0) continue;
const label = row.getCell(0).getText().trim();
if (label === CONFIG.PHOTO_LABELS.전경) insertPhotosIntoRow_(row, classified.전경);
else if (label === CONFIG.PHOTO_LABELS.기대) insertPhotosIntoRow_(row, classified.기대);
}
});
doc.saveAndClose();
}
function insertPhotosIntoRow_(row, photos) {
for (let c = 1; c < row.getNumCells(); c++) {
const idx = c - 1;
if (idx >= photos.length) break;
const cell = row.getCell(c);
cell.clear();
const para = cell.appendParagraph('');
try {
driveRetry_(() => {
const img = para.appendInlineImage(photos[idx].getBlob());
const w = CONFIG.PHOTO_WIDTH;
const ratio = img.getHeight() / img.getWidth();
img.setWidth(w); img.setHeight(Math.round(w * ratio));
return true;
}, '이미지(' + photos[idx].getName() + ')');
} catch (e) {
para.appendText('[사진 삽입 실패: ' + photos[idx].getName() + ']');
}
}
}
function classifyPhotosByName_(photos) {
const out = { 전경: [], 기대: [] };
photos.forEach(p => {
if (/전경|전체|panorama/i.test(p.getName())) out.전경.push(p);
else out.기대.push(p);
});
return out;
}
// ============================================================
// GEMINI API
// ============================================================
function getApiKey_() {
const k = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!k) throw new Error('GEMINI_API_KEY 가 스크립트 속성에 없습니다.');
return k;
}
function extractPhotoInfoWithGemini_(photos, rowData) {
const parts = [];
photos.forEach((file, i) => {
const blob = file.getBlob();
parts.push({ text: '[IMAGE ' + i + '] ' + file.getName() });
parts.push({ inline_data: {
mime_type: blob.getContentType() || 'image/jpeg',
data: Utilities.base64Encode(blob.getBytes())
}});
});
parts.push({ text:
'[주문정보] 구매자=' + (rowData['구매자명']||'') + ', 기종=' + (rowData['사업명']||'') + '\n' +
'[요청]\n1) 각 이미지를 "전경"(설치된 장소 전체) 또는 "기대"(기계 본체/명판)로 분류\n' +
'2) "기대" 사진에서 규격명/제조번호/제품번호 OCR (없으면 빈 문자열)\n' +
'[출력] {"classifications":[{"index":0,"category":"전경|기대"}], "extracted":{"규격명":"","제조번호":"","제품번호":""}}'
});
const resp = callGemini_({
contents: [{ parts }],
generationConfig: { maxOutputTokens: CONFIG.GEMINI_MAX_TOKENS, temperature: 0.2, responseMimeType: 'application/json' }
});
const parsed = parseJsonLoose_(extractGeminiText_(resp));
const classified = { 전경: [], 기대: [] };
if (parsed && Array.isArray(parsed.classifications)) {
parsed.classifications.forEach(c => {
const cat = c.category === '전경' ? '전경' : '기대';
if (photos[c.index]) classified[cat].push(photos[c.index]);
});
} else {
const fb = classifyPhotosByName_(photos);
classified.전경 = fb.전경; classified.기대 = fb.기대;
}
return { extracted: (parsed && parsed.extracted) || {}, classified };
}
function validateFinalDocWithGemini_(docFile) {
const text = DocumentApp.openById(docFile.getId()).getBody().getText();
const resp = callGemini_({
contents: [{ parts: [{ text:
'아래 서류에 문제가 있는지 검수: 치환 안된 {{...}}/필수값 누락/포맷 이상.\n' +
'출력: {"issues":["..."]}\n=== 본문 ===\n' + text
}]}],
generationConfig: { maxOutputTokens: 512, temperature: 0.1, responseMimeType: 'application/json' }
});
const parsed = parseJsonLoose_(extractGeminiText_(resp));
return (parsed && Array.isArray(parsed.issues)) ? parsed.issues : [];
}
function callGemini_(payload) {
const apiKey = getApiKey_();
let lastErr;
for (let m = 0; m < CONFIG.GEMINI_MODELS.length; m++) {
const model = CONFIG.GEMINI_MODELS[m];
for (let attempt = 0; attempt <= CONFIG.GEMINI_MAX_RETRIES; attempt++) {
const res = UrlFetchApp.fetch(
CONFIG.GEMINI_API_BASE + '/' + model + ':generateContent?key=' + encodeURIComponent(apiKey),
{ method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }
);
const code = res.getResponseCode();
if (code >= 200 && code < 300) return JSON.parse(res.getContentText());
lastErr = new Error('Gemini ' + code + ' (' + model + '): ' + res.getContentText());
const retryable = [429, 500, 502, 503, 504].includes(code);
if (!retryable) break;
if (attempt < CONFIG.GEMINI_MAX_RETRIES) {
Utilities.sleep(CONFIG.GEMINI_BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 500);
}
}
}
throw lastErr;
}
function extractGeminiText_(resp) {
const p = resp && resp.candidates && resp.candidates[0] && resp.candidates[0].content && resp.candidates[0].content.parts;
return (p || []).map(x => x.text || '').join('');
}
function parseJsonLoose_(text) {
if (!text) return null;
let s = String(text).trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');
const a = s.indexOf('{'), b = s.lastIndexOf('}');
if (a < 0 || b < 0) { try { return JSON.parse(s); } catch(e) { return null; } }
try { return JSON.parse(s.slice(a, b + 1)); } catch(e) { return null; }
}
우선
필요할때마다 구글 시트에서 스크립트만 돌리면 되고,
구글 내에서 작업이 이뤄지기 때문에 조금 더 저렴하다는 것......
위 코드는 Claude의 도움을 받았다.
오히려 Gemini가 주는 코드는 오류가 나고...Claude는 잘돌아가는 이상한 현상;;;
Gemini반성하자.
삽질 포인트 3가지
디버깅을 하며 오류는 클로드의 도움을 받았음.
SyntaxError: Identifier 'CONFIG' has already been declared Apps Script는 .gs 파일 전역 스코프가 공유된다. Code.gs와 따로 만든 파일 양쪽에 코드를 붙여넣으면 터짐. 파일 하나만 남길 것.
Gemini API 오류 503: UNAVAILABLE Gemini Flash가 가끔 과부하. GEMINI_MODELS 배열에 폴백 모델을 줄세워 놓고 지수 백오프로 자동 재시도 하게 했다. 보통 자동 복구됨.
Exception: Service error: Drive makeCopy 직후 openById 하면 가끔 인덱싱이 덜 끝나서 터진다. (1) makeCopy 뒤 600ms 대기, (2) 모든 Drive 작업을 driveRetry_()로 감싸고 Service error 패턴을 재시도 대상으로 지정해서 해결.
마치며
템플릿 측에서 {{필드명}}을 시트 컬럼명과 맞춰두기만 하면, 서류 종류가 늘어도 스크립트 수정 없이 양식 폴더에 파일만 추가하면 자동으로 처리된다. Vision이 필요한 필드만 CONFIG에 선언해두면 끝.
'Tech' 카테고리의 다른 글
| [Claude Design] 피그마 + 클로드로 나만의 포트폴리오 사이트 만들기 (1) | 2026.05.15 |
|---|---|
| Power Automate에서 SharePoint 컬럼을 못 찾는 현상, Title칼럼 삭제 불가 현상 (0) | 2026.05.14 |
| 디자이너들은 대체 되는가? Claude Design기능 출시 (0) | 2026.04.20 |
| Microsoft AI Tour Seoul 2026 후기 — 밥 공짜 그러나 다소 아쉬웠던.. (1) | 2026.04.01 |
| [Python] 노션(Notion)으로 티스토리 자동 포스팅 파이프라인 구축하기: GitHub Actions와 Playwright 삽질 기록 (0) | 2026.02.07 |