구글 시트를 활용한 홈페이지 폼 데이터 수집 시스템 구축하기
웹사이트를 운영하다 보면 사용자로부터 데이터를 수집해야 하는 경우가 많습니다. 회원가입, 문의사항, 사전등록 등 다양한 폼을 통해 수집된 데이터를 어떻게 효율적으로 관리할 수 있을까요?
오늘은 별도의 백엔드 서버 없이도 구글 시트(Google Sheets)를 데이터베이스로 활용하여 폼 데이터를 자동으로 수집하고 관리하는 방법을 소개하겠습니다.
왜 구글 시트를 선택했을까?
기존에 폼 데이터를 수집하려면 다음과 같은 복잡한 과정이 필요했습니다:
- 백엔드 서버 구축
- 데이터베이스 설정
- API 엔드포인트 개발
- 서버 호스팅 및 관리
하지만 구글 시트를 활용하면:
✅ 무료로 사용 가능 - 별도의 서버 비용 없음
✅ 실시간 데이터 확인 - 브라우저에서 바로 확인
✅ 협업 기능 - 팀원들과 데이터 공유 용이
✅ 데이터 분석 기능 - 차트, 피벗 테이블 등 내장 기능 활용
✅ 간단한 설정 - 복잡한 서버 설정 불필요
와 같은 다양한 장점이 있습니다.
전체 시스템 아키텍처
우리가 구축할 시스템의 구조는 다음과 같습니다:
[웹사이트 폼] → [Google Apps Script] → [Google Sheets]
- 웹사이트 폼: 사용자가 데이터를 입력하는 HTML 폼
- Google Apps Script: 폼 데이터를 받아서 구글 시트에 저장하는 중간 역할
- Google Sheets: 실제 데이터가 저장되는 스프레드시트
단계별 구현 가이드
1단계: 구글 시트 준비하기
먼저 데이터를 저장할 구글 시트를 만들어보겠습니다.
- Google Sheets에 접속
- 새 스프레드시트 생성
- 원하는 이름으로 변경 (예: "홈페이지 문의사항")
- URL에서 스프레드시트 ID 복사 (https://docs.google.com/spreadsheets/d/[여기가_ID]/edit 부분)
2단계: Google Apps Script 설정하기
이제 폼 데이터를 받아서 시트에 저장할 스크립트를 작성해보겠습니다.
- Google Apps Script 접속
- 새 프로젝트 생성
- 다음 코드를 Code.gs에 입력:
/**
* Google Apps Script for handling registration form submissions
* JSONP와 POST 요청을 모두 지원하는 완전한 구현
*/
// 설정 - 실제 구글 시트 ID로 교체하세요
const SPREADSHEET_ID = 'your_spreadsheet_id_here'; // 실제 구글 시트 ID로 교체
const SHEET_NAME = 'Sheet1'; // 데이터를 저장할 시트 이름
/**
* GET 요청 처리 (JSONP 콜백 지원)
*/
function doGet(e) {
return handleRequest(e);
}
/**
* POST 요청 처리
*/
function doPost(e) {
return handleRequest(e);
}
/**
* 메인 요청 핸들러
*/
function handleRequest(e) {
try {
console.log('요청 수신:', e);
// 파라미터에서 액션 가져오기
const action = e.parameter.action;
const callback = e.parameter.callback; // JSONP 지원용
console.log('액션:', action);
console.log('콜백:', callback);
let response;
switch (action) {
case 'insert':
response = insertData(e);
break;
default:
response = {
success: false,
error: '잘못된 액션이 지정되었습니다'
};
}
console.log('응답:', response);
// JSONP 콜백이 제공된 경우 처리
if (callback) {
const jsonpResponse = ContentService.createTextOutput(
callback + '(' + JSON.stringify(response) + ')'
).setMimeType(ContentService.MimeType.JAVASCRIPT);
console.log('JSONP 응답 반환');
return jsonpResponse;
}
// POST 요청용 JSON 응답 반환
return ContentService.createTextOutput(JSON.stringify(response))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
console.error('요청 처리 오류:', error);
const errorResponse = {
success: false,
error: error.toString()
};
if (e.parameter && e.parameter.callback) {
return ContentService.createTextOutput(
e.parameter.callback + '(' + JSON.stringify(errorResponse) + ')'
).setMimeType(ContentService.MimeType.JAVASCRIPT);
}
return ContentService.createTextOutput(JSON.stringify(errorResponse))
.setMimeType(ContentService.MimeType.JSON);
}
}
/**
* 스프레드시트에 데이터 삽입
*/
function insertData(e) {
try {
console.log('insertData 호출됨:', e);
// 파라미터에서 데이터 가져오기 (GET 요청)
let data;
if (e.parameter && e.parameter.data) {
console.log('원본 데이터 파라미터:', e.parameter.data);
data = JSON.parse(e.parameter.data);
console.log('파싱된 데이터:', data);
} else {
throw new Error('요청에 데이터가 제공되지 않았습니다');
}
// 필수 필드 유효성 검사
const requiredFields = ['name', 'phonenumber'];
for (const field of requiredFields) {
if (!data[field]) {
throw new Error(`필수 필드 누락: ${field}`);
}
}
// 스프레드시트 열기
console.log('스프레드시트 열기, ID:', SPREADSHEET_ID);
const spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);
let sheet = spreadsheet.getSheetByName(SHEET_NAME);
// 시트가 존재하지 않으면 생성
if (!sheet) {
console.log('새 시트 생성:', SHEET_NAME);
sheet = spreadsheet.insertSheet(SHEET_NAME);
// 헤더 추가
sheet.getRange(1, 1, 1, 3).setValues([[
'timestamp', 'name', 'phonenumber'
]]);
// 헤더 포맷팅
const headerRange = sheet.getRange(1, 1, 1, 6);
headerRange.setFontWeight('bold');
headerRange.setBackground('#4285f4');
headerRange.setFontColor('white');
}
// 올바른 순서로 행 데이터 준비
const rowData = [
data.timestamp || new Date().toLocaleString('ko-KR', {timeZone: 'Asia/Seoul'}),
data.name || '',
"'" + (data.phonenumber || ''), // 텍스트 형식 강제를 위해 단일 따옴표 접두사 추가
data.features || '',
data.message || '',
data.agree || 'N'
];
console.log('삽입할 행 데이터:', rowData);
// 데이터 삽입 및 전화번호 컬럼을 텍스트로 포맷팅
const lastRow = sheet.getLastRow() + 1;
sheet.getRange(lastRow, 1, 1, rowData.length).setValues([rowData]);
// 전화번호 컬럼 형식을 명시적으로 텍스트로 설정
sheet.getRange(lastRow, 3).setNumberFormat('@'); // @ 기호는 텍스트 형식을 의미
console.log('데이터 삽입 성공');
return {
success: true,
message: '데이터가 성공적으로 삽입되었습니다',
timestamp: new Date().toISOString(),
data: {
timestamp: rowData[0],
name: rowData[1],
phonenumber: rowData[2]
}
};
} catch (error) {
console.error('데이터 삽입 오류:', error);
return {
success: false,
error: error.toString()
};
}
}
/**
* 적절한 헤더로 스프레드시트 초기화
* 이 함수를 한 번 실행하여 시트를 설정하세요
*/
function initializeSheet() {
try {
console.log('시트 초기화 중...');
const spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);
let sheet = spreadsheet.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = spreadsheet.insertSheet(SHEET_NAME);
}
// 기존 내용 지우기
sheet.clear();
// 프론트엔드에서 예상하는 정확한 순서로 헤더 추가
const headers = ['timestamp', 'name', 'phonenumber'];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
// 헤더 포맷팅
const headerRange = sheet.getRange(1, 1, 1, headers.length);
headerRange.setFontWeight('bold');
headerRange.setBackground('#4285f4');
headerRange.setFontColor('white');
// 전화번호 컬럼(C열, 인덱스 3)을 텍스트 형식으로 설정
const phoneColumn = sheet.getRange('C:C'); // 전체 C열
phoneColumn.setNumberFormat('@'); // @ 기호는 텍스트 형식을 의미
// 컬럼 자동 크기 조정
sheet.autoResizeColumns(1, headers.length);
console.log('시트 초기화 성공');
return '시트가 헤더와 함께 성공적으로 초기화되었습니다: ' + headers.join(', ');
} catch (error) {
console.error('시트 초기화 오류:', error);
throw error;
}
}
/**
* 스크립트가 작동하는지 확인하는 테스트 함수
*/
function testScript() {
try {
// 테스트 데이터
const testData = {
timestamp: '2025-06-21 14:50:00',
name: '테스트 사용자',
phonenumber: '010-1234-5678',
};
// 모의 이벤트 객체 생성
const mockEvent = {
parameter: {
action: 'insert',
data: JSON.stringify(testData)
}
};
const result = insertData(mockEvent);
console.log('테스트 결과:', result);
return result;
} catch (error) {
console.error('테스트 오류:', error);
return { success: false, error: error.toString() };
}
}
- 스프레드시트 ID를 실제 값으로 변경
- initializeSheet 함수를 한 번 실행하여 헤더 설정
- 웹 앱으로 배포:
- "배포" → "새 배포" 클릭
- 유형: "웹 앱" 선택
- 실행 권한: "나"
- 액세스 권한: "모든 사용자"
- 배포 후 URL 복사
3. 프론트엔드 설정하기
이제 실제 웹사이트에서 사용할 파일들을 구성해보겠습니다.
프로젝트 구조:
src/
├── index.html # 메인 폼 페이지
├── form-handler.js # 폼 검증 및 제출 로직
├── google-sheets.js # 구글 시트 연동 함수
└── styles.css # 스타일시트
1단계: google-sheets.js에서 URL 설정
// google-sheets.js 파일에서 다음 부분을 수정하세요
const GOOGLE_SHEETS_CONFIG = {
url: 'https://script.google.com/macros/s/YOUR_ACTUAL_SCRIPT_ID/exec', // 실제 배포 URL로 교체
tableName: 'Sheet1',
timeout: 10000
};
2단계: HTML 파일에 스크립트 포함
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>등록하기</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- 폼 HTML (이전 코드와 동일) -->
<!-- 스크립트 로드 순서 중요! -->
<script src="google-sheets.js"></script>
<script src="form-handler.js"></script>
</body>
</html>
<form id="registrationForm" class="registration-form">
<div class="form-group">
<label for="name">이름 *</label>
<input type="text" id="name" name="name" required>
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="phonenumber">전화번호 *</label>
<input type="tel" id="phonenumber" name="phonenumber" required>
<div class="invalid-feedback"></div>
</div>
<button type="submit" id="submitBtn" class="btn-submit">
<span class="btn-text">등록하기</span>
<span class="btn-loading" style="display: none;">처리중...</span>
</button>
<div id="responseMessage" class="response-message"></div>
</form>
Google Sheets 연동 스크립트 (google-sheets.js):
// Google Sheets 연동 설정
const GOOGLE_SHEETS_CONFIG = {
// Google Apps Script 배포 URL (실제 URL로 교체 필요)
url: 'https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec',
tableName: 'Sheet1',
timeout: 10000
};
/**
* 타임스탬프 포맷팅
*/
function formatTimestamp(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 구글 시트에 데이터 전송 (JSONP 방식 - CORS 문제 해결)
*/
async function submitToGoogleSheets(formData) {
try {
console.log('폼 데이터 전송 중:', formData);
// URL 설정 확인
if (!GOOGLE_SHEETS_CONFIG.url || GOOGLE_SHEETS_CONFIG.url.includes('YOUR_SCRIPT_ID')) {
throw new Error('구글 시트 URL이 설정되지 않았습니다. google-sheets.js에서 URL을 업데이트하세요.');
}
// 시트 데이터 준비
const sheetData = {
timestamp: formatTimestamp(formData.timestamp),
name: formData.name || '',
phonenumber: formData.phonenumber || ''
};
console.log('시트 데이터 준비 완료:', sheetData);
// GET 요청용 파라미터 생성
const params = new URLSearchParams({
action: 'insert',
table: GOOGLE_SHEETS_CONFIG.tableName,
data: JSON.stringify(sheetData)
});
const requestUrl = `${GOOGLE_SHEETS_CONFIG.url}?${params.toString()}`;
// JSONP를 사용한 크로스 오리진 요청 (CORS 문제 해결)
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const callbackName = `callback_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
// 콜백 함수 설정
window[callbackName] = function(response) {
console.log('구글 시트 응답:', response);
document.head.removeChild(script);
delete window[callbackName];
if (response && response.success === true) {
resolve(response);
} else {
const errorMessage = response.error || response.message || JSON.stringify(response);
reject(new Error(`구글 시트 오류: ${errorMessage}`));
}
};
// 스크립트 에러 처리
script.onerror = function() {
document.head.removeChild(script);
delete window[callbackName];
reject(new Error('구글 시트 스크립트 로드 실패'));
};
// 콜백 파라미터와 함께 스크립트 소스 설정
script.src = `${requestUrl}&callback=${callbackName}`;
document.head.appendChild(script);
// 타임아웃 처리 (10초)
setTimeout(() => {
if (window[callbackName]) {
document.head.removeChild(script);
delete window[callbackName];
reject(new Error('요청 시간 초과 - 구글 시트 요청이 너무 오래 걸립니다'));
}
}, GOOGLE_SHEETS_CONFIG.timeout);
});
} catch (error) {
console.error('구글 시트 전송 오류:', error);
throw new Error('구글 시트 데이터 전송 실패');
}
}
// 전역 함수로 사용 가능하게 설정
window.submitToGoogleSheets = submitToGoogleSheets;
메인 폼 핸들러 (form-handler.js):
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('registrationForm');
const submitBtn = document.getElementById('submitBtn');
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
const responseMessage = document.getElementById('responseMessage');
// 폼 제출 이벤트
form.addEventListener('submit', async function(e) {
e.preventDefault();
// 유효성 검사
if (!validateForm()) {
return;
}
// 로딩 상태 시작
setLoadingState(true);
try {
// 폼 데이터 수집
const formData = new FormData(form);
const data = {
timestamp: new Date().toISOString(),
name: formData.get('name'),
phonenumber: formData.get('phonenumber')
};
// 구글 시트로 데이터 전송 (JSONP 방식)
const result = await submitToGoogleSheets(data);
if (result.success) {
showMessage('성공적으로 등록되었습니다!', 'success');
form.reset();
} else {
showMessage('등록 중 오류가 발생했습니다.', 'error');
}
} catch (error) {
console.error('제출 오류:', error);
showMessage('데이터 전송 중 오류가 발생했습니다.', 'error');
} finally {
setLoadingState(false);
}
});
// 로딩 상태 관리
function setLoadingState(isLoading) {
submitBtn.disabled = isLoading;
btnText.style.display = isLoading ? 'none' : 'inline';
btnLoading.style.display = isLoading ? 'inline' : 'none';
}
// 메시지 표시
function showMessage(message, type) {
responseMessage.textContent = message;
responseMessage.className = `response-message ${type}`;
responseMessage.style.display = 'block';
setTimeout(() => {
responseMessage.style.display = 'none';
}, 5000);
}
// 폼 유효성 검사
function validateForm() {
const requiredFields = form.querySelectorAll('[required]');
let isValid = true;
requiredFields.forEach(field => {
const feedback = field.parentNode.querySelector('.invalid-feedback');
if (!field.value.trim() || (field.type === 'checkbox' && !field.checked)) {
field.classList.add('is-invalid');
if (feedback) {
feedback.textContent = `${field.previousElementSibling.textContent.replace(' *', '')}을(를) 입력해주세요.`;
}
isValid = false;
} else {
field.classList.remove('is-invalid');
if (feedback) {
feedback.textContent = '';
}
}
});
return isValid;
}
});
4단계: 스타일링
사용자 경험을 향상시키기 위한 CSS:
.registration-form {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.form-group input.is-invalid,
.form-group textarea.is-invalid {
border-color: #dc3545;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
.btn-submit {
width: 100%;
padding: 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-submit:hover:not(:disabled) {
background: #0056b3;
}
.btn-submit:disabled {
background: #6c757d;
cursor: not-allowed;
}
.response-message {
margin-top: 1rem;
padding: 1rem;
border-radius: 6px;
font-weight: 600;
text-align: center;
}
.response-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.response-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
주요 기능 및 기술적 특징
1. CORS 문제 해결 - JSONP 방식 채택
브라우저에서 다른 도메인으로 직접 요청을 보낼 때 발생하는 CORS(Cross-Origin Resource Sharing) 문제를 해결하기 위해 JSONP(JSON with Padding) 방식을 사용했습니다.
// 일반적인 fetch 방식 (CORS 문제 발생 가능)
const response = await fetch(url, { method: 'POST', ... });
// JSONP 방식 (CORS 문제 해결)
const script = document.createElement('script');
script.src = `${url}&callback=${callbackName}`;
document.head.appendChild(script);
2. 전화번호 데이터 형식 보정
엑셀/구글 시트에서 전화번호가 숫자로 변환되는 문제를 방지하기 위해 앞에 단일 따옴표(')를 추가하여 텍스트 형식으로 강제 저장합니다.
// 전화번호를 텍스트로 저장
"'" + (data.phonenumber || ''), // '010-1234-5678
3. 이중 요청 방식 지원
GET(JSONP)과 POST 방식을 모두 지원하여 다양한 환경에서 안정적으로 작동합니다.
// Google Apps Script에서 양쪽 모두 처리
function doGet(e) { return handleRequest(e); }
function doPost(e) { return handleRequest(e); }
4. 실시간 유효성 검사
- 필수 필드 확인
- 사용자 친화적인 오류 메시지
- 실시간 피드백 제공
2. 안전한 데이터 처리
- HTTPS를 통한 데이터 전송
- 에러 핸들링으로 안정성 확보
- 타임아웃 처리로 무한 대기 방지
3. 사용자 경험 최적화
- 로딩 상태 표시
- 성공/실패 메시지 표시
- 반응형 디자인 지원
보안 고려사항
1. 접근 권한 관리
- Google Apps Script는 공개 URL이지만 폼 데이터만 받도록 제한
- 구글 시트는 스크립트 소유자만 접근 가능
2. 데이터 검증
- 서버 사이드(Apps Script)에서 데이터 검증 필수
- XSS 방지를 위한 입력 값 검증
3. 개인정보 보호
- 민감한 정보는 프론트엔드 코드에 노출 금지
- GDPR 등 개인정보 보호 규정 준수
문제 해결 가이드
자주 발생하는 문제들
1. "구글 시트 URL이 설정되지 않았습니다" 오류
- google-sheets.js에서 YOUR_SCRIPT_ID 부분을 실제 Apps Script 배포 URL로 교체했는지 확인
- 올바른 형식: https://script.google.com/macros/s/[실제_스크립트_ID]/exec
2. 폼 제출 후 "요청 시간 초과" 오류
- Google Apps Script 권한이 제대로 설정되었는지 확인
- initializeSheet() 함수를 한 번 실행했는지 확인
- 스프레드시트 ID가 정확한지 확인
3. 데이터가 시트에 저장되지 않는 경우
testScript(); // Google Apps Script에서 실행
고급 디버깅
브라우저 콘솔에서 상세 로그 확인
Google Apps Script에서 로그 확인:
- Apps Script 편집기에서 "실행" → "로그 보기"
- 오류 발생 시 정확한 오류 메시지 확인 가능
마무리
구글 시트를 활용한 폼 데이터 수집 시스템은 복잡한 백엔드 개발 없이도 효율적으로 사용자 데이터를 관리할 수 있는 훌륭한 솔루션입니다. 특히 이번에 소개한 구현 방식은 실제 운영 환경에서 발생할 수 있는 다양한 문제들을 미리 고려하여 설계되었습니다.
핵심 기술적 장점:
- JSONP 방식으로 CORS 문제 완전 해결
- GET/POST 이중 지원으로 호환성 극대화
- 전화번호 텍스트 형식 자동 보정
- 상세한 오류 처리 및 로깅
비즈니스적 장점:
- 무료 사용 가능 (월 수만 건 처리 가능)
- 실시간 데이터 확인 및 분석
- 팀원들과 즉시 데이터 공유
- CSV 내보내기로 다른 도구와 연동 용이
- 구글 워크스페이스 생태계 활용
적용 추천 케이스:
- 스타트업 MVP 개발
- 이벤트 참가자 등록
- 고객 문의 및 피드백 수집
- 뉴스레터 구독자 관리
- 베타 테스터 모집
확장 가능성:
- 이메일 자동 발송 연동 (Gmail API)
- 슬랙/디스코드 알림 연동
- 데이터 분석 대시보드 구축 (Google 차트)
- 조건부 로직으로 다양한 폼 처리
이 가이드를 통해 여러분도 빠르고 안정적인 데이터 수집 시스템을 구축해보세요. 백엔드 개발에 시간을 투자하기 전에 이런 방식으로 빠르게 검증하고 사용자 피드백을 받을 수 있습니다!
이 글이 도움이 되셨다면 댓글로 어떤 프로젝트에 적용하셨는지 공유해주세요! 더 실용적인 개발 팁으로 찾아뵙겠습니다! 🚀
추가 자료
관련 문서:
'IT 기술' 카테고리의 다른 글
npm run dev 시 EACCES: Permission Denied 포트 충돌 해결방법 (0) | 2025.07.13 |
---|---|
AWS Eventbridge 일정으로 여러 Lambda 동시에 실행하기 (0) | 2025.04.11 |
SEO와 AEO: 변화하는 디지털 마케팅 전략 (0) | 2025.04.05 |
MVP와 PoC, Prototype, Pilot의 차이 – 개념과 활용법 총정리 (1) | 2025.03.07 |
블로그 마크다운 코드블럭 색상 적용 문제 해결 방법 (Markdown Code Highlighting) (1) | 2025.03.05 |