본문 바로가기
백엔드/스프링

[Spring] WebSocket 요약 정리

by AI읽어주는남자 2025. 9. 15.
반응형

Spring Boot WebSocket 요약 정리 (day02 & day03 복습 자료)

이 문서는 day02의 기본 채팅과 day03의 다중 채팅방 구현을 바탕으로 Spring Boot에서 WebSocket을 설정하고 사용하는 방법을 요약합니다.

1. WebSocket 기본 개념

WebSocket은 단일 TCP 연결을 통해 서버와 클라이언트 간의 전이중(full-duplex) 통신을 제공하는 프로토콜입니다. HTTP와 달리 연결이 계속 유지되며, 양방향으로 실시간 데이터 전송이 필요할 때 (예: 채팅, 실시간 알림) 사용됩니다.


2. 핵심 구성 요소

Spring에서 WebSocket을 구현하기 위한 두 가지 핵심 요소는 WebSocketConfigurerTextWebSocketHandler입니다.

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 라이브러리를 사용하여 Java Map 객체와 JSON String 간의 변환을 쉽게 처리합니다.
// 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 기준)

  1. 설정: 서버가 시작되면 WebSocketConfig/chat 엔드포인트를 ChatSocketHandler에 매핑합니다.
  2. 연결: 클라이언트가 ws://서버주소/chat으로 WebSocket 연결을 요청하면 afterConnectionEstablished가 호출됩니다.
  3. 입장: 클라이언트는 연결 후 { "type":"join", "room":"1", "nickName":"유재석" }과 같은 JSON 메시지를 서버로 보냅니다.
  4. 메시지 수신 (handleTextMessage):
    • 서버는 메시지를 받고 ObjectMapper로 파싱합니다.
    • typejoin이므로, 해당 room 번호를 key로 하여 Map에서 접속자 목록을 찾습니다.
    • 목록이 없으면 새로 만들고, 있으면 기존 목록에 클라이언트 세션(session)을 추가합니다.
    • session.getAttributes()roomnickName 정보를 저장합니다.
    • 같은 방의 모든 클라이언트에게 "유재석이 입장했습니다."와 같은 alarm 메시지를 보냅니다.
  5. 채팅: 클라이언트가 { "type":"msg", "message":"안녕하세요" }와 같은 메시지를 보냅니다.
    • 서버는 handleTextMessage에서 메시지를 받고 typemsg임을 확인합니다.
    • 메시지를 보낸 클라이언트의 session에서 room 번호를 가져옵니다.
    • Map에서 해당 room의 접속자 목록을 찾아, 목록에 있는 모든 클라이언트에게 받은 메시지를 그대로 전달합니다.
  6. 퇴장: 클라이언트가 연결을 끊으면 afterConnectionClosed가 호출됩니다.
    • 서버는 해당 sessionAttributes에서 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
반응형