개요
이번 글에서는 WAS 이중화를 적용하고 생긴 문제점을 해결하는 과정을 살펴보려 한다.
[10k-Chat] Spring Boot 실시간 채팅 서버 구현 (2) - WAS 이중화
개요이번 글에서는 부하 테스트 결과를 분석하고, 이를 바탕으로 WAS 이중화를 통해 성능을 개선하는 방안을 살펴보겠다.부하 테스트이미 JMeter, Locust, K6 등 좋은 도구들이 많지만 내가 원하는 기
woong99.tistory.com
WAS 이중화로 생긴 문제점
WebSocket 연결은 WAS별로 개별적으로 유지된다.
그렇기 때문에 두 명의 사용자가 서로 다른 WAS의 WebSocket에 연결되면 서로 채팅을 할 수 없는 문제가 발생한다.
위와 같은 문제를 해결하기 위해 RabbitMQ를 도입하기로 했다.
RabbitMQ란?
RabbitMQ는 오픈 소스 메시지 브로커(Message Broker)로, 애플리케이션 간 메시지를 송수신하는 역할을 한다.
AMQP(Advanced Message Queuing Protocol)를 기본으로 지원하며, 다양한 프로토콜과 플러그인을 통해 확장 가능하다.
[SpringBoot] AMQP란?
AMQP란?AMQP(Advanced Message Queuing Protocol)는 메시지 지향 미들웨어에서 사용되는 개방형 표준 프로토콜입니다.이 프로토콜은 애플리케이션 간에 메시지를 안정적이고 효율적으로 전달하기 위한 규격
woong99.tistory.com
주요 특징
- 비동기 메시지 처리 : 메시지를 큐에 저장하고 소비자가 이를 처리하는 방식으로 비동기 통신을 지원
- 높은 확장성 : 여러 개의 노드를 클러스터링하거나 Federation, Shovel 같은 기능을 활용해 메시지를 분산 처리 가능
- 다양한 프로토콜 지원 : AMQP뿐만 아니라 MQTT, STOMP 등 다양한 프로토콜을 지원
- 내구성 보장 : 메시지를 디스크에 저장하거나 여러 개의 큐에 복제하여 신뢰성을 확보
- 다양한 언어 지원 : Java, Python, Go, C# 등 다양한 프로그래밍 언어에서 사용 가능
RabbitMQ 설치
docker-compose.yaml
Docker-Compose를 이용해 RabbitMQ를 설치한다.
services:
rabbitmq:
image: rabbitmq:management-alpine
container_name: rabbitmq
restart: always
hostname: rabbitmq
volumes:
- /containers/data/rabbitmq/etc/:/etc/rabbitmq/
- /containers/data/rabbitmq/data/:/var/lib/rabbitmq/
- /containers/data/rabbitmq/logs/:/var/log/rabbitmq/
ports:
- "5672:5672" # AMQP
- "15672:15672" # Web UI
- "61613:61613" # STOMP
environment:
TZ: "Asia/Seoul"
RABBITMQ_ERLANG_COOKIE: rabbitmqCookie
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: 1234
networks:
- spring-10k-chat-network
networks:
spring-10k-chat-network:
external: true
enabled_plugins
/etc 볼륨으로 지정한 경로에 해당 파일을 작성한다.
나의 경우는 /containers/data/rabbitmq/etc/enabled_plugins 이다.
[rabbitmq_management, rabbitmq_stomp].
코드 설명
build.gradle.kts
RabbiMQ와 Reactor-Netty 라이브러리를 추가한다.
RabbitMQ Stream은 내부적으로 연결 시 Reactor-Netty를 사용하는데 spring-boot-starter-amqp에는 내장되어 있지 않기 때문에 임의로 추가해준다.
dependencies {
// RabbitMQ
implementation("org.springframework.boot:spring-boot-starter-amqp")
// Reactor-Netty
implementation("org.springframework.boot:spring-boot-starter-reactor-netty")
}
application.yaml
spring:
rabbitmq:
username: admin
password: 1234
host: localhost
port: 5672
stomp-port: 61613
RabbitConfig.kt
TopicExchange를 이용해 라우팅 키를 패턴 기반으로 처리해 메시지를 라우팅한다.
관례상 Queue나 Exchange 이름에 구분자로 .을 이용한다.
@Configuration
@EnableRabbit
class RabbitConfig(
@Value("\${spring.rabbitmq.username}") private val username: String,
@Value("\${spring.rabbitmq.password}") private val password: String,
@Value("\${spring.rabbitmq.host}") private val host: String,
@Value("\${spring.rabbitmq.port}") private val port: Int,
) {
// Queue 등록
@Bean
fun queue() = Queue(CHAT_QUEUE_NAME, true)
// Exchange 등록
@Bean
fun exchange(): TopicExchange {
return TopicExchange(CHAT_EXCHANGE_NAME, true, false)
}
// Binding 등록
@Bean
fun binding(
queue: Queue,
exchange: TopicExchange
): Binding = BindingBuilder.bind(queue)
.to(exchange)
.with(ROUTING_KEY)
// ConnectionFactory 등록
@Bean
fun connectionFactory(): ConnectionFactory {
val factory = CachingConnectionFactory()
factory.setHost(host)
factory.username = username
factory.setPassword(password)
factory.port = port
return factory
}
// RabbitTemplate 등록
@Bean
fun rabbitTemplate(): RabbitTemplate {
val rabbitTemplate = RabbitTemplate(connectionFactory())
rabbitTemplate.messageConverter = messageConverter()
rabbitTemplate.routingKey = ROUTING_KEY
return rabbitTemplate
}
// ListenerContainerFactory 등록
@Bean
fun simpleRabbitListenerContainerFactory(
connectionFactory: ConnectionFactory
): SimpleRabbitListenerContainerFactory {
val factory = SimpleRabbitListenerContainerFactory()
factory.setConnectionFactory(connectionFactory)
factory.setMessageConverter(messageConverter())
return factory
}
// MessageConverter 등록
@Bean
fun messageConverter(): Jackson2JsonMessageConverter {
val objectMapper = ObjectMapper()
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
objectMapper.registerModule(dateTimeModule())
return Jackson2JsonMessageConverter(objectMapper)
}
@Bean
fun dateTimeModule() = JavaTimeModule()
companion object {
private const val CHAT_QUEUE_NAME = "chat.queue"
private const val CHAT_EXCHANGE_NAME = "chat.exchange"
private const val ROUTING_KEY = "room.*"
}
}
WebSocketConfig.kt
내장 STOMP 브로커를 사용할때와 크게 달라지는 점은 없다.
주의할 점은 RelayPort에 stomp-port를 적어줘야 한다.
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig(
private val stompInterceptor: StompInterceptor,
private val stompErrorHandler: StompErrorHandler,
@Value("\${spring.rabbitmq.username}") private val username: String,
@Value("\${spring.rabbitmq.password}") private val password: String,
@Value("\${spring.rabbitmq.host}") private val host: String,
@Value("\${spring.rabbitmq.stomp-port}") private val stompPort: Int,
) : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(registry: MessageBrokerRegistry) {
// 메시지 구독 설정
registry.enableStompBrokerRelay("/queue", "/topic", "/exchange", "/amq/queue")
.setAutoStartup(true)
.setClientLogin(username)
.setClientPasscode(password)
.setSystemLogin(username)
.setSystemPasscode(password)
.setRelayHost(host)
.setRelayPort(stompPort)
// 메시지 발행 설정
registry.setPathMatcher(AntPathMatcher("."))
registry.setApplicationDestinationPrefixes("/pub")
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*") // TODO : 배포 시 변경
.withSockJS()
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*") // TODO : 배포 시 변경
registry.setErrorHandler(stompErrorHandler)
}
override fun configureClientInboundChannel(registration: ChannelRegistration) {
registration.interceptors(stompInterceptor)
}
}
StompController.kt
내장 브로커 SimpMessageTemplate 대신 RabbitTemplate을 사용한다.
@RestController
class StompController(
private val rabbitTemplate: RabbitTemplate,
private val chatService: ChatService,
private val authService: AuthService,
private val chatRoomNotificationService: ChatRoomNotificationService,
) {
@MessageMapping("chat.message.{chatRoomId}")
fun chat(
@DestinationVariable chatRoomId: String,
request: MessageDto.Request,
authentication: Authentication
) {
// 닉네임 조회
val nickname = authService.getMyInfo(authentication.name.toLong()).nickname
// 채팅 저장
chatService.saveChat(
chatRoomId,
request,
authentication.name.toLong()
)
// 메시지 전송
rabbitTemplate.convertAndSend(
"chat.exchange",
"chat.room.${chatRoomId}",
MessageDto.Response.Message.of(nickname, request.message)
)
// 채팅방 실시간 갱신
chatRoomNotificationService.sendToClient(
chatRoomId,
request.message
)
}
}
실행 결과
Spring Boot를 실행하고 RabbitMQ 관리자 페이지에 들어가면 아래와 같이 chat.exchange와 chat.queue 가 생긴 걸 볼 수 있다.
채팅 또한 서로 다른 WAS에 연결되어도 잘 연동된다.
부하 테스트 결과
인프라
CPU(Core) | RAM(GB) | |
WAS1 | 2 | 2 |
WAS2 | 2 | 2 |
MariaDB | 2 | 4 |
MongoDB | 2 | 2 |
테스트 결과
내장 Broker를 사용할때와 비교해 크게 성능이 향상되지 않았다.
외부 Broker(RabbitMQ)를 사용하면 부하가 분산되어 더 좋은 성능이 나올 줄 알았다..
나의 추측으로는 RabbitMQ로 STOMP 프로토콜을 사용하면 사용자가 입장할 때마다 큐가 하나씩 생겨난다.
만약 5,000명이 동시에 입장하면 5,000개의 큐가 생긴다. 아마 이러한 이유 때문에 동시 접속자가 많으면 성능이 안나오는 것 같다.
혹시 다른 이유가 있다면 댓글로 알려주시면 감사하겠습니다,,,
결론
RabbitMQ 도입으로 인해 WAS 이중화에서 발생하는 문제를 해결할 수 있었다. 다만 성능적인 이점은 크게 가져가지 못했다.
- STOMP 부하 테스트 코드 링크 : https://github.com/woong99/stomp-load-test-tool
- 백엔드 코드 : https://github.com/woong99/spring-chat
- 프론트엔드 코드 : https://github.com/woong99/spring-chat-front
'Programming > 10K-Chat' 카테고리의 다른 글
Spring Boot 실시간 채팅 서버 구현 (6) - 10K 동시 접속 (0) | 2025.03.27 |
---|---|
Spring Boot 실시간 채팅 서버 구현 (5) - WebSocket 무중단 배포 (0) | 2025.03.15 |
Spring Boot 실시간 채팅 서버 구현 (3) - MongoDB (0) | 2025.02.15 |
Spring Boot 실시간 채팅 서버 구현 (2) - WAS 이중화 (0) | 2025.02.08 |
Spring Boot 실시간 채팅 서버 구현 (1) - Stomp (0) | 2025.01.19 |