Bolt 적용기

hbjs97·2024년 5월 10일
post-thumbnail

배경

최근 서비스 운영팀으로부터 매일 특정 지표의 통계를 확인하고 싶다는 요구를 받았다.
현재 상황에서는 프론트엔드 인력이 부족해 웹 기능 추가는 불가능하다고 생각되어 다른 해결책을 찾게 되었고, 이미 사용 중인 통신 도구인 Slack을 적극 활용하기로 결정했다.

Slack 기존 활용

지금까지는 Slack을 서비스의 헬스체크, 배치잡 수행결과, 빌드 및 배포 결과 통보 등 기본적인 알림 수단으로 활용해왔다.
Bolt 라는 프레임워크를 사용할 필요없이 간단한 webhook 을 통해 모든 메시지를 보낼 수 있었다. 하지만, 이번에 확장할 기능에는 상호작용이 필요했다.

  • 사용자 <-> Slack 앱
  • 운영 서비스 <-> Slack 앱

Slack을 통한 상호작용

상호작용을 위해 Slack 의 다양한 기능을 활용해야한다.
구체적으로는 다음과 같은 기능들을 통합해 기능을 구현한다.

  • Block Action: 사용자가 메시지 내의 요소(버튼, 메뉴 등)와 상호작용할 수 있게 하여, 사용자의 입력에 따라 동적으로 데이터를 제공한다.
  • Slash Command: 사용자가 채팅창에서 직접 명령을 입력할 수 있는 기능을 통해, 필요한 정보를 실시간으로 요청하고 받아볼 수 있다.
  • Event Subscription: 특정 이벤트가 발생했을 때 자동으로 알림을 받거나 관련 작업을 실행할 수 있도록 설정한다. 이를 통해 실시간 반응이 가능하다.

이러한 기능들을 통합함으로써, 부족한 FE 개발인력을 대체할 수 있었고, 운영팀은 웹 인터페이스를 통하지 않고도 필요한 데이터를 쉽게 확인할 수 있다.

Bolt

Bolt는 Slack 애플리케이션 개발을 위한 공식 프레임워크로, 이벤트 처리, 명령 실행 및 Block Actions를 쉽게 관리할 수 있게 해줍니다. 이를 통해 개발 과정을 단순화하고 빠르게 프로토타이핑과 배포를 진행할 수 있습니다.

이번에 추가할 기능들은 다음과같다.

  • 상호작용 가능한 버튼을 통해 필요한 데이터 조회
  • Slack 채널참여 시 안내문구
  • 정해진 command 를 통해 서버와 상호작용

Slack Bot 설정부터 위 기능 구현과정까지 하나씩 알아본다.

SlackBot 설정

Slack 홈페이지 에서 새로운 애플리케이션을 만들 수 있다.

워크스페이스를 선택하고 애플리케이션을 생성한다.


Signing Secret

첫 번째로 Signing Secret 를 확인한다.
상호작용에 대해 Slack 서버에서 주는 콜백 요청을 검증하기 위해 사용된다.


Bot User OAuth Token

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

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

설치 후 Bot User OAuth Token 를 확인할 수 있다.


Block Action 연동

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 Subscription 연동

채널에 사용자가 입장할 때 안내문구를 보여주기 위해 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)
            ...
        }
    }
}

Slash Command 연동

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

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

0개의 댓글