From c8eb08cbdc11ce66b97462d66d72005d339d3825 Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Sun, 12 Oct 2025 21:16:08 +0900 Subject: [PATCH 1/6] feat(be): add RabbitMQ dependencies # Conflicts: # build.gradle.kts --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 6745ee5..4891c6a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { // 웹소켓 - 사용자 채팅 기능 (게스트-가이드) implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.springframework.boot:spring-boot-starter-amqp") // 레디스 - 캐싱 및 세션 관리 implementation("org.springframework.boot:spring-boot-starter-data-redis") @@ -74,6 +75,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.springframework.amqp:spring-rabbit-test") testImplementation("io.mockk:mockk:1.13.12") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } From 49488635b18f7adb7613031891f0beeb904e2b2f Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Mon, 13 Oct 2025 09:45:25 +0900 Subject: [PATCH 2/6] feat(be):Add publisher implementation for RabbitMQ integration --- .../usecase/ChatMessagePublisher.kt | 8 +++ .../usecase/RabbitChatMessagePublisher.kt | 19 +++++++ .../usecase/SimpleChatMessagePublisher.kt | 18 +++++++ .../config/UserChatWebSocketConfig.kt | 31 ----------- .../stomp/UserChatRabbitWebSocketConfig.kt | 51 +++++++++++++++++++ .../UserChatStompAuthChannelInterceptor.kt | 2 +- 6 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt delete mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt rename src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/{config => stomp}/UserChatStompAuthChannelInterceptor.kt (96%) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt new file mode 100644 index 0000000..2926f96 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/ChatMessagePublisher.kt @@ -0,0 +1,8 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +interface ChatMessagePublisher { + fun publishUserChat( + roomId: Long, + payload: Any, + ) +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt new file mode 100644 index 0000000..55d37a2 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/RabbitChatMessagePublisher.kt @@ -0,0 +1,19 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Profile("prod") +@Component +class RabbitChatMessagePublisher( + private val rabbitTemplate: RabbitTemplate, +) : ChatMessagePublisher { + override fun publishUserChat( + roomId: Long, + payload: Any, + ) { + val routingKey = "userchat.$roomId" + rabbitTemplate.convertAndSend("amq.topic", routingKey, payload) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt new file mode 100644 index 0000000..38602cb --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/usecase/SimpleChatMessagePublisher.kt @@ -0,0 +1,18 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase + +import org.springframework.context.annotation.Profile +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Component + +@Profile("!prod") +@Component +class SimpleChatMessagePublisher( + private val messagingTemplate: SimpMessagingTemplate, +) : ChatMessagePublisher { + override fun publishUserChat( + roomId: Long, + payload: Any, + ) { + messagingTemplate.convertAndSend("/topic/userchat/$roomId", payload) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt deleted file mode 100644 index 5a43aaa..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.back.koreaTravelGuide.domain.userChat.config - -import org.springframework.context.annotation.Configuration -import org.springframework.messaging.simp.config.ChannelRegistration -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 - -// userChat에서만 사용할 것 같아서 전역에 두지 않고 userChat 도메인에 두었음 - -@Configuration -@EnableWebSocketMessageBroker -class UserChatWebSocketConfig( - private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, -) : WebSocketMessageBrokerConfigurer { - override fun registerStompEndpoints(registry: StompEndpointRegistry) { - registry.addEndpoint("/ws/userchat") - .setAllowedOriginPatterns("*") - .withSockJS() - } - - override fun configureMessageBroker(registry: MessageBrokerRegistry) { - registry.enableSimpleBroker("/topic") - registry.setApplicationDestinationPrefixes("/pub") - } - - override fun configureClientInboundChannel(registration: ChannelRegistration) { - registration.interceptors(userChatStompAuthChannelInterceptor) - } -} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt new file mode 100644 index 0000000..5ef5e08 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatRabbitWebSocketConfig.kt @@ -0,0 +1,51 @@ +package com.back.koreaTravelGuide.domain.userChat.stomp + +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter +import org.springframework.amqp.support.converter.MessageConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.messaging.simp.config.ChannelRegistration +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 + +// userChat에서만 사용할 것 같아서 전역에 두지 않고 userChat 도메인에 두었음 + +@Profile("prod") +@Configuration +@EnableWebSocketMessageBroker +class UserChatRabbitWebSocketConfig( + private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, + @Value("\${spring.rabbitmq.host}") private val rabbitHost: String, + @Value("\${spring.rabbitmq.stomp-port}") private val rabbitStompPort: Int, + @Value("\${spring.rabbitmq.username}") private val rabbitUsername: String, + @Value("\${spring.rabbitmq.password}") private val rabbitPassword: String, +) : WebSocketMessageBrokerConfigurer { + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/ws/userchat") + .setAllowedOriginPatterns("*") + .withSockJS() + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry + .setApplicationDestinationPrefixes("/pub") + .enableStompBrokerRelay("/topic") + .setRelayHost(rabbitHost) + .setRelayPort(rabbitStompPort) + .setClientLogin(rabbitUsername) + .setClientPasscode(rabbitPassword) + .setSystemLogin(rabbitUsername) + .setSystemPasscode(rabbitPassword) + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(userChatStompAuthChannelInterceptor) + } + + @Bean + fun rabbitMessageConverter(): MessageConverter = Jackson2JsonMessageConverter() +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt similarity index 96% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt index 9553bce..93553d9 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatStompAuthChannelInterceptor.kt @@ -1,4 +1,4 @@ -package com.back.koreaTravelGuide.domain.userChat.config +package com.back.koreaTravelGuide.domain.userChat.stomp import com.back.koreaTravelGuide.common.security.JwtTokenProvider import org.springframework.messaging.Message From a4cd39c89e4966282e67690ca8d38c4906561ea8 Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Mon, 13 Oct 2025 10:00:36 +0900 Subject: [PATCH 3/6] feat(be): route chat messaging through publisher port --- .../chatmessage/controller/ChatMessageController.kt | 8 ++++---- .../chatmessage/controller/ChatMessageSocketController.kt | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt index 500ddef..4f4c7b3 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt @@ -4,8 +4,8 @@ import com.back.koreaTravelGuide.common.ApiResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSendRequest import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase.ChatMessagePublisher import org.springframework.http.ResponseEntity -import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.security.access.AccessDeniedException import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/userchat/rooms") class ChatMessageController( private val messageService: ChatMessageService, - private val messagingTemplate: SimpMessagingTemplate, + private val chatMessagePublisher: ChatMessagePublisher, ) { @GetMapping("/{roomId}/messages") fun listMessages( @@ -49,8 +49,8 @@ class ChatMessageController( val memberId = senderId ?: throw AccessDeniedException("인증이 필요합니다.") val saved = messageService.send(roomId, memberId, req.content) val response = ChatMessageResponse.from(saved) - messagingTemplate.convertAndSend( - "/topic/userchat/$roomId", + chatMessagePublisher.publishUserChat( + roomId, ApiResponse(msg = "메시지 전송", data = response), ) return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = response)) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt index c2f9203..94f8eb4 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt @@ -4,10 +4,10 @@ import com.back.koreaTravelGuide.common.ApiResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageResponse import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSendRequest import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatmessage.usecase.ChatMessagePublisher import org.springframework.messaging.handler.annotation.DestinationVariable import org.springframework.messaging.handler.annotation.MessageMapping import org.springframework.messaging.handler.annotation.Payload -import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Controller import java.security.Principal @@ -15,7 +15,7 @@ import java.security.Principal @Controller class ChatMessageSocketController( private val chatMessageService: ChatMessageService, - private val messagingTemplate: SimpMessagingTemplate, + private val chatMessagePublisher: ChatMessagePublisher, ) { @MessageMapping("/userchat/{roomId}/messages") fun handleMessage( @@ -26,8 +26,8 @@ class ChatMessageSocketController( val senderId = principal.name.toLongOrNull() ?: throw AccessDeniedException("인증이 필요합니다.") val saved = chatMessageService.send(roomId, senderId, req.content) val response = ChatMessageResponse.from(saved) - messagingTemplate.convertAndSend( - "/topic/userchat/$roomId", + chatMessagePublisher.publishUserChat( + roomId, ApiResponse(msg = "메시지 전송", data = response), ) } From be099041f0638c5ed5374eab9227ac568debc091 Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Mon, 13 Oct 2025 11:05:53 +0900 Subject: [PATCH 4/6] feat(be): enable simple broker in non-prod profiles --- .../stomp/UserChatSimpleWebSocketConfig.kt | 31 +++++++++++++++++++ src/main/resources/application.yml | 2 +- .../ai/tour/service/TourParamsParserTest.kt | 4 +-- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt new file mode 100644 index 0000000..8e2c9ff --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/stomp/UserChatSimpleWebSocketConfig.kt @@ -0,0 +1,31 @@ +package com.back.koreaTravelGuide.domain.userChat.stomp + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.messaging.simp.config.ChannelRegistration +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 + +@Profile("!prod") +@Configuration +@EnableWebSocketMessageBroker +class UserChatSimpleWebSocketConfig( + private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, +) : WebSocketMessageBrokerConfigurer { + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/ws/userchat") + .setAllowedOriginPatterns("*") + .withSockJS() + } + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.setApplicationDestinationPrefixes("/pub") + registry.enableSimpleBroker("/topic") + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(userChatStompAuthChannelInterceptor) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2f8bc89..ca26dfe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -143,7 +143,7 @@ springdoc: # Weather API 설정 weather: api: - key: ${WEATHER__API__KEY} + key: ${WEATHER_API_KEY} base-url: https://apihub.kma.go.kr/api/typ02/openApi/MidFcstInfoService # Tour API 설정 diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt index d000173..20a4835 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -1,9 +1,9 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -import kotlin.test.assertEquals -import kotlin.test.assertNull import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class TourParamsParserTest { private val parser = TourParamsParser() From fd8a93bb8c231267e7981b46b926514b7e0525ca Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Mon, 13 Oct 2025 12:04:20 +0900 Subject: [PATCH 5/6] feat(be): RabbitMq test environment --- .gitignore | 3 ++- docker-compose.yml | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index c179ae7..7c3616a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ build/reports/ coverage/ # Test file -index.html \ No newline at end of file +index.html +docker-compose.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ccf9e7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + rabbit: + image: rabbitmq:3-management + container_name: rabbit + ports: + - "5672:5672" # AMQP (Spring ↔ RabbitMQ) + - "61613:61613" # STOMP (Relay가 붙음) + - "15672:15672" # 관리 콘솔(옵션, 로컬에서만) + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: admin From 16feb26d50ceb66a692cdd82bf700f040f52777e Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Mon, 13 Oct 2025 12:38:31 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(be):=20yml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20git=EC=97=90=EC=84=9C=20docker?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 11 ----------- src/main/resources/application.yml | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index ccf9e7f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ -services: - rabbit: - image: rabbitmq:3-management - container_name: rabbit - ports: - - "5672:5672" # AMQP (Spring ↔ RabbitMQ) - - "61613:61613" # STOMP (Relay가 붙음) - - "15672:15672" # 관리 콘솔(옵션, 로컬에서만) - environment: - RABBITMQ_DEFAULT_USER: admin - RABBITMQ_DEFAULT_PASS: admin diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ca26dfe..2f8bc89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -143,7 +143,7 @@ springdoc: # Weather API 설정 weather: api: - key: ${WEATHER_API_KEY} + key: ${WEATHER__API__KEY} base-url: https://apihub.kma.go.kr/api/typ02/openApi/MidFcstInfoService # Tour API 설정