채팅 서버 만들기 - Stomp

김규연·2023년 1월 25일
0

🧐Stomp란?

Stomp는 Simple Text Oriented Messaging Protocol의 약자로, 메시지 전송을 효율적으로 하기 위한 프로토콜이다. 기본적으로 Publish-Subscribe(pub/sub) 구조로 되어있다. 따라서 메시지를 전송하고 받아서 처리하는 부분이 확실하게 구조로 정해져 있다.
또한 Stomp 프로토콜은 WebSocket위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 메커니즘이다. 또한 헤더 값을 기반으로 통신 시 인증처리르 구현할 수 있다.

📝Publish-Subscribe(pub/sub) 구조

위에서 언급한 Publish-Subscribe 구조란 네시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메시지 방법이다. 즉, Publisher로부터 전달받은 메시지를 Subscriber에게 메시지를 주고 받게 해주는 중간 역할을 한다. pub/sub를 채팅방에 사용한다면 다음과 같을 것이다.

채팅방 생성 : pub/sub 구현을 위한 Topic이 생성됨
채팅방 입장 : Topic 구독
채팅방에서 메시지를 송수신 : 해당 Topic으로 메시지를 송신(pub), 메시지를 수신(sub)

📝COMMAND

클라이언트는 메시지를 전송하기 위해 Send, Subscribe COMMAND를 사용할 수 있다. 또한 Send, Subscribe COMMAND 요청 Frame에는 메시지가 무엇이고, 누가 받아서 처리할지에 대한 Header정보가 포함되어 있다. 이런 명령들은 "destination"헤더를 요구하는데 이것이 어디에 전송할지, 혹은 어디에서 메시지를 구독할 것인지를 나타낸다. 이러한 과정을 통해 Stomp는 Publish-Subscribe 메커니즘을 제공한다. 즉 Broker를 통헤 타 사용자들에게 메시지를 보내거나 서버가 특정 작업을 수행하도록 메시지를 보낼 수 있게 된다.

COMMAND 리스트 : CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, BEGIN, COMMIT, ABORT, ACK, NACK, DISCONNECT

📝Meassge Broker

Spring에서 지원하는 Stomp를 사용하면 Spring WebSocket 어플리케이션은 Stomp Broker로 동작하게 된다. Spring에서 지원하는 Stomp는 많은 기능을 하는데 예를 들어 Simple In-Memory Broker를 이용해 Subscribe 중인 다른 클라이언트에게 메시지를 보내주고 Simple In-Memory Broker는 클라이언트의 Subscribe 정보를 자체적으로 메모리에 유지한다. 이렇게 Spring 환경에서 추가적인 설정없이 Stomp 프로토콜을 사용하면 메시지 브로커는 자동으로 In Memory Brocker를 사용하게 된다.
문제는 이러한 기본 Simple In-Memory Broker 사용에 단점이 있다는 것이다.

1. 세션을 수용할 수 있는 크기가 제한되어 있다.
2. 장애 발생 시 메시지의 유실 가능성이 높다.
3. 따로 모니터링 하는 것이 불편하다.

따라서, In Memory 브로커 대신에 Stomp 전용 외부 브로커를 사용하는 것을 권장한다. 외부 브로커로는 RabbitMQ, ActiveMQ 등이 있으며 Message Brocker 기능을 제공한다. 구조적인 면을 보면, Spring은 메시지를 외부 Broker에게 전달하고, Broker는 WebSocker으로 연결된 클라이언트에게 메시지를 전달하는 구조가 되겠다.

📝Frames

Stomp는 HTTP에서 모델링되는 Frame 기반 프로토콜이다. Frame은 몇 개의 Text Line으로 지정된 구조인데 첫 번째 라인은 Text이고 이후 Key:Value 형태로 Header의 정보를 포함한다.

COMMAND
header1:value1
header2:value2

Body^@

COMMAND : SEND, SUBSCRIBE를 지시할 수 있다.
header : 기존의 WebSocket으로는 표현이 불가능한 header를 작성할 수 있다.
destination : 이 헤더로 메시지를 보내거나(Send), 구독(Subscribe)할 수 있다.

내가 Stomp를 이용해서 만든 채팅방을 예를 들어 보자.
한 client가 하나의 채팅방에 대해 구독을 했을 때의 경우이다.

>>> SUBSCRIBE
id:sub-0
destination:/sub/chat/room/07905aff-a14a-4162-b065-14418519c9d5

SUBSCRIBE frame은 주어진 destination에 등록하기 위해 사용된다. SEND frame과 마찬가지로 SUBSCRTBE는 client가 구독하기 원하는 목적지를 가리키는 "destination"헤더를 필요로 한다. 가입된 대상에서 수신된 모든 메시지는 이후 MESSAGE frame로서 서버에서 클라이언트에게 전달된다.
단일 견결은 여러 개의 구독을 할 수 있으므로 구독 id를 고유하게 식별라기 위해 "id"헤더가 프레임에 포함되어야 한다.


clientB가 구독한 채팅방(chatRoomNo)에서 채팅 메시지(chatMessage)를 보내는 경우이다.

>>> SEND
destination:/pub/chat/message
content-length:104

{"chatRoomNo":"07905aff-a14a-4162-b065-14418519c9d5","chatMessage":"보내기","chatWriter":"김규연"}

SEND frame은 destination의 메시징 시스템으로 메시지를 보낸다. SEND frame의 body, 즉 {}부분은 보내고자 하는 메시지이다.


Stomp 서버는 모든 구독자에게 메시지를 BroadCasting하기 위해 MESSAGE COMMAND를 사용할 수 있다. 서버는 내용을 기반(ChatRoomNo)으로 메시지를 전송할 broker에 전달한다.

<<< MESSAGE
destination:/sub/chat/room/07905aff-a14a-4162-b065-14418519c9d5
content-type:application/json
subscription:sub-0
message-id:w5xbq2x5-0
content-length:115

{"chatRoomNo":"07905aff-a14a-4162-b065-14418519c9d5","chatWriter":"김규연","chatMessage":"보내기"}

서버는 불분명한 메시지를 전송할 수 없다. 그러므로 서버의 모든 메시지는 특정 클라이언트 구독에 응담하여야 하고, 서버 메시지의 "subscription-id"헤더는 클라이언트 구독의 "id"와 일치해야 한다는 것을 주목하자.

참고 사이트
참고 사이트

📝메시지 처리 흐름

Stomp Endpoint가 노출되고 나면, Spring 어플리케이션은 연결되어있는 Client들에 대해 Stomp Broker가 된다.
(/app = /pub, /topic = /sub) 아래 그림은 내장 메시지 Broker를 사용한 경우 컴포넌트 구성을 보여준다.

spring-message 모듈은 Spring framework의 통합된 Messaging 어플리케이션을 위한 지원을 한다.

  1. 클라이언트로부터 header와 playload에 담은 메시지를 전달받는다.
  2. request channel(InboundChannel)에서 이를 알맞은 MessageHandler에 전달한다.
  3. 어노테이션 기반의 로직 처리가 포함되는 경우, SimpAnnotationMethodMessageHandler를 통해 @Controller를 호출한다.
  4. 로직 처리 후, 반환된 값을 기반으로 메시지를 만들어 broker channel에 전달한다.
  5. broker channel은 SimpleBrokerMessageHandler를 통해 구독자들을 가져온다.
  6. 그리고 각각의 구독자들에게 response channel(OutboundChannel)로 메시지를 전달한다.
  • Message : headers와 payload를 포함하는 메시지의 표현

  • MessageHandler : Message 처리에 대한 계약

  • SimpleAnnotationMethod : @MessageMapping 등 Client의 SEND를 받아서 처리한다.

  • SimpleBroker : Client의 정보를 메모리 상에 들고 있으며, Client로 메시지를 보낸다.

  • channel

    • clientInboundChannel : WebSocket Client로부터 들어오는 요청을 전당하며, WebSocketMessageBrokerConfigurer를 통해 intercept, taskExecutor를 설정할 수 있다.
      클라이언트로 받은 메시지를 전달
    • clientOutboundChannel : WebSocket Client로 Server의 메시지를 내보내며,
      WebSocketMessageBrokerConfigurer를 통해 intercept, taskExecutor를 설정할 수 있다.
      클라이언트에게 메시지를 전달
    • brokerChannel : Server내부에서 사용하는 채널이며, 이를 통해 SimpleAnnotationMethod는 SimpleBroker의 존재를 직접 알지 못해도 메시지를 전달할 수 있다.

참고한 사이트

💻Stomp를 이용해 채팅 구현하기

1. StompWebSocketConfig 작성

package site.workforus.forus.chat.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@EnableWebSocketMessageBroker
@Configuration
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp/chat")
                .setAllowedOrigins("https://workforus.site","http://localhost:8080")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub");
        registry.enableSimpleBroker("/sub");
    }
}
  • @EnableWebSocketMessageBroker : stomp를 사용하기 위해 선언하는 어노테이션

  • EndPoint : 서버와 Client가 WebSocket 통신을 하기 위한 앤드포인트. Client 측에서 Socket을 생성할 때, 여기에 정의한 문자열로 생성해야 통신이 됨

  • setApplicationDestinationPrefixes : Client에서 SEND 요청을 처리
    (Spring docs에서는 /topic, /queue로 나오나 편의상 /pub, /sub로 변경)

  • enableSimpleBroker : 해당 경로로 SimpleBroker를 등록. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메시지를 전달하는 간단한 작업을 수행

  • enableStompBrokerRelay : SimpleBroker의 기능과 외부 Message Broker(RabbitMQ, ActiveMQ 등)에 메시지를 전달하는 기능을 가짐

  • withSockJS : 클라이언트와의 연결은 SockJS로 하기 위함

2. ChatRoomDTO

package site.workforus.forus.chat.model;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.ibatis.type.Alias;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Alias("chatRoomDto")
public class ChatRoomDTO {
    private String chatRoomNo;
    private String chatTitle;
}

@Alias
@Alias는 mybatis에서 지원하는 어노테이션으로 TypeAlias, 즉 별칭을 지정할 때 사용한다. @Alias("별칭") 어노테이션이 지정된 클래스는 mapper파일에서 별칭을 클래스를 매핑해준다. mapper파일에 TypeAlias를 지정하지 않으면 site.workforus.forus.chat.model.ChatMessageDTO 와 같이 패키지명을 포함한 클래스명을 입력해야 하지만 @Alias를 사용하면

<mapper namespace="site.workforus.forus.mapper.ChatMapper">
	<resultMap type="chatRoomDto" id="chatRoomDtoMap">
		<result property="chatRoomNo" column="chat_room_no" />
		<result property="chatTitle" column="chat_title" />
	</resultMap>
</mapper>

또는 "<select id="findById" resultType="chatRoomDto"...>"와 같이 사용할 수 있다.

참고한 사이트

@NoArgsConstructor
Lombok 어노테이션 중 하나. 기본 생성자를 생성해준다.

public class ChatRoomDTO {
    private String chatRoomNo;
    private String chatTitle;
    public ChatRoomDTO() {}
}

@NoArgsConstructor에 아래와 같이 force옵션을 true로 주면 객체 내의 모든 변수가 초값으로 설정되지 않아 컴파일 에러가 발생한다.(force의 기본값은 false이다. 그래서 그냥 @NoArgsConstructor를 사용하면 변수가 초기값으로 설정되는 것이다.)

@NoArgsConstructor(force=true)

@AllArgsConstructor
Lombok 어노테이션 중 하나. 모든 필드를 인자로 받는 생성자를 주는 어노테이션이다.

public class ChatRoomDTO {
    private String chatRoomNo;
    private String chatTitle;
    public ChatRoonDTO(String chatRoomNo, String chatTitle) {
    	this.chatRoomNo = chatRoomNo;
        this.chatTitle = chatTitle;
    }
}

만약 필드중에서 @NotNull 어노테이션이 마크되어 있다면 생성자 내에서 null-check 로직을 자동적으로 생성해준다.

하지만 이 어노테이션을 사용하기보다는 build 패텉을 사용하던가 정적 팩토리 메소드를 사용하는 것이 코드 가동성에 더 좋다고 한다.

참고한 사이트
참고한 사이트

3. ChatMessageDTO

package site.workforus.forus.chat.model;

import lombok.*;
import org.apache.ibatis.type.Alias;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Alias("chatMessageDto")
public class ChatMessageDTO {
    private int chatNo;
    private String chatRoomNo;
    private String chatWriter;
    private String chatMessage;
}

4. ChatController

기존의 WebSocket만을 이용하여 ChatConroller를 만들었을 때와 다르게 임원들(employees)을 조회하는 empDtos와 채팅방들을 조회하는 chatRooms를 추가하였다.

package site.workforus.forus.chat.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import site.workforus.forus.chat.model.ChatRoomDTO;
import site.workforus.forus.chat.service.ChatRoomService;
import site.workforus.forus.employee.model.EmpDTO;
import site.workforus.forus.employee.model.LoginVO;

import java.util.List;

@Controller
@Slf4j
@RequestMapping(value="/chat")
public class ChatController {

    @Autowired
    private ChatRoomService chatRoomService;

    @GetMapping("")
    public String chat(Model model) {
        log.info("@ChatController, chat GET()");

        LoginVO user = (LoginVO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        List<EmpDTO> empDtos = chatRoomService.selectEmployeeAll();
        List<ChatRoomDTO> chatRooms = chatRoomService.findRoomAll();

        model.addAttribute("userid", user.getUsername());
        model.addAttribute("empDtoDatas", empDtos);
        model.addAttribute("chatRoomDatas", chatRooms);

        return "chat/rooms";
    }
}

5. ChatRoomController

채팅방 관련 Controller이다. ajax로 받은 insertChatRoom()부분은 전달받은 채팅방 제목(chatTitle)을 ChatRoomService로 전달한다.
getRoom()부분은 임원들(employees) 전체조회가 가능한 "empDtoDatas", 로그인한 유저정보를 가져올 수 있는 "empDto", 채팅방 전체 조회가 가능한 "chatRoomDatas", 접속한 채팅방 정보를 가져오는 "room", 접속한 채팅방의 채팅메시지를 조회하는 "message"가 있다.

package site.workforus.forus.chat.controller;

import lombok.extern.slf4j.Slf4j;
import org.json.simple.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import site.workforus.forus.chat.service.ChatMessageService;
import site.workforus.forus.chat.service.ChatParticipantService;
import site.workforus.forus.chat.service.ChatRoomService;
import site.workforus.forus.employee.model.LoginVO;

import java.util.List;

@Controller
@RequestMapping(value = "/chat")
@Slf4j
public class ChatRoomController {
    @Autowired
    private ChatRoomService chatRoomService;

    @Autowired
    private ChatMessageService chatMessageService;

    @Autowired
    private ChatParticipantService chatParticipantService;

    @PostMapping(value = "/room/add", produces = "application/json; charset=utf-8")
    @ResponseBody
    public String insertChatRoom(@RequestParam("title") String chatTitle) {
        log.info("chatTitle : " + chatTitle);

        JSONObject json = new JSONObject();

        chatRoomService.createChatRoom(chatTitle);

        return json.toJSONString();
    }

    @GetMapping(value = "/room")
    public String getRoom(@RequestParam("roomId") String roomId, Model model) {
        log.info("# get Chat Room, roomID : " + roomId);

        LoginVO user = (LoginVO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        model.addAttribute("userid", user.getUsername());
        model.addAttribute("empDtoDatas", chatRoomService.selectEmployeeAll());
        model.addAttribute("empDto", chatRoomService.selectEmployeeInfo(user.getUsername()));
        model.addAttribute(
        "chatRoomDatas", chatRoomService.findRoomAll());
        model.addAttribute("room", chatRoomService.findByRoomId(roomId));
        
        model.addAttribute("message", chatMessageService.findMessageById(roomId));
        return "chat/chat";
    }
}

6. StompChatController

message()의 insertRoomDatas()는 전달받은 메시지를 ChatMessageService로 전달한다.

package site.workforus.forus.chat.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import site.workforus.forus.chat.model.ChatMessageDTO;
import site.workforus.forus.chat.service.ChatMessageService;

@Controller
@RequiredArgsConstructor
public class StompChatController {

    private final SimpMessagingTemplate template;

    @Autowired
    private ChatMessageService chatMessageService;

    @MessageMapping(value = "/chat/message")
    public void message(ChatMessageDTO message) {
        System.out.println("message connection");
        
        chatMessageService.insertRoomDatas(message);
        template.convertAndSend("/sub/chat/room/" + message.getChatRoomNo(), message);
    }
}

@MessageMapping을 통해 WebSocket으로 들어오는 메시지(stomp.send()) 발행을 처리한다. Client에서는 applicationDestinationPrfixes를 붙여 "/pub/chat/enter"로 발행을 요청하면 StompChatController가 해당 메시지를 받아 처리하는데, 메시지가 발행되면 "sub/chat/room/[roomId]"로 메시지가 전송되는 것을 볼 수 있다.

Client에서는 해당 주소를 SUBSCRIBE하고 있다가 메시지가 전달되면 화면에 출력한다. 이때 "/sub/chat/room/[roomId]"는 채팅방을 구분하는 값이다.

기존의 핸들러인 ChatHandler의 역할을 대신 해주므로 핸들러는 없어도 된다.

SimpleMessagingTemplate의 convertAndSend 메소드를 통해 "/sub/chat/room/[roomId]"를 구독한 유저에게 해당메시지를 보낸다. @EnableWebSocketMessageBroker를 통해서 등록되는 bean이다.

@RequiredArgsConstructor
Lombok 어노테이션 중 하나. 이 어노테이션은 초기화 되지 않은 final 필드나, @NonNull이 붙은 필드에 대해 생성자를 생성해 준다. 새로운 필드를 추가할 떼 다시 생성자를 만드는 번거러움을 없앨 수 있다.
주로 의존성 주입(Dependenct Injection)편의성을 위해서 사용되곤 한다.
@Autowired를 활용한 의존성 주입을 필드 주입이라고 하는데, 이는 사용법이 매우 간단해서 대부분 의존성 주입을 필드 주입으로 접하지만, 편리하다는 것 말고는 장점이 없어서 사용하지 않는 것은 권장한다고 한다.
Lombok에서 지원하는 @RequiredArgsConstructor 어노테이션을 사용하여 의존성을 주입 하는 것을 생성자 주입이라고 한다. 스프링 팀은 생성자 주입을 사용할 것을 권장한다.
@RequiredArgsConstructor 어노테이션을 사용하지 않았을 시 원래 코드는 다음과 같다.

public class StompChatController {
    private final SimpMessagingTemplate template;
    @Autowired
    public StompChatController(SimpMessagingTemplate template) {
    	this.template = template;
    }
    ...생략
}

@Autowired를 사용할 시 다음과 같다.

public class StompChatController {
	@Autowired
    private SimpMessagingTemplate template;
    ...생략
}

참고한 사이트

의존성이란?

class HiWorld {
   private SayHi sayHi;
   public HelloWorld() {
      this.sayHi = new SayHi();
   }
   public startHelloWorld() {
      this.sayHi.hello();
   }
}

HiWorld 클래스에서 hello함수가 호출되기 위해서는 SayHi 클래스가 필요하다.
HiWorld 클래스는 SayHi 클래스의 의존성을 가진다고 말한다.

7. ChatRoomService

createChatRoom()은 ChatRoomController 중 insertChatRoom()에서 전달받은 chatTitle을 받아 저장하게 되는데 이때 chatRoomNo도 UUID로 함께 저장이 될 수 있도록 하였다.
findRoomAll()은 채팅방 전체 조회, selectEmployeeAll()은 임원들 전체 조회이다.
findByRoomId()은 ChatRoomController 중 getRoom()에서 전달받은 접속한 채팅방 id(roomId)를 통해 해당 채팅방 정보를 가져오고,
selectEmployeeInfo()은 접속한 유저의 정보를 가져오도록 하였다.

import site.workforus.forus.employee.model.EmpDTO;
import site.workforus.forus.mapper.ChatMapper;
import site.workforus.forus.mapper.EmpMapper;

import java.util.List;
import java.util.UUID;

@Service
@EnableAspectJAutoProxy(proxyTargetClass=true)
public class ChatRoomService {

    @Autowired
    private SqlSession session;

    public boolean createChatRoom(String chatTitle) {
        ChatMapper mapper = session.getMapper(ChatMapper.class);

        ChatRoomDTO chatRoomDTO = new ChatRoomDTO();

        chatRoomDTO.setChatRoomNo(UUID.randomUUID().toString());
        chatRoomDTO.setChatTitle(chatTitle);

        int result = mapper.insertChatRoom(chatRoomDTO);

        return result == 1 ? true : false;
    }

    public List<ChatRoomDTO> findRoomAll() {
        ChatMapper mapper = session.getMapper(ChatMapper.class);

        List<ChatRoomDTO> chatRoomDTO = mapper.findRoomAll();

        return chatRoomDTO;
    }

    public List<EmpDTO> selectEmployeeAll() {
        EmpMapper mapper = session.getMapper(EmpMapper.class);

        List<EmpDTO> result = mapper.selectEmployeeAll();

        return result;
    }

    public ChatRoomDTO findByRoomId(String roomId) {
        ChatMapper mapper = session.getMapper(ChatMapper.class);

        ChatRoomDTO chatRoomDTO = mapper.findByRoomId(roomId);

        return chatRoomDTO;
    }

    public EmpDTO selectEmployeeInfo(String user) {
        EmpMapper mapper = session.getMapper(EmpMapper.class);

        EmpDTO empDTO = mapper.selectEmployeeInfo(user);

        return empDTO;
    }
}

7. ChatMessageService

insertRoomDatas()는 StompChatController 중 message()에서 전달받은 메시지 정보들(ChatMessageDTO)을 저장하도록 하였다.
findMessageById()는 ChatRoomController 중 getRoom()에서 전달받은 채팅방(roomId)를 이용해 접속한 채팅방의 저장된 메시지를 전달할 수 있도록 하였다.

package site.workforus.forus.chat.service;

import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import site.workforus.forus.chat.model.ChatMessageDTO;
import site.workforus.forus.mapper.ChatMapper;

import java.util.List;

@Service
public class ChatMessageService {

    @Autowired
    private SqlSession session;

    public boolean insertRoomDatas(ChatMessageDTO message) {
        ChatMapper mapper = session.getMapper(ChatMapper.class);
        int result = mapper.insertRoomDatas(message);
        
        return result == 1 ? true : false;
    }

    public List<ChatMessageDTO> findMessageById(String roomId) {
        ChatMapper mapper = session.getMapper((ChatMapper.class));
        List<ChatMessageDTO> chatMessageDTO = mapper.findMessageById(roomId);

        return chatMessageDTO;
    }
}

8. rooms.jsp

채팅방만 조회되는 jsp이다. 채팅방을 클릭했을 때 해당 채팅방으로 이동해 Stomp가 연결되도록 하였다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
	<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
    <title>채팅</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.0/font/bootstrap-icons.css">
	<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
</head>
<body>

...생략

<!-- 채팅방 -->
<div>
	<div class="table-responsive">
		<table class="table table-lg">
			<c:if test="${not empty chatRoomDatas}">
				<c:forEach items="${chatRoomDatas}" var="data">
					<tr id="chat-room-area" class="chat-room" onclick="enterRoom('${data.chatRoomNo}');">
						<td class="chat-room-profile">
							<div class="avatar bg-warning me-3">
								<span class="avatar-content">AS</span>
							</div>
						</td>
						<td>
							<div class="chat-room-title">
								${data.chatTitle}
							</div>
							<div class="chat-room-content">
								임시내용
							</div>
						</td>
					</tr>
				</c:forEach>
			</c:if>
		</table>
		<i class="bi bi-plus-circle-fill chat-plus-icon" onclick="chatRoomAddModal()"></i>
	</div>
</div>

...생략

<!-- 채팅창 -->
<div class="chat-center-layout">
	<section class="section">
		<div class="card">
			<div class="card-header">
				<div class="media d-flex align-items-center">
					<div class="avatar me-3">
						<img src="static/images/faces/1.jpg" alt="" srcset="">
						<span class="avatar-status bg-success"></span>
					</div>
					<div class="name flex-grow-1">
						<h6 class="mb-0">Fred</h6>
						<span class="text-xs">Online</span>
					</div>
					<button class="btn btn-sm">
						<i data-feather="x"></i>
					</button>
				</div>
			</div>
			<div class="card-body pt-4 bg-grey" id="id_chat">
				<div id="chat-content">
					<c:forEach items="${message}" var="data">
						<c:choose>
							<c:when test="${empDto.empNm == data.chatWriter}">
								<div class="chat">
									<div class="chat-body">
										<div class="chat-message">${data.chatWriter} : ${data.chatMessage}</div>
									</div>
								</div>
							</c:when>
							<c:otherwise>
								<div class="chat chat-left">
									<div class="chat-body">
										<div class="chat-message">${data.chatWriter} : ${data.chatMessage}</div>
									</div>
								</div>
							</c:otherwise>
						</c:choose>
					</c:forEach>
				</div>
			</div>
			<div class="card-footer">
				<div class="message-form d-flex flex-direction-column align-items-center">
					<a href="http://" class="black"><i data-feather="smile"></i></a>
					<div class="d-flex flex-grow-1 ml-4">
						<input type="text" class="form-control" id="msg" name="context" placeholder="Type your message..">
						<button type="submit" class="submit-btn" id="button-send">전송</button>
					</div>
				</div>
			</div>
		</div>
	</section>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script type="text/javascript">
    function enterRoom(roomId) {
		location.href = "${chatUrl}/room?roomId="+roomId
	}
    
    ...생략
</script>
</html>

9. chat.jsp

이 글에서는 Stomp에 관해서만 집중할 것이기 때문에 다른 부분들은 생략했다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
	<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
    <title>채팅</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.0/font/bootstrap-icons.css">
	<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
</head>
<body>

...생략

<!-- 채팅방 -->
<div>
	<div class="table-responsive">
		<table class="table table-lg">
			<c:if test="${not empty chatRoomDatas}">
				<c:forEach items="${chatRoomDatas}" var="data">
					<tr id="chat-room-area" class="chat-room" onclick="enterRoom('${data.chatRoomNo}');">
						<td class="chat-room-profile">
							<div class="avatar bg-warning me-3">
								<span class="avatar-content">AS</span>
							</div>
						</td>
						<td>
							<div class="chat-room-title">
								${data.chatTitle}
							</div>
							<div class="chat-room-content">
								임시내용
							</div>
						</td>
					</tr>
				</c:forEach>
			</c:if>
		</table>
		<i class="bi bi-plus-circle-fill chat-plus-icon" onclick="chatRoomAddModal()"></i>
	</div>
</div>

...생략

<!-- 채팅창 -->
<div class="chat-center-layout">
	<section class="section">
		<div class="card">
			<div class="card-header">
				<div class="media d-flex align-items-center">
					<div class="avatar me-3">
						<img src="static/images/faces/1.jpg" alt="" srcset="">
						<span class="avatar-status bg-success"></span>
					</div>
					<div class="name flex-grow-1">
						<h6 class="mb-0">Fred</h6>
						<span class="text-xs">Online</span>
					</div>
					<button class="btn btn-sm">
						<i data-feather="x"></i>
					</button>
				</div>
			</div>
			<div class="card-body pt-4 bg-grey" id="id_chat">
				<div id="chat-content">
					<c:forEach items="${message}" var="data">
						<c:choose>
							<c:when test="${empDto.empNm == data.chatWriter}">
								<div class="chat">
									<div class="chat-body">
										<div class="chat-message">${data.chatWriter} : ${data.chatMessage}</div>
									</div>
								</div>
							</c:when>
							<c:otherwise>
								<div class="chat chat-left">
									<div class="chat-body">
										<div class="chat-message">${data.chatWriter} : ${data.chatMessage}</div>
									</div>
								</div>
							</c:otherwise>
						</c:choose>
					</c:forEach>
				</div>
			</div>
			<div class="card-footer">
				<div class="message-form d-flex flex-direction-column align-items-center">
					<a href="http://" class="black"><i data-feather="smile"></i></a>
					<div class="d-flex flex-grow-1 ml-4">
						<input type="text" class="form-control" id="msg" name="context" placeholder="Type your message..">
						<button type="submit" class="submit-btn" id="button-send">전송</button>
					</div>
				</div>
			</div>
		</div>
	</section>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script type="text/javascript">
	var roomName = '${room.chatTitle}'
	var roomId = '${room.chatRoomNo}'
	var username = '${empDto.empNm}';

	console.log(roomName + ", " + roomId + ", " + username);

	// StompWebSocketConfig의 EndPoint로 작성했던 부분 "/stomp/chat"
	var sockJS = new SockJS("/stomp/chat");
	var stomp = Stomp.over(sockJS);

	// Stomp 연결 시 실행
	stomp.connect({}, function (){
		console.log("Stomp Connection")

		// subscribe(path, callback)으로 메세지를 받을 수 있다. 
        // StompChatController의 @MessageMapping(value = "/chat/message") 부분 중 template.convertAndSend()를 통해 메시지가 전달된다.
		stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
			var content = JSON.parse(chat.body);

			var writer = content.chatWriter;
			var message = content.chatMessage;
			var str = '';

			console.log(writer + ", " + message);

			if(writer === username) {
				var str = "<div class='chat'><div class='chat-body'><div class='chat-message' id='id_chat'>";
				str += writer + " : " + message;
				str += "</div></div></div>";
				$("#chat-content").append(str);
			} else {
				var str = "<div class='chat chat-left'><div class='chat'><div class='chat-body'><div class='chat-message' id='id_chat'>";
				str += writer + " : " + message;
				str += "</div></div></div></div>";
				$("#chat-content").append(str);
			}
		});
	});

	$("#button-send").on("click", function(e){
		var msg = document.getElementById("msg");

        // send(path, header, message)로 메시지를 보낼 수 있다. 
        // StompChatController의 @MessageMapping(value = "/chat/message") 부분으로 메시지가 보내진다.
		stomp.send('/pub/chat/message', {}, JSON.stringify({chatRoomNo: roomId, chatMessage: msg.value, chatWriter: username}));
		msg.value = '';
	});
    
    function enterRoom(roomId) {
		location.href = "${chatUrl}/room?roomId="+roomId
	}
    
    ...생략
</script>
</html>

동작화면

🙋‍♀️느낀점 및 아쉬운 점

채팅방에 접속하는 과정을 ajax로 처리하여 화면전환없이 코드를 짜고 싶었지만 쉽지 않았다. 이틀을 고심하고 결국 전환식으로 화면전환없어 보이도록 코드를 짰다. 다음 프로젝트 때 기회가 되면 다시 도전해 보고 싶은 부분이다.
그리고 Stomp를 사용하는 또하나의 이유인 RabbitMQ, ActiveMQ를 공부해서 적용해보고 싶다.
아직 부족한게 많은 채팅방이다. 이미지나 첨부파일도 보내지지 않는, 그저 메시지만 주고받을 수 있는 채팅방인거다.

profile
오늘도 뚠뚠 개미 개발자

0개의 댓글