사내 업무 자동화로 AI시대 살아남기 / 몽주님 캠프 1주차 과제
목적
안녕하세요~ 9기는 예비창업가, 10기는 경단래퍼, 11기에서는 예비직장인으로 돌아온 오랜지입니다🍊
저는 오랫동안 2교대 근무를 하는 직장에서 팀 매니징 일을 했어요.(지금은 아님)
매 월 팀원들의 근무 스케쥴을 짜는 업무를 했는데, 고려해야 할 조건이 너무 많았습니다.
🧐고려할 사항들 (대충) :
시간대 별 최소 근무 인원
팀원들의 휴일 요청
한 직원 당 월간 근무시간
직원 별 야간, 주말 근무 선호도에 따른 스케쥴 배분
주니어 직원과 시니어 직원이 함께 일하도록 배치
월간 팀 회의 일정
실제로는 이 외에도 훨씬 많은 고려할 조건들이 있어,
팀원이 10명을 넘어가면서부터는 초고난이도 스도쿠🧩를 푸는 기분으로 스케줄을 짰습니다.
(당시 사용했던 스케줄표(진짜) 이미지. 진짜 스도쿠 뺨칩니다...🥲)
당시 엑셀파일을 잡고 야근을 너무 많이 했던지라, 이런 스케줄 작성도 자동화 툴의 도움을 받을 수 있지 않을까? 해서 시도해 보았습니다.
사용한 툴
Chat GPT, '구글 시트 및 앱스크립트 보조 GPT' (https://chatgpt.com/g/g-kJJTI82Ck-gugeul-siteu-mic-aebseukeuribteu-bojo)
Google Sheet
Apps Script
시도 방법
Apps Script 사용으로 결정
원래는 MS Excel 기반으로 짜던 파일이었지만, 저희 집 컴퓨터에는 MS office가 없는 관계로 Google Sheet로 파일을 옮겨, Apps Script의 도움을 받아 보기로 합니다.
기존 스케줄 파일 중 가능한 간단한 버전을 샘플로 만들어 준비했습니다.
이미 직원들이 요청한 휴무 또는 근무요청 (노란색 셀)을 제외한 빈 칸에 모든 조건을 충족하도록
낮근무(A 또는 O) , 밤근무(N) 또는 휴무(X)를 넣어주면 되는 것이죠!
참 쉽죠???
Chat GPT에게 프로세스를 물어보니 Google Sheet 업로드하자마자 바로 시트의 구조를 분석하고 Apps Script로 해야 한다고 강력 추천합니다.
분명 GPT는 '손쉽게' 자동화 할 수 있다고 했습니다...
GPT가 설명하는 1,2,3 단계만 보기에도 매우 손쉬워 보입니다.
그랬습니다....
Apps Script 작성 및 실행
코알못이기 때문에, 앱스스크립트 작성 후 코드 내용을 단계별로 친절히 설명해 준다는 '구글 시트 및 앱스크립트 보조 GPT' (https://chatgpt.com/g/g-kJJTI82Ck-gugeul-siteu-mic-aebseukeuribteu-bojo) 를 사용했습니다.
자신만만해 했던 첫 답변과 달리, 실제 고려할 조건들을 다 던져 주고 Apps script를 짜게 하니 중간에 조건을 빼먹고 띄엄 띄엄 짜 주거나, 앱스스크립트가 실행 완료되지 않는 상황이 반복되었습니다.
그래서 제가 실제로 스케줄을 짤 때 생각하는 흐름 그대로, step-by-step 조건을 전달해 가며, 점점 앱스스크립트를 추가&수정하도록 프롬프트를 세밀화 했습니다.
프롬프트를 잘게 쪼개고, 단계별로 돌려보고, 문제 없으면 다음 단계로 넘어가는 방식으로 스크립트를 정교화 해 나갑니다.
[! GPT에 요구한 프롬프트 전문]
1. C4부터 AG13 사이의 빈 셀에 아래 조건에 맞게 "A", "O", "N", "X"중 하나의 값이 입력되게 해 줘.2. 2행의 글자가 빨간색인 날은 휴일, 2행의 글자가 검은색인 날은 평일을 뜻해.
3. 모 든 날은 "N"에 해당하는 직원이 반드시 딱 1명 이어야 해
4. "N"스케쥴이 모든 직원에게 가능한 공평하게 배분되게 해 줘. 한 사람 당 최대 4번이야
5. Grade 가 Junior인 직원에게는 한 달 간 "N"이 최대 2번이야
---여기서 한 번 끊고---6. 휴일은 "A" 에 해당하는 직원이 반드시 딱 2명 이어야 해
7. 휴일은 "O"에 해당하는 직원이 없어야 해
8. 평일은 "A" 에 해당하는 직원이 3명 이상, 4명 이하여야 해
9. "A"스케쥴이 모든 직원에게 가능한 공평하게 배분되게 해 줘.
10. "N" 스케쥴 다음 날에는 "A"가 들어갈 수 없어.
11. 남은 빈 셀은 "X"로 채워줘.
---여기서 한 번 끊고---12. 11번까지 완료 후 AU열의 값이 -15에서 +25 사이의 범위를 유지하는지 확인해.
이때, AU열의 셀은 값이 아닌 수식을 유지해야 해.
이를 위해 스크립트에서 AU열 값을 동적으로 확인하고,
해당 행의 E열부터 AG열 사이 입력된 값의 내용 값을 아래의 조건에 따라 변경해 줘.13. AU열 값이 범위보다 마이너스일 경우, "X"값을 한 개 "A"나 "O"로 바꿔줘.
값을 바꿀 수 있는 셀은 흰색 채우기가 된 셀의 값에 한해 가능해.14. AU열 값이 범위보다 플러스일 경우, "A"값을 한 개 "X" 또는 "O"로 바꿔줘.
값을 바꿀 수 있는 셀은 흰색 채우기가 된 셀의 값에 한해 가능해.15. AU열의 값이 -15에서 +25사이가 될 때까지 13~14번을 반복해.
최대 반복 횟수는 6회야.
---여기서 한 번 끊고---16. 12~15번의 작업을 4행에서 13행까지 진행해 줘.
---아래는 아직 적용 전---
17. 16번 까지 완료했으면, 각 열 별로 6, 7, 8, 9, 10의 조건이 충족되는지 다시 검토해 줘.
18. 충족되지 않는 조건이 있을 경우, 입력했던 값의 내용을 6에서 16까지의 모든 조건이 충족될 때까지 수정해 줘. 최대 반복 횟수는 30회야.
그렇게 수 많은 시도를 거쳤으나, 결국 아직도 채워진 스케줄 표는 영 이상합니다.
제일 첫 줄의 AAA팀원 외에는 다들 월루중...
팀원이 10명이나 있는데 한 명만 출근하는 날이 이어지고...
직원들 평균 근무시간이 주 21시간... 법정 근무시간의 반밖에 일하지 않는군요!
이것이야 말로 월루를 부추기는 앱스스크립트의 계략 (MS말고Google 쓰라고~)??? 😲🫢😲
결론
너무 고려할 조건이 많은 작업을 앱스스크립트로 실행하지 말자...
단계별로 쪼개서 스크립트를 실행하는 것이 도움이 될 수는 있으나, 어느 정도 코드를 읽을 줄 알아야 부분 수정이 용이하다.
일단 손으로 짠 후, 조건에 맞는지 자동 체킹하는 용도로 Apps script를 짜는 정도라면 코알못도 활용할 수 있을 듯하다.
부록 : 현재까지 작성된 앱스스크립트 원문
function fillEmptyCellsWithConditions() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Sheet1"); // 시트 이름을 맞게 변경하세요.
var range = sheet.getRange("E4:AG13");
var values = range.getValues();
var headerRange = sheet.getRange("E2:AG2");
var headerColors = headerRange.getFontColors()[0];
var gradeRange = sheet.getRange("A4:A13");
var grades = gradeRange.getValues().flat();
var nScheduleCount = Array(values.length).fill(0);
var aScheduleCount = Array(values.length).fill(0);
var nMaxCount = 4;
var nMaxCountForJuniors = 2;
var juniorIndexes = grades.map((grade, index) => grade === "Junior" ? index : -1).filter(index => index !== -1);
for (var col = 0; col < headerColors.length; col++) {
var dayType = headerColors[col] == "#ff0000" ? "holiday" : "weekday";
var emptyCells = [];
var A_count = 0;
var N_count = 0;
var O_count = 0;
for (var row = 0; row < values.length; row++) {
var cellValue = values[row][col];
if (cellValue === "") {
emptyCells.push([row, col]);
} else if (cellValue === "A") {
A_count++;
aScheduleCount[row]++;
} else if (cellValue === "N") {
N_count++;
nScheduleCount[row]++;
} else if (cellValue === "O") {
O_count++;
}
}
while (A_count < 2 && emptyCells.length > 0) {
assignSchedule(emptyCells, values, aScheduleCount, "A");
A_count++;
}
if (N_count < 1) {
assignNSchedule(emptyCells, values, nScheduleCount, juniorIndexes, nMaxCountForJuniors, nMaxCount, col);
}
if (dayType == "holiday" && O_count > 0) {
throw new Error("휴일에 'O' 값이 입력되어 있습니다.");
}
fillRemainingCells(emptyCells, values, "X");
}
checkAndAssignAMinimum(values, aScheduleCount);
fixNextDayRestrictions(values);
range.setValues(values);
adjustAUValues(sheet);
}
function assignSchedule(emptyCells, values, scheduleCount, value) {
var minCount = Math.min(...scheduleCount);
for (var i = 0; i < emptyCells.length; i++) {
var row = emptyCells[i][0];
if (scheduleCount[row] === minCount) {
values[row][emptyCells[i][1]] = value;
scheduleCount[row]++;
emptyCells.splice(i, 1);
break;
}
}
}
function assignNSchedule(emptyCells, values, nScheduleCount, juniorIndexes, nMaxCountForJuniors, nMaxCount, col) {
var assigned = false;
for (var i = 0; i < juniorIndexes.length; i++) {
var index = juniorIndexes[i];
if (nScheduleCount[index] < nMaxCountForJuniors && !isNextDayRestricted(values, index, col)) {
for (var j = 0; j < emptyCells.length; j++) {
if (emptyCells[j][0] === index) {
values[index][emptyCells[j][1]] = "N";
nScheduleCount[index]++;
emptyCells.splice(j, 1);
assigned = true;
break;
}
}
}
if (assigned) break;
}
if (!assigned) {
assignSchedule(emptyCells, values, nScheduleCount, "N");
}
}
function isNextDayRestricted(values, row, col) {
return col + 1 < values[0].length && (values[row][col + 1] === "O" || values[row][col + 1] === "A");
}
function fillRemainingCells(emptyCells, values, fillValue) {
while (emptyCells.length > 0) {
var cell = emptyCells.pop();
values[cell[0]][cell[1]] = fillValue;
}
}
function checkAndAssignAMinimum(values, aScheduleCount) {
for (var col = 0; col < values[0].length; col++) {
var A_count = 0;
var emptyCells = [];
for (var row = 0; row < values.length; row++) {
if (values[row][col] === "A") {
A_count++;
} else if (values[row][col] === "") {
emptyCells.push([row, col]);
}
}
while (A_count < 2 && emptyCells.length > 0) {
assignSchedule(emptyCells, values, aScheduleCount, "A");
A_count++;
}
}
}
function fixNextDayRestrictions(values) {
for (var col = 0; col < values[0].length - 1; col++) {
for (var row = 0; row < values.length; row++) {
if (values[row][col] === "N" && (values[row][col + 1] === "O" || values[row][col + 1] === "A")) {
values[row][col + 1] = "X";
}
}
}
}
function adjustAUValues(sheet) {
var auRange = sheet.getRange("AU4:AU13");
var auValues = auRange.getValues().flat();
var maxAttempts = 10; // 최대 시도 횟수
for (var row = 0; row < auValues.length; row++) {
var attempts = 0;
while ((auValues[row] < -15 || auValues[row] > 25) && attempts < maxAttempts) {
if (auValues[row] < -15) {
adjustRowForNegativeAU(sheet, row);
} else if (auValues[row] > 25) {
adjustRowForPositiveAU(sheet, row);
}
auValues = sheet.getRange("AU4:AU13").getValues().flat(); // Update AU values after adjustments
attempts++;
}
}
}
function adjustRowForNegativeAU(sheet, row) {
var colorRange = sheet.getRange("E" + (row + 4) + ":AG" + (row + 4));
var colors = colorRange.getBackgrounds()[0];
var values = sheet.getRange("E" + (row + 4) + ":AG" + (row + 4)).getValues()[0];
for (var col = 0; col < values.length; col++) {
if (values[col] === "X" && colors[col] === "#ffffff") {
sheet.getRange("E" + (row + 4) + (col + 5)).setValue(Math.random() < 0.5 ? "A" : "O");
break;
}
}
}
function adjustRowForPositiveAU(sheet, row) {
var colorRange = sheet.getRange("E" + (row + 4) + ":AG" + (row + 4));
var colors = colorRange.getBackgrounds()[0];
var values = sheet.getRange("E" + (row + 4) + ":AG" + (row + 4)).getValues()[0];
for (var col = 0; col < values.length; col++) {
if (values[col] === "A" && colors[col] === "#ffffff") {
sheet.getRange("E" + (row + 4) + (col + 5)).setValue(Math.random() < 0.5 ? "X" : "O");
break;
}
}
}
```
#11기_사내업무자동화