개요

실시간 토론 앱 TocKing을 개발하기 전에 채팅 기능 구현 방법을 팀원들과 공유하기 위해 이 예제를 만들었다. Socket 통신은 클라이언트와 서버 간 양방향 통신을 가능하게 하는 기술이다. 전통적인 HTTP 통신과 달리 연결을 계속 유지하면서 실시간으로 데이터를 주고받을 수 있어 채팅, 알림, 실시간 게임 등에 적합하다. 실시간 통신 구현 방법으로는 여러 방식이 있다.

  • 폴링(Polling): 클라이언트가 주기적으로 서버에 요청
  • 롱 폴링(Long Polling): 서버가 이벤트 발생할 때까지 응답을 지연
  • 서버-센트 이벤트(SSE): 서버에서 클라이언트로 단방향 이벤트 스트림
  • WebSocket: 양방향 전이중 통신 채널

이 블로그에서는 Spring Boot(Kotlin)와 WebSocket + STOMP를 활용한 실시간 채팅 구현 방법을 다룬다. STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 메시징 프로토콜로, 메시지 라우팅과 구독 개념을 쉽게 적용할 수 있어 복잡한 채팅 시스템 구현에 유리하다.

build.gradle.kts

dependencies {
	...
    implementation("org.springframework.boot:spring-boot-starter-websocket")
    ...
}

 

WebSocket + STOMP 기능을 사용하기 위해 라이브러리를 추가해 주었다. 해당 라이브러리는 다음 기능들을 포함하고 있다.

  • WebSocket 서버 구현
  • STOMP 메시징 프로토콜 지원
  • SockJS 폴백 메커니즘 (WebSocket을 지원하지 않는 브라우저용)

프로젝트구조

이 예제는 학습과 팀 공유 목적으로 만든 간단한 구현이라 DB연동 없이 진행했다. 채팅방과 메시지 데이터는 모두 서버 메모리(인메모리)에 저장되는 방식으로 구현했다. ChatRoomService 에서 Map 으로 채팅방 정보만 관리 하고 있다.

WebSocketConfig

/**
 * WebSocket 설정 클래스
 * 
 * @Configuration: 스프링의 설정 클래스임을 나타냄
 * @EnableWebSocketMessageBroker: WebSocket 메시지 브로커 기능을 활성화
 */
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig: WebSocketMessageBrokerConfigurer {

    /**
     * 메시지 브로커 설정
     *
     * setApplicationDestinationPrefixes("/pub"): 클라이언트에서 서버로 메시지 발행(publish)할 때 사용할 주소 접두사 설정
     * 즉, 클라이언트가 메시지를 보낼 때는 /pub로 시작하는 주소로 메시지를 보냄
     * 
     * enableSimpleBroker("/sub"): 해당 주소를 구독(subscribe)하는 클라이언트에게 메시지를 전달하는 간단한 인메모리 브로커 설정
     * 즉, 서버에서 클라이언트로 메시지를 보낼 때는 /sub로 시작하는 주소로 메시지를 발송
     */
    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.setApplicationDestinationPrefixes("/pub")
        registry.enableSimpleBroker("/sub")
    }

    /**
     * STOMP 엔드포인트 등록
     * 
     * addEndpoint("/ws-chat"): 웹소켓 연결 엔드포인트 설정 (/ws-chat 경로로 웹소켓 연결 가능)
     * setAllowedOriginPatterns("*"): CORS 설정으로 모든 도메인에서의 접근 허용
     * withSockJS(): WebSocket을 지원하지 않는 브라우저를 위한 SockJS 폴백 옵션 활성화
     * 
     * 두 번째 엔드포인트(/ws-chat-native)는 네이티브 앱에서의 접근을 위한 설정으로,
     * 특정 앱 스키마(app://com.example.chatapp)만 접근 허용하고 SockJS는 사용하지 않음
     */
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry
            .addEndpoint("/ws-chat")
            .setAllowedOriginPatterns("*")
            .withSockJS()

        registry
            .addEndpoint("/ws-chat-native")
            .setAllowedOriginPatterns("app://com.example.chatapp")
    }
}

 

 

ChatMessage

/**
 * 채팅 메시지를 표현하는 데이터 클래스
 * 
 * @property roomId 메시지가 속한 채팅방의 ID
 * @property sender 메시지 발신자 이름
 * @property message 메시지 내용
 * @property type 메시지 타입(기본값: TALK)
 * 
 */
data class ChatMessage(
    val roomId: String,
    val sender: String,
    val message: String,
    val type: MessageType = MessageType.TALK,
)

/**
 * 메시지 타입을 정의하는 열거형
 * 
 * ENTER: 사용자가 채팅방에 입장했을 때 발생하는 메시지 타입
 * TALK: 일반적인 대화 메시지 타입
 * LEAVE: 사용자가 채팅방을 퇴장했을 때 발생하는 메시지 타입
 * 
 * 클라이언트는 이 타입에 따라 다른 UI 처리를 할 수 있음
 */
enum class MessageType {
    ENTER, TALK, LEAVE
}

 

ChatRoom

/**
 * 채팅방을 표현하는 데이터 클래스
 * 
 * @property roomId 채팅방의 고유 식별자
 * 
 * roomId에 UUID를 기본값으로 설정해 객체 생성 시 자동으로 고유한 ID가 부여됨
 */
data class ChatRoom(
    val roomId: String = UUID.randomUUID().toString(),
)

 

ChatController

/**
 * 채팅 메시지를 처리하는 컨트롤러
 */
@Controller
class ChatController {

    /**
     * 특정 채팅방으로 들어오는 메시지를 처리하는 메서드
     * 
     * @MessageMapping("/{roomId}"): 클라이언트가 /pub/{roomId} 주소로 메시지를 보내면 이 메서드가 처리
     *                             (WebSocketConfig에서 "/pub" 접두사 설정했기 때문)
     * @SendTo("/sub/{roomId}"): 처리 결과를 /sub/{roomId} 주소를 구독 중인 모든 클라이언트에게 전송
     * 
     * @param roomId 메시지가 전송된 채팅방 ID (URL 경로에서 추출)
     * @param chatMessage 클라이언트가 전송한 메시지 객체
     * @return 모든 구독자에게 전달할 메시지 객체
     */
    @MessageMapping("/{roomId}")
    @SendTo("/sub/{roomId}")
    fun message(@DestinationVariable("roomId") roomId: String, chatMessage: ChatMessage) : ChatMessage {

        /**
         * 메시지 타입에 따라 다른 처리를 수행
         * 여기서는 간단히 로그만 출력하지만, 실제 서비스에서는 DB 저장, 알림 발송 등의 작업을 수행할 수 있음
         */
        when (chatMessage.type) {
            MessageType.ENTER -> println("User ${chatMessage.sender} entered room $roomId")
            MessageType.LEAVE -> println("User ${chatMessage.sender} left room $roomId")
            MessageType.TALK -> println("Message from ${chatMessage.sender} in room $roomId: ${chatMessage.message}")
        }

        // 받은 메시지를 그대로 구독자들에게 반환
        return chatMessage
    }
}

 

ChatRoomController

/**
 * 채팅방 관리를 위한 REST API 컨트롤러
 */
@RestController
@RequestMapping("/rooms")
class ChatRoomController(
    private val chatRoomService: ChatRoomService
) {

    /**
     * 모든 채팅방 목록을 조회하는 API
     */
    @GetMapping
    fun getRooms(): List<ChatRoom> {
        return chatRoomService.findAllRooms()
    }

    /**
     * 새로운 채팅방을 생성하는 API
     */
    @PostMapping("/create")
    fun createChatRoom(): ChatRoom {
        return chatRoomService.createRoom()
    }

    /**
     * 특정 ID의 채팅방을 조회하는 API
     */
    @GetMapping("/{roomId}")
    fun getRoom(@PathVariable roomId: String): ChatRoom? {
        return chatRoomService.findRoomById(roomId)
    }

    /**
     * 특정 ID의 채팅방을 삭제하는 API
     */
    @DeleteMapping("/{roomId}")
    fun deleteRoom(@PathVariable roomId: String) {
        chatRoomService.deleteRoom(roomId)
    }
}

 

ChatRoomService

/**
 * 채팅방 관리를 위한 서비스 클래스
 * 이 예제에서는 DB 연동 없이 인메모리 방식으로 채팅방 정보를 관리함
 */
@Service
class ChatRoomService() {

    /**
     * 채팅방 정보를 저장하는 인메모리 맵
     * 키: 채팅방 ID, 값: 채팅방 객체
     * 
     * 애플리케이션이 재시작되면 모든 데이터가 초기화됨
     */
    private val chatRooms: MutableMap<String, ChatRoom> = HashMap()

    /**
     * 모든 채팅방 목록을 조회하는 메서드
     */
    fun findAllRooms(): List<ChatRoom> {
        return ArrayList(chatRooms.values)
    }

    /**
     * 특정 ID의 채팅방을 조회하는 메서드
     */
    fun findRoomById(roomId: String): ChatRoom? {
        return chatRooms[roomId]
    }

    /**
     * 새로운 채팅방을 생성하는 메서드
     * @return 생성된 채팅방 객체 (자동 생성된 UUID를 roomId로 가짐)
     */
    fun createRoom(): ChatRoom {
        val chatRoom = ChatRoom()
        chatRooms[chatRoom.roomId] = chatRoom
        return chatRoom
    }

    /**
     * 특정 ID의 채팅방을 삭제하는 메서드
     */
    fun deleteRoom(roomId: String) {
        chatRooms.remove(roomId)
    }
}

 

마무리

지금까지 Spring Boot 와 WebSocket + STOMP를 활용한 기본적인 채팅 서버를 구현해봤다. 이 예제는 학습목적으로 최소한의 기능만 구현했기 때문에, 실제 프로젝트에서는 추가적인 기능을 고려해야 할 거 같다.

  1. 메시지 핸들러와 예외 처리
    1. STOMP 에러 핸들링
    2. 연결 끊김 처리 및 자동 재연결
    3. 메시지 유효성 검증 로직
  2. 보안 기능
    1. JWT 기반 사용자 인증/인가
    2. 채팅방 접근 권한 관리
    3. WebSocket 연결 인증 처리 (WebSocketHandlerDecoratorFactory 구현)
  3. 데이터 영속성
  4. 성능 최적화
    1. 메시지 큐 도입 (RabbitMQ, Kafka)
    2. WebSocket 연결 풀 관리
  5. 모니터링
    1. 연결 상태 모니터링
    2. 메시지 처리량 측정
    3. 오류 로깅 및 알림
  6. 추가 기능
    1. 파일/이미지 첨부 기능
    2. 알림 시스템 연동

추가적으로 Node.js와 Socket.io를 이용한 동일한 채팅 기능도 구현해보고, 두 기술 스택 모두 장단점이 있어서 직접 구현해보고 비교 분석하는게 중요할 것 같다.

+ Recent posts