본문 바로가기

Google Sheet

[스프레드시트] Apps script로 "모두 찾기" 구현하기

Sharepoint에는 있지만, 구글 스프레드시트에는 없는 "모두 찾기" 기능을 Apps script를 이용해서 구현하였다.

 

기존에 Ctrl+F나, Ctrl+H창에서도 검색이 가능하지만, 한개한개 확인을 하여야하고 한눈에 보기 어렵기에 공유한다.

search_gs.gs

function onOpen() {
  SpreadsheetApp.getUi().createMenu('🔍 맞춤 도구')
    .addItem('모두 찾기', 'showSearchDialog')
    .addToUi();
}

function showSearchDialog() {
  const html = HtmlService.createHtmlOutputFromFile('search_html')
    .setWidth(500)
    .setHeight(550)
    .setTitle('찾기');
  SpreadsheetApp.getUi().showModelessDialog(html, ' ');
}

/**
 * [속도 최적화 버전]
 * 1. getDataRange()로 실제 데이터가 있는 영역만 타겟팅
 * 2. getDisplayValues()로 화면에 보이는 문자열 그대로 가져옴 (String 변환 비용 절감)
 * 3. 반복문 내부 변수 접근 최소화
 */
function findAll(keyword) {
  if (!keyword) return [];

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getActiveSheet();
 
  // [최적화 1] getLastRow/Col 대신 getDataRange 사용
  // 전체 시트가 아닌 '실제 데이터가 있는 영역'만 정확히 가져와서 빈 공간 탐색을 줄임
  const range = sheet.getDataRange();
 
  // [최적화 2] getValues 대신 getDisplayValues 사용
  // 날짜, 숫자 등을 별도 String() 변환 없이 화면에 보이는 텍스트 그대로 고속 로드
  const values = range.getDisplayValues();
 
  const sheetName = sheet.getName();
  const regex = new RegExp(keyword, 'i');
  let results = [];
 
  // 행/열 길이 미리 캐싱 (루프 내 접근 방지)
  const numRows = values.length;
  if (numRows === 0) return [];
  const numCols = values[0].length;

  // [최적화 3] 루프 최적화
  for (let r = 0; r < numRows; r++) {
    const row = values[r]; // 행 데이터를 변수에 할당하여 배열 인덱싱 비용 절감
   
    for (let c = 0; c < numCols; c++) {
      const val = row[c];
     
      // 빈 값은 빠르게 스킵 (Short-circuit evaluation)
      if (val && regex.test(val)) {
        // columnToLetter 함수 호출 대신, 필요할 때만 계산
        results.push({
          sheet: sheetName,
          cell: convertToA1(c, r), // 아래 최적화된 함수 사용
          value: val.substring(0, 100)
        });
      }
    }
  }
  return results;
}

/**
 * [최적화] 좌표 -> A1 표기법 변환 (가벼운 수학 연산)
 * r, c는 0부터 시작하는 인덱스 기준
 */
function convertToA1(colIndex, rowIndex) {
  let temp, letter = "";
  let column = colIndex + 1;
  while (column > 0) {
    temp = (column - 1) % 26;
    letter = String.fromCharCode(temp + 65) + letter;
    column = (column - temp - 1) / 26;
  }
  return letter + (rowIndex + 1);
}

function selectCell(sheetName, cellAddress) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(sheetName);
  if (sheet) {
    sheet.activate();
    sheet.getRange(cellAddress).activate();
  }
}

search_html.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      /* 전체 레이아웃 & 폰트 */
      body {
        font-family: 'Malgun Gothic', '맑은 고딕', sans-serif;
        padding: 15px;
        margin: 0;
        background-color: #fff;
        font-size: 13px;
        color: #333;
        display: flex;
        flex-direction: column;
        height: 100vh; /* 전체 높이 사용 */
        box-sizing: border-box;
      }

      /* 1. 검색창 영역 */
      .search-box {
        display: flex;
        gap: 5px;
        margin-bottom: 15px;
        flex-shrink: 0; /* 높이 고정 */
      }
      input {
        flex: 1;
        padding: 8px 10px;
        border: 1px solid #ccc;
        border-radius: 4px;
        outline: none;
      }
      button {
        padding: 8px 15px;
        background-color: #4CAF50; /* 버튼도 초록 계열로 맞춤 */
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-weight: bold;
      }
      button:hover { background-color: #45a049; }
      button:disabled { background-color: #ccc; cursor: default; }

      /* 2. 결과 카운트 제목 (예: 검색된 결과(66개)) */
      .result-title {
        font-weight: bold;
        font-size: 14px;
        margin-bottom: 8px;
        color: #333;
        flex-shrink: 0;
      }

      /* 3. 테이블 영역 (스크롤 처리) */
      .table-container {
        flex: 1; /* 남은 공간 모두 차지 */
        overflow-y: auto; /* 세로 스크롤 */
        border: 1px solid #ddd;
        border-top: none; /* 헤더랑 겹침 방지 */
      }

      table {
        width: 100%;
        border-collapse: collapse;
        table-layout: fixed; /* 컬럼 너비 고정 */
      }

      /* 테이블 헤더 (초록색 배경) */
      th {
        background-color: #d4ebd4; /* 이미지와 유사한 연한 초록색 */
        color: #333;
        padding: 8px;
        font-weight: bold;
        text-align: center;
        border: 1px solid #ddd;
        position: sticky; /* 스크롤 시 헤더 고정 */
        top: 0;
        z-index: 1;
      }

      /* 테이블 데이터 셀 */
      td {
        padding: 6px 8px;
        border: 1px solid #ddd; /* 격자무늬 테두리 */
        white-space: nowrap;      /* 줄바꿈 방지 */
        overflow: hidden;         /* 넘치는 텍스트 숨김 */
        text-overflow: ellipsis;  /* ... 처리 */
        cursor: pointer;
      }

      /* 행(Row) 스타일 */
      tr:nth-child(even) { background-color: #f9f9f9; } /* 짝수 행 연한 회색 */
      tr:hover { background-color: #e8f5e9; } /* 마우스 올리면 진한 초록색 하이라이트 */

      /* 컬럼 너비 조정 */
      .col-sheet { width: 25%; text-align: center; }
      .col-cell { width: 20%; text-align: center; }
      .col-value { width: 55%; text-align: left; }
     
      /* 로딩/메시지 상태 */
      .status-msg {
        text-align: center;
        padding: 20px;
        color: #666;
        background: #fff;
      }
    </style>
  </head>
  <body>

    <div class="search-box">
      <input type="text" id="keyword" placeholder="검색어를 입력하세요..." onkeyup="if(window.event.keyCode==13){runSearch()}">
      <button id="searchBtn" onclick="runSearch()">검색</button>
    </div>

    <div id="result-count" class="result-title">검색된 결과(0개)</div>

    <div class="table-container">
      <table>
        <thead>
          <tr>
            <th class="col-sheet">시트</th>
            <th class="col-cell"></th>
            <th class="col-value"></th>
          </tr>
        </thead>
        <tbody id="result-body">
          <tr><td colspan="3" class="status-msg">검색어를 입력해주세요.</td></tr>
        </tbody>
      </table>
    </div>

    <script>
      function runSearch() {
        const keyword = document.getElementById('keyword').value.trim();
        if (!keyword) return;

        const tbody = document.getElementById('result-body');
        const countDiv = document.getElementById('result-count');
        const btn = document.getElementById('searchBtn');
       
        // 로딩 표시
        tbody.innerHTML = '<tr><td colspan="3" class="status-msg">⏳ 검색 중...</td></tr>';
        countDiv.innerText = '검색 중...';
        btn.disabled = true;

        google.script.run
          .withSuccessHandler(showResults)
          .withFailureHandler(showError)
          .findAll(keyword);
      }

      function showResults(data) {
        const tbody = document.getElementById('result-body');
        const countDiv = document.getElementById('result-count');
        const btn = document.getElementById('searchBtn');
       
        btn.disabled = false;
       
        // 결과 개수 업데이트
        countDiv.innerText = `검색된 결과(${data.length}개)`;

        if (data.length === 0) {
          tbody.innerHTML = '<tr><td colspan="3" class="status-msg">검색 결과가 없습니다.</td></tr>';
          return;
        }

        // [최적화] 테이블 행(Row) 생성
        let htmlRows = [];
        for (let i = 0; i < data.length; i++) {
          const item = data[i];
          // 클릭 시 이동 함수 연결
          htmlRows.push(`
            <tr onclick="moveTo('${item.sheet}', '${item.cell}')" title="${escapeHtml(item.value)}">
              <td class="col-sheet">${item.sheet}</td>
              <td class="col-cell">${item.cell}</td>
              <td class="col-value">${escapeHtml(item.value)}</td>
            </tr>
          `);
        }
        tbody.innerHTML = htmlRows.join('');
      }

      function moveTo(sheet, cell) {
        google.script.run.selectCell(sheet, cell);
      }

      function showError(err) {
        const tbody = document.getElementById('result-body');
        tbody.innerHTML = `<tr><td colspan="3" class="status-msg" style="color:red">오류: ${err}</td></tr>`;
        document.getElementById('searchBtn').disabled = false;
      }

      function escapeHtml(text) {
        if (!text) return "";
        return text
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;")
          .replace(/"/g, "&quot;")
          .replace(/'/g, "&#039;");
      }
    </script>
  </body>
</html>