
[Spring] Discord로 project 운영
나만의 네컷 프로젝트를 런칭하고, 많은 유저들이 사용하면서 다양한 에러들이 발생했는데 Sentry를 통해서 에러 추적을 하고 있지만 조금 더 에러에 빠르게 대응하기 위한 방법에 대해 고민했다.
Slack을 보통 협업 툴로 사용하면서 slack 봇을 사용해서 문제 발생시에 대응할 수 있는 기능을 구현했었는데, 현재 사용중인 협업 툴인 Discord에도 WebHook을 활용할 수 있는 기능이 있어 해당 기능을 활용하여, 알림 채널을 운영했다.

아래 이미지 처럼 디스코드 설정에서 아래와 같이 웹후크를 만들고, 해당 웹후크로 요청을 보낼 수 있는 URL을 얻을 수 있다.

따라서 에러가 발생하면, 서버에서 해당 url로 HTTP request를 보내도록 구성했다.
RestClient는 Spring에서 client 요청을 보낼 수 있는 HttpClient 라이브러리이다.
https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient
아래 페이지에서 discord Webhook spec에 맞춰서 요청 객체를 정의했다.
https://discord.com/developers/docs/resources/webhook
단순히 text를 보내고 싶을 때는, content 값만 보내도 충분하지만 조금 더 깔끔한 포맷으로 보내고 싶을 때 embeds 필드를 활용할 수 있다.
data class DiscordRequest(
val content: String,
val embeds: List<Embed>?,
)
data class Embed(
val title: String,
val description: String,
)
이후에 Discord WebHook url로 요청을 처리하는 함수를 구현했다.
@Componentclass DiscordAlarmService(
@Value("\${discord.error-alert-url}")
private val ERROR_ALERT_URL: String,
) {
private val restClient = RestClient.create()
fun sendErrorAlert(exception: Exception) {
if (exception is NoResourceFoundException) {
val requestBody =
DiscordRequest(
content = "에러 발생 : ${exception.message} ( ${exception.javaClass.simpleName} )",
embeds = null,
)
restClient
.post()
.uri(ERROR_ALERT_URL)
.body(requestBody)
.retrieve()
return
}
}
이제 예외처리를 하는 부분에 해당 함수를 호출하도록 구현하여 에러시에 알람을 받을 수 있도록 추가해주면 끝난다.
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ErrorResponse> {
discordAlarmService.sendErrorAlert(e)
Sentry.captureException(e)
return ResponseEntity
.internalServerError()
.body(ErrorResponse(ERROR_CODE))
}
알람 성능 개선
알람을 보내는 과정 자체는 어플리케이션과 별개로 동작해야한다. 따라서 디스코드 알람을 보내는 것 때문에 어플리케이션 동작에 문제가 생기면 안되고, 또한 처리 시간을 줄여보기 위해서 비동기 처리를 적용하기로 결정하고, Kotlin 으로 프로젝트를 진행하는 만큼 코루틴을 활용한 로직을 추가했다.
build.gradle.kts
// Kotlin Coroutine
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
알람 test를 위해서 alarm을 테스트 할 수 있는 api를 만들고, 이후에 아래와 같이 수정을 진행했다.
@UseCaseclass TestAlarmUseCase(
private val discordAlarmService: DiscordAlarmService,
) {
fun testDiscordAlarm(exception: Exception) {
CoroutineScope(Dispatchers.IO).launch {
discordAlarmService.sendErrorAlarm(exception)
}
}
}
RestClient
의 경우, 비동기 요청을 지원하지 않는 것으로 알고 있어 코루틴을 효과적으로 사용하기 위해서 WebClient
로 리팩토링을 진행했다.
(이후 GPT나 일부 블로그에서는 비동기적으로 처리할 수 있다고 하는데, 알아봐야 할 것 같다.)
WebClient를 사용하기 위해 Webflux 의존성을 추가했다.
https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html
build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
}
의존성을 추가한 뒤에, suspend 함수로 리팩토링 했다.
@UseCaseclass TestAlarmUseCase(
private val discordAlarmService: TestDiscordService,
) {
suspend fun testDiscordAlarm() = withContext(Dispatchers.IO) {
launch {
discordAlarmService.sendErrorAlert(CoreException("테스트 에러 알림"))
}
}
}
이후에 각각 보내야하는 알람 케이스에 맞춰서, 채널 웹후크 주소를 추가하고 추가적으로 함수를 정의했다.
에러 알람, 회원가입 알람, 프레임 제작, 앱 내의 피드백을 신청했을 때 알람이 가도록 작성했다.
(각각 함수를 분리했는데, 이후에 요청 함수를 추상화하고, type에 맞춰서 전송할 수 있도록 리팩토링 해야겠다.)
@Componentclass DiscordAlarmService(
@Value("\${discord.error-alert-url}")
private val ERROR_ALERT_URL: String,
) {
private val restClient = RestClient.create()
fun sendErrorAlert(exception: Exception) {
if (exception is NoResourceFoundException) {
val requestBody =
DiscordRequest(
content = "에러 발생 : ${exception.message} ( ${exception.javaClass.simpleName} )",
embeds = null,
)
restClient
.post()
.uri(ERROR_ALERT_URL)
.body(requestBody)
.retrieve()
return
}
val requestBody =
DiscordRequest(
content = "에러 발생 : ${exception.message} ( ${exception.javaClass.simpleName} )",
listOf(
Embed(
title = "Stack Trace",
description = parseStackTrace(exception),
),
),
)
restClient
.post()
.uri(ERROR_ALERT_URL)
.body(requestBody)
.retrieve()
}
fun sendNewMemberAlarm(message: String) {
val requestBody =
DiscordRequest(
content = message,
embeds = null,
)
restClient
.post()
.uri(NEW_MEMBER_ALARM_BOT_URL)
.body(requestBody)
.retrieve()
}
fun sendAlarm(message: String) {
val requestBody =
DiscordRequest(
content = message,
embeds = null,
)
restClient
.post()
.uri(ALARM_URL)
.body(requestBody)
.retrieve()
}
fun sendContributionApplyAlarm(
title: String,
subTitle: String,
content: String,
) {
val requestBody =
DiscordRequest(
content = title,
embeds =
listOf(
Embed(
title = subTitle,
description = content,
),
),
)
restClient
.post()
.uri(CONTRIBUTION_APPLY_URL)
.body(requestBody)
.retrieve()
}
fun sendAdminAlarm(message: String) {
val requestBody =
DiscordRequest(
content = message,
embeds = null,
)
restClient
.post()
.uri(ADMIN_ALARM_URL)
.body(requestBody)
.retrieve()
}
fun sendMyFrameApplyAlarm(
title: String,
subTitle: String,
content: String,
) {
val requestBody =
DiscordRequest(
content = title,
embeds =
listOf(
Embed(
title = subTitle,
description = content,
),
),
)
restClient
.post()
.uri(MY_FRAME_APPLY_URL)
.body(requestBody)
.retrieve()
}
fun sendOrganizationApplyAlarm(
title: String,
subTitle: String,
content: String,
) {
val requestBody =
DiscordRequest(
content = title,
embeds =
listOf(
Embed(
title = subTitle,
description = content,
),
),
)
restClient
.post()
.uri(ORGANIZATION_APPLY_URL)
.body(requestBody)
.retrieve()
}
private fun parseStackTrace(exception: Exception): String {
val stackTrace = exception.stackTraceToString()
if (stackTrace.length > 2000) {
return stackTrace.substring(0, 2000)
}
return stackTrace
}
}
각각 함수를 정의하고 TestAlarmUseCase
에서 test 를 했을 때, 총 5개의 채널에 5개의 알람을 보내는데 기존 평균 1초 후반대의 요청시간을 100~200ms로 줄일 수 있었다.
에러 알람 뿐만 아니라, 아래와 같이 다양한 알림을 활용하여 사용자의 요청을 효과적으로 처리하면서 서비스를 이어나가고 있다.


