96일차 Team Project

웹소켓을 통한 실시간채팅을 개발하기로 했다.
앞으로 며칠간 진행될 예정이며 이를 기록하기로 하겠다.

참고한 블로그
https://myhappyman.tistory.com/100

1. 라이브러리

라이브러리 설정을 해주어야한다.

<!-- 실시간 채팅 위한 웹소켓 -->
<!-- https://mvnrepository.com/artifact/org.springframework/spring-websocket -->
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-websocket</artifactId>
</dependency>

2. SocketHandler & config

웹소켓 구현체와 등록해주는 config파일을 생성해야한다.
다양한 구현체가 있지만 텍스트만 주고받기 위한 TextWebSocketHandler를 상속받아준다.
각각 연결전 연결 이후 주고받기 소켓 종료 후에 사용되는 메소드이다.

@Component
public class SocketHandler extends TextWebSocketHandler {
    // 웹소켓 세션을 담아둘 맵
    HashMap<String, WebSocketSession> sessionMap = new HashMap<>();

    // 웹소켓 연결이 되면 동작
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 소켓 연결시 session의 id를 키로 세션저장
        super.afterConnectionEstablished(session);
        sessionMap.put(session.getId(), session);
    }

    // 메시지를 수신하면 실행
    // 상속받은 TextWebSocketHandler는 handleTextMessage를 실행시키고
    // 메시지 타입에따라 handleBinaryMessage또는 handleTextMessage가 실행된다.
    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 메시지 발송
        String msg = message.getPayload();
        for (String key : sessionMap.keySet()) {
            // sessionMap에서 WebSocketSession객체 얻기
            WebSocketSession wss = sessionMap.get(key);
            try {
                wss.sendMessage(new TextMessage(msg));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    // 웹소켓 종료시 동작
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 소켓 종료시 세션id를 기준으로 세션삭제
        sessionMap.remove(session.getId());
        super.afterConnectionClosed(session, status);
    }
}

이 구현체를 config파일에 등록해야한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final SocketHandler socketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry
           .addHandler(socketHandler, "/chat");
    }
}

3. chat.js

백엔드 영역에서 웹소켓에 대한 요청을 받고 처리를 하지만 프론트영역에서 웹소켓을 여는 설정을 해주어야한다.
jsp파일 자체는 생략하도록 하겠다.

let customerId = $('#customerId').val();

if (customerId === 'anonymousUser') {
    customerId = "guest";
}

// 콘솔 텍스트 영역
let messageTextArea = $("#messageTextArea");

// 웹소켓
let ws;

function wsOpen() {
    try {
        ws = new WebSocket("ws://" + location.host + "/chat");
        wsEvt();
    } catch (error) {
        // 웹소켓 연결 실패 처리
        messageTextArea.val(messageTextArea.val() + "웹소켓 연결에 오류가 발생했습니다...\n");
    }
}

function wsEvt() {
    //소켓이 열리면 초기화 세팅하기
    ws.onopen = function(data) {
        messageTextArea.val(messageTextArea.val() + "서버에 연결되었습니다...\n");
    }

    ws.onerror = function(event) {
        messageTextArea.val(messageTextArea.val() + "오류가 발생했습니다...\n");
    };

    ws.onmessage = function(data) {
        let msg = data.data;
        if (msg != null && msg.trim() != '') {
            messageTextArea.val(messageTextArea.val() + msg);
        }
    }

    ws.onclose = function(event) {
    };

    // enter눌리면 send() 실행
    $(document).keypress(function(e) {
        if (e.which == 13) {
            send();
        }
    });

    $("#sendMessageBtn").click(function() {
        send();
    });
}

function send() {
    let userName = customerId;
    let msg = $("#textMessage").val();
    ws.send(userName + " : " + msg + "\n");
    $('#textMessage').val("");
}

//초기화
wsOpen();

현재 이상태는 상대와 내가 구분이 안되고 단체로 채팅방에 들어가서 각자 할말만하는? 상태라고 보면된다.
wsOpen() 에서 웹소켓 객체를 만든다. config에 설정해둔 곳으로 요청을 하게 된다.
wsEvt()에서 소켓이 열린후 초기화세팅을 한다.열렷을때 메시지를 보낼때 닫앗을때 처리가 있다.
send()에서 웹소켓의 send메소드를 사용해서 메시지를 보내게 된다.

json으로 소통과 멤버 구분하기

4. json 라이브러리 추가

참고한 글에서는 simple json을 사용하고 있는데 팀원 중 누군가가 이미 등록해놓은 Gson라이브러리가 있어서 이걸 사용하기로 했다.
GSON은 JSON 데이터를 이용하기 위해 만들어진 라이브러리 중 하나이다.
google에서 제작했고 GSON을 나타내는 가장 큰 특징은 JAVA나 Kotlin 객체를 JSON 데이터로 손쉽게 변환할 수도, 그 반대도 가능하게 해준다는 점이라고 한다.

 <!-- Gson라이브러리 -->
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.8.7</version>
    </dependency>

5. jsonToObjectParser 함수 추가

들어온 Json 메시지를 파싱해주는 메소드가 필요하다.
JsonObject 객체로 파싱처리해준다.

private static JsonObject jsonToObjectParser(String jsonStr) {
    JsonObject obj = null;
    try {
        obj = JsonParser
                .parseString(jsonStr)
                .getAsJsonObject();
    } catch (JsonParseException e) {
        e.printStackTrace();
    }
    return obj;
}

6. 핸들러 로직 추가

// 웹소켓 세션을 담아둘 맵
HashMap<String, WebSocketSession> sessionMap = new HashMap<>();

// 웹소켓 연결이 되면 동작
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    // 소켓 연결시 session의 id를 키로 세션저장
    super.afterConnectionEstablished(session);
    sessionMap.put(session.getId(), session);
    JsonObject obj = new JsonObject();
    obj.addProperty("type", "getId");
    obj.addProperty("sessionId", session.getId());
    session.sendMessage(new TextMessage(obj.toString()));
}

// 메시지를 수신하면 실행
// 상속받은 TextWebSocketHandler는 handleTextMessage를 실행시키고
// 메시지 타입에따라 handleBinaryMessage또는 handleTextMessage가 실행된다.
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
    // 메시지 발송
    String msg = message.getPayload();
    JsonObject obj = jsonToObjectParser(msg);
    for (String key : sessionMap.keySet()) {
        // sessionMap에서 WebSocketSession객체 얻기
        WebSocketSession wss = sessionMap.get(key);
        try {
            wss.sendMessage(new TextMessage(obj.toString()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

// 웹소켓 종료시 동작
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    // 소켓 종료시 세션id를 기준으로 세션삭제
    sessionMap.remove(session.getId());
    super.afterConnectionClosed(session, status);
}

소켈이 연결되면 동작하는 afterConnectionEstablished() 메소드 부분에 로직을 추가했다.
생성된 세션을 저장하면 발신메시지의 type은 getId라고 명시후 생성된 세션id값을 클라이언트단으로 발송한다.
클라이언트 단에서는 type값을 통해 메시지와 초기설정값을 구분할 것이다.

메시지 전송시 JSON파싱을 위해 message.getPayload()를 통해 받은 문자열을 만든 함수 jsonToObjectParser에 넣어서 JsonObject값으로 받아서 강제 문자열 형태로 보내준다.

7. 클라이언트

let customerId = $('#customerId').val();

if (customerId === 'anonymousUser') {
    customerId = "guest";
}

// 콘솔 텍스트 영역
let messageTextArea = $("#messageTextArea");

// 웹소켓
let ws;

function wsOpen() {
    try {
        ws = new WebSocket("ws://" + location.host + "/chat");
        wsEvt();
    } catch (error) {
        // 웹소켓 연결 실패 처리
        messageTextArea.val(messageTextArea.val() + "웹소켓 연결에 오류가 발생했습니다...\n");
    }
}

function wsEvt() {
    //소켓이 열리면 초기화 세팅하기
    ws.onopen = function(data) {
        messageTextArea.val(messageTextArea.val() + "서버에 연결되었습니다...\n");
    }

    ws.onerror = function(event) {
        messageTextArea.val(messageTextArea.val() + "오류가 발생했습니다...\n");
    };

    ws.onmessage = function(data) {
        let msg = data.data;
        if (msg != null && msg.trim() != '') {
            let serverJson = JSON.parse(msg);
            if (serverJson.type === "getId") {
                let sId = serverJson.sessionId != null ? serverJson.sessionId : "";
                if (sId != '') {
                    $("#sessionId").val(sId);
                }
            } else if (serverJson.type === "message") {
                if (serverJson.sessionId === $("#sessionId").val()) {
                    messageTextArea.val(messageTextArea.val() + customerId + "님: " + serverJson.msg + "\n");
                } else {
                    messageTextArea.val(messageTextArea.val() + "관리자: " + serverJson.msg + "\n");
                }
            } else {
                console.warn("unknown type!")
            }
        }
    }

    ws.onclose = function(event) {
    };

    // enter눌리면 send() 실행
    $(document).keypress(function(e) {
        if (e.which == 13) {
            send();
        }
    });

    $("#sendMessageBtn").click(function() {
        send();
    });
}

function send() {
    let data = {
        type: "message",
        sessionId: $("#sessionId").val(),
        userName: customerId,
        msg: $("#textMessage").val()
    }
    ws.send(JSON.stringify(data));
    $('#textMessage').val("");
}

//초기화
wsOpen();

초기화 부분의 onmessage가 변경되었다.
처음 메시지가 없거나 빈게 아니라면 getId인지 message인지 확인한다.

hanlder에서 웹소켓이 처음 생성될때 type getId를 보내고 sessionid에 sessionid로 담아서 보냈기 때문에 가능한 것이다.

getId라면 초기설정된 값으로 채팅창에 값을 입력한게 아니라 추가한 태그 sessionId에 값을 세팅해준다.
이 id값은 소켓이 종료되기 전까지 자기자신을 구분할 수 있는 session값이 될 것이다

type이 message인 경우 채팅이 발생한 경우이다. 상대방과 자신ㅇ르 구분하기 위해서 sessionid값을 사용한다.
현재 이 채팅방을 기준으로 input박스에 sessionid가 들어있는데 내 sessionid일 경우는 내 메시지이고
아닐 경우는 상대의 메시지이다.

채팅방별 구분하기

8. 챗룸 domain(VO)

챗룸을 구분하기 위한 domain을 만들어주었다.

@Getter
@Setter
@ToString
public class ChatRoom {
    int roomNumber;
    String roomName;

    @Override
    public String toString() {
        return "Room [roomNumber=" + roomNumber + ", roomName=" + roomName + "]";
    }
}

번호로 구분하게 될 것이다.

9. 컨트롤러

방으로 접근시킬 뷰페이지, 방을 생성하고 방정보를 가져오는 컨트롤러를 각각 만들어주었다.
이 채팅방은 db에 데이터를 담아두거나 파일등에 저장하는게 아니므로 실시간으로 휘발시키기 위한 roomList를 컬렉션을 생성하고 메소드에 따라 방을 생성하거나 방의 정보를 가져오도록 처리했다.

movingChating메소드에서는 전달받은 파라미터 값으로 방이 생성되엇는지 체크후 해당방으로 이동시켜준다.

@Controller
@Slf4j
@RequestMapping("/chat/")
public class ChatController {
    List<ChatRoom> roomList = new ArrayList<>();
    static int roomNumber = 0;

    @GetMapping("customer")
    public String chat() {
        return "chat/customerchat";
    }

    // 방페이지
    @GetMapping("room")
    public String room() {
        return "chat/room";
    }

    // 방 생성하기
    @PostMapping("/createRoom")
    @ResponseBody
    public List<ChatRoom> createRoom(@RequestParam HashMap<Object, Object> params) {
        String roomName = (String) params.get("roomName");
        if (roomName != null && !roomName.trim().equals("")) {
            ChatRoom room = new ChatRoom();
            room.setRoomNumber(++roomNumber);
            room.setRoomName(roomName);
            roomList.add(room);
        }
        return roomList;
    }

    // 방 정보가져오기
    @PostMapping("/getRoom")
    @ResponseBody
    public List<ChatRoom> getRoom(@RequestParam HashMap<Object, Object> params) {
        log.info("log {}", roomList);
        return roomList;
    }

    // 채팅방
    @GetMapping("/moveChating")
    public String chating(Model model, @RequestParam HashMap<Object, Object> params) {
        int roomNumber = Integer.parseInt((String) params.get("roomNumber"));
        List<ChatRoom> newList = new ArrayList<>();
        for (ChatRoom room : roomList) {
            if (room.getRoomNumber() == roomNumber) {
                newList.add(room);
            }
        }
        if (newList != null && newList.size() > 0) {
            model.addAttribute("roomName", params.get("roomName"));

            model.addAttribute("roomNumber", params.get("roomNumber"));
            return "chat/customerchat";
        } else {
            return "chat/room";
        }
    }
}

10. 클라이언트 단

var ws;
window.onload = function() {
    getRoom();
    createRoom();
}

function getRoom() {
    commonAjax('/chat/getRoom', "", 'post', function(result) {
        createChatingRoom(result);
    });
}

function createRoom() {
    $("#createRoom").click(function() {
        var msg = {
            roomName: $('#roomName').val()
        };

        commonAjax('/chat/createRoom', msg, 'post', function(result) {
            createChatingRoom(result);
        });

        $("#roomName").val("");
    });
}

function goRoom(number, name) {
    location.href = "/chat/moveChating?roomName=" + name + "&" + "roomNumber=" + number;
}

function createChatingRoom(res) {
    if (res != null) {
        var tag = "<tr><th class='num'>순서</th><th class='room'>방 이름</th><th class='go'></th></tr>";
        res
            .forEach(function(d, idx) {
                var rn = d.roomName.trim();
                var roomNumber = d.roomNumber;
                tag += "<tr>"
                    + "<td class='num'>"
                    + (idx + 1)
                    + "</td>"
                    + "<td class='room'>"
                    + rn
                    + "</td>"
                    + "<td class='go'><button type='button' onclick='goRoom(\""
                    + roomNumber + "\", \"" + rn
                    + "\")'>참여</button></td>" + "</tr>";
            });
        $("#roomList").empty().append(tag);
    }
}

function commonAjax(url, parameter, type, calbak, contentType) {
    $.ajax({
        url: url,
        data: parameter,
        type: type,
        contentType: contentType != null ? contentType
            : 'application/x-www-form-urlencoded; charset=UTF-8',
        success: function(res) {
            calbak(res);
        },
        error: function(err) {
            console.log('error');
            calbak(err);
        }
    });
}

페이지 접근시 commonAjax를 통해서 방의 정보를 가져오고 접속요청한 버튼에 따라 서버에서 검증후 결과에 따라 페이지가 이동된다.

방을 나누는 것은 다음부터 작성하겠다.

2023.06.15

채팅에 관하여
웹소켓을 만들어서 세션을 발생시키는 것이라 내가 아는 세션과 웹소켓세션을 구분못하고잇엇는데
구분이 되게 되엇다.

자꾸 세션id가 초기화되길래 뭔가햇더니 이 세션id는 웹브라우저의 세션이아닌 웹소켓의 세션이다.
위의 세션을 만들어서 보내는 것 이런것이 웹소켓이 생성될때마다 만들어지는 웹소켓의 세션인 것이다.

+ Recent posts