JAVA/Spring Boot
Spring WebFlux를 이용한 chat 프로그램 - WebSocket 설정
최강깜시
2025. 5. 8. 13:33
로그인 처리까지 완료가 되었으니 이제는 채팅을 위해 WebSocket 설정을 진행한다.
WebSocketConfig.java
package com.company.config.websocket;
import java.util.HashMap;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import com.company.config.websocket.handler.ChatWebSocketHandler;
/**
* WebSocket 핸들러를 등록하고, Spring WebFlux 환경에서 WebSocket 요청을 처리할 수 있도록
* 필요한 설정을 구성하는 Configuration
*
* 주요 역할:
* - WebSocket 요청 경로("/ws/chat")에 대한 핸들러(ChatWebSocketHandler) 등록
* - WebSocket 요청 처리를 위한 핸들러 어댑터(WebSocketHandlerAdapter) 등록
*/
@Configuration
public class WebSocketConfig {
/**
* WebSocket 요청 경로에 따라 핸들러 매핑 설정
* 클라이언트가 "/ws/chat" 경로로 websocket 연결 요청을 보내면
* 해당 요청은 ChatWebSocketHandler에서 처리하도록 한다.
*
* @param handler
* @return
*/
@Bean HandlerMapping handlerMapping(ChatWebSocketHandler handler) {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/ws/chat/{roomId}", handler); // 동적 경로 지원은 불가 → 접두어만 등록
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(-1);
return mapping;
}
@Bean WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
채팅방을 만들고 채팅방마다 들어가는 방식으로 처리할 예정이기 때문 roomId 까지를 접두어로 등록한다.
ChatWebSocketHandler.java
package com.company.config.websocket.handler;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
import com.company.chat.service.ChatMessageService;
import com.company.common.database.document.ChatMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Sinks.EmitResult;
/**
* WebSocket을 통해 들어오는 메시지를 브로드캐스트(모든 클라이언트에게 전송)하는 핸들러
* - 클라이언트가 메시지를 보내면 Sinks.Many를 통해 모든 연결된 세션에 해당 메시지 전송
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class ChatWebSocketHandler implements WebSocketHandler {
// 채팅방별 메시지 브로드캐스트 sink를 저장하는 맵
private final Map<String, Sinks.Many<String>> roomSinkMap = new ConcurrentHashMap<>();
private final ChatMessageService chatMessageService;
private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱
/**
* 클라이언트와 WebSocket 연결이 수립되면 호출되는 메서드
*
* @param session 현재 연결된 WebSocket 세션 객체
* @return Mono<Void> 비동기적으로 연결을 처리
*/
@Override
public Mono<Void> handle(WebSocketSession session) {
String roomId = extractRoomId(session);
// 해당 채팅방 sink가 없으면 생성
Sinks.Many<String> roomSink = roomSinkMap.computeIfAbsent(
roomId,
key -> Sinks.many().replay().limit(1) // 마지막 메시지 1개까지 리플레이
);
// 서버가 클라이언트로 전송할 메시지 스트림
Flux<WebSocketMessage> outgoing = roomSink.asFlux() // Sink로부터 메시지 Flux 스트림 획득
.map(session::textMessage); // 문자열을 WebSocket 텍스트 메시지로 변환
// 클라이언트로부터 수신되는 메시지 스트림
Flux<String> incoming = session.receive()
.map(WebSocketMessage::getPayloadAsText).doOnNext(json -> {
try {
/*
* json 형태로 전송된 메시지 내용을 Object형태로 변환한다.
*/
ChatMessage msg = objectMapper.readValue(json, ChatMessage.class);
msg.setTimestamp(LocalDateTime.now());
msg.setRoomId(Long.parseLong(roomId));
// MongoDB 저장
chatMessageService.save(msg).subscribe();
// 브로드캐스트
EmitResult result = roomSink.tryEmitNext(json);
log.info("Emit result: {}", result.name()); // 디버깅을 위해
} catch (Exception e) {
log.error("메시지 파싱 실패: {}", e.getMessage());
}
});
// 서버 → 클라이언트 메시지 전송
return session.send(outgoing).and(incoming.then());
}
/**
* 세션 경로에서 채팅방 ID 추출 (예: /ws/chat/12345에서 12345 추출)
*
* @param session
* @return
*/
private String extractRoomId(WebSocketSession session) {
String path = session.getHandshakeInfo().getUri().getPath(); // ex) /ws/chat/1
return path.substring(path.lastIndexOf("/") + 1);
}
}
일잔적으로 WebSocket을 사용하는 방식으로 나는 SimpMessagingTemplate를 사용했었다.
그런데 ChatGPT에게 물어 서 작성을 하다 보니 WebSocketHandler을 사용하도록 알려 줘서 여기서는 WebSocketHandler을 사용한다.
둘의 차이점은
SimpMessagingTemplate | WebSocketHandler | |
레벨 | 고수준 (STOMP, pub/sub) | 저수준 (순수 WebSocket) |
쓰임새 | 메시지 발송, 구독, 브로드캐스트 쉽게 | 수신/송신 직접 제어, 커스텀 처리 |
필요조건 | STOMP/WebSocketMessageBroker 설정 필요 | WebSocketConfigurer 등록 |
코드 스타일 | 깔끔, 단순 | 복잡, 자유도 높음 |
비유 | 택배회사 호출해서 배송시키기 | 직접 들고 뛰는 거 |
이렇단다..ㅋ