프로젝트/Kimstagram

[내멋대로 만드는 Kimstagram] 13. 웹 소켓(Websocket)을 활용한 DM 구현

kim-dev 2024. 2. 4. 16:12
반응형

드디어 거의 마지막 단계에 온 것 같다.
사실 이 프로젝트를 계획했을 때, 가장 마지막에 DM을 구현하려고 했다.
왜냐하면 딱 봐도 DM이 제일 어려울 거 같으니까... 프로젝트 막바지 쯤에 접어들면 실력도 어느 정도 향상돼서 DM 정도는 쉽게 구현할 수 있을 줄 알았다.

 

ㅋㅋ 근데 전혀 아니었다. 일단 구현하기 전에 좀 찾아봤는데, 확실히 어려운 것 같다.
아니 분명 예전에 자바 Socket을 활용한 채팅 프로그램은 큰 어려움 없이 만들었는데, 웹에서 사용하는 소켓은 Websocket이라는 또 다른 라이브러리를 사용하는 것 같다.


아 참고로 소켓을 활용한 채팅 프로그램은 아래의 주소를 찾아가시면 확인하실 수 있읍니다.

 

[Java] 소켓을 이용한 간단한 채팅 프로그램 예제

GitHub - kimdevv/Chat Contribute to kimdevv/Chat development by creating an account on GitHub. github.com 주로 사용한 것들: JFrame, JDBC(MySQL), Socket 학교에서 컴퓨터 네트워크 이론과 데이터베이스를 배우면서 뭔가 깨달음

kimdev-s.tistory.com

 

여튼, 사실 웹소켓도 소켓이기 때문에 그 본질은 위 포스트에서 사용한 소켓과 똑같다.
일단 TCP 연결을 사용하기 때문에 연결지향형 → 3-way handshake를 거친 후 세션으로 연결된다.
그래서 특정 누군가가 서버로 메시지를 보낸다면, 서버와 연결된 모든 클라이언트들이 새로운 메시지가 도착했다는 사실을 알 수 있으며 그 메시지를 받아서 클라이언트에 띄울 수 있다.

 

나는 그냥... DB와 연결해서 ajax로만 채팅을 띄우는 로직을 대충 생각해 봤는데, 이 경우에는

  1. http에서는 서버와 클라이언트가 stateless하므로, 클라이언트들은 상대방이 메시지를 보내더라도, 그 사실을 알 수 없다.
  2. 그래서 각 클라이언트들이 메시지를 업데이트 하려면 일정 간격으로 계속 ajax 요청을 보내서 메시지를 최신화해야 한다.
  3. 물론 구현 자체는 가능하겠지만, 계속된 요청으로 인해 굉장히 서버가 비효율적으로 흘러갈 것이다.

그래서 결국 웹 소켓을 활용하기로 했다.
그럼... 구현을 시작해 보자.


일단 build.gradle에 아래 라이브러리 2개를 추가해 준다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.2'

윗 라인은 websocket을 사용할 수 있게 해주는 라이브러리이며,
아래 라인은 json 데이터를 다룰 수 있게 해주는 라이브러리이다.
웹소켓에서 데이터를 주고 받는 형태가 json이기 때문에, 아래의 라이브러리가 꼭 필요하다고 한다.

 

그리고 이제 JPA를 활용할 DB 객체들을 아래와 같이 만들어 주었다.

package com.kimdev.kimstagram.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.springframework.web.socket.WebSocketSession;

import javax.persistence.*;
import java.sql.Timestamp;
import java.util.HashSet;
import java.util.Set;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
public class DMRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public int id;

    @ManyToOne
    @JoinColumn(name="user1")
    public Account user1;

    @ManyToOne
    @JoinColumn(name="user2")
    public Account user2;

    @CreationTimestamp
    private Timestamp createDate;
}

이건 일단 두 유저 간의 DM 한 객체다.
이게... 말로 표현하기가 조금 애매한데... 그냥 두 유저가 만든 DM 방 하나를 생각하면 됨!

 

큰 역할은 없고, 한 DM 방에 유저가 누구 누구 참여하는지만 알려주면 될 듯?

 

각 DMRoom에는 웹소켓에 연결하는 세션들의 정보가 저장되어야 하는데, 사실 그걸 여기에 같이 넣고자 했었다. 그런데 JPA에서는 Set이나 Map 등을 다루지 못한다고 오류가 뜸...ㅋㅋ
그래서 DMService 클래스에서 세션을 따로 관리해주기로 했다.

private final Map<Integer, Set<WebSocketSession>> sessionMap = new ConcurrentHashMap<>();

public Set<WebSocketSession> getSessions(int roomId) { // 해당 DMRoom에 연결돼 있는 세션을 찾는다
    return sessionMap.computeIfAbsent(roomId, k -> new HashSet<>());
}

public void addSession(int roomId, WebSocketSession session) {
    Set<WebSocketSession> sessions = getSessions(roomId);
    sessions.add(session);
}

public void removeSession(int roomId, WebSocketSession session) {
    Set<WebSocketSession> sessions = getSessions(roomId);
    sessions.remove(session);
}

DMRoom과 웹소켓세션을 가지고 Map을 만들었다.
그래서 DMRoom의 roomId를 통해 해당 DMRoom에 연결된 세션들을 추출해서 세션을 추가하고 삭제할 수 있는 것...

 

package com.kimdev.kimstagram.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;
import java.sql.Timestamp;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
public class DMMessage {

    public enum MessageType {
        ENTER,
        QUIT,
        CHAT
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public int id;

    public MessageType messageType;

    public int roomId;

    // 0이면 user1이, 1이면 user2가 보낸 메시지인 것.
    public int who;

    @Lob
    public String message;

    @CreationTimestamp
    private Timestamp createDate;
}

그리고 이건 각 DM 방에 보내지는 채팅 메시지를 저장하는 객체이다.
DM 방에 매번 들어갈 때마다, 이전에 보낸 메시지들이 쫘르륵 떠야 하니까 메시지들을 DB에 저장해야 한다고 생각했다!

 

그리고 누가 보냈는지를 어떻게 나타낼 지 고민 좀 하다가... 그냥 int타입 who를 통해 0 또는 1으로 표현하기로 했다.
Account 자체를 저장할 수도 있겠지만, 굳이 조인으로 하는 것 보다는 숫자로 누구인지만 나타낼 수 있으면 되니까!

 

그래서 DM 방에 입장할 때마다 웹소켓에 세션 등록 + 이전 메시지들을 DB에서 불러옴
이 두 가지 작업을 해주면 될 것!


여튼 이제 객체들을 만들어 줬으니, 이 객체들을 적절히 다룰 수 있도록 웹소켓에 연결한 후 적절한 로직을 수행하는 핸들러를 구현해야 할 것이다!

사실 핸들러는 처음부터 구현하는 게 아니라, 웹소켓의 연결을 관리하는 TextWebSocketHandler을 상속받아서 메서드들을 오버라이딩해서 구현해주면 된다.

package handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kimdev.kimstagram.Repository.DMMessageRepository;
import com.kimdev.kimstagram.Repository.DMRoomRepository;
import com.kimdev.kimstagram.model.DMMessage;
import com.kimdev.kimstagram.model.DMRoom;
import com.kimdev.kimstagram.service.DMService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Set;

@RequiredArgsConstructor
@Component
public class WebSocketHandler extends TextWebSocketHandler {
    private final ObjectMapper objectMapper; // JSON 데이터를 파싱해주는 라이브러리 (Jackson)

    @Autowired
    DMRoomRepository dmRoomRepository;

    @Autowired
    DMMessageRepository dmMessageRepository;

    @Autowired
    DMService dmService;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload(); // 전송받은 message의 JSON 형태 데이터.
        DMMessage dmMessage = objectMapper.readValue(payload, DMMessage.class); // payload를 DMMessage 형태로 파싱해준다.
        dmMessageRepository.save(dmMessage); // DB에 메시지 저장

        int roomId = dmMessage.getRoomId();
        DMRoom dmroom = dmRoomRepository.findById(roomId).get();
        Set<WebSocketSession> sessions = dmService.getSessions(roomId);

        if (dmMessage.getMessageType().equals(DMMessage.MessageType.ENTER)) { // 입장하는 경우
            dmService.addSession(roomId, session);
        } else if (dmMessage.getMessageType().equals(DMMessage.MessageType.QUIT)) { // 퇴장하는 경우
            dmService.removeSession(roomId, session);
        } else { // 일반 메시지인 경우
            sessions.parallelStream().forEach(roomSession -> sendMessage(roomSession, message)); // 메시지 그냥 전달
        }
    }

    public void sendMessage(WebSocketSession roomSession, TextMessage message) {
        try {
            roomSession.sendMessage(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

위와 같이 작성했다.
해당 웹소켓에 연결된 세션들은 DMMessage 객체의 메시지를 주고 받는다.
이 메시지의 타입은 ENTER, QUIT, CHAT 세 가지로 나뉘며 각 경우에 대해 로직을 작성해 주었다.
ENTER과 QUIT 타입 메시지가 도착하면, 해당 메시지를 보낸 세션을 참여한 DMRoom의 세션에 등록/삭제해준다.
그리고 CHAT 타입 메시지인 경우, 그 메시지를 그대로 연결된 세션들에게 보내준다.

 

이제 이 핸들러를 가지고, 웹소켓 서버를 설정할 차례이다.

웹소켓 서버 설정은 WebSocketConfigurer 클래스를 상속받아서 구현하면 된다.

package com.kimdev.kimstagram.Security;

import handler.WebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@RequiredArgsConstructor
@Configuration
@ComponentScan(basePackages = "handler")
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    WebSocketHandler webSocketHandler;

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

아까 만든 WebSocketHandler을 의존성 주입 받아서 Configuration을 구성한다.

"/ws/chat"으로 들어오는 ws 요청은 모두 우리가 짠 핸들러를 거치라는 의미!!

 

여기서 깨달은 게... @Configuration을 붙여도, 다른 스프링 빈 클래스들을 바로 사용하지 못 한다는 점...
@ComponentScan을 통해 빈에 등록한 다른 클래스들의 경로를 적어준 후에야 비로소 사용할 수 있었다!
...그래서 여기서 애 좀 먹음 ㅋㅋ

 

 

여튼... 굉장히 귀찮지만 이제 프론트를 또 만들어 줘야 한다...
하 프론트 구성하는 게 진짜 세상 제일 귀찮은 듯...ㅜ 그래도 어쩌겠니 구현은 해야 하는데

일단 유저 프로필에서, '메시지 보내기' 버튼을 누르면 해당 유저와 내가 속하는 DMRoom 객체를 만들어 DB에 저장하고, 해당 방으로 이동하게 했다.

@GetMapping("/dm")
public String dm(Model model, @RequestParam int principalId, @RequestParam int userId) {
    Account principal = accountRepository.findById(principalId).get();
    Account user = accountRepository.findById(userId).get();
    model.addAttribute("principal", principal);
    model.addAttribute("user", user);

    DMRoom room = dmService.findDmRoom(principal, user);
    model.addAttribute("room", room);

    ArrayList<DMMessage> messages = dmService.findDmMessages(room);
    model.addAttribute("messages", messages);

    int whoisme = dmService.whoisme(principal, room);
    model.addAttribute("whoisme", whoisme);

    return "home/dm";
}
@Transactional
public DMRoom findDmRoom(Account principal, Account user) {
    // 방이 있을 경우
    DMRoom room = dmRoomRepository.findByUser1AndUser2(principal, user);
    if (room == null) {
        room = dmRoomRepository.findByUser1AndUser2(user, principal);
    }

    // 방이 없을 경우에는 방을 새로 만든다.
    if (room == null) {
        room = new DMRoom();

        room.setUser1(principal);
        room.setUser2(user);

        dmRoomRepository.save(room);
    }

    return room;
}

@Transactional
public ArrayList<DMMessage> findDmMessages(DMRoom room) {
    ArrayList<DMMessage> messages = dmMessageRepository.findAllByRoomId(room.getId());
    if (messages == null) {
        messages = new ArrayList<>();
    }

    return messages;
}

@Transactional
public int whoisme(Account principal, DMRoom room) {
    if (room.getUser1() == principal) {
        return 0;
    } else {
        return 1;
    }
}

클라이언트에서 필요할 만한 데이터들을 Model에 담아서 클라이언트 쪽으로 보내주었다.
이렇게 /dm으로 입장 요청이 오면 아래와 같은 DM 방으로 입장할 수 있다.

편의 상 대충 위에 상대 유저 정보나 각종 css들은 생략하고 DM에 관련된 이야기만 하겠다.


가운데 커다란 빈 div가 chatMessages div이다.
그래서 아래의 input에 메시지를 입력한 후 전송을 누르면,

  1. 웹 소켓에 해당 메시지가 전달된 후, 연결된 모든 세션에게 메시지가 전송된다.
    이후 메시지를 받은 세션들의 html에는 채팅풍선 div를 만들어서 chatMessages에 동적으로 추가한다.
  2. 입력한 메시지는 DMMessage 객체로 파싱되어 DB에 저장된다.
    이를 통해 다시 vegeta라는 유저와의 DM 방으로 입장하면, 저장된 이전 메시지들을 불러와서 html에 할당하면 이전 채팅 내역들도 볼 수 있게 된다.

위 두 가지 로직들을 모두 구현한 jsp는 아래와 같다.
스크립트를 js로 나누려고 했으나... Model에 담겨 클라이언트로 넘겨준 값들을 js에서 사용하기 어려운 이슈로 인해 그냥 jsp에서 한 번에 처리했다...ㅜ

(css는 생략)

<body>
    <div id="chatDiv">

        <div id="userInfo">
            <img id="userInfo_img">
            <div style="display: flex; flex-direction: column; margin-left: 10px">
                <span id="userInfo_username"></span>
                <span id="userInfo_name"></span>
            </div>
        </div>
        <script>
            document.getElementById('userInfo').onclick = function () {
                gotoProfile('${user.username}');
            }

            if ('${user.use_profile_img}' === '1') {
                document.getElementById('userInfo_img').src = "/dynamicImage/profile/" + '${user.username}' + "/profile.jpg";
            } else {
                document.getElementById('userInfo_img').src = "/dynamicImage/profile/default.jpg";
            }

            document.getElementById('userInfo_username').innerText = '${user.username}';
            document.getElementById('userInfo_name').innerText = '${user.name}';
        </script>

        <div id="chatMessages">
            <!-- 이전 채팅 메시지들 불러오기 -->
            <c:forEach var='message' items='${messages}'>
                <c:if test="${message.messageType eq 'CHAT'}">
                    <script>
                        var messageDiv = document.getElementById('chatMessages');

                        var newMsg = document.createElement('div');
                        if ('${message.who}' === '${whoisme}') {
                            newMsg.className = "myMsg";
                        } else {
                            newMsg.className = "yourMsg";
                        }
                        newMsg.innerText = '${message.message}';

                        messageDiv.append(newMsg);
                    </script>
                </c:if>
            </c:forEach>
            <script>
                var chatMessages = document.getElementById('chatMessages');
                chatMessages.scrollTop = chatMessages.scrollHeight;
            </script>
        </div>
    </div>

    <div id="inputIdv">
        <input id="input" onkeydown="keyHandler(event)">
        <div id="send" onclick="sendMsg()">전송</div>
    </div>
</body>


<script>
    ///////////////////////////////////////////////////////////////////
    // 소켓 설정

    // DM창에 들어오면 웹소켓을 시작한다.
    let socket = new WebSocket("ws://25.20.167.96:8000/ws/chat");

    // 세션이 소켓에 참여할 때
    socket.onopen = function () {
        var enterMsg = {
            "messageType": "ENTER",
            "roomId": '${room.id}',
            "who": '${whoisme}',
            "message": ""
        }
        socket.send(JSON.stringify(enterMsg)); // 웹소켓은 메시지를 JSON 형태로 주고 받으니, JSON 형태로 변환해서 접속 메시지를 보낸다
    }

    // 소켓에 오류가 날 때
    socket.onerror = function (error) {
        console.log(error)
    }

    // 소켓에 메시지가 도착했을 때
    socket.onmessage = function (e) {
        parsedMsg = JSON.parse(e.data);

        var messageDiv = document.getElementById('chatMessages');

        let newMsg = document.createElement('div');
        if (parsedMsg.who === '${whoisme}') {
            newMsg.className = "myMsg";
        } else {
           newMsg.className = "yourMsg";
        }
        newMsg.innerText=parsedMsg.message;

        messageDiv.append(newMsg);
        scrollToBottom();

        $.ajax({
            type: "POST",
            url: "/saveDmMessage",
            data: parsedMsg,
            contentType: "application/json; charset=utf-8",
        });
    }

    // 페이지를 나가면 소켓을 종료한다.
    window.addEventListener('beforeunload', () => {
        var quitMessage = {
            "messageType": "QUIT",
            "roomId": '${room.id}',
            "who": '${whoisme}',
            "message": ""
        }
        socket.send(JSON.stringify(quitMessage));

        socket.close();
    });

    var chatMessages = document.getElementById('chatMessages');
    function scrollToBottom() {
        chatMessages.scrollTop = chatMessages.scrollHeight;
    }

    function gotoProfile(username) {
        location.href = "/profile/" + username;
    }

    function sendMsg() {
        var message = document.getElementById('input').value;
        if (message == "") {
            alert("메시지를 입력해 주세요.")
        } else {
            document.getElementById('input').value = "";

            var chatMessage = {
                "messageType": "CHAT",
                "roomId": '${room.id}',
                "who": '${whoisme}',
                "message": message
            }
            socket.send(JSON.stringify(chatMessage));
        }
    }

    function keyHandler(event) {
        if (event.key == 'Enter') { // Enter 키를 누르는 경우
            event.preventDefault(); // 기본 Enter 키 동작을 막는다.
            sendMsg(); // 'send' Div 클릭 시 작동하는 함수
        }
    }
</script>

 

여기서 나오는 /saveDmMessage는 로직에서도 쉽게 알 수 있듯이, 전송한 메시지를 DB에 저장하는 컨트롤러이다.

@PostMapping("/saveDmMessage")
public int saveDmMessage(@RequestBody DMMessageDTO dmMessageDTO) {
    dmService.saveDmMessage(dmMessageDTO);

    return 1;
}
@Transactional
public void saveDmMessage(DMMessageDTO dmMessageDTO) {
    DMMessage dmMessage = new DMMessage();
    dmMessage.setMessageType(dmMessageDTO.getMessageType());

    int roomId = dmMessageDTO.getRoomId();
    dmMessage.setRoomId(roomId);

    dmMessage.setWho(dmMessageDTO.getWho());
    dmMessage.setMessage(dmMessageDTO.getMessage());

    dmMessageRepository.save(dmMessage);
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DMMessageDTO {
    private DMMessage.MessageType messageType;
    private int roomId;
    private int who;
    private String message;
}

아마 여기까지 완료했으면, DM이 구현이 완료될 것이다.
사실 클론 코딩 관련 정보는 구글링하면 꽤 나오는데, DM과 관련해서 구현한 사람은 구글링해도 나오지 않아서...ㅜㅜ
웹 소켓 이론만 간단히 공부한 후 직접 구현해 보았다. 그런데 뭐 꽤나 잘 동작해서 만족스러운 결과인 듯...? ㅋㅋ

마지막에 진짜 새로고침 한 건데... 티가 안 나네 ㅋㅋ

여튼 새로고침을 해도 이전에 했던 채팅이 그대로 나타난다는 것!


아래의 페이지를 정말 많이 참고했다.
사실상 말이 참고지 80%는 그냥 가져온 거라고 봐도 무방하다....
많은 것들을 배워 갑니다...!

 

Springboot- websocket을 이용한 채팅

WebSocket 기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜. 일반 Socket통신과 달리 HTTP 80 Port를 사용하므로 방화벽에 제약이 없으며 통상 WebSocket으로 불린다.

brilliantdevelop.tistory.com

 

 

[Spring Boot] WebSocket과 채팅 (1)

일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅) 기존 공부 용도의 게시판(?)에 여러

dev-gorany.tistory.com