ChatGPT API 없이 엑셀 반복 업무 자동화하기 (feat. Apps script)


#9기AI알바생

#9기 #AI알바생


안녕하세요, 업무 자동화에 관심이 많은 2년차 주니어 마케터입니다.


이전 포스팅에서는 Open AI 없이 스프레드 시트를 자동화하는 방법을 정리해보았는데요,

▶ 개인 블로그 주소: https://blog.naver.com/everyday_in_seoul/223350605989
▶ GPTers 주소: https://www.gpters.org/data-science/post/automate-google-spreadsheets-api-R2X2WA4bRHavsdo


이번 포스팅에서는 Apps script를 활용해서
원하는 데이터를 추출하는 방법을 정리해보겠습니다.


※ Google Apps script란?

간단하게 이야기하면, Google 문서, 스프레드시트, 슬라이드 등을 자동화하는 도구라고 생각하면 됩니다. Excel VBA와 비슷하며, JavaScript 기반으로 하는 스크립팅 언어입니다.

설치 없이 웹에서 사용이 가능하고 팀 업무를 수행할 때 자료를 공유하기 쉽기 때문에 많이 사용하지만, Excel VBA보다 실행 속도가 비교적 느리다는 단점이 있습니다.



프롤로그: Google Apps script?


우선 데이터를 분석하기 이전에, 저는 개발 언어에 대해서 배경 지식이 전혀 없다는 사실을 말씀드리고 싶습니다. ChatGPT가 알려주기 전까지는 Google Apps script에 대해서 알지도 못했고요. 엑셀 VBA도 사용해본 적이 없었습니다.

누구나 할 수 있는 과정이기 때문에 활용할 구석이 조금이라도 있다면
한 번 도전해보시는 걸 추천드립니다.


Google Apps script를 사용하면서 제가 겪었던 오류와 문제의 답을
조금이나 빨리 찾으실 수 있도록 해결 방법을 간단하게 정리해드리려고 합니다.


TypeError: Cannot read properties of undefined (reading 'length')

이 오류는 스크립트를 실행하는 과정에서 대상이 되는 값을 읽을 수 없을 때 나타나는 오류입니다. ChatGPT에게 물어기도 하고, 복잡한 해결 방법을 써서도 문제가 잘 해결되지 않는다면 아래 오류가 아닐지 고민해보아야 합니다.


1️⃣ 분석하고자 하는 데이터 양식이 다름

스프레드 시트에 있는 데이터 값이 스크립트가 읽을 수 없는 형태이기 때문에 발생하는 오류일 수 있습니다. 분석해야 하는 데이터를 모두 문자열로 변환해달라고 부탁해보세요.


2️⃣ 실행해야 하는 함수를 잘못 선택함

코드를 모르는 초보자가 하기 쉬운 실수입니다. 메인이 되는 함수 이름을 잘 선택해서 실행해야 하며, 만약 어떤 함수가 메인인지 잘 모르겠다면 ChatGPT에게 물어보세요.


트리거 기능

특정 조건을 사용자가 설정해두고, 조건이 충족되었을 때 자동으로 스크립트를 실행하게 하는 기능입니다. 트리거는 Google Apps script 페이지 안에서 직접 설정할 수도 있지만, onEdit 함수 등을 통해 코드에 직접 정의할 수도 있습니다.



분석 대상: 엑셀 데이터


회사 데이터센터에서 다운로드 받은 원본 데이터를 가공하는 업무에 업무 시간의 20% 정도를 할애하고 있는데, 반복 작업이 대부분입니다. 특히 매일 반복하는 업무가 있어서 이 부분을 자동화하면 시간을 많이 절약할 수 있을 것 같았습니다.

제가 해야 하는 데이터 분석은 다음과 같았습니다.


1️⃣ 원본 데이터를 구글 스프레드 시트에 입력

2️⃣ 입력한 데이터를 미리 설정한 기준에 따라 자동으로 분석

3️⃣ 분석한 데이터를 새로운 시트에 자동으로 붙여넣기

(기존에 있는 데이터와 중복검사를 거쳐 누적으로 데이터가 쌓이도록 설정)


▶ ChatGPT vs Apps script?

원본 데이터를 ChatGPT에 직접 넣는 방법은 데이터 유출이 걱정되기도 하고, 원하는 데이터를 섬세하게 가공하는 부분에는 ChatGPT나 GPTs가 적합하지 않더라고요.


▶ 엑셀 VBA vs Apps script?

VBA를 활용하면 (데이터를 바로 처리할 수 있어서) 더 편하기는 하겠지만 다른 팀원들과의 정보 공유를 위해 구글 스프레드 시트를 활용하기로 했습니다. (원본 데이터를 구글 스프레드 시트에 붙여넣고, 데이터 추출 시작)



분석 방법: Google Apps script + ChatGPT 4.0


우선 저는 데이터를 필터링할 때, 이전에는 Airtable을 활용하고 있었습니다.
(한 달에 $54를 지불하고 사용해야하다보니 가성비가 떨어지더라고요.)

때문에 저는 Airtable에 입력했던 필터의 조건을 엑셀 VBA(메크로)와
구글 스프레드 시트에서 각각 어떻게 구현할 수 있을지 물어보았습니다.



일단은 두 가지 방법을 모두 과정을 설명해주고, 더 선호하는 방법을 알려달라고 하는 모습입니다.




저는 Google Apps scirpt를 선택했고, 데이터 분석 조건을 더 자세하게 설명해주었습니다.
(분석해야 하는 시트, 누적 데이터를 저장할 시트 이름을 세세하게 알려주었습니다.)


3️⃣ 분석한 데이터를 새로운 시트에 자동으로 붙여넣기

(기존에 있는 데이터와 중복검사를 거쳐 누적으로 데이터가 쌓이도록 설정)


아래는 그 결과로 도출해준 코드입니다.


function accumulateOnlinePerformanceData() {
  var sourceSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('분석해야 하는 시트'); // '분석해야 하는 시트'로 이름 변경
  var targetSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('누적 데이터를 저장할 시트'); // '누적 데이터를 저장할 시트'로 이름 변경
  var sourceRange = sourceSheet.getDataRange(); // '분석해야 하는 시트'의 전체 데이터 범위를 가져옵니다.
  var sourceValues = sourceRange.getValues(); // 데이터 값을 배열로 가져옵니다.

  var uniqueCheck = {}; // 중복 체크를 위한 객체
  var filteredValues = []; // 조건에 따라 필터링하고 정제할 데이터를 저장할 새 배열

  // 데이터를 순회하며 조건에 맞는지 확인
  for (var i = 0; i < sourceValues.length; i++) {
    var row = sourceValues[i];
    var includeOnline = (["경로1", "경로2"].some(path => row[9].includes(path)) || ["상세경로1", "상세경로2"].some(detailPath => row[10].includes(detailPath))) && !["제외1", "제외2"].some(exclude => row[10].includes(exclude));
    var includeEtc = (row[9] === "제외" && row[10] === "제외") && (["포함1", "포함2"].some(include => row[23].includes(include)) || ["제외1", "제외2"].includes(row[23]));
    var isEmptyC = row[1] === "제외"; // 수정: B열 값이 "제외"인 경우를 체크
    var isTestD = row[2].includes("제외"); // 수정: C열에 "제외" 포함 여부를 체크

    if (!uniqueCheck.hasOwnProperty(row[1])) {
      uniqueCheck[row[1]] = true; // 중복이 아닌 경우 객체에 추가
    } else {
      continue; // 중복인 경우 다음 반복으로 넘어감
    }

    if ((includeOnline || includeEtc) && !isEmptyC && !isTestD) {
      filteredValues.push(row);
    }
  }

  // '누적 데이터를 저장할 시트'에 데이터를 누적하여 추가
  if (filteredValues.length > 0) {
    var lastRow = targetSheet.getLastRow(); // '누적 데이터를 저장할 시트'의 마지막 행 번호를 가져옵니다.
    targetSheet.getRange(lastRow + 1, 1, filteredValues.length, filteredValues[0].length).setValues(filteredValues); // 마지막 데이터 바로 아래에 필터링된 데이터를 추가합니다.
  }
}


분석 과정: 트리거 설정


우선 전체적인 내용을 실행하는 코드는 완성을 했고,
이 함수를 실행하는 트리거를 설정하는 방법을 추가로 설명해달라고 했습니다.

저는 Apps script 내에 트리거를 설정하는 페이지에서
시간/조건을 주고 주기적으로 실행해달라고 할 생각이었는데
코드에 트리거를 직접 넣는 onEdit 함수를 추천해주더라고요.



결과적으로 구현하고 싶은 내용에 onEdit가 더 적합했기 때문에,
추천해준 코드를 기존 코드에 추가했습니다.


function onEdit(e) {
  // 특정 시트나 셀의 변화를 감지하려면 e 객체를 사용
  var range = e.range;
  var sheet = range.getSheet();
  if(sheet.getName() === '원본 데이터') { // '원본 데이터' 시트에서만 반응
    accumulateOnlinePerformanceData(); // 필터링 및 누적 함수 실행
  }
}


또, 혹시 시간이 너무 오래 걸릴까봐 걱정이 되어서 전체 함수를 작은 함수들로 분할한 뒤에,
각각의 함수들을 한 번에 호출하는 함수를 추가하는 방식으로 수정했습니다.

더해서 아래와 같은 자잘한 오류들이 있어서, 추가로 수정을 해달라고 이야기했습니다.


수정해야 하는 오류 리스트들

1️⃣ 데이터가 읽히지 않아 undefined로 나타나는 현상

2️⃣ 데이터가 누적으로 쌓이는 게 아니라,
존에 있던 데이터를 지우고 필터링한 데이터를 입력하는 현상

3️⃣ 새로운 데이터를 입력한 뒤에, 중복제거를 제대로 수행하지 않는 현상



오류를 수정하는 과정을 거친 뒤에 최종적으로 도출한 코드입니다.


// 특정 조건에 맞는 데이터를 필터링하는 함수
function filterData(values) {
  var filteredValues = [];
  if (!values || values.length === 0) return filteredValues;

  for (var i = 0; i < values.length; i++) {
    var row = values[i];
    if (!row) continue;

    var row9AsString = row[9] ? String(row[9]) : "";
    var row10AsString = row[10] ? String(row[10]) : "";
    var row23AsString = row[23] ? String(row[23]) : "";
    var row3AsString = row[3] ? String(row[3]) : "";
    var row2AsString = row[2] ? String(row[2]) : "";

    // 변경된 조건 확인
    var includeOnline = (row9AsString.includes("경로") || row10AsString.includes("상세경로")) && !row10AsString.includes("제외");
    var includeEtc = (row9AsString === "포함" && row10AsString === "포함") && ["포함1", "포함2", "포함3", "포함4"].includes(row23AsString);

    var excludeWordsInD = ['제외1', '제외2', '제외3', '제외4'];
    var isExcludedInD = excludeWordsInD.some(word => row3AsString.includes(word));
    var isCColumnEmpty = row2AsString === "" || row2AsString.trim().length === 0;

    if ((includeOnline || includeEtc) && !isExcludedInD && !isCColumnEmpty) {
      filteredValues.push(row);
    }
  }
  return filteredValues;
}

// 중복 데이터를 제거하는 함수
function removeDuplicates(values) {
  var uniqueCheck = {};
  var uniqueValues = [];
  for (var i = 0; i < values.length; i++) {
    var row = values[i];
    if (!uniqueCheck.hasOwnProperty(row[1])) {
      uniqueCheck[row[1]] = true;
      uniqueValues.push(row);
    }
  }
  return uniqueValues;
}

// 필터링 및 중복 제거된 데이터를 시트에 집계하는 함수
function accumulateDataInSheet(filteredValues, targetSheet) {
  if (filteredValues.length > 0) {
    var lastRow = targetSheet.getLastRow();
    targetSheet.getRange(lastRow + 1, 1, filteredValues.length, filteredValues[0].length).setValues(filteredValues);
  }
}

// 중복 데이터 제거
function removeDuplicatesInOnlineScore() {
  var targetSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('누적 데이터를 저장할 시트');
  var dataRange = targetSheet.getDataRange();
  var dataValues = dataRange.getValues();

  if (dataValues.length > 1) {
    var header = dataValues.splice(0, 1);
    var uniqueData = removeDuplicates(dataValues);

    targetSheet.clear();
    targetSheet.appendRow(header[0]);
    if (uniqueData.length > 0) {
      targetSheet.getRange(2, 1, uniqueData.length, uniqueData[0].length).setValues(uniqueData);
    }
  }
}

// '분석해야 하는 시트'에서 데이터를 필터링하고, '누적 데이터를 저장할 시트'에 데이터를 집계하는 전체 프로세스를 실행하는 함수
function accumulateOnlinePerformanceData() {
  var sourceSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('분석해야 하는 시트');
  var targetSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('누적 데이터를 저장할 시트');
  var sourceValues = sourceSheet.getDataRange().getValues();

  var filteredValues = filterData(sourceValues);
  var uniqueValues = removeDuplicates(filteredValues);
  accumulateDataInSheet(uniqueValues, targetSheet);

  removeDuplicatesInOnlineScore();
}

// 스프레드시트가 편집될 때마다 호출되는 함수
function onEdit(e) {
  var editedSheetName = e.source.getActiveSheet().getName();
  if (editedSheetName === '분석해야 하는 시트') {
    accumulateOnlinePerformanceData();
  }
}


위에 있는 코드를 Gemini에 넣고, 분석/설명해달라고 해보았습니다.
원하던대로 잘 구현된 것 같네요.



이전에 했던 프로젝트보다 세부 조건을 설정하는 과정이 복잡해서,
전체 세팅하는 데에 3시간 정도 걸린 것 같습니다.

Apps script 개념을 이해하고 난 뒤에, 스크립트를 작성해달라고 명령하니까
오류를 수정하고 검토하는 과정이 한결 수월해졌습니다.


다음에 기회가 된다면 엑셀 vlookup 작업을 단순화하는 작업도 진행해보고 싶습니다.
(실제로 진행해보지는 않았지만 Dictionary를 활용하라고 알려주기는 하더라고요.)

2
4개의 답글

👉 이 게시글도 읽어보세요