시작하며
안녕하세요. 오프라인결제서비스파티 백엔드 개발자 밥입니다. 이번 게시글에서는 조금은 익숙하지 않은 개발 방법론에 대해 공유하려 합니다.
사내 해커톤에 출품한 죠르디 회의봇은 이벤트 기반 구조를 적용한 슬랙봇 서비스입니다. 슬랙 API와 상호 작용을 구현하는 것뿐만 아니라 모든 비즈니스 로직에도 이벤트 기반 구조를 적용해 보았습니다. 이 글에서는 이벤트 기반 구조가 필요했던 이유와 기반이 되는 구성요소를 Kotlin과 스프링 프레임워크를 이용해 직접 구현한 과정 및 그 과정에서 느낀 장단점을 공유드리겠습니다.
슬랙과 상호작용하는 방법
회의봇 서비스는 사내 보안규정상 웹훅 방식 대신 웹소켓 방식을 통해 회의봇의 정보를 받고 있습니다. 구체적인 예시를 통해 전체적인 상호작용을 확인해 봅시다.
유저가 /죠르디소개
라는 명령어를 입력하면 슬랙 API에서 웹소켓을 통해 JSON 형태의 메시지를 보내줍니다.
회의봇 서비스는 웹 소켓으로 들어온 메시지의 type과 payload 정보를 통해 유저의 /죠르디소개
액션을 감지하고 그에 대응하는 반응을 할 수 있게 됩니다.
유저가 슬랙봇과 일으키는 상호작용의 종류는 매우 다양합니다. 슬랙봇에게 명령어를 입력하는 것 뿐만 아니라 슬랙봇에게 직접 메시지를 보낼 수도 있고, 슬랙봇이 구성한 홈에 진입하거나 슬랙봇이 제공한 버튼을 누를 수도 있습니다. 이러한 유저의 모든 액션 정보는 실시간으로 웹소켓을 통해 전달되는데요, 각각의 액션 정보는 하나의 JSON으로 구성됩니다.
슬랙 API는 슬랙봇 서비스로 정보를 전송하는 엔드포인트가 단일 웹소켓으로 유일하고 모든 종류의 상호작용을 JSON 하나로 표현해야 하기에 다음과 같은 전략으로 JSON 메시지를 구성합니다.
Slack 메시지 구조와 이벤트 적용
슬랙 API에서 유입되는 JSON 메시지 구조를 살펴봅시다.
메시지 구분의 어려움
{
"type": "slash_commands",
"payload": {
"user_name": "bob.park",
"command": "\/죠르디소개",
...
},
...
}
위에서 등장한 Slash Commands 메시지입니다. 유저가 명령어를 입력했을 때 유입되는 메시지로 최상단에 위치한 type 필드를 확인하여 메시지 구분이 가능합니다. 하지만 모든 메시지가 Slash Commands처럼 구분이 수월하진 않습니다.
{
"type": "events_api",
"payload": {
"type": "event_callback",
"event": {
"type": "app_home_opened",
"user": "U03MYJNV8TS",
"channel": "D04DMT3MVJT",
"tab": "home",
...
},
...
}
}
위 JSON은 유저가 슬랙봇 홈에 진입했을때 유입되는 메시지입니다.
events_api, event_callback, app_home_opened
순서대로 총 3개의 type을 확인하여야
유입된 JSON을 App Home Opened 메시지로 구분할 수 있게 됩니다.
이렇게 슬랙을 통해 들어오는 메시지는 type 필드에 따라 구성이 달라집니다.
슬랙봇 서비스에서는 타입을 분석하여 유입된 메시지를 구분하고 해석해야 합니다.
메시지는 상위 메시지 타입에서 여러 하위 메시지 타입으로 분기하는 구조를 가지고 있습니다. 자식으로 갈수록 구체화되며 상위 메시지와 하위 메시지의 관계는 다음과 같이 트리 형태로 표현할 수 있습니다. type 필드를 통해 메시지 트리의 단말(leaf) 메시지 타입까지 확인해야 유입된 메시지를 명확히 구분할 수 있게 됩니다.
이러한 슬랙의 메시지 구조는 코틀린으로 메시지 해석을 구현할 때 다음과 같은 문제를 야기합니다.
- 내부 데이터를 먼저 확인해야 어떤 클래스로 변환할지 결정 가능하다.
- 반환 클래스 타입이 가변적이다.
상위 메시지에서 하위 메시지의 type 필드를 확인하기 전까지는 하위 메시지가 어떤 타입인지 알 수 없습니다. 또한 메시지 해석 결과로 결정되는 클래스가 가변적이기 때문에 하나의 함수로 정의할 수 없고, 그 결과를 핸들링하는 부분도 가변적 결과에 대응하여야 합니다.
이벤트로 변환하기 - JsonDeserializer
죠르디 서비스에서는 jackson의 JsonDeserializer와 이벤트 기반 구조 적용으로 위 두 문제를 해결하였습니다.
먼저 JsonDeserilzer를 통해 메시지를 해석하고 이벤트를 발생시키는 과정을 살펴보시죠.
abstract class SlackEvent
abstract class SlackJson(val node: JsonNode, val ctxt: DeserializationContext) {
abstract fun toEvent(): SlackEvent
}
class SlackEventDeserializer : JsonDeserializer<SlackEvent>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SlackEvent {
return WebSocketMessageJson(p.readValueAsTree(), ctxt).toEvent()
}
}
// 중간객체
class WebSocketMessageJson(node: JsonNode, ctxt: DeserializationContext) : SlackJson(node, ctxt) {
override fun toEvent(): SlackEvent {
val type = node.get("type").asText()
when (type) {
"hello" -> return ctxt.readTreeAsValue(node, HelloEvent::class.java)
"disconnect" -> return ctxt.readTreeAsValue(node, ConnectionRefreshEvent::class.java)
"slash_commands" -> return ctxt.readTreeAsValue(node, SlashCommandEvent::class.java)
"interactive" -> return InteractiveMessageJson(node, ctxt).toEvent()
"events_api" -> return EventsApiMessageJson(node, ctxt).toEvent()
}
}
}
// 중간객체
class InteractiveMessageJson(node: JsonNode, ctxt: DeserializationContext) : SlackJson(node, ctxt) {
...
}
// 중간객체
class EventsApiMessageJson(node: JsonNode, ctxt: DeserializationContext) : SlackJson(node, ctxt) {
...
}
Deserializer를 정의하는 코드입니다. SlackEventDeserializer는 jackson의 JsonDeserializer를 상속받고 abstract member인 deserialize 함수를 구현합니다.
메시지 해석을 위해 중간객체 WebSocketMessageJson을 먼저 생성합니다. 중간객체는 내부 type 필드를 확인한 후, 그 정보를 토대로 대응하는 이벤트로 변환해주는 역할을 담당합니다. 타입에 따라 더 깊은 깊이에서 메시지를 특정할 수 있는 경우도 마찬가지입니다. 재귀적으로 해석의 책임을 하위 중간객체에게 넘겨주어 변환을 맡깁니다.
이렇게 정의한 Deserializer를 ObjectMapper에 등록하면 JSON 메시지를 이벤트로 변환해주는 ObjectMapper를 만들 수 있습니다.
@Service
class SlackWebSocketAdapter : WebSocketAdapter() {
private val objectMapper = jacksonMapperBuilder()
.addModule(JavaTimeModule().apply {
addDeserializer(SlackEvent::class.java, SlackEventDeserializer())
})
.build()
// 웹소켓 메시지 진입점
override fun onWebSocketText(message: String) {
EventPublisher.publish(
event = objectMapper.readValue(message, SlackEvent::class.java)
)
super.onWebSocketText(message)
}
}
웹소켓 진입점으로 들어온 메시지는 Deserializer에 의해 이벤트 클래스로 변환되며, 퍼블리셔에 의해 발행되고 최종적으로 해당 이벤트의 처리를 담당하는 핸들러에게 전달됩니다.
이 모든 과정은 다음의 한 줄로 요약할 수 있습니다.
EventPublisher.publish(event = objectMapper.readValue(message, SlackEvent::class.java))
이벤트 기반 구조를 구성하는 요소들
위에서 언급한 이벤트 기반 구조는 회의봇 서비스에서 직접 구현하여 적용하였는데요. 직접 구현한 구조를 코드와 같이 설명드리겠습니다.
이벤트 퍼블리셔
@Component
object EventPublisher {
private lateinit var paymeetEventProcessor: PaymeetEventProcessor
private lateinit var slackEventProcessor: SlackEventProcessor
@Autowired
fun init(
paymeetEventProcessor: PaymeetEventProcessor,
slackEventProcessor: SlackEventProcessor
) {
this.paymeetEventProcessor = paymeetEventProcessor
this.slackEventProcessor = slackEventProcessor
}
fun publish(event: Any) {
when (event) {
is PaymeetEvent -> paymeetEventProcessor.execute(event)
is SlackEvent -> slackEventProcessor.execute(event)
}
}
}
퍼블리셔는 이벤트를 발행하는 역할로 SlackEvent와 PaymeetEvent를 구분하여 각각의 프로세서에 할당해줍니다. 코틀린의 object로 선언하여 의존성 순환참조 문제를 방지하고 의존성 주입없이 이벤트를 발행할 수 있게 합니다.
이벤트 프로세서
@Component
class SlackEventProcessor(
eventHandlers: List<SlackEventHandler<*>>
) {
private val handlers: HashMap<Class<*>, MutableList<SlackEventHandler<*>>> = HashMap()
// 이벤트-이벤트핸들러 맵 구성
init {
eventHandlers.forEach {
val handlerList: MutableList<SlackEventHandler<*>> = handlers[it.eventType()] ?: ArrayList()
handlerList.add(it)
handlers[it.eventType()] = handlerList
}
}
// 비동기 적용
@Async("asyncTaskExecutor")
fun execute(slackEvent: SlackEvent) {
getHandler(slackEvent).forEach {
it.run { handle(slackEvent) }
}
}
private fun getHandler(slackEvent: SlackEvent): List<SlackEventHandler<SlackEvent>> {
return handlers[slackEvent::class.java] as List<SlackEventHandler<SlackEvent>>
}
}
프로세서는 비동기로 로직을 실행하는 역할을 담당하고 있습니다. 이벤트-이벤트핸들러 맵을 구성하고 있어 이벤트를 파라미터로 execute 함수를 호출하면 해당 이벤트에 매핑된 핸들러를 찾아 로직을 실행합니다.
이벤트와 이벤트 핸들러
abstract class SlackEvent
interface SlackEventHandler<in EVENT> {
fun eventType(): Class<*>
fun handle(event: EVENT)
}
이벤트는 정보의 전달을 담당하고 이벤트 핸들러는 이벤트 수신시 실행할 로직을 구성합니다. 하나의 이벤트에 여러개의 핸들러를 구성하는 것도 가능합니다.
예시를 살펴봅시다.
// 이벤트 구현 예시
class SlashCommandEvent(
val envelopeId: String,
val payload: SlashCommandPayload
) : SlackEvent()
class SlashCommandPayload(
val channelId: String,
val userId: String,
val command: String,
val text: String,
)
@Component
class SlashCommandEventHandler : SlackEventHandler<SlashCommandEvent> {
override fun eventType(): Class<SlashCommandEvent> = SlashCommandEvent::class.java
override fun handle(event: SlashCommandEvent) {
when (event.payload.command) {
"/죠르디소개" -> EventPublisher.publish(
PostChat(
channelId = event.payload.channelId,
blocks = StaticMessages.howToUseJordy().toTypedArray()
)
)
"/구글봇해지" -> EventPublisher.publish(
PostChat(
channelId = event.payload.channelId,
blocks = StaticMessages.discardGoogleBot().toTypedArray()
)
)
"/버그제보" -> EventPublisher.publish(
PostChat(
channelId = event.payload.channelId,
blocks = StaticMessages.reportBug().toTypedArray()
)
)
else -> EventPublisher.publish(PostChat(channelId = event.payload.channelId, message = "hello world"))
}
}
}
위는 SlashCommand 이벤트의 구현 예시로 SlashCommand 이벤트 핸들러에서 이벤트 수신시 하위 이벤트 PostChat을 발행하고 명령어에 따라 발행하는 이벤트 구성이 조금씩 다른 모습을 볼 수 있습니다.
스프링 이벤트?
스프링에서 제공하는 애플리케이션 이벤트를 커스텀하여 적용하면 위에서 설명한 구성요소 구현을 생략할 수 있고 더불어 핵심기능 이외에 더 많은 기능을 추가할 수 있는데요. 하지만 다음과 같은 이유에서 스프링 이벤트를 적용하지 않고 구성요소를 직접 구현하였습니다.
- 팀원들의 학습 비용 필요
팀원중 대부분 스프링 이벤트를 경험해보지 않았습니다.
스프링 이벤트의 적용 난이도와는 별개로 (어렵지 않습니다) 팀원들에게 새로운 기술을 제안할 때
프레임워크 수준의 주제 보다는 5개 미만의 구성요소를 직접 구현하여 보여주는 것이 부담이 적을 것이라 판단했습니다.
- 구조가 단순하다
이벤트 기반을 이루는 필수 구성요소들은 위에서 설명한 것처럼 직관적인 구조를 가지고 있기 때문에 의존성 주입만 해결한다면 구현이 복잡하지 않습니다.
회의봇 서비스는 웹소켓 메시지가 유입되는 지점부터, 그 트리거로 인해 비즈니스 로직이 실행되는 부분까지 모든 플로우를 이벤트 기반으로 작성하여 구성했습니다. 그 과정에서 다음과 같은 장점과 단점을 직접 느낄 수 있었습니다.
이벤트 기반 구조의 장점
쉬운 비동기 적용
두 가지 관점에서 비동기 적용이 쉽다고 표현할 수 있습니다. 첫번째는 비동기 설정이 쉽습니다. 슬랙봇 서비스는 프로세서에 비동기 설정이 이미 적용되어 있습니다. 때문에 따로 설정을 하지 않아도 모든 이벤트 핸들러가 비동기로 동작합니다. 두번째는 구현이 쉽습니다. 이벤트 기반 관점에서 접근한다면 반드시 동기로 동작하여야 하는 로직과 그렇지 않은 로직을 직관적으로 구분, 분리하기 쉽습니다. 또한 비동기로 동작하는 로직들을 하위 이벤트로 정의하고 발생시키는 방식이 자연스럽습니다.
클래스간 의존성 Decoupling
class BlockActionEvent(
val type: String,
val user: SlackUser,
val actions: List<ActionPayload> = emptyList(),
) : SlackEvent() {
val madeBy = user.username
val userSlackId = user.id
}
@Component
class BlockActionEventHandler : SlackEventHandler<BlockActionEvent> {
private val log = LoggerFactory.getLogger(BlockActionEventHandler::class.java)
override fun eventType(): Class<BlockActionEvent> {
return BlockActionEvent::class.java
}
override fun handle(event: BlockActionEvent) {
event.actions.forEach { it ->
when (ActionType.getActionType(it.actionId)) {
ActionType.GOOGLE_OAUTH -> EventPublisher.publish(
GoogleOAuthLoginClickEvent(
event.madeBy,
event.userSlackId
)
)
ActionType.JORGI_ACTION -> {
EventPublisher.publish(
PostSlackJorgiMessage(
replyChannel = event.userSlackId,
actionPayload = it
)
)
}
ActionType.JORGI_REPLY_ACTION -> EventPublisher.publish(
PostSlackJorgiReplyMessage(
madeBy = event.madeBy,
actionPayload = it
)
)
...
}
}
}
}
의존성 Decoupling 예로 위의 BlockAction 이벤트와 핸들러를 살펴봅시다.
- GoogleOAuthLoginClickEvent
- PostSlackJorgiMessage
- PostSlackJorgiReplyMessage
BlockAction 이벤트에서 파생되는 이 이벤트들은 핸들링될때 비동기로 동작하므로 하위 이벤트 핸들링에서 발생한 에러가 상위 이벤트 핸들링에 영향을 주지 않습니다. 이처럼 상위 이벤트에서 하위 이벤트를 발행하는 구조에서는 의존관계가 만들어지지 않고 단방향 이벤트 전파만 존재합니다. 단적으로 상위 이벤트 핸들러가 하위 이벤트 핸들러에 의존성을 두지 않으므로 의존성 주입이 없습니다.
확장성이 좋다
현재는 모든 컴포넌트가 단일프로세스 상에서 동작하고 있지만, 트래픽이 증가하여 서비스 퍼포먼스에 문제가 생길경우 분산시스템으로의 확장을 고려할 수 있습니다. 이때 이벤트 기반으로 작성된 애플리케이션은 메시지큐를 이용한 분산 시스템으로의 확장이 용이합니다.
또한 일반적인 애플리케이션은 분산 시스템으로 확장 시에 여러 동시성 이슈를 해결해야 합니다. 하지만, 이벤트 기반으로 작성된 애플리케이션은 이미 동시성을 염두에 두고 작성되었기에 예상치 못한 동시성 이슈를 마주할 가능성이 낮습니다.
이벤트 기반 프로그래밍의 단점
로직의 흐름 파악이 어렵다
MVC 패턴과 같이 클래스간 명확한 계층이 존재하지 않기 때문에 이벤트 트리의 부모에서 자식 방향으로 전파 되는 그 과정을 따라가는 것이 쉽지 않습니다. 현재 이벤트 핸들링의 로직이 전체 줄기에서 어디에 위치하는지 혹은 전체 줄기가 얼마나 남았는지 파악하기 힘듭니다.
네이밍의 한계
일반적으로 우리가 특정 함수를 호출할 때에는 인스턴스의 변수명과 실행한 함수명 두 가지 정보를 보게 됩니다. 하지만 이벤트로 해당 함수를 대체할 경우 이벤트명 하나에 그 두 가지 의미를 전부 담아야 하기에 표현이 복잡하고 어려워집니다.
또한 이벤트는 이미 일어난 결과를 과거형 수동태로 (예를 들어 UserUpdatedEvent) 표현하는 것이 자연스러운데, PostSlackMessage와 같이 진행형 능동태로 작성하는 경우 직관적이지 않아 클래스명을 자주 잊어버려 디렉터리를 확인하곤 했습니다.😅
트랜잭션 관리가 어렵다
이벤트 기반 구조는 과도한 의존성 Decoupling과 비동기 동작으로 인해 트랜잭션을 관리하는 것이 어려워집니다. 스프링 이벤트에서 제공하는 기능인 TransactionalEventListener를 이용하면 간단한 트랜잭션 관리가 가능해지지만 높은 수준으로 적용하려면 매우 복잡해집니다.
마치며
서비스를 개발하고 운영하다 보면 기존에 하던 방식, 잘 아는 방식으로 설계하고 문제를 해결할 때가 많은데요. 새로운 개발 방법론인 이벤트 기반 구조를 적용하는 것이 큰 재미를 주었습니다. 더불어, 이론으로만 공부했던 개념을 죠르디 회의봇이라는 사내 서비스에 도입하며 장단점을 직접 느낄 수 있었습니다. 이 글을 읽으시는 여러분도 해커톤과 같은 기회를 활용하여 새로운 도전을 해보시는 건 어떨까요? 저와 죠르디 회의봇팀의 경험이 여러분에게 기술적으로 조금이나마 영감이 되고, 또 개발의 즐거움을 찾는 데에 도움이 되었으면 좋겠습니다.
💚 죠르디 회의봇 시리즈 읽어보기