RESOURCE · HR 자동화 · HR-03

난이도
★★
예상 소요
45분
전제 조건
Google Form + Gemini API 키
월 비용
0원
⚡ 셋업 전에 결과부터 만들어보기 설치·코딩 없이 입력만으로 결과를 가져갈 수 있는 도구가 따로 있습니다.
🚀 설문 응답 즉시 분석 (30~60초) →

분기 펄스서베이 + AI 감성 분석

응답 1,000건도 30분 안에. 식별 정보는 AI에 전달되지 않도록 분리 설계.

한 줄 핵심: 익명 설문을 자동 발송·수집한 뒤, AI가 감성·핵심 주제·위험 신호를 분류해 경영진 1페이지 리포트로 전달한다.

왜 이 자동화인가

항목 수동(Before) 자동(After)
응답 1,000건 코딩 외부 발주 시 200~500만 원 0원 (Gemini 무료 티어로 충분)
분석 소요 1~2주 30분
무기명성 종이/구글 폼만으로는 의심 응답 분리·집계 자동화로 신뢰성↑
액션 연결 보고서로 끝남 주제별 권고가 자동 작성됨

적용 시나리오: 분기 ENPS·번아웃 점검·회식 만족도·리더십 360°.

구성요소

개인정보 안전 원칙: 정성 응답을 AI에 보낼 때 이름·부서 등 식별 정보는 절대 함께 전송하지 않는다. 본 스크립트는 정성 텍스트만 추출하여 전송한다.

셋업 가이드

Step 1. 폼 질문 설계 (예시)

  1. 직군 (객관식 — 식별 위험 낮은 큰 그룹만: 본부 단위)
  2. 일에 몰입한다 (1~5)
  3. 회사 추천의향(ENPS) (0~10)
  4. 가장 잘된 점 (장문) ← AI 분석 대상
  5. 가장 답답한 점 (장문) ← AI 분석 대상
  6. 자유 의견 (장문) ← AI 분석 대상

중요: Google Forms는 응답 시트 첫 컬럼에 Timestamp를 자동으로 추가합니다. 따라서 시트 구조는 A=Timestamp, B=직군, C=몰입, D=ENPS, E=잘된 점, F=답답한 점, G=자유 의견이 됩니다. 코드의 enpsCol, TEXT_COLS 상수는 이 1-base 인덱스에 맞춰져 있습니다(폼 질문 순서를 바꾸면 두 상수도 함께 조정).

Step 2. 응답 시트

폼이 자동 생성한 응답 시트를 그대로 사용. 추가 탭 두 개 만들기: - analyzed: 행 | 직군 | 감성 | 주제 | 요지 (AI가 채움) - report: HR/경영진용 1페이지 리포트 결과 저장

Step 3. 스크립트 속성

Step 4. 트리거


완성 코드 (script.gs)

const PROPS = PropertiesService.getScriptProperties();
const SHEET_ID   = PROPS.getProperty('RESPONSE_SHEET_ID');
const GEMINI_KEY = PROPS.getProperty('GEMINI_API_KEY');
const EMAIL_TO   = PROPS.getProperty('RECIPIENT_EMAIL');

// 응답 시트 1-base 컬럼 인덱스
// A(1)=Timestamp, B(2)=직군, C(3)=몰입(1~5), D(4)=ENPS(0~10),
// E(5)=잘된 점, F(6)=답답한 점, G(7)=자유 의견
const GROUP_COL = 2;          // 직군 (B열)
const TEXT_COLS = [5, 6, 7];  // 정성 응답 컬럼 인덱스 — AI에 전송되는 유일한 데이터

// ─────────────────────────────────────────────
// 1) 응답 일괄 분석
// ─────────────────────────────────────────────
function analyzeAll() {
  const ss  = SpreadsheetApp.openById(SHEET_ID);
  const src = ss.getSheets()[0];
  const dst = ss.getSheetByName('analyzed') || ss.insertSheet('analyzed');
  if (dst.getLastRow() === 0) dst.appendRow(['행', '직군', '감성', '주제', '요지']);

  const data = src.getDataRange().getValues();
  const processed = new Set(dst.getRange('A2:A').getValues().flat().map(String));

  const batch = []; // [{row, group, text}]
  for (let i = 1; i < data.length; i++) {
    if (processed.has(String(i+1))) continue;
    const group = data[i][GROUP_COL - 1]; // 직군 (B열, 1-base index 2)
    const merged = TEXT_COLS.map(c => String(data[i][c-1] || '').trim()).filter(Boolean).join(' / ');
    if (!merged) continue;
    batch.push({row: i+1, group, text: merged});
  }
  if (batch.length === 0) return;

  // 50건씩 배치 처리
  for (let i = 0; i < batch.length; i += 50) {
    const slice = batch.slice(i, i+50);
    const result = classifyBatch(slice);
    slice.forEach((b, idx) => {
      const r = result[idx] || {};
      dst.appendRow([b.row, b.group, r.sentiment || '중립', (r.topics||[]).join(','), r.gist || '']);
    });
    Utilities.sleep(2000);
  }
}

function classifyBatch(items) {
  // 식별정보 분리: text만 전송, group은 후처리
  const prompt = `
각 응답에 대해 한국어로 분석하세요. JSON 배열만 반환.
- sentiment: positive/negative/neutral
- topics: 1~3개 주제(짧은 한국어 명사구) — 회사문화/보상/성장/리더십/업무량/복지/관계/제도/도구/번아웃 등에서 자유롭게
- gist: 한 문장 요약(15자 이내)

응답:
${items.map((it,i)=>`[${i+1}] ${it.text}`).join('\n')}

반환 형식: [{"sentiment":"...","topics":["..."],"gist":"..."}]
`;
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_KEY}`;
  const res = UrlFetchApp.fetch(url, {
    method:'post', contentType:'application/json',
    payload: JSON.stringify({
      contents:[{parts:[{text: prompt}]}],
      generationConfig:{responseMimeType:'application/json'}
    })
  });
  return JSON.parse(JSON.parse(res.getContentText()).candidates[0].content.parts[0].text);
}

// ─────────────────────────────────────────────
// 2) 1페이지 경영진 리포트
// ─────────────────────────────────────────────
function buildReport() {
  const ss = SpreadsheetApp.openById(SHEET_ID);
  const src = ss.getSheets()[0];
  const ana = ss.getSheetByName('analyzed').getDataRange().getValues();

  // 정량
  const all = src.getDataRange().getValues();
  // Forms는 첫 컬럼에 Timestamp를 자동 추가하므로 ENPS는 D열(1-base 4)에 위치한다.
  const enpsCol = 4;
  const enpsScores = all.slice(1).map(r => Number(r[enpsCol-1])).filter(n => !isNaN(n));
  const enps = enpsScore(enpsScores);

  // 정성: 주제 카운트 + 감성 비율
  const topicCount = {}, byTopicSamples = {};
  let pos = 0, neg = 0, neu = 0;
  for (let i = 1; i < ana.length; i++) {
    const sentiment = ana[i][2];
    const topics = String(ana[i][3]).split(',').map(s => s.trim()).filter(Boolean);
    const gist = ana[i][4];
    topics.forEach(t => {
      topicCount[t] = (topicCount[t]||0) + 1;
      byTopicSamples[t] = byTopicSamples[t] || [];
      if (byTopicSamples[t].length < 3) byTopicSamples[t].push(gist);
    });
    if (sentiment === 'positive') pos++;
    else if (sentiment === 'negative') neg++;
    else neu++;
  }

  const topTopics = Object.entries(topicCount).sort((a,b)=>b[1]-a[1]).slice(0, 6);
  const insight = aiExecutiveSummary(enps, {pos, neg, neu}, topTopics, byTopicSamples);

  const html = renderReport(enps, {pos, neg, neu}, topTopics, byTopicSamples, insight);
  if (EMAIL_TO) GmailApp.sendEmail(EMAIL_TO, `[펄스서베이] ${new Date().toISOString().slice(0,10)} 분석 리포트`, '', {htmlBody: html});

  const rep = ss.getSheetByName('report') || ss.insertSheet('report');
  rep.appendRow([new Date(), enps, JSON.stringify(insight)]);
}

function enpsScore(scores) {
  if (!scores.length) return 0;
  const promoters = scores.filter(s => s >= 9).length;
  const detractors = scores.filter(s => s <= 6).length;
  return Math.round((promoters - detractors) / scores.length * 100);
}

function aiExecutiveSummary(enps, sent, topTopics, samples) {
  const ctx = topTopics.map(([t,c]) => `- ${t}(${c}건): ${(samples[t]||[]).join(' | ')}`).join('\n');
  const prompt = `
당신은 CHRO 코치입니다. 분기 펄스서베이 결과로 경영진용 한 페이지 코멘트를 작성하세요.

데이터:
- ENPS: ${enps}
- 감성 분포: 긍정 ${sent.pos} / 부정 ${sent.neg} / 중립 ${sent.neu}
- 상위 주제:
${ctx}

규칙:
- 추측 금지, 데이터 기반
- 250자 이내
- 1) 가장 시급한 주제 1건과 권고 행동 2개  2) 칭찬할 강점 1건  3) 다음 분기 측정 제안 1건
JSON: {"urgent":"...","actions":["...","..."],"strength":"...","next":"..."}
`;
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_KEY}`;
  const res = UrlFetchApp.fetch(url, {method:'post', contentType:'application/json',
    payload: JSON.stringify({contents:[{parts:[{text:prompt}]}], generationConfig:{responseMimeType:'application/json'}})
  });
  return JSON.parse(JSON.parse(res.getContentText()).candidates[0].content.parts[0].text);
}

function renderReport(enps, sent, topTopics, samples, ins) {
  const total = sent.pos + sent.neg + sent.neu || 1;
  const topicHtml = topTopics.map(([t,c]) => `<li><b>${t}</b> · ${c}건<br><span style="color:#666;font-size:.92em;">${(samples[t]||[]).join(' · ')}</span></li>`).join('');
  return `<div style="font-family:'Noto Sans KR',sans-serif;line-height:1.7;max-width:720px;color:#222;">
    <h2 style="border-bottom:2px solid #b45309;padding-bottom:.4rem;">분기 펄스서베이 — 경영진 요약</h2>
    <p style="color:#6a604f;">${new Date().toLocaleDateString('ko-KR')} 기준</p>

    <h3>정량 지표</h3>
    <ul>
      <li><b>ENPS</b>: ${enps}</li>
      <li><b>감성 분포</b>: 긍정 ${(sent.pos/total*100).toFixed(0)}% / 부정 ${(sent.neg/total*100).toFixed(0)}% / 중립 ${(sent.neu/total*100).toFixed(0)}%</li>
    </ul>

    <h3>상위 주제 (응답 빈도순)</h3>
    <ul>${topicHtml}</ul>

    <h3>📌 핵심 인사이트</h3>
    <p><b>가장 시급한 이슈 — </b>${ins.urgent}</p>
    <p><b>권고 액션</b></p>
    <ol>${ins.actions.map(a=>`<li>${a}</li>`).join('')}</ol>
    <p><b>강점 — </b>${ins.strength}</p>
    <p><b>다음 분기 측정 제안 — </b>${ins.next}</p>

    <p style="color:#999;font-size:.85em;margin-top:2rem;">자동 생성 · 응답은 익명이며 식별 정보는 AI에 전달되지 않았습니다.</p>
  </div>`;
}

강의 시연 포인트

  1. 폼 응답 5건을 미리 준비, analyzeAll() 1회 실행 → analyzed 탭에 자동 채워지는 모습
  2. buildReport() 실행 → 메일에 1페이지 리포트 도착
  3. 개인정보 안전 강조: 코드의 classifyBatch에서 직군은 별도 보관, AI에는 텍스트만 전송됨을 보여주기

트러블슈팅

증상 원인 해결
analyzed 시트가 일부만 채워짐 50건 배치에서 일부 응답 비어 있음 빈 응답 사전 필터링(코드에 반영됨)
Gemini가 라벨을 영어로 반환 프롬프트 강제 부족 시스템 메시지에 "한국어 명사구"를 한 번 더 강조
ENPS가 NaN 객관식 응답이 텍스트로 들어옴 폼 질문을 0~10 척도형으로 정확히 설정
ENPS가 항상 -100 폼 질문 순서를 바꿔 컬럼 인덱스가 틀어짐 본 문서 Step 1의 6개 질문 순서를 그대로 유지하거나, GROUP_COL/enpsCol/TEXT_COLS 상수를 새 순서에 맞게 갱신
직군 식별 위험 본부가 5명 미만 직군 라벨링을 본부→사업부 단위로 묶어 익명성 보장

응용 아이디어