본문 바로가기

구글 시트를(Google Sheets API) 활용한 반응형 웹 테이블 구현하기

by 네이비CCTV 2025. 2. 28.
반응형

그동안 개인적으로 구글시트를 모바일에서 뷰어 용도로 많이 사용했지만, 구글시트 자체의 웹앱 기능도 마음에 들지 않는 부분이 있어서 이것 저것 수정해서 사용하고 싶었지만 내 마음대로 조작하는 것은 불가능 했습니다. 그래서 이것 저것 구글링도 하고 찾아보고 적용해 봤지만 딱히 도움되는 내용이 없었네요.

이 글에서는 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;
}
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로 변환하여 이 문제를 해결했습니다.

열 너비 비율 유지 문제

원본 시트의 열 너비 비율을 유지하면서 반응형으로 만드는 것이 도전적이었습니다. 이를 위해 다음과 같은 접근 방식을 사용했습니다:

  1. 원본 시트의 열 너비 정보 가져오기
  2. 열 너비의 상대적 비율 계산
  3. 백분율(%)로 열 너비 적용하여 반응형 구현

긴 내용 줄바꿈 문제

셀 내용이 길 때 자동으로 줄바꿈되어 모든 내용이 표시되도록 하는 것이 중요했습니다. 이를 위해 모든 셀에 white-space: normal, word-wrap: break-word 속성을 적용했습니다.

모바일 최적화

모바일 기기에서는 화면 공간이 제한적이기 때문에 다음과 같은 최적화를 적용했습니다:

  1. 네비게이션 버튼 대신 화면 하단에 인디케이터 점 표시
  2. 좌우 스와이프로 시트 간 이동 가능
  3. 텍스트 크기 및 패딩 조정으로 가독성 향상

8. 완성 결과

이 구현을 통해 다음과 같은 결과를 얻을 수 있습니다:

  1. Google Sheets 데이터를 원본 서식 그대로 웹페이지에 표시
  2. 열 너비 비율을 유지하면서 반응형으로 작동
  3. 긴 내용이 자동으로 줄바꿈되어 모든 내용 표시
  4. 모바일에서도 최적화된 사용자 경험 제공
  5. 여러 시트 간 쉬운 이동 기능

이 구현은 특히 정기적으로 업데이트되는 데이터를 웹사이트에 표시해야 하는 경우에 유용합니다. Google Sheets에서 데이터를 업데이트하면 웹페이지에 자동으로 반영되므로 별도의 웹 개발 작업 없이도 콘텐츠를 관리할 수 있습니다.

마치며

이 글에서는 Google Sheets API를 활용하여 스프레드시트 데이터를 웹페이지에 표시하는 방법을 소개했습니다. 원본 서식을 유지하면서도 반응형으로 작동하는 테이블을 구현하여 다양한 기기에서 최적의 사용자 경험을 제공할 수 있습니다.

이 코드를 기반으로 필요에 따라 추가 기능을 개발할 수도 있습니다. 예를 들어, 데이터 필터링, 검색 기능, 다크 모드 지원 등을 추가하여 더욱 향상된 웹 애플리케이션을 만들 수 있습니다.

반응형