Project

채팅 구현에 @stomp/stompjs 라이브러리를 사용한 이유

yunicornlab 2025. 3. 28. 06:05

부트캠프에서 출퇴근 관리 웹을 개발하는 Riset이라는 창작 프로젝트를 진행했었다.

Riset은 Sun Rise + SunSet의 줄임말로, 해가 뜰 때 출근하고 해가 질 때 퇴근하는 직장인을 의미한다.

여기서 나는 프론트엔드로 참여했고, 채팅과 일정 관리, 다크모드와 반응형 등을 구현했다.

이 중 채팅 구현 과정을 돌아보려고 한다.

구현한 채팅 기능 소개

아래 사진처럼 직원들 목록 화면에서 이름을 검색할 수 있고 1명 또는 다수를 선택해서 채팅방을 만들 수 있다.

채팅은 실시간 통신으로 구현했고 1:1 채팅뿐 아니라 그룹 채팅이 가능하다.

검색은 직원 이름 검색, 채팅방 목록 검색, 채팅 내용 검색 모두 구현했고, 텍스트 메시지와 파일도 전송할 수 있다.

이미지는 기본 화면과 다크모드, 그리고 반응형을 골고루 가져왔다.

직원 목록과 채팅 내용 검색
채팅 화면과 채팅방 목록

채팅에 쓰인 기술 스택(텍스트 메시지 기준)

  • 프론트엔드 : @stomp/stompjs
  • 백엔드 : Spring WebSocket, STOMP 프로토콜, MongoDB

물론, TypeScript, React 등 사용한 기술은 많지만 채팅 위주로만 써보았다.

특히 파일 관련된 내용은 따로 글을 작성할 예정이다.

 

사실 처음에 원했던 조합은 아래와 같다.

  • 프론트엔드 : @stomp/stompjs, SockJS
  • 백엔드 : Spring WebSocket, SockJS, STOMP 프로토콜, Redis

의사 결정 과정 요약

구현 과정을 간단히 요약해보자면 다음과 같다. 

1) 1:1 및 그룹 대상 실시간 채팅 구현 결정
2) Pub/Sub 모델 채택
3) STOMP 프로토콜 채택
4) @stomp/stompjs 라이브러리 채택

 

1) 1:1 및 그룹 대상 실시간 채팅 구현 결정

프로젝트 기간이 총 4주로 제한되어있는 데다가, 창작이여서 기획과 디자인에 2주가 소요되었다보니 실제로 구현할 수 있는 시간은 2주밖에 주어지지 않은 상황이었다.

그래서 처음에는 1:1 채팅만 얘기를 했었고, 단순히 WebSocket과 요청/응답 방식으로 구현할까 생각도 했었다.

하지만, 열심히 창작한 프로젝트에 애정이 생기고 코딩에 재미가 붙다보니 욕심이 생겨서 그룹 채팅까지 하기로 결정을 했다.

거기에 '실시간'까지 더해 '실시간 1:1 및 그룹 채팅'을 구현하기로 결정했다.

그러다보니 단순 요청/응답 방식으로는 적절하지 않다고 생각했고 방법을 찾기 시작했다.

2) Pub/Sub 모델 채택

채팅을 나 혼자 뚝딱뚝딱 구현하는 것이 아니라 백엔드 동료와 협업해서 만들어야하는 것이기 때문에 메시지 형식이나 구조, 전송 방식 등 많은 부분에서 규칙이 필요하게 되었다. 검색해보니 다양한 통신/메시징 모델이 있었다. 그 중 3개만 적어보았다.

 

Pub/Sub (Publish/Subscribe) 모델

  • '구독'의 개념을 사용한다. 구독이란 브로커에 특정 목적지를 미리 등록해놓고 해당 목적지로 새로운 메시지가 도착할때마다 자동으로 알림(메시지)를 받아볼 수 있는 기능을 말한다. 1:다(브로드캐스트) 형태에 장점을 가진다.
  • Publisher(발행자)와 Subscriber(구독자) 사이에 브로커가 존재해 메시지를 전달/중개하는 역할을 대신해준다.
  • 발행자와 구독자 간의 직접적인 연결이 줄어(=느슨한 결합) 의존성이 줄어들기 때문에 독립적인 개발과 유지보수를 가능하게 해준다. 때문에, 구독자가 추가되어도 영향이 적고 관리가 용이하다.
  • 발행자는 메시지를 특정 주제에 발행한다.(수신자에 전달하는 게 아니라 주제에 전달하는 것이다.) 그리고 누가 그 메시지를 받는지, 그 주제에 현재 몇 명이 구독하는 지 등을 알 필요가 없다. 이처럼 구독자도 누가 메시지를 보냈는지 알 필요 없이 브로커가 알아서 메시지를 보내준다.

XMPP (Extensible Messaging and Presence Protocol) 모델

  • XML 기반의 채팅 전용 메시징 모델이자 프로토콜로, 실시간 메시징에 최적화되어있어 즉각적인 메시지 전달이 가능하다.
  • 중앙 서버 없이 서버간 통신이 가능한 분산형 아키텍처 기반으로, 오랜 표준이었다.
  • XML 기반의 구조로 인해 프로토콜 자체가 복잡하고 무겁다.

Peer to Peer (P2P) 모델

  • 중앙 서버 없이 네트워크에 연결된 모든 참여자(Peer)가 서로 동등한 관계에서 데이터를 주고받는 모델
  • 서버 부하가 분산되어 성능을 향상시킬 수 있지만, 중앙 통제가 어려워 네트워크 관리가 복잡해질 수 있다.
  • 전통적인 클라이언트-서버 모델과 다르게, 각 피어가 클라이언트이자 서버 역할을 동시에 수행

우리는 이 중에서 Pub/Sub 모델을 채택했다.

일단, '구독'의 개념이 친숙해서 직관적으로 이해가 되었고, 채팅 방에 입장한 사람들을 구독자로 두고 각 채팅방을 주제(Topic)로 관리하면 새로운 메시지가 채팅방에 전달될 때 브로커가 알아서 메시지를 모든 구독자에게 보내기에 편리하다고 생각했다. 또한, 구독자나 주제(채팅방)가 늘어나더라도 큰 영향이 없이 독립적으로 개발과 유지보수가 가능할 것이라고 생각했다.

이러한 이유로 우리는 Pub/Sub 모델로 확정지었다.

 

3) STOMP 프로토콜 채택

Pub/Sub 모델을 구현하는 데 사용할 수 있는 라이브러리들이 또 다양하게 존재한다. 하지만 구독 개념만으로 충분할까?

메시지 발행 및 구독과 같은 기본적인 기능을 제공하지만 메시지 형식이나 라우팅, 연결 관리, 오류 처리 등의 규칙도 필요했다.

규칙, 곧 규약을 다른 말로 하면 '프로토콜'이다. 즉, 채팅 기능을 구현하는 데 있어서 특정한 메시징 프로토콜이 필요하다고 생각했다.

Pub/Sub 모델을 구현할 수 있는 메시징 프로토콜도 종류가 다양했다.

 

STOMP (Simple Text Oriented Messaging Protocol)

  • 텍스트 기반의 프로토콜로, 메시지 형식이 단순하여 프로토콜의 동작 방식이나 메시지를 직관적으로 확인하고 디버깅하기 쉽다.
  • 메시지를 '프레임(Frame)' 단위로 주고받는다. 프레임ㅇㄴ 사람이 읽을 수 있는 형태로 구성된다.(명령, 헤더, 본문, 구분자)
  • 특정 브로커에 종속되지 않고 STOMP 인터페이스만 지원되면 호환된다.
  • 텍스트 기반이기 때문에 바이너리 기반의 프로토콜보다는 트래픽이 약간 더 많고 성능이 떨어질 수 있다.

XMPP (Extensible Messaging and Presence Protocol)

  • (위의 설명과 같다. 메시징 모델이자 메시징 프로토콜이다. 추가로 Pub/Sub 모델을 부분적으로 지원한다.)

AMQP (Advanced Message Queuing Protocol)

  • Pub/Sub 모델뿐만 아니라 메시지 큐, 라우팅 등 다양한 메시징 기능을 제공한다.
  • 복잡한 메시징 요구사항을 충족하는 상황에서 많이 사용되고, 안정성과 신뢰성을 보장한다.
  • 학습 곡선이 가파르고 사람이 해석하기 어려워 디버깅하기 어렵다.

MQTT (Message Queuing Telemetry Transport)

  • IoT(사물 인터넷), 임베디드 환경에서 주로 사용되는 경량 메시징 프로토콜이다.
  • 완전한 Pub/Sub 모델을 기반으로한다.
  • 제한된 대역폭과 불안정한 네트워크 환경에서도 효율적으로 작동하도록 설계되었다.

우리는 빠른 개발과 한정된 리소스를 감당할 수 있게, 단순하고 명확하며 오버헤드가 낮은 STOMP 프로토콜을 사용하기로 결정했다.

이해하기 편하니 적용하기도 편하고, 프론트엔드와 백엔드가 협업을 위해 함께 대화하기도 편했다.


❗️STOMP 프로토콜에 대해 더 자세히 알아보기❗️

✔️ STOMP 프로토콜의 메시지의 기본 단위, 프레임

프레임은 명령(COMMAND), 헤더(HEADERS), 본문(BODY), NULL 문자로 구성되어있다.

아래는 프레임의 예시다. 딱 봐도 깔끔하고 이해하기 쉽다.

SEND
destination:/topic/greetings
content-type:text/plain

Hello, STOMP!
\u0000
  • SEND, 즉 첫 줄이 명령(COMMAND) 자리로, 프레임의 유형을 나타낸다.
  • 명령 다음 줄부터 빈 줄이 나올때까지는 헤더(HEADERS) 자리로, 메시지에 대한 추가 정보를 제공한다.
    키-값 쌍으로 구성되고 예시에서는 destination, 즉 메시지를 전송할 대상 토픽을 알려주고 있고, 
    content-type으로 메시지 본문의 데이터 유형을 알려주고 있다.
  • "Hello, STOMP!"가 메시지 본문(BODY)이다. 빈 줄 다음부터 NULL 문자가 나오기 전까지의 부분이다.
    실제 메시지의 내용을 담고 있고, 텍스트나 바이너리 데이터를 포함할 수 있다.
  • 프레임의 마지막을 나타내는 NULL 문자(0x00)가 주어진다.

✔️ STOMP 프로토콜의 명령(COMMAND) 예시

  • CONNECT : 클라이언트가 브로커에 연결할 때 사용
  • CONNECTED : 브로커가 CONNECT 요청을 성공적으로 받은 후 보내는 응답으로, 연결이 확립됨을 알수 잇음
  • DISCONNECT : 클라이언트가 브로커와의 연결을 끊을 때 사용
  • SEND : 클라이언트가 브로커에게 메시지를 전송할 때 사용
  • SUBSCRIBE : 클라이언트가 특정 주제(큐/Topic)을 구독할 때 사용
  • UNSUBSCRIBE : 클라이언트가 특정 주제(큐/Topic)의 구독을 취소할 때 사용
  • MESSAGE : 브로커가 구독자(클라이언트)에게 메시지를 전달할 때 사용
  • ACK/NACK : 클라이언트가 메시지 처리의 성공과 실패 여부를 알릴 때 사용

4) @stomp/stompjs 라이브러리 채택

그럼 이제 STOMP 프로토콜로 하면 되겠다! 하고 끝나는가? 절대 아니다.

STOMP를 직접 구현할까 생각도 했지만 이미 아주 좋은 라이브러리들이 잔뜩 있다. 개발의 효율성과 유지보수를 위해 라이브러리를 사용하기로 했다. 물론, 학습 차원에서 직접 구현해보면 많은 도움이 될 것 같다.

 

그런데 만약, STOMP를 직접 구현한다면 WebSocket도 직접 같이 사용해주어야 한다.

특히, 웹이 아닌 환경에서는 몰라도 꼭 WebSocket이 아니더라도 웹에서는 브라우저가 지원하는 통신 방법을 사용해주어야 한다.

그 이유는 브라우저의 보안때문이다.

STOMP는 본래 TCP(소켓) 위에서 동작하는 프로토콜이다. 웹 환경이 아니면 원시 TCP 소켓을 직접 연결해서 STOMP 만으로도 실시간 통신을 구현할 수 있지만, 브라우저는 보안상 원시 TCP/UDP 소켓에 직접 연결할 수 없다고 한다.

그렇기 때문에 웹 환경에서는 브라우저가 지원하는 WebSocket같은 통신 방식을 반드시 사용해주어야 한다.

그래서인지 사용 후보로 꼽은 아래의 (프론트엔드 기준) 실시간 통신 자바스크립트 라이브러리는 WebSocket 같은 브라우저 통신 방식을 기본적으로 사용한다.

 

stompjs@stomp/stompjs

라이브러리를 알아볼 때 이름이 똑같은 라이브러리가 두 개 있어서 헷갈렸다.

간단히 말해, stompjs는 이전 버전이고 @stomp/stompjs는 최신 버전이라고 생각하면 된다. stompjs는 2016년 이후 유지보수가 중단되었다고 한다.

둘다 자바스크립트로 STOMP 프로토콜을 구현한 라이브러리로, WebSocket을 기본으로 사용한다. 

최신 업데이트가 이루어지고 있는 @stomp/stompjs는 TypeScript도 지원하고 자동 재연결, 바이너리 데이터 지원, 콜백 기능 등의 다양한 기능을 제공한다. 그리고 STOMP 프로토콜 표준을 엄격히 준수한다고 한다.

그렇기 때문에 이 둘 중에서 고민한다면 당연히 @stomp/stompjs를 사용해야 한다.

 

SockJS

Node.js뿐 아니라 Spring과도 쉽게 연동이 가능해서 Spring WebSocket + SockJS + Stomp 조합으로 자주 사용된다고 한다. SockJS으로 STOMP 프로토콜도 사용할 수 있다.

그리고 사실, 원래 사용하고 싶었던 라이브러리다. 사용하고 싶었던 이유는, SockJS는 WebSocket을 기본적으로 사용하지만 WebSocket을 지원하지 않는 구형 버전과 같은 브라우저 환경에서도 폴백과 같은 기술을 사용해 WebSocket을 사용하는 것과 유사하게 통신을 구현해준다.

즉, 브라우저 호환성이 장점이다. 물론 우리 프로젝트에서는 그러한 환경에서 접근할 가능성이 높지는 않지만 그래도 어떤 식으로 동작이 되는지 확인해보고싶었다.

하지만 백엔드 동료가 테스트해보았을 때 SockJS + Redis 조합이 로컬에서는 잘 되었지만 배포 환경에서는 알 수 없는 에러가 발생하고, 시간도 부족한 상황이라 WebSocket과 STOMP 프로토콜과 MongoDB로 구현하겠다고 했다. 시간만 넉넉했어도 함께 도와주고 같이 공부하면서 해결했을텐데 이 부분이 조금 아쉬웠다. 그렇지만 우리 프로젝트 상황에 있어서는 SockJS를 사용하지 않아도 구현에 큰 문제가 있거나 엄청난 단점이 있는 건 아니었기에 함께 결정했다.

 

Socket.IO

이건 애초에 배재할 수 밖에 없었던 라이브러리다. 서버가 Spring 기반이기 때문이었다. 이 라이브러리는 Node.js 환경이 가장 최적이다. 다른 환경도 사용할 수는 있다고 하지만 추천하지는 않는다. 이것도 WebSocket 기반이고, WebSocket을 지원하지 않는 브라우저에서는 SockJS와 같이(접근 방식은 다르겠지만) 자체적인 전송 프로토콜을 사용해 호환성을 보장해준다.

Node.js 환경이었다면 사용해보고싶은 라이브러리다. 다른 라이브러리보다 더 다양한 추가 기능과 유연한 메시징을 제공한다고 한다. 커스텀 메시징 형식이 필요할 때도 좋을 것 같다.


돌아보며

코딩이 끝난 것도 아니고 사용할 기술 하나를 고르기 위해 이러한 의사결정 과정이 필요하다는 것을 깨닫게 되는 계기였다.

개발 생태계는 항상 빠르게 변화하고 새로운 기술이 끝도없이 나온다. 이 기술들을 잘 비교해가며 현재 내 상황에 적절한 판단을 내리는 역량이 특히나 프론트엔드 개발자에게는 중요하다는 생각이 들었다. 개발자는 부지런해야겠다.

그리고 혼자만 생각하고 판단할 것이 아니라 검색은 기본이고, 다른 개발자들과도 기술에 대해 자주 대화하고 질문하고 의견을 들어보는 태도가 중요하다고 생각이 들었다.

 

추가적으로, 위에서도 얘기했지만 SockJS로 WebSocket을 지원하지 않는 브라우저에서의 통신 동작 방식이 궁금하다. 

그리고 서버가 Node.js 기반이면 Socket.IO 라이브러리르 사용해 추가적인 기능들도 사용해보고싶다.