본문 바로가기

2023년/멋쟁이사자처럼 팀프로젝트

[팀프로젝트] SockJs를 활용한 STOMP 방식 채팅방에서 실시간 사용자 체크하는 기능 구현하기

 

요구 사항

  • 채팅방 사용자가 실시간으로 접속한 유저를 알 수 있게 구현
  • 입장 또는 활동 시 유저목록 내 UI 초록색
  • 퇴장 시 유저목록 내 UI 빨간색
  • 새로 가입 시 리스트 추가

 

개요

개발환경

  • 에디터 : Intellij Ultimate
  • 개발 툴 : SpringBoot 2.7.7
  • 자바 : JAVA 11
  • 빌드 : Gradle
  • 서버 : AWS EC2, AWS RDS, AWS S3
  • CI/CD : Docker, Gitlab
  • 데이터베이스 : MySql, Redis
  • 필수 라이브러리 : Spring Data JPA, Lombok, Spring Security, thymeleaf, JWT, WebSocket, OAuth2.0, Email
  • ETC : Git, IntelliJ, JS, KakaoMap API

채팅방 환경

SockJs 를 활용하여 STOMP 방식의 채팅방 
https://dev-gorany.tistory.com/235 자료 참고
  • 구글링을 통해 쉽게 구현할 수 있는 소스를 변경시킴

진행 사항

시연 사진

채팅방에 접속하게 되면 

좌 : string 정보로 접속한 클라이언트 / 우 : 1 정보로 접속한 클라이언트

먼저 User(1) 이 채팅방에 접속하게 되면 유저목록에 해당 ID가 초록색으로 변한다.

좌 : string 정보로 접속한 클라이언트 / 우 : 1 정보로 접속한 클라이언트

User(string) 이 채팅방에 접속하게 되면 채팅방 입장 메시지와 함께 유저목록에 초록색 점등으로 최신화가 된다.

좌 : string 정보로 접속한 클라이언트 / 우 : 1 정보로 접속한 클라이언트

User(1) 이 채팅방에서 나가게 되면 채팅방 퇴장 메시지와 함께 유저 목록에 빨간색으로 변경된다.

 

좌 : string 정보로 접속한 클라이언트 / 중 : 1 정보로 접속한 클라이언트 / 우 : koo 정보로 접속한 클라이언트

새롭게 모임에 참여 신청을 한 후 승인처리가 완료되면 가입한 모임 멤버가 추가 되며, 접속함에 따라 UI 가 변경함을 확인할 수 있다.

 

플로우차트

유저목록 최신화에 대한 플로우 차트

  • 채팅방 접속과 퇴장시 /pub 을 통해서 메세지를 보내 구독하고 있는 클라이언트 들에게 메세지를 보내거나
  • 채팅방 안에서 메세지를 보낼때마다
  • 받아온 message 데이터 안에 status 값을 넣어 작성자가 온라인 상태인지 오프라인 상태인지 판별하는 로직이다.

세부로직 및 코드

더보기
document.addEventListener("DOMContentLoaded", function () {
    console.log("DOMContentLoaded..");
    findMember();
    loadFetcher();
    var username = document.getElementById("myName").innerHTML;
    sockJs = new SockJS("/stomp/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
    stomp = Stomp.over(sockJs);
    stomp.connect({}, function () {
        console.log("STOMP Connection")
        stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
            var content = JSON.parse(chat.body);
            var writer = content.writer;
            const d = new Date();
            const date = d.toISOString().split('T')[0];
            const time = d.toTimeString().split(' ')[0];
            var dateTime = date + ' ' + time;
            var str = '';
            if (writer === username) {
                str = "<div class='chatbox__messages__user-message'>";
                str += "<div style='float: right;' class='chatbox__messages__user-message--ind-message'>";
                str += "<p style='color: #6c757d;  size: 3em' >" + dateTime + "</p>";
                str += "<p className=\"name\">" + writer + "</p>";
                str += "<br/>"
                str += "<span className=\"message\">" + content.message + "</span>";
                str += "</div>";
                str += "</div>";
                $("#messagearea").append(str);
                // $('#messagearea').scrollTop($('#messagearea')[0].scrollHeight);
            } else {
                str = "<div class='chatbox__messages__user-message'>";
                str += "<div style='float: left' class='chatbox__messages__user-message--ind-message'>";
                str += "<p style='color: #6c757d;  size: 3em' >" + dateTime + "</p>";
                str += "<p className=\"name\">" + writer + "</p>";
                str += "<br/>"
                str += "<span className=\"message\">" + content.message + "</span>";
                str += "</div></div>";
                $("#messagearea").append(str);
                // $('#messagearea').scrollTop($('#messagearea')[0].scrollHeight);
            }
            findMember(content);
        });
        stomp.send('/pub/chat/enter', {}, JSON.stringify({roomId: roomId, writer: username}))
    });
    console.log("DOMContentLoaded..END");
});

화면이 로드 될때, connection 및 메세지 수신시 callback 함수 정의한 JS 파일

위 플로우 차트를 참고해서 보길 바람.

 

findMember() : 해당 채팅방에 참여한 유저목록을 화면에 보여주는 사용자 정의 메소드

loadFetcher() : 채팅방 채팅내역 불러오는 사용자 정의 메소드

findMember(data) : 참여한 유저목록 중 참여한 인원만 초록색으로 변경되게 하는 사용자 정의 메소드

@Controller
@RequiredArgsConstructor
public class StompChatController {

    private final SimpMessagingTemplate template; //특정 Broker로 메세지를 전달
    private final Map<String, Long> map = new HashMap<>();

    //Client가 SEND할 수 있는 경로
    //stompConfig에서 설정한 applicationDestinationPrefixes와 @MessageMapping 경로가 병합됨
    //"/pub/chat/enter"
    @MessageMapping(value = "/chat/enter")
    public void enter(ChatMessageDTO message){
        List<String> liveUser = new ArrayList<>();
        message.setMessage(message.getWriter() + "님이 채팅방에 참여하였습니다.");

        map.put(message.getWriter(),message.getRoomId());
        for(Map.Entry<String, Long> entry : map.entrySet()){
            if(entry.getValue().equals(message.getRoomId()) ){
                liveUser.add(entry.getKey());
            }
        }
        message.setUserList(liveUser);
        message.setState(0);
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }

    @MessageMapping(value = "/chat/out")
    public void out(ChatMessageDTO message){
        message.setMessage(message.getWriter() + "님이 채팅방에 나가셨습니다.");

        List<String> liveUser = new ArrayList<>();
        map.remove(message.getWriter());
        for(Map.Entry<String, Long> entry : map.entrySet()){
            if(entry.getValue().equals(message.getRoomId()) ){
                liveUser.add(entry.getKey());
            }
        }
        message.setUserList(liveUser);
        message.setState(1);
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }

    @MessageMapping(value = "/chat/message")
    public void message(ChatMessageDTO message){
        List<String> liveUser = new ArrayList<>();
        for(Map.Entry<String, Long> entry : map.entrySet()){
            if(entry.getValue().equals(message.getRoomId()) ){
                liveUser.add(entry.getKey());
            }
        }
        message.setUserList(liveUser);
        message.setState(0);
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }
}

STOMP 방식의 핵심 컨트롤러인데, /pub 으로 오는 요청들에 대해서는 컨트롤러로 매핑되어 처리된다.

private final SimpMessagingTemplate template; //특정 Broker로 메세지를 전달
private final Map<String, Long> map = new HashMap<>();

Map 을 통해서 사용자에 대한 정보, 채팅방 정보 ( 유저이름 : 채팅방ID ) 를 저장한다.

public class ChatMessageDTO {
    private Long roomId;
    // 채팅방 정보
    private String writer;
    // 메세지 보낸사람의 Name
    private String message;
    // 메세지 내용
    private String createdAt;
    // 작성일자
    private List<String> userList;
    // 유저목록 표시할 리스트
    private Integer state;
    // 보낸사람의 접속상태 : 0 접속, 1 퇴장
}

message 에는 위와 같은 정보가 담겨 있다. 주석 참고

map.put(message.getWriter(),message.getRoomId());
for(Map.Entry<String, Long> entry : map.entrySet()){
    if(entry.getValue().equals(message.getRoomId()) ){
        liveUser.add(entry.getKey());
    }
}
message.setUserList(liveUser);
message.setState(0); // 입장하거나 채팅방 활동시
//또는 message.setState(1); 퇴장시

공통로직

1. 누군가 메세지를 보내면 작성자에 대한 정보를 key 값, 어떤 채팅방에서 왔는지 채팅방 ID 를 value 값으로 넣어서

채팅방에 대한 정보를 기억한다.

2. Map 안에 value 값이 현재 채팅방 ID 와 동일한 data 들에 대해서 list 에 추가한다.

3. 보낼 메세지(message)에 유저목록접속상태를 설정해 보낸다.

 

내부로직 Flow / 위 플로우 차트도 함께 참고

 

그렇다면 현재 참여한 정보에 대해서는 어떻게 찾을 수 있을까

https://koopi.tistory.com/38

 

[팀 프로젝트] 1명의 USER 가 n개의 모임에 참여했는지 여부를 파악하는 방법 구현하기

요구 사항 User(사용자) 는 1개의 Crew(모임) 에 참여 할 수 있다. Crew(모임) 의 작성자는 참여 신청한 사용자를 조회/승인/반려 할 수 있다. User(사용자) 는 상세페이지에 접근했을때, 참여 상태에 따

koopi.tistory.com

현재 참여하고 있는 사용자에 대한 정보는 참여 엔티티에 정의를 해둠으로 간단하게 승인처리된 사용자에 대한 정보만 가져오면 된다.

 

@GetMapping("/members/{crewId}")
public Response findJoinMember(@PathVariable long crewId){
    log.info("#1 participateController crewId: "+ crewId);
    return Response.success(participationService.AllowedMember(crewId));
}
@Transactional
public List<PartJoinResponse> AllowedMember(long crewId) {
    Crew crew = crewRepository.findById(crewId).orElseThrow(() -> new AppException(ErrorCode.CREW_NOT_FOUND, ErrorCode.CREW_NOT_FOUND.getMessage()));
    List<PartJoinResponse> list = new ArrayList<>();
    for (Participation p : crew.getParticipations()) {

        if ((p.getStatus() == 2 || p.getStatus() == 3) && p.getDeletedAt() == null) {
            PartJoinResponse partJoinResponse = PartJoinResponse.builder()
                    .crewTitle(crew.getTitle())
                    .crewId(crewId)
                    .status(p.getStatus())
                    .writerUserName(crew.getUser().getUsername())
                    .joinUserName(p.getUser().getUsername())
                    .joinUserId(p.getUser().getId())
                    .writerUserNickName(crew.getUser().getNickName())
                    .joinUserNickName(p.getUser().getNickName())
                    .now(crew.getParticipations().size())
                    .limit(crew.getCrewLimit())
                    .build();
            list.add(partJoinResponse);
        }
    }
    return list;
}

모임에 참여한 사용자에 대한 정보를 리스트로 반환한다.

 

 

 

마무리

채팅방 이슈 발생 시 처리되는 전체 로직

간단 요약해서 채팅방에 이슈가 생기면 유저 목록을 업데이트 및 최신화를 해준다 생각하면 된다.
  • UI로 화면 표시하는 방법이 또한 JS 의 document 를 활용해 HTML을 바꿔주는 방향 또는 css 속성 변경 으로 구현함
  • 이 말고도 Redis 를 통해서 실시간 접속 수를 가져오는 방법도 있음
  • 입장과 퇴장 그리고 채팅입력 시 모든 경우에 이런 API 호출이 일어나는데, 조금 더 원할하게 할 수 있는 방법은 없는가
  • 너무 주먹구구식으로 시간에 밀려 다급하게 구현하다 보니 다소 로직이 어지럽고, 효율적이지 못한 부분이 많이 아쉬웠다. 소켓을 활용해서 참여한 사용자의 정보를 가져올 순 없는가? 에 대한 생각도 들었다.
  • 다중서버 환경에서는 위 방법은 제한된다. 고차원적인 기술이 필요할것이라 생각이 듬

 

자세한 소스코드는 아래의 깃허브 참고 부탁드립니다! 궁금하신 내용은 언제나 댓글로 남겨주세요.

https://github.com/rnrudejr9/poco_a_poco

 

GitHub - rnrudejr9/poco_a_poco: 오늘부터 운동메💪 "동네 운동 메이트를 찾아 함께 스포츠를 즐기는 서

오늘부터 운동메💪 "동네 운동 메이트를 찾아 함께 스포츠를 즐기는 서비스". Contribute to rnrudejr9/poco_a_poco development by creating an account on GitHub.

github.com