
💡 Spring Boot와 Websocket만을 사용하여 실시간 채팅을 구현하여 보자
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
}
< WebsocketChatHandler >
/* 설명.
WebSocket Handler 추가
Socket은 서버와 클라이언트가 1:N 관계로 연걸되기 때문에, 힌 서버에 여러 클라이언트들 접속하여 전송한 메세지를 처리해줄 Handler 필요
TextWebSocketHandler를 상속받아서 Handler 생성
*/
@Component
@Slf4j
public class WebSocketChatHandler extends TextWebSocketHandler {
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// Client가 전송한 Message의 본문을 담는다. (json형태)
String payload = message.getPayload();
log.info(payload);
// Client와 Server가 연결되어 있는 객체를 "session"이라고 하고,
// 아래는 session을 통해 서버가 클라이언트에게 보낼 메시지를 담는다.
TextMessage textMesaage = new TextMessage("Hello World!");
session.sendMessage(textMesaage);
}
}
< WebSocketConfig>
/* 설명.
Websocket Config 설정
WebsocketChatHandler를 이요하여 websocket을 활성화하기 위한 config 파일 작성
endpoint 작성 => /ws/chat
cors 처리 => setAllowedOrigins("*")
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketChatHandler webSocketChatHandler;
@Autowired
public WebSocketConfig(WebSocketChatHandler webSocketChatHandler) {
this.webSocketChatHandler = webSocketChatHandler;
}
// Client와 Server를 연결할 Handler 등록
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// "ws/chat"로 endpoint를 설정한다. (클라이언트가 연결 생성시, ws://localhost:8080/ws/chat으로 요청을 보내야함)
// setAllowedOrigins("*") => 모든 도메인으로 부터의 접근 허용 ( CORS )
registry.addHandler(webSocketChatHandler, "ws/chat").setAllowedOrigins("*");
}
}

< WebSocketChatHandler >
@Component
@Slf4j
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChatService chatService;
@Autowired
public WebSocketChatHandler(ObjectMapper objectMapper, ChatService chatService) {
this.objectMapper = objectMapper;
this.chatService = chatService;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// Client가 전송한 Message의 본문을 담는다. (json형태)
String payload = message.getPayload();
// 사용자가 입력한 문자열이 찍힘 (Ex. hello )
log.info(payload);
// json으로 넘어오는 Payload를 message로 배분해서 넣어준다.
ChatMessageDTO chatMessage = objectMapper.readValue(payload, ChatMessageDTO.class);
// 넘어오는 message에 있는 roomId로 채팅방과 연결한다.
ChatRoomDTO chatRoom = chatService.findRoomById(chatMessage.getRoomId());
chatRoom.handleActions(session, chatMessage, chatService);
}
}
< WebsocketConfig >
위의 코드와 동일
< ChatMessageDTO >
@Getter
@Setter
public class ChatMessageDTO {
// 메시지 타입: 입장(ENTER), 채팅(TALK)
private MessageType type;
// 채팅방 번호
private String roomId;
// 전송자
private String sender;
// 메시지
private String message;
public enum MessageType {
ENTER, TALK
}
}
< ChatRoomDTO >
@Getter
public class ChatRoomDTO {
// 채팅방 번호
private String roomId;
// 채팅방 이름
private String name;
// 입장한 클라이언트의 정보 ( WebsocketSession 정보 list ) => HashSet을 이용하여 중복 세션 추가를 방지한다.
private Set<WebSocketSession> sessions = new HashSet<>();
// @Setter대신 Builder 패턴 적용하여 불변성 유지
@Builder
public ChatRoomDTO(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
// 클라이언트의 action(ENTER, TALK) 처리 => roomId를 서비스로 부터 조회하여 json에 담긴 메시지를 전달해주는 메소드
public void handleActions(WebSocketSession session, ChatMessageDTO chatMessageDTO, ChatService chatService){
if (chatMessageDTO.getType().equals(ChatMessageDTO.MessageType.ENTER)){
// 세션에 연결
sessions.add(session);
chatMessageDTO.setMessage(chatMessageDTO.getSender() + "님이 입장했습니다.");
}
sendMessage(chatMessageDTO, chatService);
}
// 채팅방에 존재하는 모든 클라이언트들에게 해당 메시지 전송
public <T> void sendMessage(T message, ChatService chatService) {
// parallelStream()은 여러 스레드를 사용해 데이터를 병렬로 처리한다.( stream보다 빠름 )
sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
}
}
💡 @PostConstruct 기능
- 빈(Bean) 생성과 의존성 주입이 완료 된 후 실행되도록 하는 설정
- Bean의 라이프 사이클 내에서 1번만 실행되는 것을 보장 ( 채팅방 정보를 저장하는 Map은 여러번 선언하면 리소스 낭비 증가)
- 동시성 문제 예방 ( 애플리케이션 시작 지점에 초기화하여, 여러 사용자가 동시에 접속하여 발생하는 동시성 문제 예방)
- 명시적인 초기화 시점 제공 ( 초기화 코드의 의도가 더 명확해짐)
< ChatService >
public interface ChatService {
<T> void sendMessage(WebSocketSession session, T message);
// 채팅방 생성 메소드
ChatRoomDTO createRoom(String name);
// 모든 채팅방을 조회하는 메소드
List<ChatRoomDTO> findAllRooms();
ChatRoomDTO findRoomById(String roomId);
}
< ChatServiceImpl >
@Service
@Slf4j
public class ChatServiceImpl implements ChatService {
private final ObjectMapper objectMapper; // java -> json or json -> java 해주는 역할
// key값인 String은 roomId, value값으로 chatRoomDTO가 들어간다. - 여러 채팅방들의 정보를 저장하는 Map이다.
private Map<String, ChatRoomDTO> chatRooms;
@Autowired
public ChatServiceImpl(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
// Bean 생성과 의존성 주입을 모두 완료한 뒤에 실행되는 메소드 (안전한 초기화 보장, 1번만 실행됨을 보장, 동시성 문제 예방)
@PostConstruct // @PostConstruct는 의존성 주입이 모두 완료된 후 초기화를 수행하는 메소드
private void init(){
chatRooms = new LinkedHashMap<>();
}
// 채팅방 생성 메소드
@Override
public ChatRoomDTO createRoom(String name) {
// 채팅방 번호는 랜덤으로 배정
String randomRoomId = UUID.randomUUID().toString();
ChatRoomDTO chatRoom = ChatRoomDTO.builder()
.roomId(randomRoomId)
.name(name)
.build();
// 새로운 채팅방이 개설되면 chatRooms(모든 채팅방 정보를 가진 Map)에 추가해줌
chatRooms.put(randomRoomId, chatRoom);
return chatRoom;
}
// 모든 채팅방을 조회하는 메소드
@Override
public List<ChatRoomDTO> findAllRooms(){
return new ArrayList<>(chatRooms.values());
}
// roomId로 채팅방을 찾는 메소드
@Override
public ChatRoomDTO findRoomById(String roomId) {
// key 값으로 방을 찾아 value를 받아간다.
return chatRooms.get(roomId);
}
// 메세지의 type이 TALK일 경우 메세지를 보내는 메소드
@Override
public <T> void sendMessage(WebSocketSession session, T message) {
try{
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
} catch(IOException e){
log.error(e.getMessage(), e);
}
}
}
< ChatController >
@RestController("chatWebsocketController")
@RequestMapping("/chat/websocket")
public class ChatController {
private final ChatService chatService;
@Autowired
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping("")
public ChatRoomDTO createRoom(@RequestBody String name){
return chatService.createRoom(name);
}
@GetMapping("")
public List<ChatRoomDTO> findAllRoom(){
return chatService.findAllRooms();
}
}


WebSocket은 양방향 통신을 지원하지만, 불안정한 네트워크 환경에서는 연결 상태를 정확히 파악하는 것이 어렵다. 클라이언트나 서버가 연결 해제 요청을 보내지 않거나 네트워크 문제로 인해 정상적인 종료가 되지 않는 경우, 이를 감지하고 적절한 조치를 취해야 한다.
WebSocket에서는 주기적인 ping/pong 메시지를 활용하여 연결 상태를 확인할 수 있다.
ping을 보내면, 상대는 반드시 pong을 응답해야 한다.ping/pong을 주고받지 못하면 연결을 끊는다.ping/pong을 비동기적으로 처리하면, 다른 메시지 송수신을 방해하지 않는다.Ping 주기를 결정하는 것은 중요한 문제이며, 공식적인 IETF(Internet Engineering Task Force) 가이드라인이 명확하지 않다. 몇 가지 사례를 참고하면:
60초 이상으로 설정되므로, ping 주기는 이보다 짧아야 한다.WebSocket-Node: 기본 ping 간격 20초IETF 권장: 25초Spring SockJS 기본값: 25초RabbitMQ: 5~20초 권장이러한 기준을 참고하여 서비스 특성에 맞는 ping 주기를 설정해야 한다.
WebSocket의 ping(0x9)과 pong(0xA) 프레임은 애플리케이션 데이터를 포함할 수 있다.
이를 활용하면 단순한 연결 유지 외에도 데이터 동기화 기능을 수행할 수 있다.
ping에 데이터를 포함시킬 수 있다.pong을 반환하며, 이에 대한 응답을 포함할 수도 있다.하지만 실시간성이 매우 중요한 애플리케이션(예: 주식 거래, 실시간 게임 등)에서는 초 단위의 차이도 문제될 수 있으므로, 이 방법을 활용하기 어렵다.
ping/pong을 활용해야 한다.ping 프레임에 데이터를 포함하여 동기화 기능을 수행할 수도 있지만, 실시간성이 중요한 서비스에서는 적절하지 않을 수 있다.즉, WebSocket에서는 단순한 연결 유지뿐만 아니라 효율적인 통신을 위해 ping/pong을 전략적으로 활용할 필요가 있다.
참조 블로그
https://rhgustmfrh.tistory.com/15
https://jaeseo0519.tistory.com/411