이 연재글은 웹소켓(websocket)으로 채팅서버 만들기의 1번째 글입니다.

Spring에서 제공하는 Websocket을 이용하여 간단한 채팅 서버를 구현해 보도록 하겠습니다.

일반적인 http통신을 하는 서버들과 달리 채팅 서버는 socket통신을 하는 서버가 필요합니다. 통상적으로 http통신은 Client의 요청이 있을 때만 서버가 응답하고 연결을 종료하는 단방향 통신입니다. 따라서 클라이언트가 서버에 접속해 콘텐츠를 요청하고 결과를 받아 소비하는 구조의 서비스에서 많이 사용됩니다. 그에 반해 socket통신은 Server와 Client가 지속적으로 연결을 유지하고 양방향으로 통신을 하는 방식입니다. 주로 채팅 같은 실시간성을 요구하는 서비스에서 많이 사용됩니다. 

Websocket

Websocket은 기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜입니다. 일반 socket통신과 달리 HTTP 80 Port를 이용하므로 방화벽에 제약이 없으며 통상 Websocket으로 불립니다. 접속까지는 HTTP 프로토콜을 이용하고 그 이후의 통신은 자체적인 Websocket 프로토콜로 통신하게 됩니다.

Springboot Websocket 서버 구축

다음과 같이 Websocket서버를 구축합니다. 일반적인 boot구성과 별다를 게 없으므로 쉽게 구축 가능합니다.

신규 프로젝트를 초기 구성하는데 어려움이 있다면 Spring initializr를 통해 간편하게 프로젝트를 생성할 수 있습니다. 다음 포스팅을 참고해 주세요.

build.gradle에 라이브러리 추가

plugins {
    id 'org.springframework.boot' version '2.1.5.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.websocket'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

Application 설정

일반적인 Boot 서버와 동일합니다.

@SpringBootApplication
public class WebsocketchatApplication {
    public static void main(String[] args) {
        SpringApplication.run(WebsocketchatApplication.class, args);
    }
}

Websocket Handler 작성

socket통신은 서버와 클라이언트가 1:N으로 관계를 맺습니다. 따라서 한 서버에 여러 클라이언트가 접속할 수 있으며, 서버에는 여러 클라이언트가 발송한 메시지를 받아 처리해줄 Handler의 작성이 필요합니다. 다음과 같이 TextWebSocketHandler를 상속받아 Handler를 작성해 줍니다. Client로부터 받은 메시지를 Console Log에 출력하고 Client로 환영 메시지를 보내는 역할을 합니다.

package com.websocket.chat.handler;

// import 생략....

@Slf4j
@Component
public class WebSockChatHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload {}", payload);
        TextMessage textMessage = new TextMessage("Welcome chatting sever~^^");
        session.sendMessage(textMessage);
    }
}

Websocket Config 작성

위에서 만든 handler를 이용하여 Websocket을 활성화하기 위한 Config 파일을 작성합니다. @EnableWebSocket을 선언하여 Websocket을 활성화합니다. Websocket에 접속하기 위한 endpoint는 /ws/chat으로 설정하고 도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowedOrigins(“*”)를 설정을 추가해 줍니다. 이제 클라이언트가 ws://localhost:8080/ws/chat으로 커넥션을 연결하고 메시지 통신을 할 수 있는 기본적인 준비가 끝났습니다.

@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSockConfig implements WebSocketConfigurer {
    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
    }
}

Websocket 테스트

Websocket 테스트를 위한 클라이언트 웹 화면이 아직 없으므로 Chrome 웹스토어에서 Simple Websocket Client를 검색해 설치합니다.

https://chrome.google.com/webstore/search/websocket

Boot서버를 구동합니다. 그리고 Simple Websocket Client를 실행하여 URL란에 ws://localhost:8080/ws/chat을 입력한 다음 Open를 누릅니다. Websocket의 경우 별개의 프로토콜이므로 http가 아닌 ws로 시작하는 주소 체계를 갖습니다. 연결이 성공하면 Status가 OPENED로 변경됩니다.

연결된 상황에서 Request에 메시지를 입력하고 Send를 누르면 Message Log에는 클라이언트가 보낸 메시지는 주황색으로 Hello~가 찍히고 서버에서 답변을 보낸 메시지는 검은색 문자로 출력됩니다. 이로서 간단하게 Websocket을 통한 client – server 통신을 구현해 보았습니다.

채팅 고도화

위에서 만든 websocket 통신은 ws://localhost:8080/ws/chat에 연결된 클라이언트 끼리만 메시지 통신이 가능합니다. 간단히 말해 채팅방이 하나뿐인 채팅 서버입니다. 여러개의 채팅방을 만들어서 해당 채팅방에 입장한 클라이언트들간에 메시지를 교환하려면 고도화가 필요합니다. 그래서 다음과 같은 컨셉으로 채팅방을 구현해 보겠습니다.

클라이언트들은 서버에 접속하면 개별의 Websocket session을 가지게 됩니다. 따라서 채팅방에 입장시 클라이언트들의 Websocket session정보를 채팅방에 맵핑시켜서 보관하고 있으면 서버에 전달된 메시지를 특정 방의 Websocket 세션으로 보낼수 있으므로 개별의 채팅방을 구현할 수 있습니다.

채팅 메시지 구현

채팅 메시지를 주고받기 위한 DTO를 하나 만듭니다. 상황에 따라 채팅방 입장, 채팅방에 메시지 보내기 두가지 상황이 있으므로 ENTER(채팅방 입장), TALK(대화하기)을  enum으로 선언합니다. 나머지 멤버 필드는 채팅방 구별 id, 메시지를 보낸 사람, 메시지로 구성합니다.

@Getter
@Setter
public class ChatMessage {
    // 메시지 타입 : 입장, 채팅
    public enum MessageType {
        ENTER, TALK
    }
    private MessageType type; // 메시지 타입
    private String roomId; // 방번호
    private String sender; // 메시지 보낸사람
    private String message; // 메시지
}

채팅방 구현

채팅방을 구현하기 위해 DTO를 하나 만듭니다. 채팅방은 입장한 클라이언트들의 정보를 가지고 있어야 하므로 WebsocketSession 정보 리스트를 멤버 필드로 갖습니다. 나머지 멤버 필드로 채팅방 id, 채팅방 이름을 추가합니다. 채팅방에서는 입장, 대화하기의 기능이 있으므로 handleAction을 통해 분기 처리합니다. 입장 시에는 채팅룸의 session정보에 클라이언트의 session리스트에 추가해 놓았다가 채팅룸에 메시지가 도착할 경우 채팅룸의 모든 session에 메시지를 발송하면 채팅이 완성됩니다.

@Getter
public class ChatRoom {
    private String roomId;
    private String name;
    private Set<WebSocketSession> sessions = new HashSet<>();

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

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

    public <T> void sendMessage(T message, ChatService chatService) {
        sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
    }
}

채팅 서비스 구현

채팅방을 생성, 조회하고 하나의 세션에 메시지 발송을 하는 서비스를 아래와 같이 구현합니다. 채팅방 Map은 서버에 생성된 모든 채팅방의 정보를 모아둔 구조체입니다. 채팅룸의 정보 저장은 빠른구현을 위해 일단 외부 저장소를 이용하지 않고 HashMap에 저장하는 것으로 구현하였습니다.
채팅방 조회 – 채팅방 Map에 담긴 정보를 조회.
채팅방 생성 – Random UUID로 구별ID를 가진 채팅방 객체를 생성하고 채탕방 Map에 추가.
메시지 발송 – 지정한 Websocket 세션에 메시지를 발송.

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {

    private final ObjectMapper objectMapper;
    private Map<String, ChatRoom> chatRooms;

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

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

    public ChatRoom findRoomById(String roomId) {
        return chatRooms.get(roomId);
    }

    public ChatRoom createRoom(String name) {
        String randomId = UUID.randomUUID().toString();
        ChatRoom chatRoom = ChatRoom.builder()
                .roomId(randomId)
                .name(name)
                .build();
        chatRooms.put(randomId, 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);
        }
    }
}

채팅 컨트롤러 구현

채팅방의 생성 및 조회는 Rest api로 구현할 것이므로 아래와 같이 Controller를 생성하여 내용을 작성합니다..

@RequiredArgsConstructor
@RestController
@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();
    }
}

WebSocket Handler 수정

위에서 만든 채팅 로직을 handler에 추가합니다.

  • 웹소켓 클라이언트로부터 채팅 메시지를 전달받아 채팅 메시지 객체로 변환
  • 전달받은 메시지에 담긴 채팅방 Id로 발송 대상 채팅방 정보를 조회함
  • 해당 채팅방에 입장해있는 모든 클라이언트들(Websocket session)에게 타입에 따른 메시지 발송
@Slf4j
@RequiredArgsConstructor
@Component
public class WebSockChatHandler extends TextWebSocketHandler {
    private final ObjectMapper objectMapper;
    private final ChatService chatService;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload {}", payload);
// 삭제        TextMessage textMessage = new TextMessage("Welcome chatting sever~^^ ");
// 삭제       session.sendMessage(textMessage);
        ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
        ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
        room.handleActions(session, chatMessage, chatService);
    }
}

테스트

지금까지 만든 내용을 저장하고 서버를 재시작합니다.

채팅룸 생성

Postman으로 다음과 같이 채팅방을 생성합니다. 생성된 채팅룸id로 채팅방에 입장하여 메시지를 보내야하므로 결과의 roomId를 복사해 놓습니다.

채팅방 입장

Chrome에서 Simple websocket을 실행하고 ws://localhost:8080/ws/chat에 연결합니다.
그리고 다음과 같이 채팅방 입장을 위한 Json을 구성하여 websocket으로 발송합니다. roomId는 위에서 채팅방 생성 결과의 roomId를 입력합니다. 이때 해당 roomId의 채팅방에 session리스트에 클라이언트의 Websocket session을 저장하는 작업이 이루어집니다.

{
  "type":"ENTER",
  "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
  "sender":"happydaddy",
  "message":""
}

입장 메시지 Json을 입력한 후 Send를 누르면 아래처럼 메시지가 웹소켓 서버로 전달되고 웹소켓 서버에서는 채팅룸의 sesssion리스트에 있는 클라이언트 session에 입장 환영 메시지를 보냅니다. 따라서 아래와 같이 바로 응답이 오게 됩니다. 주황색은 클라이언트가 보낸 메시지, 검은색은 서버로부터 받은 메시지입니다 

// 메시지 발송
{
  "type":"ENTER",
  "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
  "sender":"happydaddy",
  "message":""
}
// 메시지 수신 
{
   "type":"ENTER",
   "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
   "sender":"happydaddy",
   "message":"happydaddy님이 입장했습니다."
}

채팅 메시지 발송

채팅 메시지 발송은 type을 TALK으로 해서 발송합니다. ENTER로 보낼 때와의 차이점은 ENTER는 입장한 클라이언트의 웹소켓 세션을 채팅룸에 저장하는 액션이 추가로 있다는 것입니다. TALK은 이미 입장한 클라이언트에게 메시지를 보내는 요청이므로 채팅룸이 가지고 있는 클라이언트 sesssion 리스트를 조회하여 메시지를 발송하는 액션만을 수행합니다.

{
  "type":"TALK",
  "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
  "sender":"happydaddy",
  "message":"안녕하세요"
}

채팅 메시지 Json 입력후 Send를 누르면 아래처럼 웹소켓 서버로 메시지가 전달되고 수신되는것을 확인할 수 있습니다.

// 메시지 발송
{
  "type":"TALK",
  "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
  "sender":"happydaddy",
  "message":"안녕하세요"
}
// 메시지 수신
{
   "type":"TALK",
   "roomId":"9e648d2d-5e2e-42b3-82fc-b8bef8111cbe",
   "sender":"happydaddy",
   "message":"안녕하세요"
}

Simple websocket client를 하나 더 띄워 아래처럼 서로 대화도 가능합니다.

하나의 서버에서 여러 개의 채팅방을 만들어 메시지를 교환해 보는 실습을 해보았습니다. 생각보다 방법이 좀 번거롭고 이해하기 쉽지 않은데요. 다음 장에서는 Stomp를 이용하여 채팅방을 좀 더 고도화해보도록 하겠습니다.

이번장에서 사용한 소스는 다음 Github에서 확인 가능합니다.

https://github.com/codej99/websocket-chat-server/tree/feature/basic-websocket-server

연재글 이동
[다음글] Spring websocket chatting server(2) – Stomp로 채팅서버 고도화하기