
최근 서비스 운영팀으로부터 매일 특정 지표의 통계를 확인하고 싶다는 요구를 받았다.
현재 상황에서는 프론트엔드 인력이 부족해 웹 기능 추가는 불가능하다고 생각되어 다른 해결책을 찾게 되었고, 이미 사용 중인 통신 도구인 Slack을 적극 활용하기로 결정했다.
지금까지는 Slack을 서비스의 헬스체크, 배치잡 수행결과, 빌드 및 배포 결과 통보 등 기본적인 알림 수단으로 활용해왔다.
Bolt 라는 프레임워크를 사용할 필요없이 간단한 webhook 을 통해 모든 메시지를 보낼 수 있었다. 하지만, 이번에 확장할 기능에는 상호작용이 필요했다.
상호작용을 위해 Slack 의 다양한 기능을 활용해야한다.
구체적으로는 다음과 같은 기능들을 통합해 기능을 구현한다.
이러한 기능들을 통합함으로써, 부족한 FE 개발인력을 대체할 수 있었고, 운영팀은 웹 인터페이스를 통하지 않고도 필요한 데이터를 쉽게 확인할 수 있다.
Bolt는 Slack 애플리케이션 개발을 위한 공식 프레임워크로, 이벤트 처리, 명령 실행 및 Block Actions를 쉽게 관리할 수 있게 해줍니다. 이를 통해 개발 과정을 단순화하고 빠르게 프로토타이핑과 배포를 진행할 수 있습니다.
이번에 추가할 기능들은 다음과같다.
Slack Bot 설정부터 위 기능 구현과정까지 하나씩 알아본다.
Slack 홈페이지 에서 새로운 애플리케이션을 만들 수 있다.

워크스페이스를 선택하고 애플리케이션을 생성한다.
첫 번째로 Signing Secret 를 확인한다.
상호작용에 대해 Slack 서버에서 주는 콜백 요청을 검증하기 위해 사용된다.


앱을 생성하고 워크스페이스에 설치하지 않으면 토큰을 확인할 수 없다. 설치하기 위해 최소 한개 이상의 권한을 부여해야한다.
Bot 이 채널에 메시지를 전송하기 위해 incoming-webhook, chat:write 권한을 부여한다.

권한을 부여하면 Install to Workspace 버튼이 활성화되고 설치할 수 있다.

설치 후 Bot User OAuth Token 를 확인할 수 있다.
Spring Batch 에서 발행한 메시지에 상호작용 가능한 버튼을 포함시킨다. 그리고, 채널의 멤버가 이 버튼을 눌러 필요한 데이터를 수신하는 flow 를 구현한다.
implementation("com.slack.api:bolt:1.39.0")
배치 모듈에서 위 의존성을 설치한다.
batch job 에 리스너를 붙일 수 있는데, 이 리스너에서 job 이 성공하면 적절한 메시지를 보내게 구현한다.
open class DailyReportListener: JobExecutionListenerSupport() {
@Profile("!test")
@Component
class SlackListener(
@Value("\${slack.bot-token}") private val botToken: String,
@Value("\${slack.channel-id}") private val channelId: String,
...
): DailyReportListener() {
override fun afterJob(jobExecution: JobExecution) {
log.info("[Batch][${jobExecution.jobId}][${jobExecution.jobInstance.jobName}] 배치 수행")
log.info("job : $jobExecution")
if (jobExecution.status != BatchStatus.COMPLETED) return
...
Slack.getInstance()
.methods()
.chatPostMessage { r ->
r.token(botToken)
.channel(channelId)
.text("[${requestDate.toRequest()}] 통계")
.blocks(
makeBlocks(...)
)
}
}
...
}
...
}
상호작용 버튼이 들어간 LayoutBlock 은 다음과같다.
Blocks.actions(
listOf(
BlockElements.button { b ->
b.text(BlockCompositions.plainText("TestButton"))
.value(it.value) // 적절한 값
.style("primary")
.actionId("test_action")
}
)
)
리스너 구현이 끝나고 job 을 실행시키면, 코드는 정상적으로 실행되고 성공하는데 정작 채널에 메시지가 수신되지 않는 경우가 있다.
token 과 channelId 모두 틀리지 않았다면 채널에 봇을 초대하지 않았을 수 있다.
Slack 의 채널에서 봇을 초대한다.
/invte @TestBot

그러면 위와같이 버튼이 포함된 메시지가 전송된다.
지금은 위 action 에 대한 콜백처리가 되어있지 않아 동작하지 않는다.

이제 콜백을 받아 적절한 데이터를 내려주는 애플리케이션을 개발해야한다.
Spring Boot & Bolt 를 사용해 구현한다.
Spring Boot 을 사용하는 모듈에 아래 의존성을 설치한다.
implementation("com.slack.api:bolt-servlet:1.39.0")
그리고 SlackApp 을 초기화하는 Bean 과 WebServlet endpoint를 정의한다.
@Configuration
class SlackAppConfiguration {
@Bean
fun initSlackApp(
@Value("\${slack.bot-token}") botToken: String,
@Value("\${slack.signing-secret}") signingSecret: String,
): App {
return App(
AppConfig.builder()
.signingSecret(signingSecret)
.singleTeamBotToken(botToken)
.build()
)
}
}
---
@WebServlet("/slack/events")
class SlackEventController(
private val app: App,
): SlackAppServlet(app)
action 콜백을 받고 로직을 처리한 핸들러를 구현한다.
@Component
class MyActionHandler(
...
): BlockActionHandler {
override fun apply(req: BlockActionRequest, ctx: ActionContext): Response {
...
val blocks: List<LayoutBlock> = listOf(
Blocks.header { h ->
h.text(
plainText("Test Header Block")
)
},
Blocks.richText { rt ->
rt.elements(
listOf(
RichTextPreformattedElement.builder()
.elements(
listOf(
Text.builder()
.text("[ ${req.payload.actions.first().value} ]\n\n")
.build(),
Text.builder()
.text("Test RichText Block")
.build(),
)
)
.build()
)
)
},
)
val response = ctx.client().chatPostEphemeral { ephemeral ->
ephemeral.channel(req.payload.channel.id)
.user(req.payload.user.id)
.text("[${req.payload.actions.first().value}] Test Message")
.blocks(blocks)
}
if (response.isOk.not()) {
throw IllegalStateException(response.error)
}
return ctx.ack()
}
}
ActionHandler 를 구현했으면 SlackApp 에 등록한다.
@Configuration
class SlackAppConfiguration(
private val myActionHandler: MyActionHandler,
) {
@Bean
fun initSlackApp(
@Value("\${slack.bot-token}") botToken: String,
@Value("\${slack.signing-secret}") signingSecret: String,
): App {
return App(
AppConfig.builder()
.signingSecret(signingSecret)
.singleTeamBotToken(botToken)
.build()
).apply {
blockAction("test_action", myActionHandler)
}
}
}
이제 애플리케이션을 실행시키고 ngrok 으로 서비스를 외부에 노출시킨다.
ngrok http {YOUR_BOLT_APP_PORT}
ngrok 에서 포워딩되는 도메인과 아까 정의한 엔드포인트를 등록한다.

그리고 버튼을 다시 누르면 아래와 같이 응답이 오는것을 확인할 수 있다.

채널에 사용자가 입장할 때 안내문구를 보여주기 위해 Event 를 구독하고 처리하는 과정을 구현한다.

위와같이 적절한 url 을 입력, member_joined_channel 이벤트를 등록하고 저장하면

앱을 reinstall 하라는 문구가 나온다. 재설치한다.
이제 해당 채널에 사용자가 입장하면 Bolt 애플리케이션으로 이벤트가 날아온다.
이 이벤트를 받아 적절한 문구를 반환하면된다.
@Component
class MemberJoinedChannelEventHandler: BoltEventHandler<MemberJoinedChannelEvent> {
override fun apply(p0: EventsApiPayload<MemberJoinedChannelEvent>, p1: EventContext): Response {
val welcomeMessage = listOf(
"채널에 오신 것을 환영합니다, <@${p0.event.user}>님!",
"Slack 채널에서 효과적으로 활동하시려면, 다양한 명령어들을 사용해 보세요.",
"도움이 필요하시다면 언제든지 `/help`를 입력하여 사용 가능한 명령어들과 그 설명을 확인할 수 있습니다."
).joinToString("\n")
p1.logger.info(welcomeMessage)
val response = p1.client().chatPostMessage { req ->
req.channel(p0.event.channel)
.text(welcomeMessage)
}
if (response.isOk.not()) {
throw IllegalStateException(response.error)
}
return p1.ack()
}
}
이벤트 핸들러를 구현해주고 Slack App 에 등록한다.
@Configuration
class SlackAppConfiguration(
...,
private val memberJoinedChannelEventHandler: MemberJoinedChannelEventHandler,
) {
@Bean
fun initSlackApp(
@Value("\${slack.bot-token}") botToken: String,
@Value("\${slack.signing-secret}") signingSecret: String,
): App {
return App(
AppConfig.builder()
.signingSecret(signingSecret)
.singleTeamBotToken(botToken)
.build()
).apply {
event(MemberJoinedChannelEvent::class.java, memberJoinedChannelEventHandler)
...
}
}
}

이제 사용자가 호출할 수 있는 명령어를 구현한다.

우선 사용할 command 를 등록한다.
여기 등록된 커맨드는 채팅창에서 확인할 수 있다.

앞의 과정과 같다. 커맨드 핸들러를 구현하고 Slack App 에 등록한다.
@Component
class HelpSlashCommandHandler: SlashCommandHandler {
override fun apply(p0: SlashCommandRequest, p1: SlashCommandContext): Response {
val blocks = asBlocks(
section {
it.text(markdownText("*SlackBot 명령어 사용 안내*"))
},
section {
it.text(
markdownText(
"""
|• `/help`
| *설명*: 명령어를 사용하면 사용 가능한 모든 슬래시 커맨드 목록과 각 명령어의 간략한 설명을 확인할 수 있습니다.
|
|
|• `/report`
| *설명*: 특정 날짜의 통계 데이터를 조회하여 보여줍니다.
| 이 명령어를 사용할 때는 날짜를 'yyyymmdd' 형식으로 입력해야 합니다.
| 예를 들어, 2022년 1월 1일의 통계를 보고 싶다면 `/report 20220101`과 같이 입력합니다.
| *인자*:
| `date`: 조회하고자 하는 날짜 (형식: yyyymmdd)
| *예시*:
| `/report 20220101` (2022년 1월 1일의 통계 보기)
""".trimMargin()
)
)
}
)
return p1.ack(blocks)
}
}
@Configuration
class SlackAppConfiguration(
...
private val helpSlashCommandHandler: HelpSlashCommandHandler,
private val reportSlashCommandHandler: ReportSlashCommandHandler,
) {
@Bean
fun initSlackApp(
@Value("\${slack.bot-token}") botToken: String,
@Value("\${slack.signing-secret}") signingSecret: String,
): App {
return App(
AppConfig.builder()
.signingSecret(signingSecret)
.singleTeamBotToken(botToken)
.build()
).apply {
...
command("/help", helpSlashCommandHandler)
command("/report", reportSlashCommandHandler)
}
}
}
ReportSlashCommandHandler 는 첨부하지 않았습니다.
요청받는 인자의 유효성을 검증하는 로직, db 에서 적절한 데이터를 가져와 LayoutBlock 을 빌딩하는 로직등이 구현되어있습니다.




Slack을 활용한 이 혁신적인 접근 방식으로 프론트엔드 리소스가 부족함에도 불구하고 쉽고 빠르게 기능을 제공할 수 있었다. 이를 통해 익숙치않은 웹 개발 없이도 필요한 데이터 실시간으로 제공할 수 있게 되었다.
이와 같은 도구의 활용은 단순히 우리 팀뿐만 아니라, 비슷한 리소스 제약을 겪고 있는 다른 팀들에게도 유익한 예시가 될 수 있을것같다.