개발 이야기/etc.

[Javascript/Algorithm/Slack] 중복없는 랜덤 커피챗 봇 만들기

Heman 2023. 8. 15. 23:21

[Javascript/Algorithm/Slack] 중복없는 랜덤 커피챗 봇 만들기

지금(2023년 8월 기준) 회사를 다니면서 좋다고 생각한 문화 중 하나는 바로 구성원들이 직접 회사의 문화를 만들어갈 수 있다는 점인데요.

매번 새로운 팀원이 입사를 하면 서로서로 친해지는 계기나 과정이 필요하여, 팀원 중 누군가가 제안했던 것이 바로 랜덤 커피챗입니다.

 

랜덤 커피챗은 팀 구성원들과 랜덤으로 그룹을 만들어 커피타임을 가지면서 친해지고, 업무 도중 휴식도 가질 수 있는 이벤트랍니당

 

아무튼 이번에는 바로 그 랜덤 커피챗을 위한 슬랙 봇을 만들어보았습니다..!

 

History

기존 앱들을 잘 사용하면 되는데 저는 왜 직접 슬랙 봇을 개발했을까요?

처음 커피챗을 위한 슬랙 앱 M(편의상 M 이라고 칭하겠습니다)을 팀원 중 한분이 찾아 사용을 해보았는데요.

 

동작이 정상적으로 잘 이루어지는 것 같았지만, 어느날 버그를 발견하게 되었습니다.

 

 

홀수 인원으로 홀수 풀의 그룹을 생성시 일부 남은 인원에 대해 전부 할당하지 않고, N 등분으로 그룹이 나뉘는 버그인것 같습니다.

M 개발자는 현재 많은 사람들이 사용하고 있어 사이드 이펙트로 유지보수를 하기 어렵다고...

 

그래서 대안으로 또 다른 앱을 발견하였지만, 그룹 사이즈를 커스터마이징 하기 위해서는 결제가 필요했습니다 ㅎㅎㅎ

게다가 기성 앱들을 이용하면 매번 랜덤으로 새 그룹을 짜주지만, 말그대로 랜덤이기에 직전에 만났던 인원들과 또 마주칠 확률도 존재했습니다. 

 

난관에 봉착했다고 생각했습니다만.. 저희는 문제를 해결하는 직업을 가진 개발자 아닙니까?!?!

 

에라이 직접만들자~~ 하고 제가 만들겠다고 질러버렸씀미다

사실 기존에 하던 프로젝트가 끝나서 할일이 없었..

 

역시 개발자는 개발로 문제를 해결해야죠!

 

Slack/bolt 로 반응형 Slack App 만들기

우선 랜덤 그룹을 짜기 위해서는 그룹을 짜달라는 명령어가 필요하다고 생각했습니다.

특별한 세팅 없이 커멘드나 키워드 만으로 프로그램을 실행시키고 싶었거든요.

 

그러기 위해서는 slack/bolt 라는 웹 소켓 기반 패키지를 사용해야했습니다.

 

https://slack.dev/bolt-js/concepts

 

Slack | Bolt for JavaScript

Actions, commands, and options requests must always be acknowledged using the ack() function. This lets Slack know that the request was received and updates the Slack user interface accordingly. Depending on the type of request, your acknowledgement may be

slack.dev

js 로 bolt 를 세팅하는 방법은 위 주소에서 보시면 쉽게 따라하실 수 있습니다..!

 

다시 본론으로 들어가서, 우선 yarn 으로 node 세팅을 해준 후 index.js 에 아래 코드를 작성합니다.

 

import pkg from '@slack/bolt';
import dotenv from 'dotenv';

const { App } = pkg;

dotenv.config();

const app = new App({
  token: process.env.API_TOKEN,
  signingSecret: process.env.SIGN_SECRET,
  socketMode: true,
  appToken: process.env.APP_TOKEN,
  port: process.env.PORT,
});

app.message("커피 좀 내오거라~", async ({ _, say }) => {
  await say("예~ 대령하겠습니다요~~")

  return;
});

(async () => {
  await app.start();

  console.log('⚡️ Bolt app is running!');
})();

 

이렇게 작성한 후 node index.js 명령어로 실행을 시켜주면 socket-mode 로 슬랙앱과 실시간 통신이 가능한 앱이 실행됩니다.

 

 

위와 같은 메세지가 나온다면 잘 연결된 상태입니다.

 

이제 그룹에 슬랙 앱을 추가한 뒤 "커피 좀 내오거라~" 라고 말하면, 앱이 "예~ 대령하겠습니다요~~" 라는 답을 할 것입니다 ㅋㅋ

 

랜덤 그루핑 로직

커피챗에서 만났던 팀원들과 다음 커피챗에서 만나지 않도록 하려면 어떻게 해야할까요?

저는 이부분에서 제일 고민을 많이 했습니다.

 

팀원의 정보를 객체로 구성하고 객체 안에 이전에 만났던 사람들의 정보를 배열로 넣어서 판별해야하나 생각을 해보았지만,

그렇게 하기위해서는 일차적으로 모든 팀원들을 탐색(n) 후, 현재 탐색중인 팀원이 가진 배열정보를 가지고(n^2) 한번 더 비교(n^3)를 해야했습니다.

 

정확하지는 않지만 최소 O(n^3) 의 시간 복잡도를 가지게 될 것이라..

이렇게 되면 너무 극악의 알고리즘이 되기 때문에 정보를 가지고 비교하는 로직은 지양해야겠다고 생각했습니다.

 

단순하게 해결할 수 없을까 생각을 하다가 패턴을 이용하기로 했습니다.

일정 규칙을 가지고 셔플을 하여 이전에 만난 사람과의 재 만남을 최소로 하게끔 할 수 있을 것 같았습니다.

 

그렇게 생각해낸 알고리즘이 바로 아래에있는 내용인데요.

3명씩 그룹으로 묶어주고, 남은 인원들은 4명 그룹으로 배정해주는 로직입니다.

 

 

이 알고리즘은 유저의 정보를 탐색하지 않고 패턴을 이용한 셔플을 사용하여 이중 반복문, 즉 O(n^2) 의 시간복잡도만으로 그룹을 재구성 할 수 있습니다.

 

코드로 구현하면 아래와 같습니다.

 

const shuffleMembers = (multiDimensionalList) => {
  const newMemberList = [...multiDimensionalList];
  const groupLength = multiDimensionalList.length;
  const shuffledList = [];
  let tmpGroup = [];
  let count = 0;

  // 각 그룹내에서 순서 셔플
  for (let i = 0; i < groupLength; i++) {
    newMemberList[i].sort(() => 0.5 - Math.random());
  }

  // 각 그룹의 인덱스를 기준으로 3명씩 뽑아 새로운 그룹으로 조합
  for (let i = 0; i < groupLength; i++) {
    for (let j = 0; j < groupLength; j++) {
      if (count !== 0 && count % 3 === 0) {
        shuffledList.push(tmpGroup);

        tmpGroup = [];
        count = 0;
      }

      if (newMemberList[j][i]) {
        tmpGroup.push(newMemberList[j][i]);
        count++;
      }
    }
  }

  // 2명 이하의 그룹을 3명인 그룹의 마지막에서부터 재배치
  if (tmpGroup.length) {
    tmpGroup.forEach((member, index) => {
      shuffledList[shuffledList.length - (index + 1)].push(member);
    });
  }

  // 그룹의 멤버 수를 기준으로 정렬
  return tmpGroup.length
    ? shuffledList.sort((a, b) => b.length - a.length)
    : shuffledList;
};

 

DB 없이 데이터 저장하기

이제 핵심 로직을 작성을 했으니, 팀원들의 이름 정보를 저장하고 꺼내오는 작업이 필요합니다.

그런데 팀원들의 이름 정보를 DB에 저장한다면 배보다 배꼽이 더 큰 것 같지 않나요?

 

그래서 저는 그냥 txt 파일에 저장하기로 했습니다 ㅎㅋㅎㅋ

어차피 이름과 그룹만 저장하면 되는데, txt 면 차고 넘치지요..!!

 

txt 파일을 조회해서 기존의 커피챗 데이터가 존재한다면 셔플을, 존재하지 않는다면 랜덤으로 팀을 재구성합니다.

 

const fireByFile = (fire, textFile) => {
  fs.promises
    .readFile(textFile, 'utf8', (error) => {
      if (error) {
        console.log(error, ': Read Error');
      }
    })
    .then((data) => {
      if (data) {
        const newGroups = shuffleMembers(JSON.parse(data));
        fs.writeFile(textFile, JSON.stringify(newGroups), (error) => {
          if (error) {
            console.log(error, ': Update Value Write Error');

            return;
          }
        });

        console.log('Updated new random coffee groups');

        fire(createRandomCoffee(newGroups));
        return;
      } else {
        const initialValue = createRandomMembers();
        fs.writeFile(textFile, JSON.stringify(initialValue), (error) => {
          if (error) {
            console.log(error, ': Initial Value Write Error');

            return;
          }
        });

        console.log('Created new random coffee groups');

        fire(createRandomCoffee(initialValue));
        return;
      }
    });
};

 

우선 fs 라이브러리를 이용하여 파일을 읽습니다.

 

읽은 파일에서 커피챗 데이터가 존재한다면 셔플을하여 새 데이터를 만든 후 저장하고, 잘 가공하여 fire 함수의 파라미터로 넣어 실행합니다. fire에는 slack/bolt 의 say 함수를 넣어줄 예정입니다.

 

커피챗 데이터가 없다면 새로 랜덤 커피챗 함수를 실행하여, 마찬가지로 가공 후 fire 함수에 넣어줍니다.

 

coffee.js 

위에서 기재한 함수들과 그 외 가공을 위한 함수들을 작성한 coffee.js 파일입니다.

 

import fs from 'fs';
import { pool } from './constants.js';
import dayjs from 'dayjs';
import { REJECT_MESSAGE, SUDO_OPEN } from './message.js';

const chunk = (data = [], size = 1) => {
  const arr = [];

  for (let i = 0; i < data.length; i += size) {
    arr.push(data.slice(i, i + size));
  }

  return arr;
};

const createRandomCoffee = (list = []) => {
  let result = `커피나왔습니다~ \n`;

  if (!list.length) {
    return '그룹을 생성해주세요.';
  }

  list.forEach(
    (team, idx) => (result = result + `${idx + 1}그룹: ${team.join(', ')} \n`),
  );

  return result;
};

const createRandomMembers = () => {
  const chunkedList = chunk(
    pool.sort(() => 0.5 - Math.random()),
    3,
  );
  const lastIndex = chunkedList.length - 1;
  const lastTeam = chunkedList[lastIndex];

  if (lastTeam.length <= chunkedList.length && lastTeam.length < 3) {
    const result = chunkedList.slice(0, lastIndex);
    lastTeam.forEach((member, idx) => result[idx].push(member));

    return result;
  }

  return chunkedList;
};

const shuffleMembers = (multiDimensionalList) => {
  ...
};

const fireByFile = (fire, textFile) => {
  ...
};

export const executeCoffeeBot = async (text, fire, count) => {
  const TXT_FILE = './coffee-chat.txt';
  const today = new dayjs();
  const startDate = new dayjs('2023-07-31');
  const diff = today.diff(startDate, 'day') % 14;

  if (text !== SUDO_OPEN && diff !== 0) {
    if (!count) {
      await fire(`다음 커피챗까지 ${14 - diff}일이 남았어요!`);

      return;
    }

    const randomNumber = Math.floor(Math.random() * 5);

    await fire(REJECT_MESSAGE[randomNumber]);

    return;
  }

  fireByFile(fire, TXT_FILE);
  return;
};

 

위 코드에서 특이한 점을 몇가지 볼 수 있을텐데요!

그 중 하나는 day.js 사용부분일 것 같습니다. ㅎㅎ

 

저희는 커피챗의 주기를 2주에 한번 첫주의 시작으로 정했는데요.

 

그래서 2주가 되기 전에 누군가가 실수로 커맨드 텍스트를 사용하여 그룹 정보를 업데이트하는 것을 방지하고자, 다음 커피챗까지 남은 기간을 안내해주는 기능을 추가했습니다.

또 기간안내 멘트만 넣기보다는 다양한 멘트를 사용하고자 reject message 배열을 따로 구성했어요. 

 

 

커피챗을 실행 후 coffee 라는 커맨드 텍스트를 반복적으로 말하면 이렇게 봇에게 혼이 납니다!  흠흠

 

커피챗 실행 날짜를 기준으로 2주뒤로 설정할 수도 있었지만, 바쁘다는 이유로 팀원들과의 소통이나 휴식시간을 미루는 것보다는 주기적으로 정해진 날에 커피챗을 하는 것이 좋다고 생각했습니다. 

 

두번째 특이사항은 SUDO_OPEN 이라는 상수를 볼 수 있는데요.

첫 커피챗을 진행하고 2주뒤가 공휴일이거나 다른 이유로 커피챗을 미루었을때, 날짜를 검증하지 않고 바로 커피챗 프로그램을 실행하는 커맨드를 만들었습니다.

 

제가 지정한 커맨드는 "sudo open cafe" ㅎㅎ

그래서 상수명이 SUDO_OPEN 이랍니다.

 

 

이제 최종적으로는 index.js 에서 해당 파일의 excuteCoffeeBot 함수만 실행하면 됩니다.

 

index.js 

최종 index.js 파일입니다.

 

count 라는 변수가 보이는데 로직을 짤때 처음에는 count 를 올리는 방식을 채택했지만, boolean 값으로만 판단해도 된다고 생각하여 타입을 변경했습니다. 변수명을 수정 못했네요..ㅎㅎ

 

import pkg from '@slack/bolt';
import dotenv from 'dotenv';

import { executeCoffeeBot } from './coffee.js';
import { GET_COFFEE, SUDO_OPEN } from './message.js';

const { App } = pkg;

dotenv.config();

const app = new App({
  token: process.env.API_TOKEN,
  signingSecret: process.env.SIGN_SECRET,
  socketMode: true,
  appToken: process.env.APP_TOKEN,
  port: process.env.PORT,
});

let count = false;

app.message(SUDO_OPEN, ({ _, say }) => {
  executeCoffeeBot(SUDO_OPEN, say, count);

  return;
});

app.message(GET_COFFEE, ({ _, say }) => {
  executeCoffeeBot(GET_COFFEE, say, count);

  count = true;
  return;
});

(async () => {
  await app.start();

  console.log('⚡️ Bolt app is running!');
})();

 

마무리하며

 

팀에서 잘 사용하고 있는 것 같아 뿌듯하네요 ㅎㅎ

이런 소소한 성취감에 개발을 하지 않나 싶습니다.

 

추후에는 스케쥴러를 달아서 자동화시켜도 좋을지도..?