이번엔 제가 활동하고 있는 SIPE(사이프)에서 진행한 해커톤, 사이프톤에서 Slack Bot을 개발한 이야기를 해보려 합니다.
사이프톤이란?
SIPE 내부 해커톤으로, SIPE 동아리에서 이전 진행한 내친소 세션에서 해커톤에 관심있는 외부 인원과 협업하여 짧은 시간내에 프로덕트를 개발하는 세션입니다!
완성물이 있는 자유주제로, 평가 기준은 참가자 선호 평가로 진행이 되었습니다!
진행된 사이프톤 아이디어 목록들 중에서 왜 Slack Bot 주제를 선택하였는가 하면
장기적으로 활용 가치가 있는 프로덕트 혹은 도구를 개발하고 싶은 욕심에 초점을 두었어요. 현재 사이프에서는 현재 출석체크를 CSV 시트에서 수기적으로 작성하고, 뒷풀이도 운영진이 직접 찾아가며 인원 조사를 하기에 이를 자동화할 수 있는 수단이 없는가에 대해 고민해봤습니다.
이를 해소해줄 수 있는 것이 커뮤니티에서 현재 소통 도구로 사용되는 Slack Bot이 해결책 중 하나라 생각하였습니다.
사이프톤은 오전 9시부터 저녁 9시까지 12시간 동안 개발 시간이 주어지기에 빠른 시간내에 코어 기능부터 기획을 하기로 결정했어요.
팀원들과 사전 미팅을 통해 개발하고 싶은 기능 사항과 기획에 들어가기로 했어요
사이프 세션 중 핵심은 미션이기에 미션에 대한 자동화는 필수적으로 개발할 계획이였고, 추가로 이야기 나왔던 사항으로 출석 체크에 대한 편의성, 그리고 뒷풀이 수요 조사에 대한 편의를 제공하기 위해 기획 사항에 추가하여 디벨롭을 진행했습니다.
![]() | ![]() |
---|
우선 위 기능에 대한 요구사항 개발에 초점을 두고, 추가 기능 사항이
있다면 이후 운영진과 컨택하여 추가 개발에 들어갈 지 반응을 보고 결정하기로 했습니다.
그리고 개발 스택같은 경우, 팀원 구성 프론트엔드 2명, 백엔드 3명으로 어느 하나 언어에 대한 통합을 이루기 어려운 상황에서 Github Repo는 하나로 하되 프론트엔드 두 분은 Javascript, 남은 백엔드 세 명은 Java 플랫폼 별도 패키징을 하여 애플리케이션 개발을 진행하도록 하였습니다.
(저는 Java 플랫폼입니당)
그렇게 시작된 사이프톤..!
![]() | ![]() |
---|
우선 Slack Bot을 만들기 위해 Spring Initializr로 애플리케이션 생성합니다.
위 개발 환경으로 진행할 것이며, 필요한 라이브러리 의존성을 추가로 받아줄 것입니다.
// slack bot
// reference url: https://tools.slack.dev/java-slack-sdk/guides/getting-started-with-bolt/
implementation("com.slack.api:bolt:${slackVersion}")
implementation("com.slack.api:slack-api-client:${slackVersion}")
implementation("com.slack.api:slack-api-model:${slackVersion}")
implementation("com.slack.api:slack-app-backend:${slackVersion}")
implementation("com.slack.api:bolt-servlet:${slackVersion}")
implementation("com.slack.api:bolt-socket-mode:${socketMode}")
implementation("com.slack.api:bolt-jetty:${slackVersion}")
### Dependency versions ###
slackVersion=1.45.1
glassfishVersion=1.19
websocketVersion=1.1
socketMode=1.28.1
slf4jVersion=1.7.36
Slack Bolt에 있는 라이브러리 의존성을 받아오도록 하였고 필요한 Java SDK 레퍼런스는 바로 아래에 명시해두도록 할게요.
의존성을 다 받았다면 이제부터 Slack APP 환경 구성을 진행하면서 개발을 시작해보겠습니다.
Slack API 앱 세팅화면에서 Socket 모드에 대한 토글이 보일 겁니다.
이는 Socket 모드로 개발할 시 WebSocket 연결을 통해 Slack에 연결하고 소켓 연결을 통해 Slack에서 데이터를 수신하도록 합니다.
Socket 모드로 개발할 때 App Config에서 Slack Bolt 문서에 따른 SocketModeApp Config가 필요하며 그에 필요한 환경 변수를 주입해줍니다.
String botToken = System.getenv("SLACK_BOT_TOKEN");
String appToken = System.getenv("SLACK_APP_TOKEN");
String signingSecret = System.getenv("SLACK_SIGNING_SECRET");
App app = new App(AppConfig.builder()
.singleTeamBotToken(botToken)
.signingSecret(signingSecret)
.build());
별도 Properties로 record를 통해 개발할 수도 있지만, 빠르게 개발하기 위해 getenv를 사용했습니다. BOT, APP TOKEN, SIGNING_SECRET는 별도로 찾을 수 있을거라 생각하여 생략할게요.
다음으로 사용할 커맨드를 정의할 것인데, 위 이미지처럼 Slash Commands
탭에서 확인하면 Create New Command
를 사용해 추가할 커맨드를 사용자 지정합니다.
Socket 모드를 사용하기에 별도의 Request Url에 대한 엔드포인트는 작성하지 않아도 될 것입니다.
app.command("/출석여부", findAttendanceCommand.findMeAttendanceCommand(botToken));
app.command("/출석", attendance.attendance());
app.command("/뒷풀이", hangOut.HangoutHandler());
SocketModeApp socketModeApp = new SocketModeApp(appToken, SocketModeClient.Backend.JavaWebSocket, app);
socketModeApp.startAsync();
return socketModeApp;
다음으로 위에서 작성한 app 객체에 추가로 command를 지정해 추가하도록 합니다.
마지막으로 SocketModeApp에 필요한 인자를 넣어 객체를 생성해 return 해줄 겁니다.
설정 Full Code 공유드릴테니 추가 참고하시면 되겠습니다!
command까지 작성을 하였다면 그에 따른 UI view가 보이거나 이벤트가 발생되도록 요구사항 내용을 구현해주도록 하였습니다.
제가 개발한 내용은 "출석 여부 확인" 기능으로 본인이 속한 Slack 워크스페이스에서 본인의 출석 정보를 조회하는 기능이였어요.
그렇다면..? 어떻게 애플리케이션 단에서 내 Slack 정보를 가져오는 게 관건인가? 일텐데..
저도 이 부분에 대해 머리카락을 뽑기 직전까지(머리 한올한올 소중합니다) 쥐어잡으면서 공식문서를 보았습니다.
이전 Slash Commands
탭을 접속해서 command를 지정해주었죠??
이번엔 같은 탭에 있는 OAuth & Permissions
탭을 접속해봅니다.
조금 내리다보면 Scopes에서 Bot Token에 대해서 Scope를 지정해줄 수 있어요.
왜 Scope를 지정해주는 것인지 궁금해 하실텐테 해당 문서에서 보듯이 Bot Token을 다루는 영역에서 users:read
권한을 필수로 열어줘야 user 정보를 조회할 수 있습니다.
추가로 이와 유사한 users.profile을 통해 사용자의 상세 프로필 정보, Slack 워크스페이스 어드민 권한을 가진 사용자 정보가 필요하다면 admin.users, usergroups 등등 필요한 요구사항에 맞춰 Scope를 넓할 수 있습니다.
import com.slack.api.bolt.handler.builtin.SlashCommandHandler;
import com.slack.api.methods.SlackApiException;
import com.slack.api.methods.response.users.UsersInfoResponse;
// 본인 출석확인
public SlashCommandHandler findMeAttendanceCommand(String botToken) {
return (req, ctx) -> {
try {
// userId
String userId = req.getPayload().getUserId();
UsersInfoResponse response = ctx.client().usersInfo(r -> r.user(userId));
String realName = response.getUser().getRealName();
....
// Open a modal with the attendance information
ctx.client().viewsOpen(r -> r
.triggerId(req.getPayload().getTriggerId())
.view(buildAttendanceView(findMeSheetDto))
);
} catch (IOException | SlackApiException e) {
log.error("Error handling submit command", e);
}
return ctx.ack();
};
}
넓힌 Scope 기반으로 req, ctx(컨텍스트) 정보를 받아와 req payload에 심어져 있는 사용자 ID를 인자로 이전에 생성했던 Slack App 객체의 컨텍스트 클라이언트를 통해 usersInfo
메서드를 호출합니다.
UsersInfoResponse는 slack api에서 제공된 response 모델입니다.
public class UsersInfoResponse implements SlackApiTextResponse {
private boolean ok;
private String warning;
private String error;
private String needed;
private String provided;
private transient Map<String, List<String>> httpResponseHeaders;
private User user;
// 아래는 getter, setter, toString, equals 등등 정의
...
User 객체가 있네요? 한 번 열어볼게요.
public class User {
private String id;
private String teamId;
private String name;
private boolean deleted;
private String color;
private String realName;
private String tz;
private String tzLabel;
private Integer tzOffset;
private Profile profile;
@SerializedName("is_admin")
private boolean admin;
@SerializedName("is_owner")
private boolean owner;
@SerializedName("is_primary_owner")
private boolean primaryOwner;
@SerializedName("is_invited_user")
private boolean invitedUser;
@SerializedName("is_restricted")
private boolean restricted;
@SerializedName("is_ultra_restricted")
private boolean ultraRestricted;
@SerializedName("is_bot")
private boolean bot;
@SerializedName("is_connector_bot")
private boolean connectorBot;
@SerializedName("is_stranger")
private boolean stranger;
@SerializedName("is_app_user")
private boolean appUser;
private Long updated;
@SerializedName("has_2fa")
private boolean has2fa;
...
// 동일하게 아래는 getter, setter, toString, equals 등등 정의
...
이처럼 Slack User 정보를 API를 통해 Response 받아봤습니다.
본인이 아니여도 userId만 있다면, 타인의 User 정보도 조회를 해서 요구사항에 적합한 기능을 만들어볼 수도 있어요.
그리고 DataSource 단계에서 이름이나 고유정보를 where 조건을 넣어 조회를 통해 적절히 Domain 모델로 구현했다 가정을 할게요. 다음은 끌고온 데이터 기반으로 View를 그려볼 거에요.
import static com.slack.api.model.block.Blocks.*;
import static com.slack.api.model.block.composition.BlockCompositions.*;
import com.slack.api.model.block.LayoutBlock;
import com.slack.api.model.view.View;
import com.slack.api.model.view.*;
private View buildAttendanceView(FindMeSheetDto findMeSheetDto) {
List<LayoutBlock> blocks = new ArrayList<>();
blocks.add(section(s -> s.text(markdownText("*" + findMeSheetDto.crewMember().name() + "* 님의 출석 여부입니다."))));
blocks.add(divider());
// Add total score section
int exclusiveScore = findMeSheetDto.crewMember().scores().stream()
.mapToInt(score -> {
if (score.equals("5")) return 5;
if (score.equals("0")) return 10;
return 0;
})
.sum();
// Add total score section
int totalScore = findMeSheetDto.crewMember().scores().stream()
.mapToInt(score -> {
return switch (score) {
case "10" -> 10;
case "5" -> 5;
default -> 0;
};
})
.sum();
blocks.add(section(s -> s.text(markdownText("출석 점수: " + findMeSheetDto.crewMember().scores().getFirst()))));
// Calculate the total score for 지각 and 결석
blocks.add(section(s -> s.text(markdownText("출석 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("10")).count()))));
blocks.add(section(s -> s.text(markdownText("지각 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("5")).count()))));
blocks.add(section(s -> s.text(markdownText("결석 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("0")).count()))));
// Determine if the total score is 30 or more
String expulsionStatus = exclusiveScore >= 30 ? "Y" : "N";
blocks.add(section(s -> s.text(markdownText("제명 대상 여부: " + expulsionStatus))));
blocks.add(divider());
// Add sections for each week
for (int week = 1; week < findMeSheetDto.crewMember().scores().size(); week++) {
blocks.add(createWeekSection(week, findMeSheetDto));
}
return View.builder()
.type("modal")
.title(ViewTitle.builder().type("plain_text").text("출석 여부 확인").build())
.close(ViewClose.builder().type("plain_text").text("닫기").build())
.blocks(blocks)
.build();
}
private LayoutBlock createWeekSection(int week, FindMeSheetDto findMeSheetDto) {
String attendanceStatus = getAttendanceStatus(findMeSheetDto.crewMember().scores().get(week));
return section(s -> s.text(markdownText(week + "주차: " + attendanceStatus)));
}
private String getAttendanceStatus(String score) {
if (score.equals("10")) {
return "출석";
} else if (score.equals("5")) {
return "지각";
} else {
return "결석";
}
}
우선 출석현황을 확인하는 메서드 View, buildAttendanceView를 선언했습니다.
인자는 각자 정의한 Domain 객체로 보시면 되겠습니다.
우선 List<LayoutBlock>
객체부터 보겠습니다.
출석 현황을 확인하는 View
를 생성하는 buildAttendanceView
메서드를 구현했습니다. 이 메서드는 FindMeSheetDto
객체를 인자로 받아 Slack의 모달 형식으로 출석 정보를 보여주는 역할을 합니다.
List<LayoutBlock>
객체 생성Slack의 UI 블록을 구성하기 위해 List<LayoutBlock>
객체를 선언하고 다양한 섹션을 추가합니다.
List<LayoutBlock> blocks = new ArrayList<>();
blocks.add(section(s -> s.text(markdownText("*" + findMeSheetDto.crewMember().name() + "* 님의 출석 여부입니다."))));
blocks.add(divider());
section
을 이용하여 사용자 이름과 출석 정보를 포함한 텍스트를 추가합니다.divider()
를 사용해 가독성을 높이기 위해 아래와 같은 구분선을 추가합니다.👆 요 구분선
출석 점수를 계산하는 로직을 추가하여 exclusiveScore
와 totalScore
를 구하는 로직인데 score는 String으로 되어 있어 빠르게 mapToInt로 변환하여 return 하였습니다.
int exclusiveScore = findMeSheetDto.crewMember().scores().stream()
.mapToInt(score -> {
if (score.equals("5")) return 5;
if (score.equals("0")) return 10;
return 0;
})
.sum();
int totalScore = findMeSheetDto.crewMember().scores().stream()
.mapToInt(score -> {
return switch (score) {
case "10" -> 10;
case "5" -> 5;
default -> 0;
};
})
.sum();
exclusiveScore
는 5점
(지각)과 0점
(결석)만을 포함하여 계산합니다.totalScore
는 10점
(출석), 5점
(지각), 0점
(결석)을 고려하여 점수를 합산합니다.각 출석 점수 및 횟수를 Slack 블록에 추가합니다.
blocks.add(section(s -> s.text(markdownText("출석 점수: " + findMeSheetDto.crewMember().scores().getFirst()))));
blocks.add(section(s -> s.text(markdownText("출석 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("10")).count()))));
blocks.add(section(s -> s.text(markdownText("지각 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("5")).count()))));
blocks.add(section(s -> s.text(markdownText("결석 횟수: " + findMeSheetDto.crewMember().scores().stream().filter(score -> score.equals("0")).count()))));
stream().filter()
를 활용하여 출석, 지각, 결석 횟수를 각각 계산하고 출력합니다.출석 기록이 일정 기준 이상일 경우 제명 여부를 판단합니다.
String expulsionStatus = exclusiveScore >= 30 ? "Y" : "N";
blocks.add(section(s -> s.text(markdownText("제명 대상 여부: " + expulsionStatus))));
blocks.add(divider());
exclusiveScore
가 30점 이상
이면 제명 대상(Y
), 그렇지 않으면 N
으로 표시합니다.각 주차별 출석 정보를 Slack 블록에 추가합니다.
for (int week = 1; week < findMeSheetDto.crewMember().scores().size(); week++) {
blocks.add(createWeekSection(week, findMeSheetDto));
}
for
문을 사용해 각 주차별 출석 정보를 createWeekSection
을 이용해 추가합니다.출석 점수를 사람이 이해하기 쉬운 문자열로 변환합니다.
private LayoutBlock createWeekSection(int week, FindMeSheetDto findMeSheetDto) {
String attendanceStatus = getAttendanceStatus(findMeSheetDto.crewMember().scores().get(week));
return section(s -> s.text(markdownText(week + "주차: " + attendanceStatus)));
}
private String getAttendanceStatus(String score) {
if (score.equals("10")) {
return "출석";
} else if (score.equals("5")) {
return "지각";
} else {
return "결석";
}
}
getAttendanceStatus
메서드를 활용해 10점
은 "출석", 5점
은 "지각", 0점
은 "결석"으로 변환합니다.createWeekSection
을 이용해 각 주차별 출석 상태를 Slack 블록 형식으로 추가합니다.마지막으로 Slack의 모달(View) 형식으로 출석 정보를 반환합니다.
return View.builder()
.type("modal")
.title(ViewTitle.builder().type("plain_text").text("출석 여부 확인").build())
.close(ViewClose.builder().type("plain_text").text("닫기").build())
.blocks(blocks)
.build();
modal
타입의 뷰를 생성하며, 닫기
버튼을 포함합니다.blocks
리스트를 포함하여 최종적으로 Slack에 전달할 출석 정보를 구성합니다.buildAttendanceView
메서드를 통해 Slack에서 모달을 띄워 출석 현황을 확인할 수 있었습니다.
진짜 출석점수 아님. 암튼 아님
이렇게 SIPE 동아리 내에 본인 출석 현황을 볼 수 있는 Slack 봇과 커맨드를 만들어봤습니다.
그 외에도 미션 자동화, 뒷풀이 참/불참, 출석하기 등등 기능을 각 팀원들과 분담하여 개발하였고 아래 레포지토리 README에서 결과와 코드 확인 가능합니다! (Star도 눌러주면 압도적 감사...)
Repo: https://github.com/sipe-team/sipethon-3_3_helpingbot
사실 Slack 연동이라도 하면 다행이라는 속마음이 있었는데 팀원들과 머리싸매고 재밌게 만들고나니 뿌듯했었어요😆
실제로 SIPE에서는 현재 스프레드시트로 출석 현황을 관리하고 있기에 구글 시트와 연동하는 중이였습니다. 그래서 시트의 값을 직접 변경도 하고 시트의 칸 정보를 긁어서 저희는 사용자 정보를 읽도록 하는.. 그저 다시 보기 싫었던 데이터베이스랄까.. 이걸 편하게 사용하게 해준 정화님 Shout out...!
만약 SIPE에서 회원들을 DB를 사용해 관리한다면 JPA 환경까지 고려해서 연동을 하여 보다 쉽게 데이터를 읽고 쓸 수 있는 제법 쓸만한 애플리케이션이 될 듯합니다:)
다만 Java 플랫폼이 배보다 배꼽이 커지는 사이즈가 되어버린다면 Python으로 가시성과 편의성을 챙겨 손쉽게 개발도 가능해보입니다..! 다른 분들의 레퍼런스는 많으니 참고하시면 될 듯합니다!