Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IDLE-476] 웹소켓, Redis pub/sub을 이용한 채팅 전송 기능 #217

Merged
merged 9 commits into from
Nov 27, 2024
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core",
kotest-extensions-spring = { group = "io.kotest.extensions", name = "kotest-extensions-spring", version.ref = "kotest-extensions-spring" }
spring-boot-starter-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator", version.ref = "spring-boot" }
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "spring-boot" }
spring-boot-starter-websocket = { group = "org.springframework.boot", name = "spring-boot-starter-websocket", version.ref = "spring-boot" }
kotest-extensions-testcontainers = { group = "io.kotest.extensions", name = "kotest-extensions-testcontainers", version.ref = "kotest-extensions-testcontainers" }
testcontainers-junit-jupiter = { group = "org.testcontainers", name = "junit-jupiter", version.ref = "testcontainers" }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.swm.idle.application.chat.domain

import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import com.swm.idle.domain.chat.enums.SenderType
import org.springframework.stereotype.Service
import java.util.*

@Service
class ChatMessageService {

fun createByUser(roomId: UUID, userId: UUID, contents: List<ChatMessage.Content>): ChatMessage {
return ChatMessage(
roomId = roomId,
senderId = userId,
senderType = SenderType.USER,
contents = contents
)
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

fun publish(it: ChatMessage) {

}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.swm.idle.application.chat.event

import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Component

@Component
class ChatMessagePublisher(
private val eventPublisher: ApplicationEventPublisher,
) {

fun publish(chatMessage: ChatMessage) {
eventPublisher.publishEvent(chatMessage)
}

wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.swm.idle.application.chat.facade

import com.swm.idle.application.chat.domain.ChatMessageService
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import com.swm.idle.domain.chat.enums.SenderType
import com.swm.idle.domain.chat.event.ChatMessageRedisPublisher
import org.springframework.stereotype.Service
import java.util.*

@Service
class ChatMessageFacadeService(
private val chatMessageRedisPublisher: ChatMessageRedisPublisher,
private val chatMessageService: ChatMessageService,
) {
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

fun sendTextMessage(
roomId: UUID,
senderId: UUID,
contents: List<ChatMessage.Content>,
) {
ChatMessage(
roomId = roomId,
senderId = senderId,
senderType = SenderType.USER,
contents = contents
).also {
chatMessageRedisPublisher.publish(it)
}
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

fun saveMessage(chatMessage: ChatMessage) {
// TODO : 메세지 저장 로직 구현
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swm.idle.application.chat.facade

import org.springframework.stereotype.Service

@Service
class ChatRoomFacadeService {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.swm.idle.domain.chat.config

import com.swm.idle.domain.chat.event.ChatMessageRedisConsumer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.listener.ChannelTopic
import org.springframework.data.redis.listener.RedisMessageListenerContainer
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter

@Configuration
class ChatMessageRedisConfig {

@Bean
fun redisListenerContainer(
connectionFactory: RedisConnectionFactory,
messageListenerAdapter: MessageListenerAdapter,
): RedisMessageListenerContainer {
val container = RedisMessageListenerContainer()
container.setConnectionFactory(connectionFactory)
container.addMessageListener(messageListenerAdapter, chatChannelTopic())
return container
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

@Bean
fun messageListenerAdapter(chatMessageRedisConsumer: ChatMessageRedisConsumer): MessageListenerAdapter {
return MessageListenerAdapter(chatMessageRedisConsumer)
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

@Bean
fun chatChannelTopic(): ChannelTopic {
return ChannelTopic(CHAT_MESSAGE)
}

companion object {

const val CHAT_MESSAGE = "chat_message"

}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.domain.chat.event

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.redis.connection.Message
import org.springframework.data.redis.connection.MessageListener
import org.springframework.stereotype.Component

@Component
class ChatMessageRedisConsumer(
private val applicationEventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
) : MessageListener {

val logger = KotlinLogging.logger {}

override fun onMessage(
message: Message,
pattern: ByteArray?,
) {
logger.debug { "Received message: $message" }

objectMapper.readValue<ChatMessage>(message.body)
.also { applicationEventPublisher.publishEvent(it) }
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.swm.idle.domain.chat.event

import com.swm.idle.domain.chat.config.ChatMessageRedisConfig
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component

@Component
class ChatMessageRedisPublisher(
private val redisTemplate: RedisTemplate<String, Any>,
) {
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

private val logger = KotlinLogging.logger {}

fun publish(chatMessage: ChatMessage) {
logger.info { "RedisPublisher 도달 " }

redisTemplate.convertAndSend(ChatMessageRedisConfig.CHAT_MESSAGE, chatMessage)
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +16 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

예외 처리 로직 추가가 필요합니다.

Redis 서버 연결 실패나 메시지 발행 실패와 같은 예외 상황에 대한 처리가 없습니다. 적절한 예외 처리와 로깅을 추가하는 것이 좋겠습니다.

     fun publish(chatMessage: ChatMessage) {
-        logger.info { "RedisPublisher 도달 " }
-
-        redisTemplate.convertAndSend(ChatMessageRedisConfig.CHAT_MESSAGE, chatMessage)
+        try {
+            logger.info { "채팅 메시지 발행 시도: messageId=${chatMessage.id}" }
+            redisTemplate.convertAndSend(ChatMessageRedisConfig.CHAT_MESSAGE, chatMessage)
+            logger.info { "채팅 메시지 발행 성공: messageId=${chatMessage.id}" }
+        } catch (e: Exception) {
+            logger.error(e) { "채팅 메시지 발행 실패: messageId=${chatMessage.id}" }
+            throw ChatMessagePublishException("채팅 메시지 발행 중 오류가 발생했습니다", e)
+        }

Committable suggestion was skipped due to low confidence.


}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.swm.idle.domain.common.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
import com.swm.idle.domain.common.properties.RedisProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
Expand All @@ -10,6 +13,8 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.StringRedisSerializer

@Configuration
@EnableConfigurationProperties(RedisProperties::class)
Expand All @@ -30,10 +35,19 @@ class RedisConfig(
}

@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<*, *> {
val template = RedisTemplate<ByteArray, ByteArray>()
template.connectionFactory = redisConnectionFactory
return template
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory?): RedisTemplate<String, Any> {
val serializer = GenericJackson2JsonRedisSerializer(objectMapper)

return RedisTemplate<String, Any>().apply {
connectionFactory = redisConnectionFactory!!
keySerializer = StringRedisSerializer()
valueSerializer = serializer
}
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

private val objectMapper = ObjectMapper().apply {
registerModule(KotlinModule.Builder().build())
registerModule(JavaTimeModule())
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
}

}
1 change: 1 addition & 0 deletions idle-presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies {
implementation(project(":idle-infrastructure:monitoring"))

implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.websocket)
implementation(libs.spring.boot.starter.data.jpa)
implementation(libs.springdoc.openapi.starter.webmvc.ui)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.swm.idle.presentation.chat.config

import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry
.addEndpoint("/websocket")
.setAllowedOriginPatterns("*")
.withSockJS()

registry
.addEndpoint("/websocket")
.setAllowedOriginPatterns("*")
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

override fun configureMessageBroker(registry: MessageBrokerRegistry) {
registry.enableSimpleBroker("/topic")
registry.setApplicationDestinationPrefixes("/app")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.presentation.chat.controller

import com.swm.idle.application.chat.facade.ChatMessageFacadeService
import com.swm.idle.domain.chat.entity.jpa.ChatMessage
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.event.EventListener
import org.springframework.messaging.simp.SimpMessageSendingOperations
import org.springframework.stereotype.Controller

@Controller
class ChatMessageHandler(
private val simpMessageSendingOperations: SimpMessageSendingOperations,
private val chatMessageFacadeService: ChatMessageFacadeService,
) {

private val logger = KotlinLogging.logger { }

@EventListener
fun handleChatMessage(chatMessage: ChatMessage) {
logger.info { "Handler까지 도달 " }

simpMessageSendingOperations.convertAndSend(
"/topic/chat-rooms/${chatMessage.roomId}",
chatMessage
)

chatMessageFacadeService.saveMessage(chatMessage)
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.swm.idle.presentation.chat.controller

import com.swm.idle.application.chat.facade.ChatMessageFacadeService
import com.swm.idle.support.common.uuid.UuidCreator
import com.swm.idle.support.transfer.chat.SendChatMessageRequest
import org.springframework.messaging.handler.annotation.DestinationVariable
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.stereotype.Controller
import java.util.*

@Controller
class ChatWebSocketController(
private val chatMessageService: ChatMessageFacadeService,
) {

@MessageMapping("/chat-rooms/{roomId}")
@SendTo("/topic/chat-rooms/{roomId}")
fun sendTextMessage(
@DestinationVariable roomId: UUID,
request: SendChatMessageRequest,
) {
chatMessageService.sendTextMessage(
roomId = roomId,
senderId = UuidCreator.create(), // TODO: 토큰 인증 구현
contents = request.contents,
)
}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved

}
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swm.idle.support.transfer.chat

import com.swm.idle.domain.chat.entity.jpa.ChatMessage

data class SendChatMessageRequest(
val contents: List<ChatMessage.Content>,
)
wonjunYou marked this conversation as resolved.
Show resolved Hide resolved
Loading