reDuck 채팅 서비스 1

김동훈·2023년 8월 21일
2

reDuck

목록 보기
3/5
post-thumbnail

졸업 프로젝트로 진행하고 있는 reDuck서비스의 채팅 시스템을 도입하며 고민했던 것을 기록하기 위해 작성합니다. 채팅 시스템는 우리에게 익숙한 카카오톡 서비스를 참고하여 1:1채팅을 구현하였습니다. 여러 기능 중 고민이 많았던 채팅방 목록, 채팅방 개설, 채팅방 입장 & 퇴장 에 대해 적어보겠습니다.

더 좋은 설계나, 제가 한 방식들에 부족한 부분이 많이 있겠지만, 초기 단계를 기록하며 발전되어가는 흐름을 보는것에 의미가 있을 것 같아 작성하게 되었습니다.

STOMP (Simple Text Oriented Messaging Protocol)를 사용하였고, 현재는 내부 브로커를 이용하고 있습니다. 추후에 외부 브로커를 사용하려고 합니다.
채팅 내역은 지금은 MySQL에 저장하고 있어, 모든 설명은 MySQL기준입니다. NoSQL 에 채팅 내역들을 저장하고 여러 다른 기능들을 사용해야겠지만, 우선 빠르게 기능을 완성시킨 후 고도화할 예정입니다.


ERD

먼저 설계한 ER-다이어그램입니다. 채팅서비스와 관련된 필드들만 언급하였습니다.

평상시에는 테이블간 관계를 FK를 통해 매핑하고 있었습니다. 최근에 실무에서는 FK사용을 안한다는 글을 읽고, 충격을 받았습니다. 데이터의 수정이나, 성능, lock과 관련하여 몇가지 이슈때문에 사용하지 않는다고 합니다. 이러한 부분을 보완할 수 있는 방법을 제시한 글도 보았지만, FK를 사용하지 않고 개발하면 어떤 점이 다를까 하고 이번 채팅 기능에서는 FK를 사용하지 않고 개발하였습니다.

위 ERD는 설계 단계이니, FK를 통해 테이블간 관계를 명시했습니다.

ERD를 그리며 신경 쓴 부분은 추후에 API를 사용하여 발생할 UPDATE쿼리의 최소화였습니다.
update쿼리의 사용은 불가피하다고 느꼈고, 그렇다면 최대한 SELECT를 이용하여 줄여보려고 노력했습니다. 그에따라, chat_room_users라는 테이블을 설계하게 되었고, 하나씩 설계이유를 적겠습니다.

채팅방 목록 조회

카카오톡의 채팅방 목록을 보면, 마지막 채팅 메시지의 시간순으로 정렬되어 있습니다. 또한 내가 읽지 않은 메시지 수들도 보여주고 있습니다.

이 기능을 개발하며 고민이 많았던 부분이 안 읽은 메시지 개수입니다. 처음엔 unread_message_size 필드를 두어, 이 필드를 update 해가며 계산하는 방식을 생각했습니다.

제 PC환경에서 대략 800만개의 데이터에서 select는 약 0.8sec가 소요되지만, 800만번의 update는 약 220sec정도가 소요됩니다. 적합한 비교는 아니었을지라도 제 생각에는 unread_message_size필드는 메시지를 보낼 때마다 update요청을 해야하니 문제가 생길 것 이라고 생각했습니다.

해결

chat_room_users테이블의 last_chat_message필드와 안 읽은 메시지 수의 제한 을 두어 해결합니다. 현재 카카오톡 서비스에서는 그룹챗의 경우 안 읽은 메시지 수를 99개로 제한을 두고 있습니다. 과거에는 300개로 기억하고 있었는데 업데이트됨에 따라 99개로 줄인 것 같습니다. 여기에서 힌트를 얻어, 300개로 제한을 한다면, 조회성능도 높일 수 있을 것 같았습니다.

last_chat_message필드란 사용자가 읽은 마지막 메시지의 id 값입니다. 최신의 300개의 메시지들을 조회 한 후 last_chat_message를 통해 더 최신의 메시지의 갯수를 계산한다면, 그 값이 안 읽은 메시지 개수가 됩니다.


사용자 A가 마지막으로 읽은 메시지인 last_chat_message_id=6이라고 해봅시다.

  1. 최근 300개의 메시지를 모두 불러온다.
  2. 300개의 메시지id 값과 last_chat_message_id값을 비교.
  3. 비교 결과 더 큰 id값의 수를 계산.

위와 같은 로직을 통해 사용자 A가 안 읽은 메시지 수는 2개 인 것을 알 수 있습니다.

채팅방 입장 & 퇴장

위 채팅방 목록 조회를 하기 위해 last_chat_message_id필드를 만들었습니다. 그럼 이 필드는 언제 update되어야 최소화 할 수 있을까에 대해 고민해보았습니다.

4가지 상황을 생각해 볼 수 있습니다.

  1. 메시지를 읽고 있는 상태.
  2. 메시지를 읽지 않고 있는 상태.
  3. 채팅방에 입장하는 순간.
  4. 채팅방에서 퇴장하는 순간.

첫 번째의 경우, last_chat_message_id를 업데이트 할 필요가 없습니다. 이 필드는 내가 읽지 않은 상대방의 메시지의 갯수를 계산하기 위해 존재합니다. 따라서, 상대방의 메시지를 계속해서 읽고 있는 상태라면 update할 필요가 없는 것입니다.

두 번째의 경우, 역시 update할 필요가 없습니다. 반대로 update를 한다는 것은 내가 상대방의 메시지를 읽었다는 뜻입니다. 그러니 내가 읽지 않고 있는 상태이므로 update를 해서는 안됩니다.

세 번째의 경우, 가장 마지막의 메시지로 update합니다. 첫 번째 경우를 보면 채팅방에 입장 후 상대방이 메시지를 보낼 경우 last_chat_message_id를 update하지 않습니다. 그렇다면 내 last_chat_message_id는 새로운 메시지 id보다 이전의 것이므로, 계속해서 안 읽은 메시지 개수가 누적될 것 입니다.

네 번째의 경우, 역시 가장 마지막의 메시지로 update합니다. 이유는 두 번째와 동일합니다.
우리가 update하지 않고 퇴장한다면, 상대가 메시지를 보낼 때 마다 이전의 안 읽은 메시지 개수에 누적하여 계산될 것입니다.

채팅방 개설

보통 카카오톡에서 1:1 채팅 아이콘을 누르면 해당 채팅방이 개설됩니다. 만약 기존에 채팅한 이력이 있다면 새로운 채팅방이 아닌 기존 채팅방으로 이동하게 됩니다.
또한, 그룹채팅방의 경우 동일한 참여자들에 대해 채팅방을 개설하려고 하면, 채팅방으로 이동 또는 채팅방 만들기 라는 선택지를 볼 수 있습니다.

이러한 기능을 구현하기 위해 생각한 방법은 각 사용자 id로 구성된 Alias를 사용하는 것입니다. 사용자의 id는 유일하므로 참여자들이 동일하지 않은 채팅방의 alias또한 유일합니다.

현재는 단일 채팅을 지원하지만, 나중에 그룹채팅으로 확장할 계획입니다. 따라서, 사용자의 id값을 받을 때 List형태로 받아, 한번에 Alias로 변환하는 작업을 하고 있습니다.

조금 나중에 알게 된 내용인데, YAGNI라는 원칙에 위배되는 설계인 것 같기도 합니다.
YAGNI원칙이란, You aren't gonna need it로 실제로 필요할 때 기능을 구현하라 라는 내용이라고 합니다.

이 원칙이 클라이언트로부터 받아오는 파라미터에도 해당되는지 고민해볼 필요가 있을 것 같습니다.

public ResponseEntity<Void> create(@RequestHeader HttpHeaders headers,
@RequestBody ChatRoomDto chatRoomDto) {
        String redirectUrl = simpleChatService.createRoom(chatRoomDto);
        headers.setLocation(URI.create("/chat/room/" + redirectUrl));
        return new ResponseEntity(headers, HttpStatus.FOUND);
    }

채팅방 개설 API는 채팅방 조회로 리다이렉션 하고 있습니다. createRoom이라는 메소드를 통해 리다이렉션 할 url를 매핑해줍니다.

public String createRoom(ChatRoomDto chatRoomDto) {
        String userId = AuthenticationToken.getUserId();
        String roomId = chatRoomDto.getRoomId();
        List<String> participantIds = mergeParticipantIds(userId, chatRoomDto.getOtherIds());
        return createRoomIfAbsent(roomId, participantIds);
    }
    
private String createRoomIfAbsent(String roomId, List<String> participantIds) {
        String alias = createAlias(participantIds);
        Optional<ChatRoom> oldChatRoom = chatRoomRepository.findByAlias(alias);
        return oldChatRoom.isPresent() ?
                oldChatRoom.get().getRoomId() :
                createNewRoom(roomId, participantIds, alias);
    }

만약, 기존에 존재하는 alias에 해당된 채팅방 개설 요청이라면 기존 채팅방으로 리다이렉션해주고
만약, 새로운 채팅방 개설 요청이라면 채팅방 개설 후 해당 채팅방으로 리다이렉션합니다.


제가 처음으로 설계했던 방식입니다.
위의 방식은 아직 부족한 부분이 많습니다. 예를 들어, 정상적인 소켓 종료가 아닌 브라우저 창 닫기 같은 비정상적으로 소켓이 종료된다면 last_chat_message_id의 update작업이 올바르게 이루어지지 않습니다.

다음 reduck 채팅 서비스2에서는 STOMP헤더에 JWT를 담아 사용자 인증하는 것과, session을 사용하여 안 읽은 메시지 개수 계산하는 방법에 대해 적어보게 될 것 같습니다.

감사합니다.

profile
董訓은 영어로 mentor

0개의 댓글