article content thumbnail

[Spring] Discord로 project 운영

나만의 네컷 프로젝트에서 Discord Webhook을 활용하여 서비스 운영하기.

나만의 네컷 프로젝트를 런칭하고, 많은 유저들이 사용하면서 다양한 에러들이 발생했는데 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로 줄일 수 있었다.

에러 알람 뿐만 아니라, 아래와 같이 다양한 알림을 활용하여 사용자의 요청을 효과적으로 처리하면서 서비스를 이어나가고 있다.


최신 아티클
Article Thumbnail
최윤한
|
2025.03.30
A-MEM: Agentic Memory for LLM Agents 리뷰
AI Agent의 기억 메모리 조직화 시스템 방법론
Article Thumbnail
최윤한
|
2025.03.08
IT 커뮤니티 SIPE 3기 운영진 마침표.
SIPE 운영진 활동 회고와 SIPE를 고민하는 하는 사람을 위한 후기와 추천글
(python) spotlight로 시각화 feature vector
최윤한
|
2025.03.02
(python) spotlight로 시각화 feature vector
Spotlight로 Feature Vector 시각화