본문 바로가기
Spring

[WebSocket] 스프링 채팅 구현 (2) - 다수 채팅방

by 서피 2021. 6. 4.

채팅 방만 구현해보려다가 채팅중인 사람들 목록을 표시하는것도 어렵지는 않을 것 같아서 같이 해보기로 했다.

따라서 전송되는 메세지 종류에 따라 4가지 메세지 타입이 필요하다.

  • 회원의 입장을 알리는 메세지 타입
  • 회원의 퇴장을 알리는 메세지 타입
  • 일반 채팅전송 타입
  • 새로 들어온 사람에게, 기존에 채팅중이던 사람들의 목록을 전송하는 타입

이를 Enum으로 만들어준다.

public enum MessageType {
	OPEN, 
	SEND, 
	CLOSE,
	/**
	 * 기존에 있던 사용자명을 새로 입장한 사용자에게 보낼 때 사용하는 타입입니다.
	 * EXISTING 타입으로 보낸 메세지는 채팅창에 출력되지 않으며, 참여중인 사람들 목록에 사용자명만 추가됩니다.
	 */
	EXISTING 
}

 

 

 

채팅 메세지 VO를 생성하고, 앞서 생성한 MessageType을 필드변수로 추가했다.

롬복을 이용해 구현하다가 웹소켓을 통해 주고받는 과정에서 롬복과의 충돌로 추정되는 오류가 발생하였고, 롬복을 사용하지 않도록 변경해주었다.

public class ChatMessage {
	
	private String studyUrl;
	private String nickname;
	private String message;
	private Date insertDate;
	private MessageType type;
	
	public ChatMessage () {
		insertDate = new java.util.Date();
	}
	
	public String getStudyUrl() {
		return studyUrl;
	}
	public void setStudyUrl(String studyUrl) {
		this.studyUrl = studyUrl;
	}
	public String getNickname() {
		return nickname;
	}
	public void setNickname(String nickname) {
		this.nickname = nickname;
	}
	public String getMessage() {
		return message;
	}
	public void setMessage(String message) {
		this.message = message;
	}
	public Date getInsertDate() {
		return insertDate;
	}
	public void setInsertDate(Date insertDate) {
		this.insertDate = insertDate;
	}
	public MessageType getType() {
		return type;
	}
	public void setType(MessageType type) {
		this.type = type;
	}
	@Override
	public String toString() {
		return "ChatMessage [studyUrl=" + studyUrl + ", nickname=" + nickname + ", message=" + message + ", insertDate="
				+ insertDate + ", type=" + type + "]";
	}
}

 

 

 

각 채팅 채널(방)에 대한 VO를 만들어준다.

채널 클래스에 두 가지 필드변수가 필요하다.

  • 해당 채널의 url주소
  • 해당 채널에 연결된 모든 세션들

세션은 map에 담아주고, 해당 세션 사용자의 아이디를 String 값으로 넣어준다.

채널 클래스에는 addSession, removeSession등의 메소드가 있어 세션 리포지터리 역할을 겸한다.

handleMessage메소드에 ChatMessage VO와 세션을 넣어주면, 해당 채널 모두에게 입,퇴장을 알리거나 채팅 메세지를 전송하게 된다.

@Getter
@Setter
public class ChatChannel {
	private String url;
	private Map<WebSocketSession, String> sessions = new HashMap<>();
	
	public static ChatChannel create(String url) {
		ChatChannel chatChannel = new ChatChannel();
		chatChannel.url = url;
		
		return chatChannel;
	}
	
	/**
	 * 채널에 접속한 모든 session에게 chatMessage를 전송
	 * chatMessage의 MessageType에 따라 입장, 퇴장, 일반 메세지로 분류되어 처리
	 * @param session
	 * @param chatMessage
	 * @throws Exception
	 */
	public void handleMessage(WebSocketSession session, ChatMessage chatMessage) throws Exception {
		if (chatMessage.getType() == MessageType.OPEN) {
			sessions.put(session, chatMessage.getNickname());
			chatMessage.setMessage("알림:" + chatMessage.getNickname() + " 님이 입장하셨습니다.");
		} else if (chatMessage.getType() == MessageType.CLOSE) {
			sessions.remove(session);
			chatMessage.setMessage("알림:" + chatMessage.getNickname() + " 님이 퇴장하셨습니다.");
		} else {
			chatMessage.setMessage(chatMessage.getMessage());
		}
		send(chatMessage);
	}
	
	/**
	 * 채널의 모든 세션에게 message 전송
	 * @param chatMessage
	 * @throws Exception
	 */
	private void send(ChatMessage chatMessage) throws Exception {
		Gson gson = new Gson();
		TextMessage textMessage = new TextMessage(gson.toJson(chatMessage));
		for (WebSocketSession s : sessions.keySet()) {
			if (s.isOpen()) {
				s.sendMessage(textMessage);
			}
		}
	}
	
	/**
	 * 해당 세션이 사용중인 닉네임을 반환합니다.
	 * @param nickname
	 * @return
	 */
	public String getNicknameBySession (WebSocketSession session) {
		return sessions.get(session);
	}
	
	/**
	 * 채널에 연결된 모든 세션을 반환합니다.
	 * @return
	 * 연결된 세션이 0 이면 null, 1 이상이면 모든 session
	 */
	public ArrayList<WebSocketSession> getAllSessions () {
		if (sessions == null || sessions.isEmpty()) {
			return null;
		}
		return (ArrayList)sessions;
	}
	
	/**
	 * 채널에 접속중인 모든 사용자들의 닉네임을 반환합니다.
	 * @return
	 * 사용자 닉네임
	 */
	public ArrayList<String> getAllNicknames () {
		return new ArrayList<>(sessions.values());
	}
	
	public void addSession (WebSocketSession session, String nickname) {
		sessions.put(session, nickname);
	}
	
	public void removeSession (WebSocketSession session) {
		sessions.remove(session);
	}
}

 

 

ChatChannel 클래스를 관리할 Repository를 만든다.

필드변수에 map을 만들고 <채널에 해당하는 URL, 채널>을 각각 key, value로 설정한다.

@Component
public class ChatChannelStore {
	
	/*
	 * key: 스터디 url
	 * value: url에 해당하는 스터디의 ChatChannel
	 */
	private Map<String, ChatChannel> map;
	
	@PostConstruct
	private void init() {
		map = new LinkedHashMap<>();
	}
	
	public ChatChannel getChanelByUrl(String url) {
		ChatChannel channel = map.get(url);
		return channel;
	}
	
	/**
	 * 해당 url의 채널이 존재하면 참가하고, 없으면 생성한 후 참가
	 * @param session
	 * url에 참가할 session
	 * @param url
	 * @param nickname
	 * session 사용자의 닉네임
	 * 스터디 url
	 * @return
	 * 참가한 채널
	 */
	public ChatChannel joinOrCreateChannel (WebSocketSession session, String url, String nickname) {
		ChatChannel channel = null;
		channel = map.get(url);
		if (channel == null) {
			channel = ChatChannel.create(url);
			channel.addSession(session, nickname);
			map.put(url, channel);
			return channel;
		} else {
			channel.addSession(session, nickname);
			return channel;
		}
	}
	
	/**
	 * url로부터 session을 제거, 마지막 session일 경우 ChatChannel 제거
	 * @param session
	 * url로부터 제거할 session
	 * @param url
	 * 스터디 url
	 * @return 
	 * ChatChannel or null
	 * session이 채널의 마지막 session이었다면 null, 다른 session이 존재한다면 ChatChannel
	 */
	public ChatChannel leaveOrRemoveChannel (WebSocketSession session, String url) {
		try {
			ChatChannel channel = map.get(url);
			channel.removeSession(session);
			if (channel.getAllSessions() == null) {
				map.remove(url);
				return null;
			} else {
				return channel;
			}
		} catch (NullPointerException omg) {
			omg.printStackTrace();
		}
		return null;
	}

	/**
	 * 세션이 연결되어 있던 채널을 찾은 후 해당 채널에 연결된 사용자들에게 연결 종료를 알림
	 * @param session
	 */
	public ChatChannel findChannelWithSession (WebSocketSession session) {
		ChatChannel channel = null;
		for (ChatChannel c : map.values()) {
			if (c.getSessions().containsKey(session))
				return c;
		}
		
		return channel;
	}
	
}

 

 

 

ChatHandler 클래스를 만들고, TextWebSocketHandler클래스를 상속한다.

클라이언트에서 JSON형태의 메세지를 보내오면, handleTextMessage 메소드에서 Gson을 이용해 ChatMessage.class 클래스로 변환한다.

chatMessage 클래스의 enum MessageType 에 따라, 입장을 알리거나 채팅을 전송하는 등 역할을 분기한다.

세션의 연결이 종료되면 afterConnectionClosed 메소드가 실행된다. 그 과정은

  • 파리미터로 session값이 주어지는데, chatChannelStore.findChannelWithSession(session)메소드를 통해 해당 세션이 연결되어 있던 채널을 가져온다.
  • 퇴장 메세지를 전송할 new ChatMessage를 생성한다.
  • message의 MessageType은 Close로 설정한다.
  • nickname은 해당 세션을 이용해 channel.getNicknameBySession(session)으로 가져온다.
  • channel.handleMessage(session, message)를 실행시키면, message의 Type이 Close이므로 그에 따라 퇴장 메세지가 전송된다.
public class ChatHandler extends TextWebSocketHandler {
	
	private static final Logger logger = LoggerFactory.getLogger(ChatHandler.class);
	
	@Inject
	ChatChannelStore chatChannelStore;
	
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage msg) throws Exception {
		logger.info("handleTextMessage: " + msg.getPayload());
		
		Gson gson = new Gson();
		ChatMessage chatMessage = gson.fromJson(msg.getPayload(), ChatMessage.class);
		chatMessage.setMessage(chatMessage.getMessage().replaceFirst("알림:", ""));
		if (chatMessage.getType() == MessageType.OPEN) {
			ChatChannel channel = chatChannelStore.joinOrCreateChannel(session, chatMessage.getStudyUrl(), chatMessage.getNickname());
			channel.handleMessage(session, chatMessage);
			
			// 기존에 채팅중이던 회원들의 닉네임 전송
			ArrayList<String> nicknameList = channel.getAllNicknames();
			for (String n : nicknameList) {
				if (n == chatMessage.getNickname())
					continue;
				ChatMessage tempMessage = new ChatMessage();
				tempMessage.setNickname(n);
				tempMessage.setType(MessageType.EXISTING);
				session.sendMessage(new TextMessage(gson.toJson(tempMessage)));
			}
			
		} else {
			try {
				chatChannelStore.getChanelByUrl(chatMessage.getStudyUrl()).handleMessage(session, chatMessage);;
			} catch (NullPointerException omg) {
				omg.printStackTrace();
			}
		}
	}
	
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		logger.info("afterConnectionClosed: " + status);
		
		// 해당 세션이 연결되어 있던 채널의 모든 사용자에게 퇴장 메세지를 전송합니다.
		ChatChannel channel = chatChannelStore.findChannelWithSession(session);
		ChatMessage message = new ChatMessage();
		message.setType(MessageType.CLOSE);
		message.setNickname(channel.getNicknameBySession(session));
		channel.handleMessage(session, message);
	}
}

 


댓글