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

이번 장에서는 채팅방의 기능을 좀 더 고도화하는 실습을 진행하겠습니다. 기존 채팅방에서는 메시지 전달이 무조건 클라이언트에서 서버 측으로 전달된 후에 처리되었는데요. 이번에는 서버에서 처리할 수 있는 메시지와 클라이언트에서 처리할 수 있는 메시지를 구분하여 좀 더 효율적으로 프로세스를 개선해 보겠습니다. 그리고 채팅방 입장 시 클라이언트의 숫자를 표시할 수 있도록 기능을 추가해보겠습니다. 

  • 채팅방 입장/퇴장시 알림 메시지를 서버에서 처리하도록 개선
  • 채팅방 입장/퇴장시 인원수 표시

채팅 메시지는 현재 ENTER(입장), TALK(대화) 두 가지 타입을 가지고 있습니다. 여기에 QUIT(퇴장)을 추가하여 총 3가지 타입을 사용하도록 합니다. 그리고 채팅방에 메시지가 전달될 때 인원수 정보도 포함되도록 하여 실시간으로 인원수가 갱신되도록 합니다. 

채팅방 인원수의 갱신은 입장, 퇴장 이벤트에 한 번씩만 갱신해도 됩니다. 다만 채팅방에 입장한 모든 클라이언트가 입장, 퇴장 이벤트를 수신 못하는 경우를 대비하여 비효율적이긴 하지만 메시지가 수신될 때마다 갱신하도록 처리하겠습니다.

ChatMessage DTO에 userCount 및 메시지 타입 QUIT 추가

@Getter
@Setter
public class ChatMessage {
    
    public ChatMessage() {
    }

    @Builder
    public ChatMessage(MessageType type, String roomId, String sender, String message, long userCount) {
        this.type = type;
        this.roomId = roomId;
        this.sender = sender;
        this.message = message;
        this.userCount = userCount;
    }

    // 메시지 타입 : 입장, 퇴장, 채팅
    public enum MessageType {
        ENTER, QUIT, TALK
    }
    
    private MessageType type; // 메시지 타입
    private String roomId; // 방번호
    private String sender; // 메시지 보낸사람
    private String message; // 메시지
    private long userCount; // 채팅방 인원수, 채팅방 내에서 메시지가 전달될때 인원수 갱신시 사용
}

메시지 타입 중 입장과 퇴장은 안내 메시지의 성격을 띠고 있습니다. 그런데 현재는 입장 메시지를 클라이언트가 서버 측에 보내어 처리하게 되어있습니다. 안내 메시지는 공통적인 부분이므로 클라이언트가 메시지를 보내기보단 서버에서 일괄적으로 처리하는 것이 좋습니다. 실습에서는 클라이언트의 입장/퇴장 이벤트를 서버에서 체크하여 직접 안내 메시지를 보내도록 프로세스를 개선해 보겠습니다. 

이전 장에서 추가한 StompHandler에서는 클라이언트의 액션에 따른 이벤트를 다음 정보로 캐치할 수 있습니다.

  • 채팅방 입장시 이벤트 : StompCommand.SUBSCRIBE
  • 채팅방 퇴장시 이벤트 : StompCommand.DISCONNECT

채팅방 입장/퇴장 시 이벤트를 체크할 수 있으므로 입장/퇴장 시 채팅룸의 인원수도 +-처리가 가능합니다. Redis에 관련 정보를 저장하고 불러오는 방식으로 요구 사항을 구현해 보겠습니다. 

  • 채팅방 입장시 채팅방 인원수를 +1 갱신하여 캐시에 저장합니다. 이때 해당 클라이언트 세션이 어떤 채팅방에 들어가있는지 sessionId와 roomId를 조합하여 캐시를 남겨놓습니다.
  • 채팅방 퇴장시 채팅방 인원수를 -1 갱신하여 캐시에 저장합니다. 퇴장시에는 해당 클라이언트 세션이 어떤 방에서 퇴장된것인지 정보를 알수 없으므로 위에서 저장한 sessionId와 roomId를 조합한 캐시에서 채팅방 정보를 얻어 인원수를 -1로 갱신합니다.

위의 로직이 추가된 StompHandler입니다. 자세한 내용은 소스의 주석을 참고 하세요.

public class StompHandler implements ChannelInterceptor {

    private final JwtTokenProvider jwtTokenProvider;
    private final ChatRoomRepository chatRoomRepository;
    private final ChatService chatService;

    // websocket을 통해 들어온 요청이 처리 되기전 실행된다.
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
            String jwtToken = accessor.getFirstNativeHeader("token");
            log.info("CONNECT {}", jwtToken);
            // Header의 jwt token 검증
            jwtTokenProvider.validateToken(jwtToken);
        } else if (StompCommand.SUBSCRIBE == accessor.getCommand()) { // 채팅룸 구독요청
            // header정보에서 구독 destination정보를 얻고, roomId를 추출한다.
            String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
            // 채팅방에 들어온 클라이언트 sessionId를 roomId와 맵핑해 놓는다.(나중에 특정 세션이 어떤 채팅방에 들어가 있는지 알기 위함)
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            chatRoomRepository.setUserEnterInfo(sessionId, roomId);
            // 채팅방의 인원수를 +1한다.
            chatRoomRepository.plusUserCount(roomId);
            // 클라이언트 입장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
            log.info("SUBSCRIBED {}, {}", name, roomId);
        } else if (StompCommand.DISCONNECT == accessor.getCommand()) { // Websocket 연결 종료
            // 연결이 종료된 클라이언트 sesssionId로 채팅방 id를 얻는다.
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
            // 채팅방의 인원수를 -1한다.
            chatRoomRepository.minusUserCount(roomId);
            // 클라이언트 퇴장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
            // 퇴장한 클라이언트의 roomId 맵핑 정보를 삭제한다.
            chatRoomRepository.removeUserEnterInfo(sessionId);
            log.info("DISCONNECTED {}, {}", sessionId, roomId);
        }
        return message;
    }
}

채팅방에 관련된 데이터를 한군데서 처리하기 위하여 ChatRepository 내용을 다음과 같이 수정합니다. 자세한 내용은 소스의 주석을 참고 하세요.

public class ChatRoomRepository {
    // Redis CacheKeys
    private static final String CHAT_ROOMS = "CHAT_ROOM"; // 채팅룸 저장
    public static final String USER_COUNT = "USER_COUNT"; // 채팅룸에 입장한 클라이언트수 저장
    public static final String ENTER_INFO = "ENTER_INFO"; // 채팅룸에 입장한 클라이언트의 sessionId와 채팅룸 id를 맵핑한 정보 저장

    @Resource(name = "redisTemplate")
    private HashOperations<String, String, ChatRoom> hashOpsChatRoom;
    @Resource(name = "redisTemplate")
    private HashOperations<String, String, String> hashOpsEnterInfo;
    @Resource(name = "redisTemplate")
    private ValueOperations<String, String> valueOps;

    // 모든 채팅방 조회
    public List<ChatRoom> findAllRoom() {
        return hashOpsChatRoom.values(CHAT_ROOMS);
    }

    // 특정 채팅방 조회
    public ChatRoom findRoomById(String id) {
        return hashOpsChatRoom.get(CHAT_ROOMS, id);
    }

    // 채팅방 생성 : 서버간 채팅방 공유를 위해 redis hash에 저장한다.
    public ChatRoom createChatRoom(String name) {
        ChatRoom chatRoom = ChatRoom.create(name);
        hashOpsChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }

    // 유저가 입장한 채팅방ID와 유저 세션ID 맵핑 정보 저장
    public void setUserEnterInfo(String sessionId, String roomId) {
        hashOpsEnterInfo.put(ENTER_INFO, sessionId, roomId);
    }

    // 유저 세션으로 입장해 있는 채팅방 ID 조회
    public String getUserEnterRoomId(String sessionId) {
        return hashOpsEnterInfo.get(ENTER_INFO, sessionId);
    }

    // 유저 세션정보와 맵핑된 채팅방ID 삭제
    public void removeUserEnterInfo(String sessionId) {
        hashOpsEnterInfo.delete(ENTER_INFO, sessionId);
    }

    // 채팅방 유저수 조회
    public long getUserCount(String roomId) {
        return Long.valueOf(Optional.ofNullable(valueOps.get(USER_COUNT + "_" + roomId)).orElse("0"));
    }

    // 채팅방에 입장한 유저수 +1
    public long plusUserCount(String roomId) {
        return Optional.ofNullable(valueOps.increment(USER_COUNT + "_" + roomId)).orElse(0L);
    }

    // 채팅방에 입장한 유저수 -1
    public long minusUserCount(String roomId) {
        return Optional.ofNullable(valueOps.decrement(USER_COUNT + "_" + roomId)).filter(count -> count > 0).orElse(0L);
    }
}

채팅 메시지 발송을 일원화 하기 위해 ChatService를 다음과 같이 생성합니다.

package com.websocket.chat.service;

// import 생략...

@RequiredArgsConstructor
@Service
public class ChatService {

    private final ChannelTopic channelTopic;
    private final RedisTemplate redisTemplate;
    private final ChatRoomRepository chatRoomRepository;

    /**
     * destination정보에서 roomId 추출
     */
    public String getRoomId(String destination) {
        int lastIndex = destination.lastIndexOf('/');
        if (lastIndex != -1)
            return destination.substring(lastIndex + 1);
        else
            return "";
    }

    /**
     * 채팅방에 메시지 발송
     */
    public void sendChatMessage(ChatMessage chatMessage) {
        chatMessage.setUserCount(chatRoomRepository.getUserCount(chatMessage.getRoomId()));
        if (ChatMessage.MessageType.ENTER.equals(chatMessage.getType())) {
            chatMessage.setMessage(chatMessage.getSender() + "님이 방에 입장했습니다.");
            chatMessage.setSender("[알림]");
        } else if (ChatMessage.MessageType.QUIT.equals(chatMessage.getType())) {
            chatMessage.setMessage(chatMessage.getSender() + "님이 방에서 나갔습니다.");
            chatMessage.setSender("[알림]");
        }
        redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessage);
    }
}

서버에서 보내는 메시지(입장, 퇴장) 이외의 대화 메시지는 ChatController에서 처리합니다. 다음과 같이 내용을 수정합니다. 입장 시 메시지 처리 로직이 없어져서 기존 로직에 비해 간소화되었고, ChatService.sendChatMessage를 사용하여 메서드가 일원화되었습니다. 

public class ChatController {

    private final JwtTokenProvider jwtTokenProvider;
    private final ChatRoomRepository chatRoomRepository;
    private final ChatService chatService;

    /**
     * websocket "/pub/chat/message"로 들어오는 메시징을 처리한다.
     */
    @MessageMapping("/chat/message")
    public void message(ChatMessage message, @Header("token") String token) {
        String nickname = jwtTokenProvider.getUserNameFromJwt(token);
        // 로그인 회원 정보로 대화명 설정
        message.setSender(nickname);
        // 채팅방 인원수 세팅
        message.setUserCount(chatRoomRepository.getUserCount(message.getRoomId()));
        // Websocket에 발행된 메시지를 redis로 발행(publish)
        chatService.sendChatMessage(message);
    }
}

채팅방 리스트에서도 입장한 채팅인원수를 표시하기 위해 다음과 같이 수정합니다.

Chatroom DTO에 userCount추가

public class ChatRoom implements Serializable {

    private static final long serialVersionUID = 6494678977089006639L;

    private String roomId;
    private String name;
    private long userCount; // 채팅방 인원수

    public static ChatRoom create(String name) {
        ChatRoom chatRoom = new ChatRoom();
        chatRoom.roomId = UUID.randomUUID().toString();
        chatRoom.name = name;
        return chatRoom;
    }
}

채팅방 리스트 조회시 userCount정보를 세팅하도록 ChatRoomController 수정

public class ChatRoomController {
    private final ChatRoomRepository chatRoomRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @GetMapping("/rooms")
    @ResponseBody
    public List<ChatRoom> room() {
        List<ChatRoom> chatRooms = chatRoomRepository.findAllRoom();
        chatRooms.stream().forEach(room -> room.setUserCount(chatRoomRepository.getUserCount(room.getRoomId())));
        return chatRooms;
    }
 // 나머지 내용 생략...
}

View 수정

채팅룸 리스트 수정 – room.ftl

입장한 회원수 표시를 위해 {{userCount}} 부분이 추가되었습니다. 그외 수정된 내용은 소스를 참고하세요.

<!doctype html>
<html lang="en">
  <head>
    <title>Websocket Chat</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <!-- CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
      [v-cloak] {
          display: none;
      }
    </style>
  </head>
  <body>
    <div class="container" id="app" v-cloak>
        <div class="row">
            <div class="col-md-6">
                <h3>채팅방 리스트</h3>
            </div>
            <div class="col-md-6 text-right">
                <a class="btn btn-primary btn-sm" href="/logout">로그아웃</a>
            </div>
        </div>
        <div class="input-group">
            <div class="input-group-prepend">
                <label class="input-group-text">방제목</label>
            </div>
            <input type="text" class="form-control" v-model="room_name" v-on:keyup.enter="createRoom">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="createRoom">채팅방 개설</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item list-group-item-action" v-for="item in chatrooms" v-bind:key="item.roomId" v-on:click="enterRoom(item.roomId, item.name)">
                <h6>{{item.name}} <span class="badge badge-info badge-pill">{{item.userCount}}</span></h6>
            </li>
        </ul>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
    <script>
        var vm = new Vue({
            el: '#app',
            data: {
                room_name : '',
                chatrooms: [
                ]
            },
            created() {
                this.findAllRoom();
            },
            methods: {
                findAllRoom: function() {
                    axios.get('/chat/rooms').then(response = > {
                        // prevent html, allow json array
                        if(Object.prototype.toString.call(response.data) === "[object Array]"
                )
                    this.chatrooms = response.data;
                })
                    ;
                },
                createRoom: function() {
                    if("" === this.room_name) {
                        alert("방 제목을 입력해 주십시요.");
                        return;
                    } else {
                        var params = new URLSearchParams();
                        params.append("name",this.room_name);
                        axios.post('/chat/room', params)
                        .then(
                            response => {
                                alert(response.data.name+"방 개설에 성공하였습니다.")
                                this.room_name = '';
                                this.findAllRoom();
                            }
                        )
                        .catch( response => { alert("채팅방 개설에 실패하였습니다."); } );
                    }
                },
                enterRoom: function(roomId, roomName) {
                    localStorage.setItem('wschat.roomId',roomId);
                    localStorage.setItem('wschat.roomName',roomName);
                    location.href="/chat/room/enter/"+roomId;
                }
            }
        });
    </script>
  </body>
</html>

채팅방 수정 – roomdetail.ftl

입장한 회원수 표시를 위해 {{userCount}} 부분이 추가되었습니다. 구독후 입장 메시지를 보내던 부분은 서버에서 처리하므로 삭제되었습니다. 그 외 수정된 내용은 소스를 참고하세요. 

<!doctype html>
<html lang="en">
  <head>
    <title>Websocket ChatRoom</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
      [v-cloak] {
          display: none;
      }
    </style>
  </head>
  <body>
    <div class="container" id="app" v-cloak>
        <div class="row">
            <div class="col-md-6">
                <h4>{{roomName}} <span class="badge badge-info badge-pill">{{userCount}}</span></h4>
            </div>
            <div class="col-md-6 text-right">
                <a class="btn btn-primary btn-sm" href="/logout">로그아웃</a>
                <a class="btn btn-info btn-sm" href="/chat/room">채팅방 나가기</a>
            </div>
        </div>
        <div class="input-group">
            <div class="input-group-prepend">
                <label class="input-group-text">내용</label>
            </div>
            <input type="text" class="form-control" v-model="message" v-on:keypress.enter="sendMessage('TALK')">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="sendMessage('TALK')">보내기</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item" v-for="message in messages">
                {{message.sender}} - {{message.message}}</a>
            </li>
        </ul>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
    <script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
    <script>
        // websocket & stomp initialize
        var sock = new SockJS("/ws-stomp");
        var ws = Stomp.over(sock);
        // vue.js
        var vm = new Vue({
            el: '#app',
            data: {
                roomId: '',
                roomName: '',
                message: '',
                messages: [],
                token: '',
                userCount: 0
            },
            created() {
                this.roomId = localStorage.getItem('wschat.roomId');
                this.roomName = localStorage.getItem('wschat.roomName');
                var _this = this;
                axios.get('/chat/user').then(response => {
                    _this.token = response.data.token;
                    ws.connect({"token":_this.token}, function(frame) {
                        ws.subscribe("/sub/chat/room/"+_this.roomId, function(message) {
                            var recv = JSON.parse(message.body);
                            _this.recvMessage(recv);
                        });
                    }, function(error) {
                        alert("서버 연결에 실패 하였습니다. 다시 접속해 주십시요.");
                        location.href="/chat/room";
                    });
                });
            },
            methods: {
                sendMessage: function(type) {
                    ws.send("/pub/chat/message", {"token":this.token}, JSON.stringify({type:type, roomId:this.roomId, message:this.message}));
                    this.message = '';
                },
                recvMessage: function(recv) {
                    this.userCount = recv.userCount;
                    this.messages.unshift({"type":recv.type,"sender":recv.sender,"message":recv.message})
                }
            }
        });
    </script>
  </body>
</html>

기능 테스트

채팅 서버를 실행합니다. SpringSecurity가 적용되어있어 브라우저당 1개의 계정만 로그인 가능합니다. 서로 다른 브라우저로 2개의 계정에 로그인합니다. 저의 경우 크롬과 사파리 2개를 띄워 로그인하였습니다. 각각 happydaddy/1234, angrydaddy/1234로 계정으로 로그인하였고, 채팅 계정을 추가하려면 WebSecurityConfig의 configure(AuthenticationManagerBuilder auth)에 계정을 추가하면 됩니다. 

채팅방에 입장, 퇴장 시 알림 메시지가 각 클라이언트에게 잘 전달되는지 확인합니다. 또한 입장한 인원수가 실시간으로 업데이트되는지 확인합니다. 

최초 아무도 입장하지 않았을때 채팅방 리스트
채팅방에 1명 입장
채팅방에 1명 입장시 채팅방 리스트
채팅방에 2명 입장후 대화
채팅방에서 1명 퇴장

실제 사용하려면 여러 가지 처리가 추가로 필요하겠지만 이번 실습은 최소한의 멀티 채팅 기능을 구현해 보는 것이 목적이므로 다소 부족하지만 원하는 기능을 하나씩 적용해 나가는 것에 의미를 두고 진행해 보았습니다.

실습에서 사용한 최신 코드는 GitHub에서 확인하실 수 있습니다.
https://github.com/codej99/websocket-chat-server/tree/feature/developchatroom

연재글 이동[이전글] Spring websocket chatting server(4) – SpringSecurity + Jwt를 적용하여 보안강화하기
[다음글] Spring websocket chatting server(6) – Nginx+Certbot 무료 SSL인증서로 WSS(Websocket Secure) 구축하기