개요
실시간 토론 앱 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를 활용한 기본적인 채팅 서버를 구현해봤다. 이 예제는 학습목적으로 최소한의 기능만 구현했기 때문에, 실제 프로젝트에서는 추가적인 기능을 고려해야 할 거 같다.
- 메시지 핸들러와 예외 처리
- STOMP 에러 핸들링
- 연결 끊김 처리 및 자동 재연결
- 메시지 유효성 검증 로직
- 보안 기능
- JWT 기반 사용자 인증/인가
- 채팅방 접근 권한 관리
- WebSocket 연결 인증 처리 (WebSocketHandlerDecoratorFactory 구현)
- 데이터 영속성
- 성능 최적화
- 메시지 큐 도입 (RabbitMQ, Kafka)
- WebSocket 연결 풀 관리
- 모니터링
- 연결 상태 모니터링
- 메시지 처리량 측정
- 오류 로깅 및 알림
- 추가 기능
- 파일/이미지 첨부 기능
- 알림 시스템 연동
추가적으로 Node.js와 Socket.io를 이용한 동일한 채팅 기능도 구현해보고, 두 기술 스택 모두 장단점이 있어서 직접 구현해보고 비교 분석하는게 중요할 것 같다.