그동안 개인적으로 구글시트를 모바일에서 뷰어 용도로 많이 사용했지만, 구글시트 자체의 웹앱 기능도 마음에 들지 않는 부분이 있어서 이것 저것 수정해서 사용하고 싶었지만 내 마음대로 조작하는 것은 불가능 했습니다. 그래서 이것 저것 구글링도 하고 찾아보고 적용해 봤지만 딱히 도움되는 내용이 없었네요.
이 글에서는 Google Sheets API를 활용하여 스프레드시트 데이터를 웹페이지에 반응형 테이블로 표시하는 방법을 소개합니다. 원본 서식을 최대한 유지하면서도 모바일 환경에 최적화된 솔루션을 구현해보겠습니다.
1. 개요
Google Sheets는 데이터를 관리하는 강력한 도구이지만, 이 데이터를 웹사이트에 표시하려면 몇 가지 과제가 있습니다. 특히 원본 서식을 유지하면서 모바일 기기에서도 잘 보이도록 하는 것이 중요합니다. 이 글에서는 Google Sheets API를 활용하여 이러한 문제를 해결하는 방법을 소개합니다.
2. 구현 목표
- Google Sheets의 데이터를 웹페이지에 표시
- 원본 서식(셀 색상, 폰트, 테두리 등) 유지
- 원본 열 너비 비율 유지
- 긴 내용의 자동 줄바꿈 처리
- 모바일 기기에서도 최적화된 표시
- 여러 시트 간 쉬운 이동 기능
3. 필요한 파일 구조
project/
├── index.html # 메인 HTML 파일
├── styles.css # 기본 스타일
├── navigation-styles.css # 네비게이션 및 테이블 스타일
├── app.js # 메인 JavaScript 코드
├── format-handler.js # 서식 처리 함수
└── utils.js # 유틸리티 함수
4. HTML 기본 구조
Copy<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#ffffff">
<title>샘플 계획표</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="navigation-styles.css">
<!-- Font Awesome 아이콘 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
</head>
<body>
<div class="container">
<h1>샘플 계획표</h1>
<div class="controls">
<!-- 컨트롤 버튼 영역 -->
</div>
<!-- 현재 시트 이름 표시 -->
<div id="current-sheet-name"></div>
<div id="loading" class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">데이터를 불러오는 중...</div>
</div>
<div id="content"></div>
<!-- 시트 인디케이터는 JS에서 자동 생성됩니다 -->
</div>
<!-- Google API 라이브러리 로드 -->
<script src="https://apis.google.com/js/api.js"></script>
<!-- 스크립트 로드 순서 중요 -->
<script src="utils.js"></script>
<script src="format-handler.js"></script>
<script src="app.js"></script>
<!-- 페이지 로드 상태 확인 -->
<script>
// 페이지 로드 상태 확인
window.addEventListener('load', function() {
setTimeout(function() {
const loadingElement = document.getElementById('loading');
const contentElement = document.getElementById('content');
// 30초 후에도 로딩 중이고 콘텐츠가 비어있으면 오류 메시지 표시
if (loadingElement &&
loadingElement.style.display !== 'none' &&
(!contentElement || contentElement.innerHTML === '')) {
loadingElement.innerHTML = `
<p>데이터를 불러오는 중 문제가 발생했습니다.</p>
<p>네트워크 연결을 확인하고 다시 시도해주세요.</p>
<button onclick="location.reload()" class="retry-button">새로고침</button>
`;
}
}, 30000); // 30초 타임아웃
});
</script>
</body>
</html>
5. JavaScript 코드 구현
app.js (주요 부분)
Copy// 설정
const CONFIG = {
API_KEY: '[API_KEY]',
SPREADSHEET_ID: '[SPREADSHEET_ID]',
DEFAULT_RANGE: '샘플시트1', // 기본 시트 이름
DISPLAY_RANGES: {
// 시트별 표시 범위 설정 (A1 표기법)
'샘플시트1': 'B1:C287', // 표시할 범위 지정
'샘플시트2': 'B1:C287' // 다른 시트의 범위
}
};
// 전역 변수
let currentSheet = null;
let spreadsheetInfo = null;
let availableSheets = []; // 사용 가능한 시트 목록 저장
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', initializeApp);
function initializeApp() {
// 모바일 최적화
optimizeForMobile();
// 스와이프 이벤트 리스너 설정
setupSwipeListeners();
// 키보드 이벤트 리스너 설정
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') {
navigateToPreviousSheet();
} else if (e.key === 'ArrowRight') {
navigateToNextSheet();
}
});
// Google API 클라이언트 로드
gapi.load('client', initClient);
}
// API 클라이언트 초기화
function initClient() {
gapi.client.init({
apiKey: CONFIG.API_KEY,
discoveryDocs: ["https://sheets.googleapis.com/$discovery/rest?version=v4"],
}).then(() => {
// 스프레드시트 정보 가져오기
return getSpreadsheetInfo();
}).then(() => {
// 시트 목록 설정 및 네비게이션 버튼 초기화
setupSheets();
// 기본 시트 데이터 가져오기
getSheetWithFormatting();
}).catch(error => {
handleErrors(error);
});
}
// 열 너비 자동 조정 함수 - 원본 비율 유지, 줄바꿈 허용
function adjustColumnWidths() {
const table = document.querySelector('.sheet-table');
if (!table) return;
// 컨테이너 너비 확인
const container = document.querySelector('.container');
const containerWidth = container.clientWidth;
const availableWidth = containerWidth - 20; // 여백 고려
// 첫 번째 행의 셀 수
const firstRow = table.querySelector('tr');
if (!firstRow) return;
const cellCount = firstRow.cells.length;
// 원본 시트의 열 너비 정보 가져오기
let colWidths = [];
if (spreadsheetInfo && spreadsheetInfo.sheets) {
const currentSheetInfo = spreadsheetInfo.sheets.find(s => s.properties.title === currentSheet);
if (currentSheetInfo && currentSheetInfo.data && currentSheetInfo.data[0] && currentSheetInfo.data[0].columnMetadata) {
colWidths = currentSheetInfo.data[0].columnMetadata.map(col => col.pixelSize || 100);
}
}
// 원본 너비가 없으면 콘텐츠 기반 계산
if (colWidths.length < cellCount) {
colWidths = new Array(cellCount).fill(100);
// 콘텐츠 기반 너비 계산 로직 (생략)
}
// 원본 비율 계산 및 적용
const totalOriginalWidth = colWidths.reduce((sum, width) => sum + width, 0);
const widthRatios = colWidths.map(width => width / totalOriginalWidth);
// CSS로 열 너비 적용
const styleSheet = document.createElement('style');
let styleRules = `.sheet-table { table-layout: fixed; width: 100%; }\n`;
widthRatios.forEach((ratio, index) => {
const widthPercent = (ratio * 100).toFixed(2);
styleRules += `.sheet-table td:nth-child(${index + 1}) { width: ${widthPercent}%; }\n`;
});
// 모든 셀에 줄바꿈 허용
styleRules += `
.sheet-table td, .sheet-table th {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: visible;
}
`;
styleSheet.textContent = styleRules;
document.head.appendChild(styleSheet);
}
format-handler.js (주요 부분)
Copyconst formatHandler = (function() {
// 열 문자를 인덱스로 변환
function columnLetterToIndex(column) {
let result = 0;
for (let i = 0; i < column.length; i++) {
result = result * 26 + (column.charCodeAt(i) - 64);
}
return result - 1;
}
// 범위 파싱
function parseRange(rangeString) {
if (!rangeString) return null;
const match = rangeString.match(/([A-Z]+)(\d+):([A-Z]+)(\d+)/);
if (!match) return null;
return {
startCol: columnLetterToIndex(match[1]),
startRow: parseInt(match[2]) - 1,
endCol: columnLetterToIndex(match[3]),
endRow: parseInt(match[4]) - 1
};
}
// 테이블 생성
function createFormattedTable(gridData, merges, sheetProperties, displayRange) {
const rows = gridData.rowData || [];
const range = parseRange(displayRange);
// 테이블 생성
let html = '<table class="sheet-table" style="border-collapse: collapse; width: 100%; table-layout: fixed;">';
// 각 행 처리
rows.forEach((row, rowIndex) => {
if (range && (rowIndex < range.startRow || rowIndex > range.endRow)) {
return;
}
html += `<tr data-row="${rowIndex}">`;
// 각 셀 처리
if (row.values) {
const startCol = range ? range.startCol : 0;
const endCol = range ? range.endCol : row.values.length - 1;
for (let colIndex = startCol; colIndex <= endCol; colIndex++) {
const cell = colIndex < row.values.length ? row.values[colIndex] : null;
// 셀 스타일 생성
let style = getStyleForCell(cell);
// 줄바꿈 허용
style += "white-space: normal; word-wrap: break-word; overflow-wrap: break-word;";
// 셀 값 가져오기
const value = cell && cell.formattedValue ? cell.formattedValue : '';
// 셀 생성
html += `<td data-row="${rowIndex}" data-col="${colIndex}" style="${style}">${value}</td>`;
}
}
html += '</tr>';
});
html += '</table>';
return html;
}
// 셀 스타일 생성
function getStyleForCell(cell) {
if (!cell) return 'border: 0px solid transparent; padding: 4px 6px;';
let style = '';
// 배경색 처리
if (cell.effectiveFormat && cell.effectiveFormat.backgroundColor) {
const bg = cell.effectiveFormat.backgroundColor;
const bgColor = `rgb(${Math.round(bg.red*255)}, ${Math.round(bg.green*255)}, ${Math.round(bg.blue*255)})`;
style += `background-color: ${bgColor};`;
}
// 테두리 처리
if (cell.effectiveFormat && cell.effectiveFormat.borders) {
const borders = cell.effectiveFormat.borders;
// 테두리 스타일 처리 로직 (생략)
}
// 텍스트 서식
if (cell.effectiveFormat && cell.effectiveFormat.textFormat) {
const textFormat = cell.effectiveFormat.textFormat;
if (textFormat.fontSize) {
style += `font-size: ${textFormat.fontSize}pt;`;
}
if (textFormat.bold) {
style += 'font-weight: bold;';
}
if (textFormat.foregroundColor) {
const fg = textFormat.foregroundColor;
style += `color: rgb(${Math.round(fg.red*255)}, ${Math.round(fg.green*255)}, ${Math.round(fg.blue*255)});`;
}
}
// 정렬
if (cell.effectiveFormat && cell.effectiveFormat.horizontalAlignment) {
style += `text-align: ${cell.effectiveFormat.horizontalAlignment.toLowerCase()};`;
}
// 패딩
style += 'padding: 4px 8px;';
return style;
}
// 병합 셀 적용
function applyMerges(merges) {
if (!merges || !merges.length) return;
merges.forEach(merge => {
const startRow = merge.startRowIndex;
const endRow = merge.endRowIndex;
const startCol = merge.startColumnIndex;
const endCol = merge.endColumnIndex;
// 첫 번째 셀 찾기
const firstCell = document.querySelector(`table.sheet-table tr[data-row="${startRow}"] td[data-col="${startCol}"]`);
if (!firstCell) return;
// rowspan 설정
if (endRow - startRow > 1) {
firstCell.rowSpan = endRow - startRow;
}
// colspan 설정
if (endCol - startCol > 1) {
firstCell.colSpan = endCol - startCol;
}
// 병합된 다른 셀 제거
for (let r = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
if (r === startRow && c === startCol) continue;
const cell = document.querySelector(`table.sheet-table tr[data-row="${r}"] td[data-col="${c}"]`);
if (cell) cell.remove();
}
}
});
}
// 공개 API
return {
createFormattedTable,
parseRange,
applyMerges
};
})();
6. CSS 스타일링
styles.css (기본 스타일)
Copybody {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 10px;
}
h1 {
text-align: center;
margin-bottom: 20px;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f8f8f8;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #e8e8e8;
}
#loading {
text-align: center;
padding: 20px;
font-style: italic;
}
.error-message {
color: #721c24;
padding: 12px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin: 20px 0;
}
navigation-styles.css (네비게이션 및 테이블 스타일)
Copy/* 시트 전환 애니메이션 */
.sheet-transition {
opacity: 0.5;
transition: opacity 0.3s ease;
}
/* 시트 인디케이터 스타일 */
#sheet-indicator {
display: flex;
justify-content: center;
margin: 15px 0;
}
.indicator-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #ccc;
margin: 0 5px;
display: inline-block;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
position: relative;
}
.indicator-dot:hover {
transform: scale(1.2);
background-color: #aaa;
}
.indicator-dot.active {
background-color: #007bff;
animation: pulse 1.5s infinite;
}
/* 현재 시트 이름 표시 */
#current-sheet-name {
text-align: center;
font-weight: bold;
margin: 10px 0;
font-size: 16px;
color: #333;
}
/* 네비게이션 버튼 스타일 */
.navigation-buttons {
display: flex;
justify-content: space-between;
margin: 10px 0;
width: 100%;
}
.nav-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #007bff;
padding: 5px 10px;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease;
}
.nav-button:hover {
color: #0056b3;
transform: scale(1.1);
}
/* 로딩 컨테이너 스타일 */
.loading-container {
text-align: center;
padding: 30px;
margin: 20px 0;
min-height: 100px;
}
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #007bff;
animation: spin 1s ease-in-out infinite;
margin-bottom: 10px;
}
.loading-text {
font-style: italic;
color: #666;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
/* 테이블 스타일 */
.sheet-table {
border-collapse: collapse;
margin: 0 auto;
width: 100%;
max-width: 100%;
table-layout: fixed;
}
.sheet-table td, .sheet-table th {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: visible;
}
/* 모바일 최적화 */
@media (max-width: 768px) {
.container {
max-width: 100%;
padding: 10px 5px;
overflow-x: hidden;
}
#content {
overflow-x: hidden;
}
/* 모바일에서 네비게이션 버튼 숨기기 */
.nav-button {
display: none !important;
}
/* 시트 인디케이터 위치 및 스타일 강화 */
#sheet-indicator {
position: fixed;
bottom: 20px;
left: 0;
right: 0;
z-index: 99;
background-color: rgba(255, 255, 255, 0.8);
padding: 12px 0;
margin: 0;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
}
/* 인디케이터 점 크기 키우기 */
.indicator-dot {
width: 14px;
height: 14px;
margin: 0 8px;
}
}
7. 문제 해결 및 최적화
원본 서식 유지 문제
Google Sheets API에서 가져온 데이터의 원본 서식(배경색, 폰트, 테두리 등)을 유지하는 것은 중요한 과제였습니다. getStyleForCell 함수를 통해 셀의 모든 서식 정보를 CSS로 변환하여 이 문제를 해결했습니다.
열 너비 비율 유지 문제
원본 시트의 열 너비 비율을 유지하면서 반응형으로 만드는 것이 도전적이었습니다. 이를 위해 다음과 같은 접근 방식을 사용했습니다:
- 원본 시트의 열 너비 정보 가져오기
- 열 너비의 상대적 비율 계산
- 백분율(%)로 열 너비 적용하여 반응형 구현
긴 내용 줄바꿈 문제
셀 내용이 길 때 자동으로 줄바꿈되어 모든 내용이 표시되도록 하는 것이 중요했습니다. 이를 위해 모든 셀에 white-space: normal, word-wrap: break-word 속성을 적용했습니다.
모바일 최적화
모바일 기기에서는 화면 공간이 제한적이기 때문에 다음과 같은 최적화를 적용했습니다:
- 네비게이션 버튼 대신 화면 하단에 인디케이터 점 표시
- 좌우 스와이프로 시트 간 이동 가능
- 텍스트 크기 및 패딩 조정으로 가독성 향상
8. 완성 결과
이 구현을 통해 다음과 같은 결과를 얻을 수 있습니다:
- Google Sheets 데이터를 원본 서식 그대로 웹페이지에 표시
- 열 너비 비율을 유지하면서 반응형으로 작동
- 긴 내용이 자동으로 줄바꿈되어 모든 내용 표시
- 모바일에서도 최적화된 사용자 경험 제공
- 여러 시트 간 쉬운 이동 기능
이 구현은 특히 정기적으로 업데이트되는 데이터를 웹사이트에 표시해야 하는 경우에 유용합니다. Google Sheets에서 데이터를 업데이트하면 웹페이지에 자동으로 반영되므로 별도의 웹 개발 작업 없이도 콘텐츠를 관리할 수 있습니다.
마치며
이 글에서는 Google Sheets API를 활용하여 스프레드시트 데이터를 웹페이지에 표시하는 방법을 소개했습니다. 원본 서식을 유지하면서도 반응형으로 작동하는 테이블을 구현하여 다양한 기기에서 최적의 사용자 경험을 제공할 수 있습니다.
이 코드를 기반으로 필요에 따라 추가 기능을 개발할 수도 있습니다. 예를 들어, 데이터 필터링, 검색 기능, 다크 모드 지원 등을 추가하여 더욱 향상된 웹 애플리케이션을 만들 수 있습니다.