설문지 작성, 배포, 관리 실무 자동화 [2단계: 구글 폼 자동 생성 웹앱 구축 및 권한 설정]

1편의 엑셀 양식을 실제 프로그램으로 바꿀 시간입니다. 이번 가이드에서는 구글 드라이브에서 직접 앱스 스크립트(Apps Script) 프로젝트를 생성하고, 복사+붙여넣기만으로 나만의 자동화 웹앱을 완성하는 절차를 다룹니다.

설문지 작성, 배포, 관리 실무 자동화 [2단계: 구글 폼 자동 생성 웹앱 구축 및 권한 설정]
 

💡 본문 핵심 요약

 
       
  • 환경 세팅: appsscript.json 파일 하나로 드라이브 API 권한 해결
  •    
  • 메인 구축: Code.gsIndex.html 코드로 자동화 기능 구현
  •    
  • 보안 통과: '안전하지 않은 앱' 경고를 해결하는 완벽 배포 가이드
  •  

Ⅰ. [2단계] 자동화 웹앱 구축 상세 절차

이제 아래 순서대로 진행해 주세요. 그림을 보며 클릭하고, 제공된 코드를 전체보기 누르시면 복사버튼일 활성화 됩니다. 복사해서 붙여넣으시면 됩니다.

STEP 1. 구글 드라이브에서 프로젝트 생성하기

     
  1. 구글 드라이브의 원하는 폴더에서 [+ 신규] → [더보기] → [Google Apps Script]를 클릭합니다.
  2.  
  3. 프로젝트 이름을 SFO_설문자동화_웹앱으로 변경합니다.
구글 드라이브에서 앱스 스크립트 프로젝트 생성하는 방법

STEP 2. 프로젝트 환경 설정 (JSON 파일)

웹앱이 내 드라이브와 설문지에 접근할 수 있도록 '출입증(권한)'을 만드는 단계입니다.

     
  1. 좌측 [톱니바퀴 아이콘(프로젝트 설정)]을 누르고 "appsscript.json 매니페스트 파일 표시"를 체크합니다.
  2.  
  3. 다시 좌측 편집기(<>)로 돌아와 생성된 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. 메인 기능 구현 (코드 복사 & 붙여넣기)

프로그램의 실제 동작을 담당하는 메인 코드들을 작성합니다.

     
  1. Code.gs 파일의 기존 내용을 지우고 아래 [백엔드 코드]를 붙여넣습니다.
  2.  
  3. 좌측 + 아이콘을 눌러 [HTML 파일]을 만들고 이름을 Index로 지정(대소문자 구분 철저)한 뒤, [화면 코드]를 붙여넣습니다.
Code.gs 및 Index.html 파일 생성 후 코드 복사 붙여넣기
📄 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. 새 배포 만들기

     
  1. 우측 상단 [배포] → [새 배포]를 누릅니다.
  2.  
  3. 유형 선택(톱니바퀴)에서 [웹앱]을 선택합니다.
  4.  
  5. 액세스 권한을 [모든 사용자]로 설정한 뒤 [배포]를 클릭합니다.
웹앱 새 배포 위치 확인
웹앱 배포 세부 설정

STEP 2. 구글 앱 권한 승인 (보안 경고 통과하기)

배포 버튼을 누르면 구글 보안 정책상 권한 승인 팝업이 뜹니다. "확인되지 않은 앱"이라는 경고가 나와도 당황하지 마세요. 내가 만든 코드를 내가 허용하는 과정입니다.

     
  1. [액세스 승인]을 클릭하고 본인의 구글 계정을 선택합니다.
  2.  
  3. 경고창 좌측 하단의 [고급] 버튼을 누릅니다.
  4.  
  5. 아래에 숨겨진 [(프로젝트명)(안전하지 않음)으로 이동] 링크를 클릭합니다.
  6.  
  7. 마지막으로 [허용]을 누르면 끝입니다.
구글 권한 승인 단계 1
구글 권한 승인 단계 2 - 고급 설정

STEP 3. 배포 링크 확인 및 실행

모든 과정이 끝나면 최종적으로 웹앱 URL이 생성됩니다. 이 URL이 바로 여러분이 앞으로 설문지를 자동 생성할 때 접속할 프로그램 주소입니다.

웹앱 배포 확인 위치
생성된 웹앱 URL 확인

수고하셨습니다! 이제 여러분만의 강력한 업무 자동화 도구가 준비되었습니다.


시작하기
설문 자동화 시스템 개요 및 개발 배경
행정 소모를 획기적으로 줄여주는 엑셀 기반 시스템의 전체적인 구성과 도입 효과에 대해 알아봅니다.
제1편
엑셀 양식 작성 지침 및 데이터 세팅 방법
설문 폼 생성의 뼈대가 되는 엑셀 템플릿(문항, 대상자 명단)의 정확한 작성법과 필수 세팅값을 알아봅니다.
제2편
구글 폼 자동 생성 웹앱 구축 및 설정 방법
복잡한 코딩 없이 구글 앱스스크립트(GAS)를 복사하여 우리 회사만의 전용 자동화 시스템을 구축하는 과정을 다룹니다.
제3편
웹앱 세부 사용법 - 구글 폼 생성 및 배포 절차 및 결과 확인
완성된 웹앱에 엑셀 파일을 업로드하고, 단 10초 만에 구글 폼과 배포용 QR코드를 생성하여 안내하는 실무 절차, 실시간 대조, 과거 이력 결과 확인, 응답자 엑셀 확인 방법을 설명합니다.

댓글 쓰기

0 댓글