1편의 엑셀 양식을 실제 프로그램으로 바꿀 시간입니다. 이번 가이드에서는 구글 드라이브에서 직접 앱스 스크립트(Apps Script) 프로젝트를 생성하고, 복사+붙여넣기만으로 나만의 자동화 웹앱을 완성하는 절차를 다룹니다.
💡 본문 핵심 요약
- 환경 세팅:
appsscript.json파일 하나로 드라이브 API 권한 해결- 메인 구축:
Code.gs와Index.html코드로 자동화 기능 구현- 보안 통과: '안전하지 않은 앱' 경고를 해결하는 완벽 배포 가이드
Ⅰ. [2단계] 자동화 웹앱 구축 상세 절차
이제 아래 순서대로 진행해 주세요. 그림을 보며 클릭하고, 제공된 코드를 전체보기 누르시면 복사버튼일 활성화 됩니다. 복사해서 붙여넣으시면 됩니다.
STEP 1. 구글 드라이브에서 프로젝트 생성하기
- 구글 드라이브의 원하는 폴더에서 [+ 신규] → [더보기] → [Google Apps Script]를 클릭합니다.
- 프로젝트 이름을
SFO_설문자동화_웹앱으로 변경합니다.
STEP 2. 프로젝트 환경 설정 (JSON 파일)
웹앱이 내 드라이브와 설문지에 접근할 수 있도록 '출입증(권한)'을 만드는 단계입니다.
- 좌측 [톱니바퀴 아이콘(프로젝트 설정)]을 누르고 "appsscript.json 매니페스트 파일 표시"를 체크합니다.
- 다시 좌측 편집기(
<>)로 돌아와 생성된appsscript.json파일을 열고 아래 코드를 모두 덮어씌웁니다.
📄 appsscript.json
{
"timeZone": "Asia/Seoul",
"dependencies": {
"enabledAdvancedServices": [{
"userSymbol": "Drive",
"serviceId": "drive",
"version": "v2"
}]
},
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
STEP 3. 메인 기능 구현 (코드 복사 & 붙여넣기)
프로그램의 실제 동작을 담당하는 메인 코드들을 작성합니다.
Code.gs파일의 기존 내용을 지우고 아래 [백엔드 코드]를 붙여넣습니다.- 좌측
+아이콘을 눌러 [HTML 파일]을 만들고 이름을Index로 지정(대소문자 구분 철저)한 뒤, [화면 코드]를 붙여넣습니다.
📄 Code.gs (백엔드 코드)
function createFormFromUploadedFile(fileBlob) {
try {
if (!fileBlob) return "❌ 오류: 업로드된 파일이 없습니다.";
// 0. 스크립트가 위치한 현재 폴더 ID 찾기
const scriptFile = DriveApp.getFileById(ScriptApp.getScriptId());
const parentFolder = scriptFile.getParents().next();
const folderId = parentFolder.getId();
const fileName = fileBlob.getName();
// 1. 엑셀 파일을 구글 스프레드시트로 변환 (처음부터 스크립트 폴더에 생성)
let resource = {
title: fileName,
mimeType: MimeType.GOOGLE_SHEETS,
parents: [{ id: folderId }] // 최상위가 아닌 현재 폴더에 직접 생성 (v2 문법)
};
let driveFile = Drive.Files.insert(resource, fileBlob);
let ssId = driveFile.id;
// 2. 스프레드시트 열기 및 시트 검증
const ss = SpreadsheetApp.openById(ssId);
const surveySheet = ss.getSheetByName('설문지 내용');
const rosterSheet = ss.getSheetByName('대상자');
if (!surveySheet || !rosterSheet) {
return "❌ 오류: 업로드한 엑셀에 '설문지 내용' 또는 '대상자' 시트가 없습니다.";
}
// 3. 부서 리스트 추출
let departmentList = [];
const rosterLastRow = rosterSheet.getLastRow();
if (rosterLastRow > 1) {
const rosterData = rosterSheet.getRange("A2:A" + rosterLastRow).getValues();
departmentList = [...new Set(rosterData.map(row => row[0].toString().trim()).filter(val => val !== ""))];
}
// 4. 설문 제목 및 날짜 설정
const surveyData = surveySheet.getDataRange().getValues();
let baseTitle = surveySheet.getRange("B1").getValue().toString().trim() || "설문지";
const dateYYMMDD = Utilities.formatDate(new Date(), "GMT+9", "yyMMdd");
const formTitle = baseTitle + "_" + dateYYMMDD;
// 5. 구글 폼 생성 및 폴더 이동
const form = FormApp.create(formTitle);
const formFile = DriveApp.getFileById(form.getId());
parentFolder.addFile(formFile); // 현재 폴더에 추가
DriveApp.getRootFolder().removeFile(formFile); // 최상위(루트)에서 제거
let formDesc = surveySheet.getRange("C1").getValue().toString().trim();
if (formDesc) form.setDescription(formDesc);
// 6. 응답 시트 연결 (업로드된 파일 자체를 응답지로 사용)
form.setDestination(FormApp.DestinationType.SPREADSHEET, ssId);
DriveApp.getFileById(ssId).setName(formTitle + "_응답");
// 이미 존재하는 '대상자' 시트를 첫 번째 탭으로 이동
const existingRoster = ss.getSheetByName("대상자");
if (existingRoster) {
ss.setActiveSheet(existingRoster);
ss.moveActiveSheet(1);
}
// 7. 퀴즈 판별 (E열 정답 유무)
const isQuiz = surveyData.slice(3).some(row => row[4] && row[4].toString().trim() !== "");
if (isQuiz) form.setIsQuiz(true);
// 8. 문항 생성 로직 (전체 포함)
let isFirstSection = true;
for (let i = 3; i < surveyData.length; i++) {
let [title, type, content, isRequired, answer, score] = surveyData[i];
if (!title) continue;
title = title.toString().trim();
type = type ? type.toString().trim() : "";
content = content ? content.toString().trim() : "";
answer = answer ? answer.toString().trim() : "";
score = score ? parseInt(score) : 0;
let item;
switch (type) {
case "섹션":
if (isFirstSection) {
form.setDescription((form.getDescription() || "") + "\n\n" + content);
isFirstSection = false;
} else {
form.addPageBreakItem().setTitle(title).setHelpText(content);
}
break;
case "동의블록":
item = form.addMultipleChoiceItem().setTitle(title).setHelpText(content);
item.setChoices([item.createChoice("동의합니다.")]);
break;
case "선형배율":
item = form.addScaleItem().setTitle(title).setBounds(1, 4);
if (content) {
const labels = content.split(',').map(s => s.trim());
if (labels.length >= 2) item.setLabels(labels[0], labels[1]);
}
break;
case "단답형":
item = form.addTextItem().setTitle(title);
if (content) item.setHelpText(content);
if (isQuiz && score > 0) item.setPoints(score);
break;
case "장문형":
item = form.addParagraphTextItem().setTitle(title);
if (content) item.setHelpText(content);
break;
case "드롭다운":
item = form.addListItem().setTitle(title);
if (title.includes("부서") || title.includes("공정") || title.includes("소속")) {
if (departmentList.length > 0) item.setChoices(departmentList.map(dept => item.createChoice(dept)));
} else if (content) {
const choices = content.split(',').map(s => s.trim());
if (isQuiz && answer) {
item.setChoices(choices.map(c => item.createChoice(c, c === answer.toString().trim())));
if(score > 0) item.setPoints(score);
} else {
item.setChoices(choices.map(c => item.createChoice(c)));
}
}
break;
case "객관식":
item = form.addMultipleChoiceItem().setTitle(title);
if (content) {
const choices = content.split(',').map(s => s.trim());
if (isQuiz && answer) {
item.setChoices(choices.map(c => item.createChoice(c, c === answer.toString().trim())));
if(score > 0) item.setPoints(score);
} else {
item.setChoices(choices.map(c => item.createChoice(c)));
}
}
break;
}
if (item && type !== "섹션" && type !== "동의블록" && (isRequired === "Y" || isRequired === "y")) {
item.setRequired(true);
}
}
// 9. 별도 이력 파일에 저장 (정확한 ID 사용)
const formType = isQuiz ? "퀴즈/평가" : "일반설문";
saveToHistory(baseTitle, formType, dateYYMMDD, form.getId(), ssId, form.getEditUrl(), form.getPublishedUrl());
// ⚠️ 중요: 응답 시트(ssId)를 휴지통으로 보내던 코드를 삭제했습니다.
return "✅ 업로드 및 폼 생성 완료: " + formTitle;
} catch (error) {
return "❌ 오류 발생: " + error.toString();
}
}
/**
* 00. 설문 이력 파일을 스크립트와 동일한 폴더에 기록하는 함수
*/
function saveToHistory(title, type, dateStr, formId, resId, editUrl, pubUrl) {
const fileName = "00. 설문 이력";
const scriptFile = DriveApp.getFileById(ScriptApp.getScriptId());
const parentFolder = scriptFile.getParents().next();
const files = parentFolder.getFilesByName(fileName);
let historySS;
if (files.hasNext()) {
historySS = SpreadsheetApp.open(files.next());
} else {
historySS = SpreadsheetApp.create(fileName);
const ssFile = DriveApp.getFileById(historySS.getId());
parentFolder.addFile(ssFile);
DriveApp.getRootFolder().removeFile(ssFile);
const sheet = historySS.getSheets()[0];
sheet.appendRow(["생성일시", "제목", "유형", "날짜", "폼ID", "응답ID", "편집URL", "배포URL"]);
sheet.getRange(1, 1, 1, 8).setFontWeight("bold").setBackground("#f3f3f3");
}
const sheet = historySS.getSheets()[0];
const timestamp = Utilities.formatDate(new Date(), "GMT+9", "yyyy-MM-dd HH:mm:ss");
sheet.appendRow([timestamp, title, type, dateStr, formId, resId, editUrl, pubUrl]);
}
/**
* 프론트엔드에서 받은 Base64 파일 데이터를 Blob으로 변환하여 생성 함수로 넘기는 헬퍼 함수
*/
function processUploadedFile(obj) {
try {
const blob = Utilities.newBlob(Utilities.base64Decode(obj.data), obj.mimeType, obj.fileName);
return createFormFromUploadedFile(blob);
} catch (e) {
return "❌ 파일 디코딩 오류: " + e.message;
}
}
/**
* 3단계: 미수료자 및 미해당 응답자 정밀 대조
*/
function getNonRespondents(formId, responseId) {
try {
const responseSS = SpreadsheetApp.openById(responseId);
const rosterSheet = responseSS.getSheetByName("대상자");
// '폼 응답' 시트를 자동으로 찾습니다.
const responseSheet = responseSS.getSheets().find(s => s.getName().includes("폼 응답") || s.getFormUrl() !== null);
if (!rosterSheet) return { success: false, message: "❌ 해당 파일에 '대상자' 시트가 없습니다." };
if (!responseSheet) return { success: false, message: "❌ 응답 시트를 찾을 수 없습니다." };
// 1. 대상자 데이터 가져오기 (B2:B) - 성명 기준
const rosterLastRow = rosterSheet.getLastRow();
const roster = (rosterLastRow > 1) ?
rosterSheet.getRange("B2:B" + rosterLastRow).getValues().flat().map(v => v.toString().trim()).filter(v => v !== "") : [];
// 2. 응답 시트 데이터 가져오기 (D2:D) - 설문 시 작성한 성명 기준
const respLastRow = responseSheet.getLastRow();
const responses = (respLastRow > 1) ?
responseSheet.getRange("D2:D" + respLastRow).getValues().flat().map(v => v.toString().trim()).filter(v => v !== "") : [];
// --- 대조 로직 ---
// [A] 미제출자: 대상자(roster)에는 있는데, 응답(responses)에는 없는 사람
const listNon = roster.filter(name => !responses.includes(name));
// [B] 미해당 인원: 응답(responses)에는 있는데, 정작 대상자(roster)에는 없는 사람
const listIrr = responses.filter(name => !roster.includes(name));
// [C] 실제 수료 인원: 대상자 인원 중 응답을 완료한 고유 인원 수
const validRespondedNames = [...new Set(responses.filter(name => roster.includes(name)))];
return {
success: true,
total: roster.length,
responded: validRespondedNames.length,
nonResponded: listNon.length,
listNon: listNon, // 미제출자 목록
listIrr: [...new Set(listIrr)] // 미해당 인원 목록 (중복 제거)
};
} catch (e) {
return { success: false, message: "❌ 데이터 조회 중 오류: " + e.message };
}
}
function doGet() {
return HtmlService.createHtmlOutputFromFile("Index");
}
/**
* 4단계: 응답 결과 CSV 다운로드
* 웹앱 드롭다운에서 전달받은 응답 파일 ID(responseId)를 사용하여 즉시 다운로드 링크를 생성합니다.
*/
function exportResponseExcel(responseId) {
try {
// 1. 전달받은 응답 파일 ID로 해당 스프레드시트를 엽니다.
const ss = SpreadsheetApp.openById(responseId);
// 2. 폼 응답이 담긴 시트(탭)를 찾습니다.
// 시트 이름에 '폼 응답'이 포함되어 있거나, 구글 폼 URL이 연결된 첫 번째 시트를 자동으로 찾습니다.
const sheet = ss.getSheets().find(s => s.getName().includes("폼 응답") || s.getFormUrl() !== null) || ss.getSheets()[0];
if (!sheet) {
return "❌ 응답 시트를 찾을 수 없습니다. 파일이 삭제되었거나 손상되었을 수 있습니다.";
}
// 3. [교체] 한글 깨짐 방지를 위해 CSV 대신 XLSX(엑셀) 형식으로 URL 생성
// format=xlsx를 사용하면 gid(탭번호) 없이도 파일 전체를 엑셀로 깨끗하게 내려받습니다.
const url = `https://docs.google.com/spreadsheets/d/${ss.getId()}/export?format=xlsx`;
// 4. 완성된 엑셀 다운로드 링크를 웹앱 화면으로 반환
return url;
} catch (e) {
return "❌ 파일 접근 오류 발생: " + e.message;
}
}
/**
* 공통함수
*/ function getLastForm() {
const formId = PropertiesService.getScriptProperties().getProperty("LAST_FORM_ID");
if (!formId) return null;
return FormApp.openById(formId);
}
/**
* 1. 스크립트 폴더 내의 '00. 설문 이력' 파일을 찾아 목록 불러오기
*/
function getFormHistoryList() {
// 1. 스크립트 폴더 찾기
const scriptFile = DriveApp.getFileById(ScriptApp.getScriptId());
const parentFolder = scriptFile.getParents().next();
// 2. 해당 폴더 안에서 이력 파일 찾기
const files = parentFolder.getFilesByName("00. 설문 이력");
if (!files.hasNext()) return [];
const historySS = SpreadsheetApp.open(files.next());
const sheet = historySS.getSheets()[0];
const data = sheet.getDataRange().getValues();
let historyList = [];
if (data.length <= 1) return [];
for (let i = data.length - 1; i > 0; i--) {
let [ts, title, type, dateStr, formId, resId] = data[i];
historyList.push({
label: `${title} / ${type} / ${dateStr}`,
formId: formId,
responseId: resId
});
}
return historyList;
}
/**
* 질문/설정 탭을 거치지 않고 바로 '응답 요약 차트'로 이동하는 링크
*/
function getFormSummaryLink(formId) {
try {
// 폼 ID 뒤에 /viewanalytics 를 붙이면 관리자 로그인 시
// 사진으로 보내주신 그 '차트 요약' 화면으로 즉시 연결됩니다.
return `https://docs.google.com/forms/d/${formId}/viewanalytics`;
} catch (e) {
return null;
}
}
/**
* 선택된 설문의 배포용 링크를 가져오는 함수 (history.gs)
*/
function getFormLinkForQR(formId) {
try {
if (!formId) throw new Error("설문 ID가 전달되지 않았습니다.");
// 폼을 열어 실제 배포용 URL을 가져옵니다.
const form = FormApp.openById(formId);
const url = form.getPublishedUrl();
return {
success: true,
url: url
};
} catch (e) {
console.error(e.toString());
return {
success: false,
message: "❌ 링크 추출 실패: " + e.message
};
}
}
📄 Index.html (화면 UI 코드)
<!DOCTYPE html>
<html lang="ko">
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>설문지 자동화 시스템 | 얕넓지식</title>
<style>
:root {
--nt-bg: #ffffff;
--nt-text: #202124;
--nt-sub: #5f6368;
--nt-border: #e0e0e0;
--nt-hover: #f1f3f4;
--nt-blue: #1a73e8;
--nt-green: #34a853;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f8f9fa; color: var(--nt-text); margin: 0; padding: 0;
height: 100vh; overflow: hidden; display: flex; flex-direction: column;
}
/* 로딩 오버레이 */
#loadingOverlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255,255,255,0.85); display: none; flex-direction: column;
justify-content: center; align-items: center; z-index: 2000; backdrop-filter: blur(2px);
}
.spinner { width: 36px; height: 36px; border: 3px solid var(--nt-hover); border-top: 3px solid var(--nt-blue); border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* 헤더 */
header { padding: 15px 24px; border-bottom: 1px solid var(--nt-border); background: white; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 18px; font-weight: 700; color: #202124; display: flex; align-items: center; gap: 8px; }
/* 메인 영역 */
main { flex: 1; overflow-y: auto; padding: 24px; display: flex; flex-direction: column; align-items: center; }
.content-container { width: 100%; max-width: 1000px; }
.view-section { display: none; width: 100%; animation: fadeIn 0.3s ease; }
.view-section.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
/* --- [1] 생성 화면 전용 스타일 --- */
.template-box { margin-bottom: 24px; }
.template-btn {
display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 16px 20px; border: 1px solid #c6dafc; background: #e8f0fe; border-radius: 8px;
color: var(--nt-blue); font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none; transition: 0.2s;
}
.template-btn:hover { background: #d2e3fc; }
#drop-zone {
border: 2px dashed #dadce0; border-radius: 12px; padding: 60px 20px;
text-align: center; cursor: pointer; background: white; position: relative; transition: 0.2s;
}
#drop-zone:hover { background: #f8f9fa; border-color: #bdc1c6; }
#drop-zone.dragover { background: #e8f0fe; border-color: var(--nt-blue); }
#excelFile { position: absolute; width: 100%; height: 100%; top: 0; left: 0; opacity: 0; cursor: pointer; }
.success-box { margin-top: 15px; padding: 15px; background: #e6f4ea; color: #137333; border: 1px solid #ceead6; border-radius: 8px; font-weight: 600; text-align: center; display: none; }
/* --- [2] 탐색기 스플릿 레이아웃 --- */
.explorer-layout { display: flex; gap: 24px; align-items: flex-start; height: calc(100vh - 200px); }
.explorer-left {
width: 280px; flex-shrink: 0; background: transparent;
display: flex; flex-direction: column; height: 100%;
}
.explorer-right { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; }
.file-list { flex: 1; overflow-y: auto; padding-right: 5px; }
.file-item {
display: flex; align-items: center; padding: 14px 16px; background: white; border: 1px solid transparent;
border-radius: 0 12px 12px 0; border-left: 4px solid transparent; cursor: pointer; margin-bottom: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.02); transition: 0.2s;
}
.file-item:hover { background: var(--nt-hover); border-color: var(--nt-border); border-left-color: #ccc; }
.file-item.selected { background: #e8f0fe; border-left-color: var(--nt-blue); border-color: #d2e3fc; }
.file-icon { font-size: 20px; margin-right: 12px; }
.file-info { flex: 1; overflow: hidden; }
.file-name { font-size: 14px; font-weight: 700; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.file-date { font-size: 12px; color: var(--nt-sub); }
/* --- 프리뷰 카드 스타일 (생성 후 & 탐색기 우측 공통) --- */
.preview-card { border: 1px solid var(--nt-border); border-radius: 12px; padding: 24px; background: white; box-shadow: 0 2px 10px rgba(0,0,0,0.04); overflow-y: auto; }
.preview-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; border-bottom: 1px solid var(--nt-border); padding-bottom: 15px; }
.preview-title { font-size: 20px; font-weight: 700; margin-top: 4px; color: #202124; }
.preview-body { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.qr-section { width: 150px; display: flex; flex-direction: column; gap: 10px; }
.qr-img { width: 100%; aspect-ratio: 1; border: 1px solid var(--nt-border); border-radius: 8px; object-fit: contain; padding: 5px; }
.action-section { flex: 1; min-width: 250px; display: flex; flex-direction: column; gap: 12px; }
.link-box { background: #f8f9fa; padding: 12px 16px; border-radius: 8px; font-size: 12px; color: #5f6368; word-break: break-all; border: 1px solid #f1f3f4; }
.action-row { display: flex; gap: 12px; }
.btn {
flex: 1; padding: 12px 10px; font-size: 13px; font-weight: 600; border-radius: 6px;
border: 1px solid var(--nt-border); background: white; cursor: pointer; color: #3c4043; transition: 0.2s;
}
.btn:hover { background: var(--nt-hover); }
.btn-blue { background: var(--nt-blue); color: white; border: none; }
.btn-blue:hover { background: #1557b0; }
.btn-green { background: var(--nt-green); color: white; border: none; }
.btn-green:hover { background: #288c42; }
/* 대조 결과 박스 */
.result-box { padding: 16px; background: #f8f9fa; border-radius: 8px; font-size: 13px; line-height: 1.6; border: 1px solid #eee; margin-top: 15px; }
/* 모바일 대응 */
@media (max-width: 768px) {
.explorer-layout { flex-direction: column; height: auto; }
.explorer-left { width: 100%; height: 250px; border-bottom: 1px solid var(--nt-border); padding-bottom: 15px; margin-bottom: 15px; }
.preview-body { flex-direction: column; align-items: center; }
.action-row { flex-direction: column; }
}
/* 하단 네비게이션 */
.bottom-nav {
height: 65px; border-top: 1px solid var(--nt-border); background: white;
display: flex; justify-content: space-around; align-items: center; flex-shrink: 0; padding-bottom: env(safe-area-inset-bottom);
}
.nav-item { background: none; border: none; flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--nt-sub); font-size: 11px; font-weight: 600; cursor: pointer; }
.nav-item i { font-size: 20px; font-style: normal; }
.nav-item.active { color: var(--nt-blue); }
</style>
</head>
<body>
<div id="loadingOverlay">
<div class="spinner"></div>
<p id="loadingText" style="font-size: 14px; margin-top: 16px; font-weight: 600; color: #333;">작업 진행 중...</p>
</div>
<header onclick="window.open('https://www.infostorage-go.com/2026/04/ehs-survey-automation-management-setup.html', '_blank')" style="cursor: pointer;" title="사이트로 이동">
<div class="header-title" id="header-title">✨ 새 설문지 생성</div>
<div style="font-size: 12px; color: var(--nt-sub); font-weight: 500;"> <사용방법 클릭> Made by 얕넓지식</div>
</header>
<main>
<div class="content-container">
<section id="view-create" class="view-section active">
<div class="template-box">
<div style="font-size: 16px; font-weight: 700; margin-bottom: 10px;">양식 다운로드</div>
<a href="https://www.infostorage-go.com/2026/04/google-form-automation-excel-setup-guide-v1.html" target="_blank" class="template-btn">
<span>📊 설문/평가 기본 양식 (엑셀)</span>
<span>다운로드 📥</span>
</a>
</div>
<div style="font-size: 18px; font-weight: 700; margin-bottom: 15px;">구글 설문지 자동생성</div>
<div id="drop-zone">
<div style="font-size: 40px; margin-bottom: 12px;">📄</div>
<div id="file-label" style="font-weight: 700; font-size: 15px;">엑셀 파일을 드래그하거나 클릭하세요</div>
<p style="font-size: 13px; color: var(--nt-sub); margin-top: 6px;">양식: '설문지 내용' 및 '대상자' 시트 필수</p>
<input type="file" id="excelFile" accept=".xlsx, .xls" onchange="handleFileSelect(this)">
</div>
<button class="btn btn-blue" style="width: 100%; margin-top: 15px; padding: 16px; font-size: 15px;" onclick="uploadAndCreateForm()">
🚀 폼 자동 생성 시작
</button>
<div id="resultCreate" class="success-box"></div>
<div id="create-preview" class="preview-card" style="display:none; margin-top: 20px;">
<div class="preview-header">
<div>
<div id="cp-type" style="color:var(--nt-blue); font-size:12px; font-weight:700;">생성 완료</div>
<div id="cp-title" class="preview-title">설문명</div>
</div>
</div>
<div class="preview-body">
<div class="qr-section">
<img id="cp-qr" class="qr-img" src="" alt="QR">
<button class="btn" onclick="downloadImage(document.getElementById('cp-qr').src)">QR 저장</button>
</div>
<div class="action-section">
<div style="font-size:12px; color:var(--nt-sub);">배포 링크</div>
<div id="cp-link" class="link-box"></div>
<div class="action-row">
<button class="btn" onclick="copyText(document.getElementById('cp-link').innerText)">🔗 링크 복사</button>
<button id="btn-cp-edit" class="btn" style="border-color: var(--nt-blue); color: var(--nt-blue);">✏️ 폼 직접 편집</button>
</div>
<div class="action-row" style="margin-top: 8px;">
<button class="btn" onclick="switchView('explorer', document.getElementById('nav-explorer'));">📂 발행 설문 관리로 이동</button>
</div>
</div>
</div>
</div>
</section>
<section id="view-explorer" class="view-section">
<div style="font-size: 20px; font-weight: 700; margin-bottom: 20px;">발행 설문 관리</div>
<div class="explorer-layout">
<div class="explorer-left">
<div style="font-size: 12px; color: var(--nt-sub); font-weight: 600; padding-bottom: 8px; border-bottom: 1px solid var(--nt-border); margin-bottom: 8px;">설문명 / 분류</div>
<div id="file-list" class="file-list">
</div>
</div>
<div class="explorer-right">
<div id="empty-preview" style="display:flex; align-items:center; justify-content:center; height:100%; min-height: 300px; border:2px dashed #e0e0e0; border-radius:12px; color:#9aa0a6; font-size:14px; font-weight:500;">
👈 좌측에서 설문을 선택해주세요.
</div>
<div id="detail-preview" class="preview-card" style="display:none; height:100%;">
<div class="preview-header">
<div>
<div id="pv-type" style="color:var(--nt-blue); font-size:12px; font-weight:700;">분류</div>
<div id="pv-title" class="preview-title">제목</div>
<div id="pv-date" style="color:var(--nt-sub); font-size:12px;">생성일</div>
</div>
<button class="btn" style="width:auto; padding:6px 12px; font-size:12px;" onclick="closePreview()">닫기 ✕</button>
</div>
<div class="preview-body">
<div class="qr-section">
<div id="pv-qr-box"><span style="font-size:11px; color:#ccc;">로딩중...</span></div>
<button class="btn" onclick="downloadImage(currentQRUrl)">QR 저장</button>
</div>
<div class="action-section">
<div style="font-size:12px; color:var(--nt-sub);">배포 링크</div>
<div id="pv-link" class="link-box">불러오는 중...</div>
<div class="action-row">
<button class="btn" onclick="copyText(currentSelected.pubUrl)">🔗 링크 복사</button>
<button class="btn btn-blue" onclick="checkNon()">🔍 실시간 미수료자 조회</button>
</div>
<div class="action-row">
<button class="btn" onclick="goSummary()">📊 구글 폼 차트 요약</button>
<button class="btn btn-green" onclick="downloadCSV()">📥 엑셀(결과) 다운로드</button>
</div>
<div class="action-row" style="margin-top: 8px;">
<button class="btn" style="border-color: var(--nt-blue); color: var(--nt-blue);" onclick="if(currentSelected) window.open('https://docs.google.com/forms/d/' + currentSelected.formId + '/edit', '_blank')">✏️ 구글 설문지 직접 편집</button>
</div>
</div>
</div>
<div id="resultNon" class="result-box" style="display:none;"></div>
</div>
</div>
</div>
</section>
</div>
</main>
<nav class="bottom-nav">
<button id="nav-create" class="nav-item active" onclick="switchView('create', this)">
<i>✨</i><span>구글설문지 신규 생성</span>
</button>
<button id="nav-explorer" class="nav-item" onclick="switchView('explorer', this)">
<i>📂</i><span>발행 설문 관리</span>
</button>
</nav>
<script>
let currentSelected = null;
let currentQRUrl = null;
window.onload = function() { loadHistory(); };
// 화면 전환
function switchView(viewId, btn) {
document.querySelectorAll('.view-section').forEach(v => v.classList.remove('active'));
document.getElementById('view-' + viewId).classList.add('active');
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('header-title').innerText = (viewId === 'create') ? "✨ 새 설문 생성" : "📂 발행 설문 관리";
if(viewId === 'explorer') loadHistory();
}
// 파일 드래그앤드롭
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('excelFile');
const fileLabel = document.getElementById('file-label');
['dragover', 'dragleave', 'drop'].forEach(evt => { dropZone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); }); });
dropZone.addEventListener('dragover', () => dropZone.classList.add('dragover'));
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
// 1. 드래그해서 놓았을 때
dropZone.addEventListener('drop', (e) => {
dropZone.classList.remove('dragover');
if (e.dataTransfer.files[0]) {
fileInput.files = e.dataTransfer.files;
fileLabel.innerText = e.dataTransfer.files[0].name; // 버그 수정 완료
// 아이콘을 체크 표시(✅)로 변경
dropZone.querySelector('div').innerText = "✅";
}
});
// 2. 클릭해서 파일을 선택했을 때
function handleFileSelect(input) {
if(input.files[0]) {
fileLabel.innerText = input.files[0].name;
// 아이콘을 체크 표시(✅)로 변경
dropZone.querySelector('div').innerText = "✅";
}
}
function showLoading(show, text) {
document.getElementById("loadingOverlay").style.display = show ? "flex" : "none";
if(text) document.getElementById("loadingText").innerText = text;
}
// [1] 생성 로직 및 즉시 프리뷰
function uploadAndCreateForm() {
const file = fileInput.files[0];
if (!file) { alert("엑셀 파일을 선택해주세요."); return; }
showLoading(true, "엑셀 데이터를 분석하여 폼을 생성합니다...");
const reader = new FileReader();
reader.onload = function(e) {
const obj = { fileName: file.name, mimeType: file.type, data: e.target.result.split(',')[1] };
google.script.run.withSuccessHandler(function(msg) {
// 메시지 띄우기
const resEl = document.getElementById("resultCreate");
resEl.style.display = 'block'; resEl.innerText = msg;
fileInput.value = ""; fileLabel.innerText = "엑셀 파일을 드래그하거나 클릭하세요";
// 방금 생성한 내역을 가져와서 즉시 하단에 뿌려줌
google.script.run.withSuccessHandler(function(list) {
showLoading(false);
if(list.length > 0) {
const latest = list[0];
const cpBox = document.getElementById("create-preview");
cpBox.style.display = 'block';
document.getElementById("cp-title").innerText = latest.label.split(' / ')[0];
// QR 및 링크 세팅
google.script.run.withSuccessHandler(function(res) {
if(res.success) {
const qr = "https://quickchart.io/qr?text=" + encodeURIComponent(res.url) + "&size=200";
document.getElementById("cp-qr").src = qr;
document.getElementById("cp-link").innerText = res.url;
}
}).getFormLinkForQR(latest.formId);
document.getElementById("btn-cp-edit").setAttribute("onclick", "window.open('https://docs.google.com/forms/d/" + latest.formId + "/edit', '_blank')");
setTimeout(() => cpBox.scrollIntoView({behavior:'smooth'}), 200);
}
}).getFormHistoryList();
}).withFailureHandler(function(err){ showLoading(false); alert("오류: " + err); }).processUploadedFile(obj);
};
reader.readAsDataURL(file);
}
// [2] 탐색기 로직
function loadHistory() {
google.script.run.withSuccessHandler(function(list) {
const listEl = document.getElementById("file-list");
listEl.innerHTML = "";
if(list.length === 0) { listEl.innerHTML = "<div style='text-align:center; padding:20px; color:#999;'>이력이 없습니다.</div>"; return; }
list.forEach(item => {
const parts = item.label.split(' / ');
const div = document.createElement("div");
div.className = "file-item";
div.innerHTML = `
<div class="file-icon">📊</div>
<div class="file-info">
<div class="file-name">${parts[0]}</div>
<div class="file-date"><span style="color:var(--nt-blue); font-weight:600;">[${parts[1]}]</span> ${parts[2]}</div>
</div>`;
div.onclick = () => selectFile(item, div, parts[0], parts[1], parts[2]);
listEl.appendChild(div);
});
}).getFormHistoryList();
}
function selectFile(item, element, title, type, date) {
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
currentSelected = item;
document.getElementById('empty-preview').style.display = 'none';
const preview = document.getElementById('detail-preview');
preview.style.display = 'block';
document.getElementById('pv-title').innerText = title;
document.getElementById('pv-type').innerText = type;
document.getElementById('pv-date').innerText = "생성일: " + date;
document.getElementById('resultNon').style.display = 'none';
document.getElementById('pv-link').innerText = "로딩중...";
document.getElementById('pv-qr-box').innerHTML = `<span style="font-size:11px; color:#ccc;">로딩중...</span>`;
google.script.run.withSuccessHandler(function(res) {
if(res.success) {
currentSelected.pubUrl = res.url;
document.getElementById('pv-link').innerText = res.url;
currentQRUrl = "https://quickchart.io/qr?text=" + encodeURIComponent(res.url) + "&size=250";
document.getElementById('pv-qr-box').innerHTML = `<img src="${currentQRUrl}" class="qr-img">`;
}
}).getFormLinkForQR(item.formId);
}
function closePreview() {
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('selected'));
document.getElementById('detail-preview').style.display = 'none';
document.getElementById('empty-preview').style.display = 'flex';
currentSelected = null;
}
// 공통 기능들
function copyText(txt) { if(txt) navigator.clipboard.writeText(txt).then(() => alert("링크가 복사되었습니다.")); }
function downloadImage(url) {
if(!url) return;
fetch(url).then(r => r.blob()).then(b => {
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'QR_배포용.png'; a.click();
});
}
function checkNon() {
if(!currentSelected) return;
const resEl = document.getElementById("resultNon");
resEl.style.display = 'block'; resEl.innerHTML = "⏳ 명단을 대조 중입니다...";
google.script.run.withSuccessHandler(function(res) {
if (!res.success) { resEl.innerText = res.message; return; }
let html = `<b>📊 현황:</b> 총 대상 ${res.total}명 | 제출 ${res.responded}명 | <b style="color:#d32f2f;">미제출 ${res.nonResponded}명</b><hr style="border:0.5px solid #e0e0e0; margin:12px 0;">`;
html += `<div><b style="color:#d32f2f;">🚩 미제출자 목록 (대상 인원 기준)</b><br>`;
html += (res.listNon.length > 0) ? `<span style="line-height:1.6; font-weight:600;">${res.listNon.join(", ")}</span>` : "<span style='color:var(--nt-blue);'>전원 제출 완료! 🎉</span>";
html += `</div><br>`;
html += `<div><b style="color:#757575;">⚠️ 응답자 중 미해당 인원 (대상 인원 외)</b><br>`;
html += (res.listIrr.length > 0) ? `<span style="color:#555; font-size:12px;">${res.listIrr.join(", ")}</span>` : "<span style='color:gray; font-size:12px;'>없음</span>";
html += `</div>`;
resEl.innerHTML = html;
resEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
}).getNonRespondents(currentSelected.formId, currentSelected.responseId);
}
function goSummary() { if(currentSelected) google.script.run.withSuccessHandler(u => {if(u) window.open(u, '_blank')}).getFormSummaryLink(currentSelected.formId); }
function downloadCSV() { if(currentSelected) google.script.run.withSuccessHandler(u => {if(u) window.open(u, '_blank')}).exportResponseExcel(currentSelected.responseId); }
</script>
</body>
</html>
💡 중요: 모든 코드를 넣었다면 상단의 저장(💾) 버튼을 반드시 누르세요!
Ⅱ. 웹앱 배포 및 '안전하지 않음' 권한 설정 해결
코드를 다 넣었다면 이제 이 프로그램을 웹 브라우저에서 실행할 수 있게 '배포'해야 합니다.
STEP 1. 새 배포 만들기
- 우측 상단 [배포] → [새 배포]를 누릅니다.
- 유형 선택(톱니바퀴)에서 [웹앱]을 선택합니다.
- 액세스 권한을 [모든 사용자]로 설정한 뒤 [배포]를 클릭합니다.
STEP 2. 구글 앱 권한 승인 (보안 경고 통과하기)
배포 버튼을 누르면 구글 보안 정책상 권한 승인 팝업이 뜹니다. "확인되지 않은 앱"이라는 경고가 나와도 당황하지 마세요. 내가 만든 코드를 내가 허용하는 과정입니다.
- [액세스 승인]을 클릭하고 본인의 구글 계정을 선택합니다.
- 경고창 좌측 하단의 [고급] 버튼을 누릅니다.
- 아래에 숨겨진 [(프로젝트명)(안전하지 않음)으로 이동] 링크를 클릭합니다.
- 마지막으로 [허용]을 누르면 끝입니다.
STEP 3. 배포 링크 확인 및 실행
모든 과정이 끝나면 최종적으로 웹앱 URL이 생성됩니다. 이 URL이 바로 여러분이 앞으로 설문지를 자동 생성할 때 접속할 프로그램 주소입니다.
수고하셨습니다! 이제 여러분만의 강력한 업무 자동화 도구가 준비되었습니다.
시작하기
설문 자동화 시스템 개요 및 개발 배경
행정 소모를 획기적으로 줄여주는 엑셀 기반 시스템의 전체적인 구성과 도입 효과에 대해 알아봅니다.
제1편
엑셀 양식 작성 지침 및 데이터 세팅 방법
설문 폼 생성의 뼈대가 되는 엑셀 템플릿(문항, 대상자 명단)의 정확한 작성법과 필수 세팅값을 알아봅니다.
제2편
구글 폼 자동 생성 웹앱 구축 및 설정 방법
복잡한 코딩 없이 구글 앱스스크립트(GAS)를 복사하여 우리 회사만의 전용 자동화 시스템을 구축하는 과정을 다룹니다.
제3편
웹앱 세부 사용법 - 구글 폼 생성 및 배포 절차 및 결과 확인
완성된 웹앱에 엑셀 파일을 업로드하고, 단 10초 만에 구글 폼과 배포용 QR코드를 생성하여 안내하는 실무 절차, 실시간 대조, 과거 이력 결과 확인, 응답자 엑셀 확인 방법을 설명합니다.
![설문지 작성, 배포, 관리 실무 자동화 [2단계: 구글 폼 자동 생성 웹앱 구축 및 권한 설정]](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg4vLvbPel6oV4ALiXt2tm8vKJ60cfnNXHTy23czirgWWPi8fpiDrmcXbpoSUNkuIO0lN7wtvC4WIL_kT9yP2c6UWHogYfzVQyvp7qo7lZZbGYVprRXE3bfXf4igyPyduEaVuLxX3gcnABQRDsDpXKY8jrFpKLdT0VQx0DB6v-TqOmL_BgcqNym-sH3zvo/w400-h388/1776647234559(1).png)









0 댓글