오타/잘못된 내용에 대한 지적은 언제나 환영입니다!
안녕하시렵니까.
이전 포스팅에서는 back_end 서버에서 어떻게 서비스를 구현했는지에 대해 알아보았습니다.
글을 작성하기 앞서, 이전의 실시간 채팅 서비스에 변경점이 생겼습니다.
실시간 채팅 서비스를 백준(BOJ)에서 사용하면 재밌을 것 같다 라는 생각에 기존의 "익명 채팅 서비스"를 콘텐츠 스크립트 형태의 크롬 익스텐션 서비스 형태로 변형했습니다.
백준 알고리즘 문제를 풀 때 막히는 점이 생기면, “질문 게시판”, “블로그 포스팅”, “카카오톡 오픈채팅방” 등을 통해 도움을 얻고는 했습니다. 하지만 이는 단방향 적이거나, 즉각적인 피드백을 받기 어려웠습니다.
알고리즘 문제를 풀 때, 필요시에 실시간으로 도움을 주고받고 토론할 수 있다면 학습 효율과 재미가 크게 높아질 것이라 생각했습니다.
백준 서버 자체에 채팅 기능을 직접 추가할 수 없으므로, 크롬 익스텐션 개발을 통해 사용자가 BOJ 사이트를 이용할 때 자연스럽게 채팅 기능을 활용할 수 있도록 했습니다.

별도의 설치 과정 없이 크롬 브라우저에 확장 프로그램만 추가하면, 백준 사이트 접속 및 로그인 시 자동으로 채팅창이 표시됩니다.
함께 문제를 풀고있는 사람이 몇 명인지 확인할 수 있습니다.
Cursor(beforeId) 기반으로 데이터를 동적으로 로딩하며, 데이터 추가 로딩시 기존의 스크롤 위치를 벗어나지 않도록 유지합니다.
각 문제 페이지마다 고유한 채팅방이 생성되며, 같은 문제를 푸는 사람들끼리 대화할 수 있습니다.
복합 인덱스를 기반으로 쿼리 성능 최적화를 했습니다.
JWT 기반 인증을 통해 서버는 별도의 세션 상태를 유지하지 않고, 서버 부하를 감소 시켰습니다. STOMP를 통해 순수 WebSocket에서는 구현이 어려운 Header 기반의 토큰 전달 방식을 활용했습니다.
기본적인 실시간 채팅 서비스를 구현하며 "채팅을 주고받는 서비스를 굳이 사람들이 사용할까?” 라는 생각이 들었습니다.
이미 대중적으로 사용되는 서비스에 이를 추가하면 상대적으로 많은 사람들에게 용이한 접근성을 제공할 수 있다 생각하여, Chrome Extension 을 기반으로 서비스를 배포했으며 다음과 같이 기존의 페이지에 콘텐츠 스크립트를 삽입하는 방식으로 구현했습니다.
이 과정에서 다음과 같은 문제를 해결했습니다.
원인:
크롬 익스텐션의 콘텐츠 스크립트(content script)는 기존 (BOJ)페이지의 컨텍스트를 상속받기 때문에 Origin또한 상속받는 문제가 발생.

해결:
서버에서 CORS 정책을 수정하여 Origin 헤더에 해당 도메인을 추가. 또한, 필요한 경우 "chrome-extension://[ID]"도 예외 처리하여 요청 허용.


최근 e-commerce 서비스에서 많이 보이는 "현재 상품을 보고 있는 사람 수"를 표시하는 기능을 본 뒤, 이와 유사한 기능을 실시간 채팅 서비스에 구현했습니다.
이는 사용자들의 채팅 참여도를 높이고 동시 접속자 수를 실시간으로 집계하여 표시하는 방식으로, 사용자가 활동 중인 페이지에 대해 다른 사용자들과의 상호작용을 더욱 직관적으로 느낄 수 있습니다.


@EventListener를 활용해, SessionSubscribeEvent(CONNECT가 아닌) 감지 이후 접속자를 증가시키며, SessionDisconnectEvent 감지 이후 접속자 수를 감소시킴.
서버의 메모리에서 접속자 수를 관리하며, 멀티 스레드 환경에서 안전(thread-safe)한 ConcurrentHashMap을 사용.

이 과정에서 다음과 같은 문제를 해결했습니다.
자세한 내용은 이전 포스팅의 Entry(접속자) 실시간 체킹 을 참고해주세요.
개별 사용자에게 발생한 예외 Message 를 전달하기 위해 @RestContollerAdvice 기반의 전역 예외처리 핸들러를 사용했습니다.
채팅의 생성, 삭제, 수정 시 새로고침 없이 실시간으로 데이터를 반영하기 위해 클라이언트에서 관련 API 경로를 STOMP 방식으로 구독했습니다.
[Frontend code]

[Backend code]

N개의 채팅방에서 M개의 채팅을 로딩한다면 기본적으로 O(N * M)의 시간 복잡도를 가집니다.
이러한 채팅 로딩 시간 복잡도를 감소시키고자 offset/limit 기반 페이지네이션(무한 스크롤)을 활용했으며, M개의 채팅을 모두 로딩하는 것이 아닌 10개로 고정시켜 각 채팅방에 로드되는 메시지를 상수 K(=10)로 줄일 수 있었습니다.
이 경우 시간 복잡도는 O(N)으로 표현할 수 있습니다.

이 과정에서 다음과 같은 문제를 해결했습니다.
원인:
offset은 쿼리 실행 시 offset만큼 모든 행을 "읽으며" offset을 감소시킨 후 원하는 데이터 M개를 가져옴. 따라서, offset에 비례하여 성능 저하 이슈 발생.
해결:
커서 기반 페이지네이션을 통해 해결
offset 처럼 n개의 row를 skip 하는것이 아닌, 이 row(Cursor) 다음 것 limit 개의 데이터를 요청하는 방식으로 개선.
"lastMessageId"가 Cursor 역활을 하며 만약, "lastMessageId"의 값이 초기화 되어있지 않다면 초기로딩 시점으로 간주하며, 값이 할당되어 있다면 Cursor를 기반으로 데이터를 페이지네이션 진행.
최초 로딩 시점에는 Cursor가 지정되어 있지 않기 때문에 가장 최신 데이터(id 역순)에서 LIMIT 개의 메시지를 가져옵니다.

지정된 Cursor 이후의 데이터 중, LIMIT 개의 최신 데이터(예: id 역순)를 가져옵니다.

자세한 내용은 이전 포스팅의 페이지네이션 을 참고해주세요.
초기 구현시에는 RDS 기반으로 각 문제별로 채팅을 분리하기 위해 채팅방(문제 번호), 채팅 엔티티를 분리했습니다.
실시간 채팅에 적합한 NoSQL로 데이터 마이그레이션 작업을 진행하며, JOIN 쿼리를 사용할 수 없게 됨에 따라 채팅 테이블의 컬럼에 채팅방 정보를 포함시켜 각 채팅방 별로 채팅을 로딩할 수 있도록 구현했습니다.
이 과정에서 다음과 같은 문제를 해결했습니다.
ex) 1037번 문제의 채팅을 찾기 위해서 모든 데이터를 탐색해야 함.


- 쿼리 수행 시간: 144 ms
- 전체 서버 통신 포함 시간: 153 ms

- 쿼리 수행 시간: 1 ms
- 전체 서버 통신 포함 시간: 9 ms
- 쿼리 수행 시간: 약 99.31% 단축
- 전체 통신 시간: 약 94.12% 단축
WebSocket 기반의 STOMP 프로토콜은 요청시 Header를 지정할 수 있습니다.
서버의 부담을 줄이고 추후 확장성 측면을 고려하여 세션 기반이 아닌 Spring Security와 JWT 토큰 인증 방식을 사용했습니다.

이 과정에서 다음과 같은 문제를 해결했습니다.
자세한 내용은 이전 포스팅의 jwt 검사 시점 을 참고해주세요.
채팅에 innerHTML을 입력시 그대로 렌더링 되는 문제점이 발견되었습니다.

AWS EC2
Docker
Nginx/Certbot