반응형
Spring Boot WebSocket 요약 정리 (day02 & day03 복습 자료)
이 문서는 day02의 기본 채팅과 day03의 다중 채팅방 구현을 바탕으로 Spring Boot에서 WebSocket을 설정하고 사용하는 방법을 요약합니다.
1. WebSocket 기본 개념
WebSocket은 단일 TCP 연결을 통해 서버와 클라이언트 간의 전이중(full-duplex) 통신을 제공하는 프로토콜입니다. HTTP와 달리 연결이 계속 유지되며, 양방향으로 실시간 데이터 전송이 필요할 때 (예: 채팅, 실시간 알림) 사용됩니다.
2. 핵심 구성 요소
Spring에서 WebSocket을 구현하기 위한 두 가지 핵심 요소는 WebSocketConfigurer와 TextWebSocketHandler입니다.
1) WebSocket 설정 (WebSocketConfigurer)
- 역할: WebSocket을 활성화하고, 어떤 URL 요청을 어떤 핸들러가 처리할지 등록하는 설정 클래스입니다.
- 주요 어노테이션:
@Configuration: 이 클래스가 Spring의 설정 파일임을 나타냅니다.@EnableWebSocket: WebSocket 기능을 활성화합니다.
- 핵심 메소드:
registerWebSocketHandlers- 이 메소드를 오버라이드하여 WebSocket 핸들러와 엔드포인트(URL)를 매핑합니다.
// day03/WebSocketConfig.java
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
// DI
private final ChatSocketHandler chatSocketHandler;
// 1. 서버웹소켓(핸들러) 객체를 스프링이 알 수 있게 경로 등록
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// registry.addHandler( 서버웹소켓객체 , '경로' );
// "/chat" 경로로 오는 WebSocket 요청을 chatSocketHandler가 처리하도록 등록
registry.addHandler(chatSocketHandler , "/chat");
}
}
2) WebSocket 핸들러 (TextWebSocketHandler)
- 역할: WebSocket 연결, 메시지 수신, 연결 종료 등 실제 이벤트가 발생했을 때 실행될 로직을 정의하는 클래스입니다.
TextWebSocketHandler를 상속받아 텍스트 기반의 메시지를 처리합니다.
3. TextWebSocketHandler의 주요 생명주기 메소드
TextWebSocketHandler를 상속받으면 다음과 같은 주요 메소드를 오버라이드하여 사용할 수 있습니다.
1) afterConnectionEstablished(session)
- 시점: 클라이언트가 WebSocket 서버에 성공적으로 연결되었을 때.
- 주요 로직: 접속한 클라이언트(
WebSocketSession)를 접속자 명단에 추가합니다.
// day02/ChatHandler.java - 단일 채팅방
private static final List<WebSocketSession> list = new Vector<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("[서버] 클라이언트소켓과 연동 성공");
list.add(session); // 접속자 명단에 추가
}
2) handleTextMessage(session, message)
- 시점: 클라이언트로부터 텍스트 메시지를 수신했을 때.
- 주요 로직: 받은 메시지를 파싱하고, 특정 조건에 따라 다른 클라이언트들에게 메시지를 전송(브로드캐스팅)합니다.
// day02/ChatHandler.java - 단일 채팅방
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("[메시지확인] " + message.getPayload());
// 접속된 모든 클라이언트에게 받은 메시지를 그대로 전송
for (WebSocketSession clientSocket : list){
clientSocket.sendMessage(message);
}
}
3) afterConnectionClosed(session, status)
- 시점: 클라이언트와의 WebSocket 연결이 끊어졌을 때.
- 주요 로직: 접속자 명단에서 해당 클라이언트를 제거합니다.
// day02/ChatHandler.java - 단일 채팅방
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
System.out.println("[서버] 클라이언트소켓과 연동 종료");
list.remove(session); // 접속자 명단에서 제거
}
4. 발전 과정: 단일 채팅방(day02) vs 다중 채팅방(day03)
day03 예제는 day02의 기본 구조를 확장하여 더 실용적인 기능을 구현합니다.
1) 접속자 관리: List -> Map
- day02:
List<WebSocketSession>를 사용하여 모든 접속자를 하나의 목록으로 관리합니다. (모두가 같은 채팅방에 있음) - day03:
Map<String, List<WebSocketSession>>를 사용하여 채팅방 별로 접속자 목록을 관리합니다.key는 방 번호(room),value는 해당 방에 접속한 클라이언트List가 됩니다. 이를 통해 다중 채팅방 구현이 가능해집니다.
// day03/ChatSocketHandler.java
private static final Map<String , List<WebSocketSession>> list = new Hashtable<>();
2) 메시지 형식: String -> JSON
- day02: 단순 텍스트(
String) 메시지를 주고받습니다. - day03: JSON 형식의 문자열을 사용합니다. 이를 통해 메시지
type(join,msg,alarm), 보낸 사람(nickName), 내용(message) 등 구조화된 데이터를 주고받을 수 있습니다.ObjectMapper라이브러리를 사용하여 JavaMap객체와 JSONString간의 변환을 쉽게 처리합니다.
// day03/ChatSocketHandler.java
// JSON 문자열을 Map으로 변환
Map<String , String> msg = objectMapper.readValue(message.getPayload() , Map.class);
// 메시지 타입에 따라 다른 로직 처리
if (msg.get("type").equals("join")) {
// 입장 처리
} else if (msg.get("type").equals("msg")) {
// 메시지 처리
}
3) 세션에 부가 정보 저장
- day03:
session.getAttributes().put("key", "value")를 사용하여 각 클라이언트 세션에 부가적인 정보(메타데이터)를 저장할 수 있습니다. 예제에서는room(방 번호)과nickName(닉네임)을 저장하여, 어떤 클라이언트가 어느 방에 어떤 이름으로 있는지 식별합니다.
// day03/ChatSocketHandler.java - 입장 시
// 세션에 방 번호와 닉네임 저장
session.getAttributes().put("room" , room);
session.getAttributes().put("nickName" , nickName);
// day03/ChatSocketHandler.java - 퇴장 시
// 세션에서 방 번호와 닉네임 가져오기
String room = (String) session.getAttributes().get(("room"));
String nickName = (String) session.getAttributes().get("nickName");
5. 다중 채팅방 전체 흐름 요약 (day03 기준)
- 설정: 서버가 시작되면
WebSocketConfig가/chat엔드포인트를ChatSocketHandler에 매핑합니다. - 연결: 클라이언트가
ws://서버주소/chat으로 WebSocket 연결을 요청하면afterConnectionEstablished가 호출됩니다. - 입장: 클라이언트는 연결 후
{ "type":"join", "room":"1", "nickName":"유재석" }과 같은 JSON 메시지를 서버로 보냅니다. - 메시지 수신 (
handleTextMessage):- 서버는 메시지를 받고
ObjectMapper로 파싱합니다. type이join이므로, 해당room번호를key로 하여Map에서 접속자 목록을 찾습니다.- 목록이 없으면 새로 만들고, 있으면 기존 목록에 클라이언트 세션(
session)을 추가합니다. session.getAttributes()에room과nickName정보를 저장합니다.- 같은 방의 모든 클라이언트에게 "유재석이 입장했습니다."와 같은
alarm메시지를 보냅니다.
- 서버는 메시지를 받고
- 채팅: 클라이언트가
{ "type":"msg", "message":"안녕하세요" }와 같은 메시지를 보냅니다.- 서버는
handleTextMessage에서 메시지를 받고type이msg임을 확인합니다. - 메시지를 보낸 클라이언트의
session에서room번호를 가져옵니다. Map에서 해당room의 접속자 목록을 찾아, 목록에 있는 모든 클라이언트에게 받은 메시지를 그대로 전달합니다.
- 서버는
- 퇴장: 클라이언트가 연결을 끊으면
afterConnectionClosed가 호출됩니다.- 서버는 해당
session의Attributes에서room번호를 확인합니다. Map에서 해당room의 목록을 찾아, 목록에서 해당session을 제거합니다.- 같은 방의 남은 클라이언트들에게 "유재석이 퇴장했습니다."와 같은
alarm메시지를 보냅니다.
- 서버는 해당
1) day02 ChatHandler
package example.day02;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;
@Component // 스프링 컨테이너(메모리)에 빈(객체) 등록
public class ChatHandler extends TextWebSocketHandler {
// *** 서버에 접속된 클라이언트 소켓 접속자 명단 목록 *** //
private static final List<WebSocketSession> list = new Vector<>();
// ArrayList 동기화 안됨 , Vector 동기화 됨. : 채팅은 동시다발적 요청이 있으므로 동기화
// 1. 클라이언트 소켓이 서버소켓으로부터 연결을 성공했을 때 실행되는 메소드
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("[서버] 클라이언트소켓과 연동 성공");
// WebSocketSession이란 : 서버로부터 요청한 클라이언트 정보가 저장된 객체
// HttpSession이란 : http 기반으로 클라이언트가 요청한 정보가 저장된 객체
System.out.println("[클라이언트 웹소켓 객체]" + session);
// 1) 접속된 클라이언트 소켓들을 저장 : 받은 메시지를 접속된 소켓들에게 재전송하기
list.add(session); // 서버와 접속 성공한 클라이언트 소켓(세션)을 리스트에 저장
}
// 2. 클라이언트 소켓이 서버소켓으로부터 연결이 끊겼을 때 실행되는 메소드
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
System.out.println("[서버] 클라이언트소켓과 연동 종료");
// 1) 클라이언트 소켓과 연결이 끊어졌을때 접속명단에서 제외
list.remove(session);
}
// 3. 클라이언트 소켓이 서버소켓에게 메시지를 보냈을때 실행되는 메소드
// JS의 onmessage로 전송한다.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("[서버] 클라이언트로부터 메시지가 도착");
System.out.println("[메시지확인] " + message.getPayload());
// [*] 서버가 클라이언트에게 메시지 보내기
// session.sendMessage( new TextMessage("Loud and Clear"));
// 메시지를받을세션.sendMessage( );
// 서버로부터 메시지를 보내온 클라이언트 소켓에게 메시지를 보내는 예제
// 1) 클라이언트 소켓이 보내온 메시지를 받아, 현재 접속된 다른 클라이언트 소켓들에게 보내기
// 김현수 --> 안녕하세요 --> 카카오톡서버 --> 김재영
// --> 김현수
// 서버는 다른 클라이언트와 보낸 클라이언트 모두에게 메시지를 보냄
for (WebSocketSession clientSocket : list){
// 접속명단(list)내 저장(접속)된 클라이언트 소켓들을 하나씩 꺼낸다.
// 클라이언트 소켓 하나씩 .sendMessage 함수를 이용한 서버가 받은 메시지를 보내준다.
clientSocket.sendMessage(message);
}
} // func end
} // class end
2) day02 WebSocketConfig
package example.day02;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
// ws 프로토콜 통신이 왔을 때 특정한 핸들러(클래스)로 매핑/연결
@Configuration // 스프링 컨테이너(메모리) 빈(객체) 등록
@EnableWebSocket // 웹소켓 사용 활성화
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
// implements : 구현체. 오버라이딩(재정의) 해줘야 돌아감
// DI
final private ChatHandler chatHandler; // 서버웹소켓 객체
// 1. 서버웹소켓(핸들러) 객체를 스프링이 알 수 있게 경로를 등록한다.
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 개발자가 만든 서버웹소켓(핸들러)을 주소와 함께 등록한다.
// registry.addHandler( 서버웹소켓객체 , "경로" );
registry.addHandler(chatHandler , "/chat");
}
} // class end
3) day03 ChatSocketHandler
package example.day03;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.*;
// 서버소켓 역할
@Component // MVC 패턴은 아니지만 스프링 컨테이너(메모리)의 빈(객체) 등록
public class ChatSocketHandler extends TextWebSocketHandler {
// * 접속된 클라이언트소켓 명단 목록
private static final Map<String , List<WebSocketSession>> list = new Hashtable<>();
// { 0 : [ "유재석" , "강호동" ] , 1 : [ "서장훈" , "김희철" ] }
// key : 방번호 , value : 해당 key(방)의 접속된 클라이언트들(리스트)
// [*] JSON 타입을 자바 타입으로 변환해주는 라이브러리 객체 , ObjectMapper
// 주요 메소드
// 1. objectMapper.readValue ( json문자열 , Map.class ) : 문자열(json) -> Map
// 2. objectMapper.writeValueAsString( map객체 ) : Map객체 -> 문자열(json)
private final ObjectMapper objectMapper = new ObjectMapper();
// [1] 클라이언트소켓과 서버소켓이 연동 되었을때 이벤트
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("*클라이언트소켓* 입장");
}
// [2] 클라이언트소켓과 서버소켓이 연동 끊겼을 때 이벤트
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
System.out.println("*클라이언트소켓* 퇴장");
// 2-1 : 접속이 끊긴 세션(클라이언트소켓) 정보를 확인한다.
String room = (String) session.getAttributes().get(("room")); // Object 타입이라, (String)으로 타입변환
String nickName = (String) session.getAttributes().get("nickName"); // 강제타입변환 : (새로운타입)값
// => 닉네임은 유효성 검사 위한 것이지 별로 안 중요함
// 2-2 : 만약에 방과 닉네임이 일치한 데이터가 접속명단에 존재하면
if ( room != null && nickName != null) {
List<WebSocketSession> dataList = list.get(room); // 해당 방의 key(방 번호) 접속(목록) 꺼내기
dataList.remove( session ); // 접속자 세션 만료시키기
// 2-3 : 세션 제거를 성공했을 때 알림 메시지 보내기
alarmMessage(room , nickName + "이 퇴장했습니다.");
} // 2-2 if end
// 저장 불러오기
// Map컬렉션 : .put( key , value ) .get( key )
// List컬렉션 : .add( value ) .get( 인덱스 )
} // func end
// [3] 클라이언트소켓으로부터 메시지 받았을때 이벤트
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("*클라이언트소켓*으로부터 메시지 받음");
// 3-1 : 클라이언트 보낸 메시지
System.out.println(message.getPayload()); // 메시지 확인
// 3-2 : JSON 형식은 자바 기준으로 모르기 때문에 JSON 형식을 Map 타입으로 변환
Map<String , String> msg = objectMapper.readValue(message.getPayload() , Map.class);
// 3-3 : 만약 메시지 타입이 'join'이면
if (msg.get("type").equals("join")){
String room = msg.get("room"); // 방 번호
String nickName = msg.get("nickName"); // 접속자 닉네임
// 3-4 : 현재 메시지를 보내온 클라이언트소켓(세션)에 부가정보(방 번호와 접속자 닉네임) 추가 , 로그인 세션과 비슷
session.getAttributes().put("room" , room); // 브라우저세션 vs HTTP세션 vs 웹소켓세션
session.getAttributes().put("nickName" , nickName);
// 3-5 : 접속 명단에 등록하기
if( list.containsKey( room ) ) { // 만약 등록할 방 번호(key)이 존재하면
list.get( room ).add( session );
} else { // 존재하지 않으면 방 생성해야함
List<WebSocketSession> dataList = new Vector<>();
dataList.add(session); // 새로운 목록에 세션 추가
list.put( room , dataList ); // 새로운 방번호(key) 새로운 목록(list)을 map(접속명단)에 추가
}
System.out.println(list); // 같은 방에 여러 유저가 들어있을 때 확인
//{1=[StandardWebSocketSession[id=d9611d90-58e9-9cbb-58dc-024ec71566df, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=bcc4043b-065e-af8a-e2d9-9a426e62eec7, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=b975e2e0-ce0c-a4ed-6ebf-f9982b852806, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=fbfd9841-cc15-6e38-7cfc-caf6e037451c, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=f79375fb-3817-10ae-7ffa-1797f012b510, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=d17dac14-fbe9-2737-e45f-a4bff0ad568a, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=2cb6e23e-dd00-3cd0-33f7-af9b1159c401, uri=ws://localhost:8080/chat],
// StandardWebSocketSession[id=5e8c0481-f95e-e574-73c5-08e032f8baa9, uri=ws://localhost:8080/chat]],
// 0=[StandardWebSocketSession[id=ffb54eaa-8513-a191-52ff-9c299e979918, uri=ws://localhost:8080/chat]]}
// 한번에 여러 세션이 들어왔음을 알 수 있다.
// 3-6 : 접속한 닉네임을 [4] 알림메시지 보내기
alarmMessage( room , nickName + "이 입장했습니다.");
} // 3-3 if end
// 3-7 : 만약 메시지에서 타입(type)이 'msg'이면
else if ( msg.get("type").equals("msg")) {
// 3-8 : 메시지를 보낸 세션의 방 번호 확인
String room = (String) session.getAttributes().get("room");
// 3-9 : 같은 방에 위치한 모든 세션들에게 받은 메시지 보내기
for ( WebSocketSession client : list.get(room) ){
client.sendMessage(message); // 서버가 받은 메시지를 클라이언트들에게 다시 전달
} // for end
} // 3-7 end
} // func end
// [4] 개발자가 만든 서비스 메소드 , 접속[3-6]/퇴장[2-3] 했을 때 실행
public void alarmMessage( String room , String message ) throws Exception {
// String room : 몇 번 방에? , String message : 메시지내용
// throws : 예외처리 던지기 , 해당 메소드에서 모든 예외/오류를 해당 메소드를 호출한 곳으로 반환함 , try/catch로 대체 가능
// 4-1 : 보내고자하는 정보를 map 타입으로 구성
Map<String , String> msg = new HashMap<>();
msg.put("type" , "alarm");
msg.put("message" , message);
// 4-2 : map 타입을 JSON 형식으로 변환 , objectMapper
String sendMsg = objectMapper.writeValueAsString( msg );
// 4-3 : 현재 같은 방에 위치한 모든 세션들에게 '알람' 메시지 보내기
for (WebSocketSession session : list.get(room) ){
session.sendMessage( new TextMessage( sendMsg ));
}
}
} // class end
4) day03 WebSocketConfig
package example.day03;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
// DI
private final ChatSocketHandler chatSocketHandler;
// 1. 서버웹소켓(핸들러) 객체를 스프링이 알 수 있게 경로 등록
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// registry.addHandler( 서버웹소켓객체 , '경로' );
registry.addHandler(chatSocketHandler , "/chat");
}
} // class end
반응형
'백엔드 > 스프링' 카테고리의 다른 글
| [Spring] 5장: Spring Security를 이용한 인증과 인가 (0) | 2025.09.18 |
|---|---|
| [Spring] 4장: JPA와 Spring Data JPA로의 도약 (0) | 2025.09.18 |
| [Spring] 3장: MyBatis를 이용한 데이터베이스 연동 (0) | 2025.09.18 |
| [Spring] 2장: Spring MVC와 웹 요청 처리 (0) | 2025.09.18 |
| [Spring] 1장: Spring Boot와 제어의 역전(IoC) / 의존성 주입(DI) (0) | 2025.09.18 |
| [Spring] Spring Boot 로드맵: 실무 역량을 갖춘 백엔드 개발자로 거듭나기 (0) | 2025.09.18 |
| [Spring] 자바 스프링 프레임워크 개론 (0) | 2025.09.16 |
| [Spring] MyBatis 어노테이션 기반 문법 (0) | 2025.09.15 |