[Springboot] 채팅서버를 만들어보자

양예성·2024년 5월 10일
2

스기~

목록 보기
3/4
post-thumbnail

🎉 들어가며..

기존 자바 + 스프링으로 개발을 하다 코프링으로 넘어온지 1달...

코틀린과 사랑에 빠지게 되었다🥰🥰🥰
문법공부를 깊게 한건 아니지만 사용하면 사용할수록 편하단 생각을 늘 한다

기존 자바 + 스프링 개발자라면 주저없이 코틀린 + 스프링을 경험해보길 추천한다.

채팅을 만들자

인터넷에 채팅앱 혹은 채팅 만들기 치면 가장 많이 언급되는것이 Socket 일것이다.

Socket이란?

양방향 통신/소통을 할 때 음성/텍스트/이미지 등의 데이터가 전송되는 도착지점.
즉, 데이터가 도착하는 지점 들이라고 해석할 수 있다.

하지만 소켓의 치명적인 단점이 있다.
(내가 모르는거일수도 있지만) 소켓은 양방향 소통만 된다.

?? 양방향 소통이면 그게 채팅이지

그렇다. 다만 소통하는 통로가 하나뿐이다....

이게 뭔소리냐?

소켓에서 핸드쉐이크 하여 방에 접속시 그 방의 세션을 통하여 서로 통신을 한다.
(사실 세션을 통하여 여러 통로를 열 수 있지만 Stomp가 훨 효율적이다)

하지만 개발자가 세션을 관리해주어야 하고 또 메시지 전달과정에서 서버에 부화가 갈 수 있기때문에 (돈없는 고등학생) 개발자 입장에선 조금 부담스러웠다.

그래서 다른방법을 찾아보던와중 STOMP를 알게 되었다.

STOMP

STOMP는 HTTP에서 모델링 되는 Frame 기반 프로토콜

메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜(websocket위에서 동작)
클라이언트와 서버가 전송할 메시지의 유형, 형식, 내용들을 정의하는 메커니즘
텍스트 기반 프로토콜으로 subscriber, sender, broker를 따로 두어 처리를 한다.
stomp를 이용하면 채팅방을 여러개로 개설이 가능하다.

[동작과정]
채팅방 생성 -> 채팅방에 참가하는 사람들은 subscriber로 메시지 구독 -> sender로 메시지 발송 -> 브로커가 구독중인 방에 메시지 전달

로 이루어져있다.

Stomp의 장점이라 생각되는것 중 하나는 처음 헨드쉐이크시 헤더를 달 수 있다.

즉 JWT 전달이 가능하단 소리다. 그래서 Stomp를 선택하였다.

👻 스프링으로 구현해보자~

먼저 아래코드를 build.gradle.kts에 추가해주자


    implementation("org.webjars:stomp-websocket:2.3.4")
    implementation("org.springframework.boot:spring-boot-starter-websocket")

웹소켓과 stomp 사용을 위한 설정이다

StompWebSocketConfig

 @Configuration
 @EnableWebSocketMessageBroker
 class StompWebSocketConfig : WebSocketMessageBrokerConfigurer {

     override fun registerStompEndpoints(registry: StompEndpointRegistry) {
         registry.addEndpoint("/stomp/chat")
             .setAllowedOriginPatterns("*")
     }

     override fun configureMessageBroker(config: MessageBrokerRegistry) {
         config.enableSimpleBroker("/sub")
         config.setApplicationDestinationPrefixes("/pub")
     }

}

클라이언트는 처음 핸드쉐이크시 /stomp/chat 으로 핸드쉐이크 요청을 한다
/sub로 채팅방을 구독하고 메시지 발송은 /pub로 한다

StompChatController

@Controller
 class StompChatController(
     val template: SimpMessagingTemplate
 ) {
 
     @MessageMapping("/chat/message")
     fun message(message: ChatMessageDto){
         template.convertAndSend("/sub/chat/room/" + message.roomId, message)
         
     }
 } 

클라이언트가 보낼 메시지를 중간에서 전달해줄 Stomp Broker이다
SimpMessagingTemplate로 ChatMessageDto를 /sub 로 구독중인 채널에 전달해준다.

ChatMessageDto

data class ChatMessageDto(
     var roomId : String,
     var writer : String,
     var message : String
 )

기본 메시지 형식이다.
roomId = 이 메시지를 전달할 방 Id (SimpMessagingTemplate로가 전달해줌)
writer = 메시지 작성자
message = 메시지 내용

이렇게 세팅한 뒤

https://apic.app/online/#/tester

이 사이트에서 테스트 해보자.

참고로 로컬에선 문제가 없지만 서버가 배포된 상태라면 이 apic 클라이언트로 테스트를 못한다;; (알아만두자)

들어가서 ws를 클릭 후

Stomp 를 클릭하면 위와 같은 화면이 나올 것이다.

Request URL에 서버 주소와 기본 헨드쉐이크 주소를 입력해준다.

단 소켓은 기본 연결방식이 http:// 가 아닌 ws:// 를 사용한다
ex) ws://localhost:8080/stomp/chat

Subscription URL은 /sub 를 적어준 뒤 Connect를 클릭해보자.

왼쪽에 Stomp connected 가 뜬다면 성공한 것이다.

그럼 메시지를 보내보자

Send 부분에
Destination Queue 엔 보낼 주소 (우리는 /pub 이다) 를 적고
Json 칸엔 아까 DTO형식에 맞춰 메시지를 보내면

짠 이렇게 메시지가 올 것이다.

뭔가 다른건 기분탓이다가 아닌 내 DTO 형식이 조금 달라서 그렇다...

Subscription URL : /sub
Destination Queue : /pub

로 되있다면 메시지가 잘 도착하였을 것인데

사진을 보면 Subscription URL과 Destination Queue 가 다른걸 확인 할 수 있다.

저건 RabbitMQ라는 메시지 브로커를 사용하였기 때문이다.
RabbitMQ에 대해선 조금있다 알아보자.

이렇게 잘 전달이 되는것을 확인하였다면

이젠 JWT 로직을 추가해보자
각 프로젝트 설정에 따라 차이가 발생할 수 있다.

JWT 검증 로직 추가

StompWebSocketConfig

로 돌아가서 아래 코드를 추가하자

override fun configureClientInboundChannel(registration: ChannelRegistration) {
        registration.interceptors(object : ChannelInterceptor {
            override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> {
                val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)!!

                when (accessor.command) {
                    StompCommand.CONNECT -> {
                        val authToken = accessor.getNativeHeader("Authorization")?.firstOrNull()

                        if (authToken != null && authToken.startsWith("Bearer ")) {
                            val token = authToken.removePrefix("Bearer ")
                            val auth = jwtUtils.getAuthentication(token)

                            val userDetails = auth.principal as? JwtUserDetails

                            val userId: String? = userDetails?.id?.value?.toString()

                            if (userId != null) {
                                val simpAttributes = SimpAttributesContextHolder.currentAttributes()
                                simpAttributes.setAttribute("user-id", userId)

                                return MessageBuilder.createMessage(message.payload, accessor.messageHeaders)
                            } else {
                                throw IllegalStateException("유저 아이디를 찾을 수 없습니다.")
                            }
                        }
                    }
                    StompCommand.SEND,
                    StompCommand.DISCONNECT,
                    StompCommand.SUBSCRIBE,
                    StompCommand.STOMP,
                    StompCommand.UNSUBSCRIBE
                }
                return message
            }
        })
    }
}

STOPM Connect 시 Key : Value 형태로 헤더를 달 수 있다.
그래서 우린 Authorization : Bearer Token 형태로 JWT 를 전달받을것이다.

코드를 살펴보자

registration.interceptors 로 메시지를 가로챈 뒤 해더를 분석한다. Key가 Authorization 이며 Bearer로 시작하는 토큰을 가저와 JwtUserDetails로 검증하여 유저 PK값을 가져온다 그리고 그걸 다시 user-id 라는 key 에 user pk 값을 헤더로 담는다.

다시 StompChatController로 돌아가서 코드를 수정해보자
헤더에 담은 user-id 의 value 값을 가져와야한다.

StompChatController

@MessageMapping("chat.message")
    fun message(chat: ChatMessageDto) {
        val simpAttributes = SimpAttributesContextHolder.currentAttributes()
        val userId = simpAttributes.getAttribute("user-id") as String?
        chat.writer = userId // 임시적으로 보낸사람 값 수정하기
        template.convertAndSend("/sub/chat/room/" + message.roomId, message)
    }

simpAttributes에선 여러 값을 가져올 수 있는데 우린 헤더에 user-id 에 담긴 값을 userId로 만들고 그걸 chat.writer 에 넣어 메시지를 전달하면 서버측에서 유저 정보를 제어할 수 있는 코드가 되었다.

이를 응용하면 다양한 방법으로 채팅 서비스를 만들 수 있다.

💬 현재 코드는 언제까지나 예시 코드이다, 실제 프로젝트에선 수정하도록하자.

마치며

실제 서비스를 할땐 메시지 브로커 서버를 따로 두어 부하를 줄여야한다.
다음장에선 RabbitMQ를 활용하여 브로커 서버를 따로두는 방법을 서술하겠다.

0개의 댓글