본문 바로가기

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

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

    반응형