Bookmark 프로젝트에서 채팅 기능을 구현하게 되었다.
이런 느낌의 댕근 채팅 스타일... 을 구현해야 했당
React와 WebSocket을 사용해서 실시간 채팅을 구현해봤고 그 과정에서 얻은 경험과 삽질 일대기를 ㅜㅜ 공유하려고 한다.
1. 서버와의 사전 논의사항
먼저 알아둘 점! 채팅 기능은 일반적인 REST API와는 달리 Swagger에서 확인할 수 없다. 실시간 양방향 통신이 필요한 채팅의 특성상 WebSocket을 사용하기 때문이다. 그래서 백엔드 개발자와 미리 논의해야 할 사항들이 꽤 있다. 스웨거만 보고 api 연결을 할 수 없기 때문에 소통이 진짜 중요한 기술 구현이었다.
백엔드 개발자분도 나도 둘 다 웹소켓 채팅 기능 구현이 처음이라 무슨 정보와 내용을 서로 주고받아야 할지 몰랐고 이 때문에 발생한 소통 오류의 비용이 꽤나 컸다 ㅜㅜ 그리고 이것때문에 스트레스도 많이 받았고... 사전지식의 부재인듯 하다! 다른 분들은 이런 문제를 겪지 않길 바라며 ㅜㅜㅜ
1.1 WebSocket 엔드포인트
- WebSocket URL (ws:// 또는 wss://)
- ws://: 기본 WebSocket 프로토콜. HTTP와 비슷하다.
- wss://: SSL/TLS로 암호화된 보안 WebSocket 프로토콜. HTTPS와 비슷하다.
- 실제 서비스라면 보안을 위해 wss://를 사용하는 게 좋다.
- 채팅방별 URL 구조 (예: ws://your-server/chat/${chatRoomId})
- 채팅방 구분을 URL에서 할건지, 메시지에서 할건지 정해야 한다.
1.2 연결 관련
- 인증 방식
- JWT 토큰을 헤더에 포함할건지
- WebSocket 연결 시 쿼리 파라미터로 전달할건지
- 이에 따라 클라이언트 코드가 달라진다.
- 초기 handshake 과정 유무
- 연결 직후 특별한 인증/초기화 메시지가 필요한지
- 필요하다면 어떤 포맷으로 보내야 하는지
- 이는 useEffect에서 연결 직후 처리해야 할 부분이다.
1.3 메시지 포맷
- 주고받을 메시지의 데이터 구조
interface ChatMessage {
type: 'CHAT' | 'JOIN' | 'LEAVE'; // 메시지 종류
sender: string; // 발신자
content: string; // 내용
timestamp: string; // 시간
}
- 시스템 메시지나 에러 메시지 등 특별한 타입이 있는지
- 입장/퇴장 메시지
- 에러 발생 시 메시지
- 이에 따라 메시지 렌더링 로직이 달라진다.
1.4 재연결 정책
- 연결 끊김 상황에서의 처리 방법
- 자동 재연결 시도할건지
- 사용자에게 재연결 버튼을 보여줄건지
- 재연결 시도 간격
- 즉시 시도할건지
- 지수 백오프(exponential backoff) 적용할건지
- 이는 useEffect의 cleanup과 재연결 로직에 반영된다.
1.5 이벤트 핸들링
- 채팅방 입장/퇴장 알림
- 다른 사용자의 입장/퇴장을 어떻게 표시할지
- 시스템 메시지로 보여줄지, UI로만 표시할지
- 에러 상황 대응
- 연결 실패 시 처리
- 메시지 전송 실패 시 처리
- 이에 따라 에러 핸들링 코드가 달라진다.
2. WebSocket 채팅 구현을 위한 사전 지식
2.1 WebSocket vs HTTP
WebSocket은 클라이언트와 서버 간의 양방향 통신을 가능하게 하는 프로토콜이다. HTTP와는 다음과 같은 차이가 있다
- HTTP: 클라이언트의 요청이 있을 때만 서버가 응답하는 단방향 통신
- WebSocket: 한 번 연결을 맺으면 양쪽에서 자유롭게 데이터를 주고받을 수 있는 양방향 통신
2.2 WebSocket vs SockJS
SockJS는 WebSocket의 폴백(fallback) 라이브러리다.
WebSocket
- 순수한 WebSocket 프로토콜 구현
- 최신 브라우저에서만 지원
- 프록시나 방화벽에서 차단될 수 있음
SockJS
- WebSocket을 지원하지 않는 브라우저를 위한 대체 수단 제공
- 자동으로 최적의 전송 방식 선택 (WebSocket, xhr-streaming, xhr-polling 등)
- 더 안정적인 연결 보장
2.3 STOMP의 역할과 장점
STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 메시징 프로토콜이다.
순수 WebSocket만 사용할 때의 한계
- 메시지 형식을 직접 정의해야 함
- 메시지 라우팅 로직을 직접 구현해야 함
- pub/sub 구조 구현이 복잡함
STOMP 사용시 장점
- 메시지 형식이 표준화되어 있음
- 메시지 브로커를 통한 pub/sub 구조 쉽게 구현
- 구독/발행 기반의 메시징 패턴 지원
- 메시지 큐잉과 라우팅을 자동으로 처리
STOMP를 사용하면 다음과 같이 간단히 메시지를 주고받을 수 있다 이런 식으로!
// 구독
client.subscribe('/topic/chat/123', callback);
// 발행
client.publish({
destination: '/app/chat/123/send',
body: JSON.stringify(message)
});
3. 실제 구현
3.0 백엔드 개발자분과 사전 논의 구체화
- /ws 엔드포인트로 WebSocket 연결
- /topic/chat/{roomId}로 채팅방 구독
- /app/chat/{roomId}/send로 메시지 보내기
- /app/chat/{roomId}/join으로 입장 알림을보내기
이렇게 진행하기로 했다!
3.1 필요한 라이브러리 설치
먼저 필요한 라이브러리들을 설치해주자.
npm install @stomp/stompjs sockjs-client
3.2 WebSocket 커스텀 훅 구현
WebSocket 연결과 메시지 처리를 관리하는 useChat 훅을 만들었다. 따로 안만들고 그냥 한 페이지에서 전부 해도 된다.
export const useChat = (chatRoomId: number, nickname: string) => {
const client = useRef<Client | null>(null);
const [messages, setMessages] = useState<ChatMessageDTO[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!chatRoomId) return;
client.current = new Client({
webSocketFactory: () => new SockJS('https://your-server.com/ws'),
connectHeaders: {
Authorization: `Bearer ${token}`,
},
onConnect: () => {
setIsConnected(true);
// 채팅방 구독 로직
// 입장 메시지 전송 로직
},
// ... 기타 설정
});
client.current.activate();
return () => {
client.current?.deactivate();
};
}, [chatRoomId, nickname]);
// ... 메시지 전송 함수 등
};
3.3 채팅 컴포넌트 구현
채팅 UI는 메시지 목록과 입력창으로 구성했다.
export const ChatPage = () => {
const { messages, sendMessage, isConnected } = useChat(chatRoomId, nickname);
// 자동 스크롤 구현
useEffect(() => {
scrollToBottom();
}, [messages]);
return (
<ChatContainer>
<MessagesWrapper>
{messages.map(renderMessage)}
</MessagesWrapper>
<ChatInput onSendMessage={handleSendMessage} />
</ChatContainer>
);
};
코드 전문은 밑 주소에서 확인할 수 있다.
https://github.com/ssu-B1G4/Bookmark_web/pull/116
4. 트러블슈팅
근데... 인터넷 코드와 전혀 다른 점이 없는데 ㅜㅜ 안되는거임!!! ㅜㅜㅜㅜㅜ 원인은 간단했는데 이 문제로 하루종일 끙끙앓고 트러블과의 전쟁을 한 기억이 난다... 문제는 다음과 같다.
4.1 Vite에서 WebSocket 통신 이슈
문제 상황
Vite 환경에서 웹소켓 연결을 시도했는데 연결이 전혀 되지 않았다. 코드 상으로는 전혀 문제가 없어 보였고, 여러 번 확인해봐도 원인을 찾을 수 없었다.
문제를 파악하기 위해 같은 코드를 CRA로 만들어서 테스트해보았다. 그 결과, CRA에서는 정상적으로 동작하는 것을 확인했다.
두 환경의 차이점을 분석해보니 다음과 같은 특이점을 발견했다:
- Vite는 vite-hmr이라는 프로토콜을 사용
- CRA와 달리 switching protocols가 발생할 때 /ws가 자동으로 붙지 않음
문제 원인
Vite의 HMR(Hot Module Replacement) 기능이 문제의 원인이었다. HMR은 애플리케이션을 다시 시작하지 않고도 모듈을 교체할 수 있게 해주는 Vite의 핵심 기능이다. 그런데 이 HMR이 기본적으로 WebSocket 프로토콜을 사용하고 있어서, 우리가 구현하려는 채팅용 WebSocket과 충돌이 발생했던 것이다.
# CRA에서의 WebSocket 연결
ws://localhost:3000/ws
# Vite에서의 WebSocket 연결 (HMR 때문에 경로가 다름)
ws://localhost:5173
해결 방법
Vite의 설정 파일에서 WebSocket 프록시를 설정해주어야 한다.
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/ws': {
target: 'your-websocket-server',
ws: true, // WebSocket 프록시 활성화
}
}
}
});
이렇게 설정하면
- /ws 경로로 오는 WebSocket 요청을 지정된 서버로 프록시
- ws: true로 WebSocket 프로토콜 지원 활성화
- changeOrigin으로 CORS 문제 해결
이제 HMR과 채팅용 WebSocket이 충돌 없이 동작한다!
4.2 global is not defined 에러
문제 상황
global is not defined 에러가 발생했다.
에러를 검색해보니 Vite에서는 global 객체를 지원하지 않고, SockJS는 global 객체를 의존하고 있어서 발생하는 문제였다.
문제 원인
SockJS가 global 객체를 의존하는데, Vite에서는 이를 지원하지 않아 발생하는 문제였다. 원인은 Vite의 특성에 있었다. Vite는 최신 표준을 준수하고 경량화를 중시하는 번들러라서, 기본적으로 global 객체를 참조하지 않는다. 반면 Webpack은 Node.js 모듈과의 호환성을 위해 global 객체를 미리 정의해두었다.
해결 방법
global 객체를 설정하는 방법에는 두 가지가 있다.
1. index.html에 다음 스크립트를 추가해준다.
<script>
const global = globalThis;
</script>
2. vite.config 설정을 변경해준다.
// vite.config.ts
export default defineConfig({
define: {
global: 'window' // 브라우저 환경이므로 window로 정의
}
});
두 방식의 차이점
- 적용 시점
- index.html 방식: 클라이언트 런타임에서 global 객체 정의
- vite.config.ts 방식: 빌드 시점에 global 객체 정의
- 작동 방식
- index.html 방식: globalThis를 통해 현재 실행 환경의 전역 객체를 참조
- vite.config.ts 방식: 빌드 과정에서 'global'이라는 식별자를 'window'로 직접 대체
- 환경 대응
- index.html 방식: globalThis가 환경에 따라 적절한 전역 객체 제공
- vite.config.ts 방식: 브라우저 환경만을 가정하고 window로 고정
- 권장 사용
- SSR이나 다양한 실행 환경 고려시: index.html 방식 권장
- 순수 클라이언트 사이드 앱: vite.config.ts 방식이 더 명시적
나는 vite.config.ts에서 global을 window로 직접 대체하는 것보다, globalThis를 사용하는 것이 더 안전하다고 생각해서 1번 방식으로 설정을 했다.
구현 화면
끼악
WebSocket을 이용한 실시간 채팅 구현은 생각보다 까다로웠다. 특히 Vite 환경에서의 이슈 해결이 주요 포인트였는데 난생 처음 보는 에러와 정보도 많이 없어서 눈물을 흘리며 오류를 찾았는데 어쨌든 해결하니까 너무너ㅓ무 뿌듯했다!!!
내 글이 다른 개발자분들에게도 도움이 되었으면 좋겠다 ㅜㅜ 나처럼 삽질하지 말구...
추가로 메시지 종류에 따라 내가 보낸 메시지, 상대가 보낸 메시지 구분해서 왼쪽 오른쪽에 채팅 ui 구현하는 것도 다음에 글로 써보겠다! 난 이게 젤 어려웠어.,,
'React' 카테고리의 다른 글
[React/리액트] API 성능 최적화하기: 단일 조회 API와 중앙화된 상태 관리 (2) | 2025.01.08 |
---|---|
[React/리액트] 프로젝트에 Sentry 도입하기 / 모니터링 시스템 구축하기 (0) | 2025.01.08 |
React에서 웹과 앱 환경에 따라 하단바 다르게 표시하기 (0) | 2025.01.07 |
TanStack Router v7로 타입 안전한 라우팅 구현하기 (3) | 2024.11.17 |
[React/리액트] 스크롤 감지 버튼 구현하기 / 스크롤 이벤트와 버튼 스타일 동적 변경 (4) | 2024.10.13 |