[Project] STOMP 구현

Yumya's record·2024년 10월 11일
0

[회고] Auction Project

목록 보기
5/7
post-thumbnail

본격적으로 STOMP를 구현하는 방법에 대해 설명하고자 한다.



✏️ build.gradle

// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// STOMP
implementation 'org.webjars:stomp-websocket:2.3.4'

// SockJS
implementation 'org.webjars:sockjs-client:1.5.1'

//thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

//JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  • WebSocket 속성 : WebSocketMessageBrokerConfigurer Interface를 상속하기 위해 추가한다.
  • STOMP 속성 : STOMP 사용을 위해 추가한다.
  • SockJS 속성 : 다양한 기술을 이용해 웹 소켓을 지원하지 않는 환경에서도 정상적으로 동작하게 하기 위해 추가한다.
  • Thymleaf 속성 : 타임리프 사용을 위해 추가한다.
  • JPA 속성 : JPA 사용을 위해 추가한다.

DB의 경우 H2를 사용했으며, 필요시 H2 속성이나 다른 DB 속성을 추가해서 사용하면 된다.


✏️ Domain

Domain의 경우 chatRoom(채팅방)과 Chatter(채팅방 참여자)로 구분해 구현했다.


ChatRoom.java

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {

    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(name = "chatRoomId")
    private String chatRoomId;

    @Column(name = "name")
    private String name;

    @OneToMany
    @Column(name = "chatterList")
    private List<Chatter> chatters;

    @Builder
    public ChatRoom(String name) {
        this.chatRoomId = UUID.randomUUID().toString();
        this.name = name;
        this.chatters = new ArrayList<>();
    }

    ... //아래에서 계속
  • Id : 채팅방 구분자
  • ChatRoomId : 채팅방 아이디(UUID)
    • UUID를 사용해 랜덤하면서 유일한 아이디를 만든다.
      숫자 등 단순한 Id를 사용하게 될 경우보다 데이터에 대한 정보를 노출하지 않아 보안에 좋다.
  • Name : 채팅방 이름
  • ChatterList : 채팅방 참여자 리스트
    • @OneToMany 매핑으로 하나의 채팅방이 여러 명의 참여자를 가지게 했다.

Chatter.java

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Chatter {

    @Id
    @Column(name = "chatterId")
    private String chatterId;

    @Builder
    public Chatter(String id) {
        this.chatterId = id;
    }
}
  • ChatterId : 채팅방 참여자 Id


✏️ Repository

ChatRoom 저장소와 채팅 Chatter 저장소로 나누어 구현했다.

ChatRoomRepository.java

@Repository
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
}

ChatRoom 객체와 ChatRoom의 구분자인 Long을 사용해 JpaRepository를 상속받는 인터페이스를 구성한다.


ChatterRepository.java

@Repository
public interface ChatterRepository extends JpaRepository<Chatter, String> {
}

Chatter 객체와 Chatter의 구분자(Id)인 String을 사용해 JpaRepository를 상속받는 인터페이스를 구성한다.



✏️ DTO

MessageDto를 사용해 메시지를 전송한다.

DTO : Data Transfer Object, 프로세스 간 데이터 전달을 위해 사용되는 객체

MessageDto.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MessageDto {
    public enum MessageType {
        ENTER, TALK, LEAVE
    }

    private MessageType type;
    private String roomId;
    private String sender;
    private String message;


    public void setMessage(String s) {
        this.message = s;
    }
}
  • MessageType : 메시지 타입
    • ENTER : 채팅방 입장
    • TALK : 채팅 입력
    • LEAVE : 채팅방 퇴장
  • RoomId : 채팅방 Id
  • Sender : 채팅을 보낸 사람
  • Message : 채팅 내용
  • setMessage : Message Setter
    • @Setter를 사용하지 않고 직접 구현하였다. (@Setter 사용 지양)


✏️ Config

WebSocket과 STOMP를 사용하기 위해서는 설정이 필요하다.

WebConfig.java

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("Authorization", "Content-Type")
                .exposedHeaders("Custom-Header")
                .maxAge(3600);
    }
}
  • WebMvcConfigurer : 스프링 프레임워크에서 제공하는 인터페이스
    • 스프링에 추가적인 설정을 위해 사용했다.
  • addCorsMappings : CORS 규칙을 정의하기 위한 메서드
  • addMapping("/**") : CORS를 적용할 URL 패턴을 정의
    다음과 같은 값들이 기본값으로 설정되어 있다.
    • Allow All Origins
    • Allow Method GET, HEAD and POST
    • Allow All Headers
    • Set max age to 1800 seconds(30 min)
  • allowedOriginPatterns : 허용할 도메인 목록 설정
  • allowedMethods : 허용할 HTTP method 설정
  • allowedHeaders : 허용되는 Header 설정
  • exposedHeaders : 클라이언트가 응답에서 접근할 수 있는 헤더 이름 목록 설정
  • maxAge : 캐싱 시간 설정

CORS를 방지하기 위한 설정이다.
CORS를 설정하면서 다양한 오류가 발생할 수 있으니 주의하기 바란다.


WebSockConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry brokerRegistry) {
        brokerRegistry.enableSimpleBroker("/sub"); //Publisher가 /sub 경로로 메시지 전달 시 Subscriber에게 전달
        brokerRegistry.setApplicationDestinationPrefixes("/pub"); //Publisher가 /pub 경로로 메시지 전달 시 가공 후 Subscriber에게 전달
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("http://localhost:8080").withSockJS();
    }
}
  • WebSocketMessageBrokerConfigurer : STOMP로 메시지를 처리하는 방법들을 정의한 인터페이스
  • @EnableWebSocketMessageBroker : WebSocket을 통한 메시지 브로커 지원 기능 활성화
    -> Broker 역할
  • configureMessageBroker : 메시지를 보낼 때 관련 경로를 설정해주는 메서드
    • enableSimpleBroker : 해당 경로로 시작하는 메시지가 메시지 브로커로 라우팅 되어야 한다고 정의
      -> Subscriber 경로 정의
    • setApplicationDestinationPrefixes : 해당 경로로 시작하는 메시지가 메시지 처리 메서드로 라우팅 되어야 한다고 정의
      -> Publisher 경로 정의
  • registerStompEndpoints : 웹 소켓 엔드포인트 지정 메서드
    • addEndpoint : 엔드포인트 지정
    • setAllowedOriginPatterns : 허용할 도메인 목록
    • withSockJs : 다양한 기술을 이용해 웹 소켓을 지원하지 않는 환경에서도 정상적으로 동작하도록 함


✏️ Service

ChatService.java

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {

    private final ObjectMapper mapper;
    private ConcurrentHashMap<String, ChatRoom> chatRoomMap;
    private final ChatRoomRepository chatRoomRepository;

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

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

    public ChatRoom findRoomById(String id) {
        return chatRoomMap.get(id);
    }

    @Transactional
    public ChatRoom createRoom(String name) {
        ChatRoom chatRoom = chatRoomRepository.save(new ChatRoom(name));
        log.info("Create Room : {} {}", chatRoom.getId(), chatRoom.getName());
        chatRoomMap.put(chatRoom.getChatRoomId(), chatRoom);
        return chatRoom;
    }
}
  • @Slf4j : 콘솔에서 로그를 사용하기 위한 어노테이션
  • init : 초기화 메서드
    • ConcurrentHashMap : Hashtable 클래스의 단점을 보완하면서 Multi-Thread 환경에서 사용할 수 있도록 나온 클래스
    • @PostConstruct : 의존성 주입이 이루어진 후 초기화를 수행하는 어노테이션
  • findAllRoom : 채팅방 목록 조회 메서드
  • findRoomById : 채팅방 Id에 해당하는 채팅방 조회 메서드
  • createRoom : 채팅방 생성 메서드
    • @Transactional : 트랜잭션 처리를 위해 사용되는 어노테이션
    • log.info : 해당 메시지를 콘솔에 출력
    • 새로운 채팅방을 하나 생성해 채팅방 목록에 저장한 뒤 반환한다.


✏️ Controller

컨트롤러에서는 채팅과 채팅방 로직을 수행한다.
이때 @RestController가 아닌 @Controller를 사용해 View와 Controller를 연결할 수 있도록 했다. (@RestController를 사용하게 될 경우 View가 아닌 객체 자체를 반환)

ChatController.java

@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessageSendingOperations simpMessageSendingOperations;

    @MessageMapping("/chat/message")
    public void message(MessageDto messageDto) {
        if (MessageDto.MessageType.ENTER.equals(messageDto.getType())) {
            messageDto.setMessage(messageDto.getSender() + "님이 입장하셨습니다.");
        }

        if (MessageDto.MessageType.LEAVE.equals(messageDto.getType())) {
            messageDto.setMessage(messageDto.getSender() + "님이 퇴장하셨습니다.");
        }
        simpMessageSendingOperations.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto);
    }
}
  • SimpMessageSendingOperations : Spring 프레임워크에서 메시지 브로커로 메시지를 전송하는 작업을 처리하는 인터페이스
  • message : '/sub/chat/room/ + {chatRoomId}'에 구독 중인 클라이언트에게 메시지 전송하는 메서드
    • @MessageMapping : 메시지 처리를 위한 어노테이션
      • 클라이언트가 '/pub/chat/message'로 메시지 발행
    • MessageDto로 받아온 데이터 타입에 따라 메시지를 다르게 했다.
      • ENTER : '{sender} + "님이 입장하셨습니다."'
      • LEAVE : '{sender} + "님이 퇴장하셨습니다."'

ChatRoomController.java

@RequiredArgsConstructor
@Controller
public class ChatRoomController {

    private final ChatService chatService;

    @GetMapping("/chat/room")
    public String chatRoomList(Model model) {
        return "/chat/room";
    } //채팅방 목록 화면

    @GetMapping("/chat/room/all")
    @ResponseBody
    public List<ChatRoom> chatRoomAll() {
        return chatService.findAllRoom();
    } //채팅방 목록 조회

    @PostMapping("/chat/room")
    @ResponseBody
    public ChatRoom createChatRoom(@RequestParam String name) {
        return chatService.createRoom(name);
    } //채팅방 생성

    @GetMapping("/chat/room/enter/{chatRoomId}")
    public String enterChatRoom(Model model, @PathVariable(value = "chatRoomId") String chatRoomId) {
        model.addAttribute("chatRoomId", chatRoomId);
        return "/chat/enter";
    } //채팅방 입장 화면

    @GetMapping("/chat/room/{chatRoomId}")
    @ResponseBody
    public ChatRoom chatRoom(@PathVariable(value = "chatRoomId") String chatRoomId) {
        return chatService.findRoomById(chatRoomId);
    } //채팅방 조회


✏️ View

templates 안에 view 파일을 만들어 사용했다.
HTML, CSS, Vanilla JS를 사용해 html 파일을 구성했다.

Room.html

<!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="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div class="container" id="pub">
    <div class="row">
        <div class="col-md-12">
            <h3>채팅방 리스트</h3>
        </div>
    </div>
    <div class="input-group">
        <div class="input-group-prepend">
            <label class="input-group-text">방 제목</label>
        </div>
        <label for="room_name"></label><input type="text" class="form-control" id="room_name" />
        <div class="input-group-append">
            <button class="btn btn-primary" type="button" id="createRoomBtn">채팅방 개설</button>
        </div>
    </div>
    <ul class="list-group" id="chatroomList">
    </ul>
</div>

<!-- JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
    document.addEventListener('DOMContentLoaded', function() {
        const roomNameInput = document.getElementById('room_name');
        const chatroomList = document.getElementById('chatroomList');
        const createRoomBtn = document.getElementById('createRoomBtn');

        // 채팅방 목록 로드
        function findAllRoom() {
            axios.get('http://localhost:8080/chat/room/all')
                .then(response => {
                    chatroomList.innerHTML = ''; // 리스트 초기화
                    response.data.forEach(item => {
                        const li = document.createElement('li');
                        li.className = 'list-group-item list-group-item-action';
                        li.textContent = item.name;
                        li.addEventListener('click', () => enterRoom(item.chatRoomId));
                        chatroomList.appendChild(li);
                    });
                });
        }

        // 채팅방 생성
        function createRoom() {
            const roomName = roomNameInput.value.trim();
            if (roomName === "") {
                alert("방 제목을 입력해 주십시요.");
                return;
            }
            const params = new URLSearchParams();
            params.append("name", roomName);
            axios.post('http://localhost:8080/chat/room', params)
                .then(response => {
                    alert(response.data.name + " 방 개설에 성공하였습니다.");
                    roomNameInput.value = '';
                    findAllRoom();
                })
                .catch(() => {
                    alert("채팅방 개설에 실패하였습니다.");
                });
        }

        // 방에 들어가기
        function enterRoom(roomId) {
            const sender = prompt('대화명을 입력해 주세요.');
            if (sender) {
                localStorage.setItem('wschat.sender', sender);
                localStorage.setItem('wschat.roomId', roomId); 
                window.location.href = "http://localhost:8080/chat/room/enter/" + roomId;
            }
        }

        // Enter 키로 채팅방 생성하기
        roomNameInput.addEventListener('keyup', function(event) {
            if (event.key === 'Enter') {
                createRoom();
            }
        });

        // 버튼 클릭으로 채팅방 생성
        createRoomBtn.addEventListener('click', createRoom);

        // 페이지 로드 시 채팅방 목록 불러오기
        findAllRoom();
    });
</script>
</body>
</html>

Enter.html

<!doctype html>
<html lang="en">
<head>
    <title>Websocket ChatRoom</title>
    <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="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div class="container" id="pub">
    <div>
        <h2 id="roomName"></h2>
        <button class="btn btn-primary" type="button" id="leaveButton">나가기</button>
    </div>
    <div class="input-group">
        <div class="input-group-prepend">
            <label class="input-group-text">내용</label>
        </div>
        <input type="text" class="form-control" id="messageInput">
        <div class="input-group-append">
            <button class="btn btn-primary" type="button" id="sendButton">보내기</button>
        </div>
    </div>
    <ul class="list-group" id="messagesList">
    </ul>
</div>

<!-- JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
    var sock = new SockJS('http://localhost:8080/ws-stomp'); //소켓 생성
    var ws = Stomp.over(sock);
    var reconnect = 0;

	//채팅방 Id와 채팅 참여자 닉네임을 가져와서 저장
    var roomId = localStorage.getItem('wschat.roomId');
    var sender = localStorage.getItem('wschat.sender');
    var room = {};
    var messages = [];
    
    console.log("Chat Room ID: " + roomId); //서버에서 채팅방 입장 확인

    document.getElementById('sendButton').addEventListener('click', sendMessage); 
    document.getElementById('leaveButton').addEventListener('click', leaveChatRoom);
    document.getElementById('messageInput').addEventListener('keypress', function(event) {
        if (event.key === 'Enter') {
            sendMessage();
        }
    });

    function findRoom() {
        axios.get('http://localhost:8080/chat/room/' + roomId).then(function(response) {
            room = response.data;
            document.getElementById('roomName').textContent = room.name;
        });
    } //채팅방 찾기 기능

    function sendMessage() {
        var messageInput = document.getElementById('messageInput');
        var message = messageInput.value;
         if (message) {
            ws.send("/pub/chat/message", {}, JSON.stringify({type: 'TALK', roomId: roomId, sender: sender, message: message}));
           messageInput.value = '';
        }
    } //메시지 전송 기능

    function revMessage(rev) {
        var messageList = document.getElementById('messagesList');
        var newMessage = document.createElement('li');
        newMessage.classList.add('list-group-item');
        newMessage.textContent = (rev.type === 'ENTER' ? '[알림]' : rev.sender) + ' - ' + rev.message;
        messageList.insertBefore(newMessage, messageList.firstChild);
    } //전송받거나 전송한 메시지를 채팅방에 표시

    function leaveChatRoom() {
        ws.send("/pub/chat/message", {}, JSON.stringify({type: 'LEAVE', roomId: roomId, sender: sender, message: ''}));
        window.location.href = "http://localhost:8080/chat/room";
    } //채팅방 나가기 기능

    function connect() {
        ws.connect({}, function(frame) {
            ws.subscribe("/sub/chat/room/" + roomId, function(message) {
                var rev = JSON.parse(message.body);
                revMessage(rev);
            });
            ws.send("/pub/chat/message", {}, JSON.stringify({type: 'ENTER', roomId: roomId, sender: sender}));
        }, function(error) {
            if (reconnect++ <= 5) {
                setTimeout(function() {
                    console.log("connection reconnect");
                    sock = new SockJS("http://localhost:8080/ws-stomp");
                    ws = Stomp.over(sock);
                    connect();
                }, 10 * 1000);
            }
        });
    } //소켓 연결이 되지 않을 경우 5번 재연결 시도

    findRoom();
    connect();
</script>
</body>
</html>


✏️ 메시지 전송 과정

  • "/sub"로 시작하는 경로는 메시지 브로커를 향하도록 설정

    1. Subscirber는 '"/sub/chat/room/" + roomId' 경로로 메시지 전송
    2. 메시지 브로커가 '"/sub/chat/room/" + roomId' 경로를 구독 중인 Subscriber들에게 메시지 전송
  • "/pub"로 시작하는 경로는 @MessageMapping을 향하도록 설정

    1. Publisher는 '/pub/chat/message' 경로로 메시지 전송
    2. /pub으로 시작하기 때문에 @MessageMapping 경로로 메시지가 이동
    3. Message Type에 따라 메시지 내용 구성
    4. '"/sub/chat/room/" + roomId' 경로로 메시지 전송
    5. 메시지 브로커가 이 메시지를 '"/sub/chat/room/" + roomId'를 구독 중인 Subscriber들에게 메시지 전송

참고-메시지 전송 과정


이제 localhost에서 view에 접속해 채팅방 생성, 참여, 채팅 보내기, 퇴장 등을 했을 때 실시간으로 메시지를 받을 수 있다.

profile
🍀 ٩(ˊᗜˋ*)و 🍀

0개의 댓글