실시간 채팅을 구현해보자 (3) - BOJ Chat Service

이원석·2025년 3월 8일
3

사이드 프로젝트

목록 보기
3/10
post-thumbnail

오타/잘못된 내용에 대한 지적은 언제나 환영입니다!


안녕하시렵니까.
이전 포스팅에서는 back_end 서버에서 어떻게 서비스를 구현했는지에 대해 알아보았습니다.

글을 작성하기 앞서, 이전의 실시간 채팅 서비스에 변경점이 생겼습니다.

실시간 채팅 서비스를 백준(BOJ)에서 사용하면 재밌을 것 같다 라는 생각에 기존의 "익명 채팅 서비스"를 콘텐츠 스크립트 형태의 크롬 익스텐션 서비스 형태로 변형했습니다.


🌱 배경 및 동기

(1) 기존 커뮤니케이션 한계

백준 알고리즘 문제를 풀 때 막히는 점이 생기면, “질문 게시판”, “블로그 포스팅”, “카카오톡 오픈채팅방” 등을 통해 도움을 얻고는 했습니다. 하지만 이는 단방향 적이거나, 즉각적인 피드백을 받기 어려웠습니다.

(2) 실시간 소통의 필요성

알고리즘 문제를 풀 때, 필요시에 실시간으로 도움을 주고받고 토론할 수 있다면 학습 효율과 재미가 크게 높아질 것이라 생각했습니다.

(3) 크롬 익스텐션을 통한 확장

백준 서버 자체에 채팅 기능을 직접 추가할 수 없으므로, 크롬 익스텐션 개발을 통해 사용자가 BOJ 사이트를 이용할 때 자연스럽게 채팅 기능을 활용할 수 있도록 했습니다.




🌐 아키텍처




🚀 시연




🔍 주요 특징

(1) Chrome Extension 기반

별도의 설치 과정 없이 크롬 브라우저에 확장 프로그램만 추가하면, 백준 사이트 접속 및 로그인 시 자동으로 채팅창이 표시됩니다.

(2) 동시 접속자 수 집계

함께 문제를 풀고있는 사람이 몇 명인지 확인할 수 있습니다.

(3) 커서 페이지네이션 기반 무한 스크롤링

Cursor(beforeId) 기반으로 데이터를 동적으로 로딩하며, 데이터 추가 로딩시 기존의 스크롤 위치를 벗어나지 않도록 유지합니다.

(4) 문제별 채팅방 자동 생성

각 문제 페이지마다 고유한 채팅방이 생성되며, 같은 문제를 푸는 사람들끼리 대화할 수 있습니다.

복합 인덱스를 기반으로 쿼리 성능 최적화를 했습니다.

(5) SpringSecurity 기반 JWT 신원 인증

JWT 기반 인증을 통해 서버는 별도의 세션 상태를 유지하지 않고, 서버 부하를 감소 시켰습니다. STOMP를 통해 순수 WebSocket에서는 구현이 어려운 Header 기반의 토큰 전달 방식을 활용했습니다.

(6) XSS 취약점 보완




1️⃣  Chrome Extension 기반

기본적인 실시간 채팅 서비스를 구현하며 "채팅을 주고받는 서비스를 굳이 사람들이 사용할까?” 라는 생각이 들었습니다.

이미 대중적으로 사용되는 서비스에 이를 추가하면 상대적으로 많은 사람들에게 용이한 접근성을 제공할 수 있다 생각하여, Chrome Extension 을 기반으로 서비스를 배포했으며 다음과 같이 기존의 페이지에 콘텐츠 스크립트를 삽입하는 방식으로 구현했습니다.



이 과정에서 다음과 같은 문제를 해결했습니다.

1. CORS(교차 출처 리소스 공유 정책) 문제:

  • 원인:
    크롬 익스텐션의 콘텐츠 스크립트(content script)는 기존 (BOJ)페이지의 컨텍스트를 상속받기 때문에 Origin또한 상속받는 문제가 발생.

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



2. HTTP/HTTPS 혼합 콘텐츠(Mixed Content) 문제

  • 원인:
    - 기존 BOJ(https://www.acmicpc.net)는 HTTPS를 사용하지만, 콘텐츠 스크립트에서는 HTTP 통신을 사용.
    - 크롬 브라우저에서는 보안상 이슈로 이러한 혼합 콘텐츠를 차단.

  • 해결:
    Nginx 기반의 SSL 인증을 적용하여 클라이언트(콘텐츠 스크립트)에서 요청시 HTTPS 통신을 사용하도록 변경.




2️⃣  동시 접속자 수 집계

최근 e-commerce 서비스에서 많이 보이는 "현재 상품을 보고 있는 사람 수"를 표시하는 기능을 본 뒤, 이와 유사한 기능을 실시간 채팅 서비스에 구현했습니다.

이는 사용자들의 채팅 참여도를 높이고 동시 접속자 수를 실시간으로 집계하여 표시하는 방식으로, 사용자가 활동 중인 페이지에 대해 다른 사용자들과의 상호작용을 더욱 직관적으로 느낄 수 있습니다.



💡 구현 방식

  • @EventListener를 활용해, SessionSubscribeEvent(CONNECT가 아닌) 감지 이후 접속자를 증가시키며, SessionDisconnectEvent 감지 이후 접속자 수를 감소시킴.

  • 서버의 메모리에서 접속자 수를 관리하며, 멀티 스레드 환경에서 안전(thread-safe)한 ConcurrentHashMap을 사용.



이 과정에서 다음과 같은 문제를 해결했습니다.

1. SessionConnectEvent 가 감지되지 않는 문제

  • 원인:
    - 클라이언트가 CONNECT 이후 Entry event를 구독하기 전, 그 사이에 서버는 CONNECT Event를 감지하여 broadcast를 통해 이벤트를 전달.
    - 채팅방에 참여한 사용자는 갱신된 broadcast message를 놓치는 문제가 발생.
  • 해결:
    모든 클라이언트는 CONNECT 이후 Subscribe 과정을 거치기 때문에, Event 감지 시점을 SubScribe로 늦추며 문제를 해결.

자세한 내용은 이전 포스팅의 Entry(접속자) 실시간 체킹 을 참고해주세요.



💡 이외의 MQ 기반 기능들

  • 개별 사용자에게 발생한 예외 Message 를 전달하기 위해 @RestContollerAdvice 기반의 전역 예외처리 핸들러를 사용했습니다.

  • 채팅의 생성, 삭제, 수정 시 새로고침 없이 실시간으로 데이터를 반영하기 위해 클라이언트에서 관련 API 경로를 STOMP 방식으로 구독했습니다.

[Frontend code]

[Backend code]

Github 링크: Backend, Frontend




3️⃣  커서 페이지네이션 기반 무한 스크롤링

N개의 채팅방에서 M개의 채팅을 로딩한다면 기본적으로 O(N * M)의 시간 복잡도를 가집니다.

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

이 경우 시간 복잡도는 O(N)으로 표현할 수 있습니다.



이 과정에서 다음과 같은 문제를 해결했습니다.

1. offset / limit 기반 페이지네이션의 문제

1-1. 데이터 중복 문제

  • 원인:
    실시간으로 메시지가 추가되는 채팅 시스템에서 고정된 offset을 사용하면, 새로운 데이터가 추가될 때 페이징 일관성이 깨지는 문제(중복 데이터 로딩)가 발생.

1-2. offset 쿼리의 퍼포먼스 이슈

  • 원인:
    offset은 쿼리 실행 시 offset만큼 모든 행을 "읽으며" offset을 감소시킨 후 원하는 데이터 M개를 가져옴. 따라서, offset에 비례하여 성능 저하 이슈 발생.

  • 해결:
    커서 기반 페이지네이션을 통해 해결



💡 커서 기반 페이지네이션 (Cursor-based Pagination)

offset 처럼 n개의 row를 skip 하는것이 아닌, 이 row(Cursor) 다음 것 limit 개의 데이터를 요청하는 방식으로 개선.

"lastMessageId"가 Cursor 역활을 하며 만약, "lastMessageId"의 값이 초기화 되어있지 않다면 초기로딩 시점으로 간주하며, 값이 할당되어 있다면 Cursor를 기반으로 데이터를 페이지네이션 진행.


1. 최초 로딩 시점

최초 로딩 시점에는 Cursor가 지정되어 있지 않기 때문에 가장 최신 데이터(id 역순)에서 LIMIT 개의 메시지를 가져옵니다.

2. 이후 스크롤 시점

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


자세한 내용은 이전 포스팅의 페이지네이션 을 참고해주세요.




4️⃣  문제별 채팅방 자동 생성

초기 구현시에는 RDS 기반으로 각 문제별로 채팅을 분리하기 위해 채팅방(문제 번호), 채팅 엔티티를 분리했습니다.

실시간 채팅에 적합한 NoSQL로 데이터 마이그레이션 작업을 진행하며, JOIN 쿼리를 사용할 수 없게 됨에 따라 채팅 테이블의 컬럼에 채팅방 정보를 포함시켜 각 채팅방 별로 채팅을 로딩할 수 있도록 구현했습니다.



이 과정에서 다음과 같은 문제를 해결했습니다.

1. 쿼리 성능의 저하

  • 원인:
    - 특정 채팅방의 채팅을 찾기 위해서는 WHERE 조건 기준 쿼리가 필요함
    - 채팅방(problem_id) 데이터는 대체적으로 정렬이 되지 않은 무작위 상태로 저장이 되었으며, 순차 검색시에 성능 저하를 예상.

ex) 1037번 문제의 채팅을 찾기 위해서 모든 데이터를 탐색해야 함.


  • 해결:
    - 복합 인덱스(problem_id, id)를 활용하여 쿼리 성능을 최적화
    - 복합 인덱스를 통해 이전보다 쿼리 처리 시간이 94.12% 단축되었으며, 이는 데이터량이 많을수록 더욱 효과적인 성능 개선을 기대할 수 있음.



📈 10만 건 채팅 데이터 기준 성능 개선 결과

1. 인덱스 미적용시

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

2. 복합 인덱스 적용시

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

3. 결과

- 쿼리 수행 시간: 약 99.31% 단축 
- 전체 통신 시간: 약 94.12% 단축




5️⃣  SpringSecurity 기반 JWT 신원 인증

WebSocket 기반의 STOMP 프로토콜은 요청시 Header를 지정할 수 있습니다.

서버의 부담을 줄이고 추후 확장성 측면을 고려하여 세션 기반이 아닌 Spring Security와 JWT 토큰 인증 방식을 사용했습니다.



이 과정에서 다음과 같은 문제를 해결했습니다.

1. WebSocket CONNECT 신원 인증(JWT) 검사 시점 문제

  • 원인:
    - WS CONNECT 요청은 HTTP 통신이 아니기 때문에 SecurityFilter를 거치지 않음.
    - 최초 접속자는 CONNECT 요청을 보내기 전에 JWT가 없음

  • 해결:
    - CONNECT 시점에 인증 및 유효성 처리를 위해 별도의 Custom Channel Interceptor를 구현해 인증을 처리
    - 별도의 JWT 유효성 검사용 api를 호출 (api/init)

자세한 내용은 이전 포스팅의 jwt 검사 시점 을 참고해주세요.




6️⃣  XSS 취약점 보완

채팅에 innerHTML을 입력시 그대로 렌더링 되는 문제점이 발견되었습니다.






AWS EC2
Docker
Nginx/Certbot

0개의 댓글