WebSocket

김준영·2023년 8월 29일
1

WebSocket

목록 보기
1/1
post-thumbnail

웹소켓


웹 기술 중 하나로, 양방향 통신을 지원하는 프로토콜입니다.
웹 브라우저와 서버간의 지속적인 연결을 통해 실시간 데이터를 주고받을 수 있게 해주는 기술입니다.
웹소켓은 HTTP 프로토콜과는 다르게 단일 연결을 통해 계속적인 데이터 교환을 할 수 있어서, 실시간성이 중요한 애플리케이션에서 유용하게 사용됩니다.

HTTP와 차이점

  1. 양방향 통신: HTTP는 클라이언트가 서버에 요청을 보내면 서버가 응답을 주는 단방향 통신입니다. 웹소켓은 클라이언트와 서버 간에 양방향 통신이 가능하며, 양쪽 모두 데이터를 주고받을 수 있습니다.
  2. 상태 유지: HTTP는 각 요청과 응답 사이에 상태를 유지하지 않습니다. 즉, 클라이언트가 서버에 요청을 보낼 때마다 매번 새로운 연결이 만들어집니다. 반면 웹소켓은 연결을 유지하며, 상태 정보를 계속 유지할 수 있습니다.
  3. 헤더 오버헤드 감소: HTTP 요청 및 응답은 매번 헤더 정보가 포함되어 전송됩니다. 웹소켓은 연결을 한 번 맺으면 추가적인 헤더 정보가 필요하지 않아 오버헤드가 감소합니다.

웹소켓 동작 방식


  1. 핸드셰이크: 웹소켓 연결을 수립하려면 클라이언트와 서버 간에 핸드셰이크 과정을 거쳐야 합니다. 클라이언트는 HTTP 요청을 보내고, 서버는 이에 대한 응답을 반환합니다. 이후에는 양쪽 간의 웹소켓 연결이 확립됩니다.
  2. 데이터 전송: 연결이 확립되면 양쪽은 언제든 데이터를 주고받을 수 있습니다. 클라이언트나 서버 어느 한쪽이 데이터를 보내면, 상대편은 이를 수신하고 필요한 처리를 수행합니다.
  3. 연결 종료: 양쪽 중 하나가 연결을 종료하려면 연결을 닫는 과정을 거칩니다. 이는 클라이언트나 서버가 명시적으로 연결을 닫거나, 예기치 않은 상황에서 연결이 끊어졌을 때 발생할 수 있습니다.

웹소켓 활용


웹소켓은 실시간 채팅 애플리케이션, 주식 시세 업데이트, 온라인 게임 등 실시간 정보 전달이 필요한 많은 영역에서 사용됩니다. 대부분의 주요 웹 브라우저와 서버 플랫폼은 웹소켓을 지원하며, 여러 프로그래밍 언어와 라이브러리에서 웹소켓을 쉽게 구현할 수 있도록 도와줍니다.

Spring Boot + WebSocket

간단한 이론을 봤으며 이제 간단히 Spring Boot와 WebSocket을 통해 채팅을 구현해보자

블로그를 보고 공부했으며 참조한 블로그를 링크로 남기겠습니다.

의존성 추가

//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'

//view
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-devtools'
implementation 'org.webjars.bower:bootstrap:4.3.1'
implementation 'org.webjars.bower:vue:2.5.16'
implementation 'org.webjars.bower:axios:0.17.1'
implementation 'com.google.code.gson:gson:2.8.0'

위처럼 WebSocket 의존성을 추가해주었습니다.
웹소켓을 Spring Boot 프로젝트에서 사용할 때, 웹소켓 라이브러리 외에도 웹 애플리케이션의 프론트엔드 부분에서도 관련 라이브러리 및 리소스를 사용해야 할 수 있습니다. 이때 웹 프론트엔드 라이브러리를 관리하기 위해 웹자들(WebJars)을 사용합니다.

WebSocketHandler

소켓 통신은 서버와 클라이언트의 1:N 관계를 맺습니다.

따라서 여러 클라이언트가 발송한 메시지를 받아 처리해줄 핸들러가 필요합니다.
TextWebSocketHadnler를 상속받습니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {

    private final ChatService chatService;
    private final ObjectMapper objectMapper;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload : {}", payload);

        Message msg = objectMapper.readValue(payload, Message.class);
        ChatRoom chatRoom = chatService.findById(msg.getRoomId());

        chatRoom.handleActions(session, msg, chatService);
    }
}

서비스와 메시지를 DTO로 매핑을 위해 ObjectMapper를 주입받습니다.
그 후, TextWebSocketHandler의 메서드를 Override하여 재작성하겠습니다.

  • payload는 클라이언트로부터 받은 메세지입니다.
  • payload를 Message클래스로 매핑합니다.
  • 메세지에 포함된 방 번호를 사용해 채팅서비스의 findById를 통해 ChatRoom을 찾습니다.
  • 그 후, chatRoom에 handleActions를 통해 메세지를 전송합니다.

WebSocketConfig

위에서 작성한 Handler를 이용해서 WebSocket을 활성화하기 위한 Config 파일을 작성합니다.

@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOriginPatterns("*");

    }
}
  • @EnableWebSocket을 선언하여 WebSocket을 사용합니다.
  • WebSocket에 접속하기 위한 엔드포인트는 "/ws/chat"으로 설정합니다.
  • 다른 도메인에서도 접속이 가능하도록 CORS를 모든 도메인을 허용하도록 설정합니다.

Message

채팅 메세지를 주고받기 위한 DTO

@Getter
@Setter
public class Message {
    public enum MessageType {
        ENTER, TALK
    }

    private MessageType messageType;
    private String roomId;
    private String sender;
    private String message;
}
  • MessageType을 ENTER, TALK를 사용하는 enum 타입으로 구현하였습니다.
  • 방 번호, 수신자, 메세제로 구성되었습니다.

ChatRoom

채팅방 클래스

@Getter
@Setter
public class ChatRoom {

    private String roomId;
    private Set<WebSocketSession> sessions = new HashSet<>();

    @Builder
    public ChatRoom(String roomId) {
        this.roomId = roomId;
    }

    public void handleActions(WebSocketSession session, Message message, ChatService chatService) {
        if (message.getMessageType().equals(Message.MessageType.ENTER)) {
            sessions.add(session);
            message.setMessage(message.getSender() + "님이 입장했습니다.");
        }

        sendMessage(message, chatService);
    }

    public <T> void sendMessage(T message, ChatService chatService) {
        sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
    }
}
  • 채팅방 아이디와 사용자들의 Session 정보를 저장할 HashSet을 사용하였다.
  • @Builder를 이용해서 채팅방을 생성한다.
  • 채팅방에는 입장 / 통신 기능이 있으므로 handleAction을 통해 분기 처리한다.
  • 입장 시에는 채팅방의 session 정보 리스트에 클라이언트를 추가하고, 채팅방에 메세지를 보낼 경우 채팅방의 모든 Session에 메세지를 발송한다.

public <T> void sendMessage(T message, ChatService chatService) {
}

여기서 <T>는 제네릭(Generic) 타입을 나타내는 표현입니다. 제네릭은 클래스나 메서드를 선언할 때 타입을 매개변수화하여 여러 종류의 타입에 대해 동작하도록 만들 수 있는 기능입니다. 제네릭을 사용함으로써 코드의 재사용성과 유연성을 높일 수 있습니다.

여기서의 <T>는 메서드 sendMessage의 매개변수인 message의 타입을 지칭합니다. 실제로 메서드가 호출될 때, T는 실제 타입으로 대체됩니다. 즉, sendMessage 메서드를 호출할 때 어떤 타입의 message가 넘어오더라도 해당 타입에 맞게 메서드가 작동하게 됩니다.

예를 들어, 만약 sendMessage("Hello", chatService)를 호출한다면 T는 String 타입으로 대체되어 메시지의 타입이 String이 되고, sendMessage(42, chatService)를 호출한다면 T는 Integer 타입으로 대체되어 메시지의 타입이 Integer가 됩니다.

이렇게 제네릭을 사용하면 같은 기능을 하는 코드를 다양한 타입에 대해 재사용할 수 있으며, 컴파일 시 타입 안정성을 보장받을 수 있습니다.

ChatService

채티방 생성, 조회, 메세지 발송을 하는 서비스 클래스입니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
    private final ObjectMapper objectMapper;
    private Map<String, ChatRoom> chatRoomMap;

    @PostConstruct
    private void init() {
        chatRoomMap = new LinkedHashMap<>();
    }

    public List<ChatRoom> findAllRoom() {
        return new ArrayList<>(chatRoomMap.values());
    }

    public ChatRoom findById(String roomId) {
        return chatRoomMap.get(roomId);
    }

    public ChatRoom createRoom(String name) {
        ChatRoom chatRoom = ChatRoom.builder().roomId(name).build();
        chatRoomMap.put(name, chatRoom);
        return chatRoom;
    }

    public <T> void sendMessage(WebSocketSession session, T message) {
        try {
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
}
  • 채팅방은 테스트를 하기위해 DB가 아닌 Map을 사용해서 메모리에 저장하는 형식으로 작동합니다.
  • findAllRoom: 모든 채팅방을 조회하는 로직입니다.
  • findById: 채팅방 ID를 통해 채팅방을 찾는 로직입니다.
  • createRoom: 채팅방 이름을 통해 채팅방을 생성하고, 저장소에 저장하는 로직입니다.
  • sendMessage: Message를 String형식으로 변경하고 새로운 TextMessage를 만들어 해당 세션에 전송하는 로직입니다.

ChatController

Rest API로 구현하였습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {

    private final ChatService chatService;

    @PostMapping
    public ChatRoom createRoom(@RequestParam String name) {
        return chatService.createRoom(name);
    }

    @GetMapping
    public List<ChatRoom> findAllRoom() {
        return chatService.findAllRoom();
    }
}

채팅방 생성과 조회만 할 수 있도록 했습니다.

동작방식

클라이언트로부터 메세지 받음 →
WebSocketHandler에서 메세지 처리 →
메세지를 Message 클래스로 매핑 →
채팅방 ID를 통해 해당 채팅방을 찾음 →
해당 채티방의 handleActions를 통해 입장인지, 통신인지 분기 처리 →
그 후 메세지를 채팅방에 저장되어있는 Session(클라이언트들)에게 모두 전송 → ChatService의 SendMessage 메서드 사용

이런 순서로 동작을 합니다.

테스트

https://chrome.google.com/webstore/detail/simple-websocket-client/pfdhoblngboilpfeibdedpjgfnlcodoo/related?hl=ko
링크를 통해 Websocket 테스트를 할 수 있도록 추가해줍니다.

우선 포스트맨을 통해 채팅방을 생성합니다.

아까 작성한 엔드포인트를 작성해준 후,

{
"messageType":"ENTER",
"roomId":"chatRoom1",
"sender":"sender1",
"message":"hi"
}

요청에 위와 같이 작성한 후, Send를 누르면 서버로 보내집니다.
주황색 글씨는 클라이언트가 보낸 요청이고, 검은 글씨는 서버에서 보내준 응답 메세지입니다.

MessageType을 ENTER로 했기때문에 hi라는 메세지가 아닌 입장 메세지를 응답 받았습니다.

Type을 TALK으로 변경하고 다시 보내면 위와 같이 응답한다.

이제 창을 두 개 띄어 채팅방에서 이야기를 나눠보자

위 사진처럼 채팅을 할 수 있다.

profile
ㅎㅎ

0개의 댓글