안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 WebSocket을 활용하여 채팅 기능을 구현한 방식에 대해 공유하고자 글을 작성하게 되었습니다.
💻 개발 환경
React-native(expo) + TypeScript로 진행되었습니다.
🧐 구현하게 된 계기
지난번에 제가 작성한 포스트에도 작성했던 내 용지만 제가 지금 진행 중인 토이 프로젝트에서 유저 간의 채팅 기능은 선택이 아닌 필수였습니다.
서비스의 특성상 사용자끼리의 채팅으로 소통을 해야 하는 케이스가 존재하기 때문입니다.
지난번에는 채팅방 목록을 보여주는 방 목록 컴포넌트를 만드는 것에 대해 작성했으나 이번에는 채팅 기능을 구현하는 글을 작성하게 되었습니다.
🤔 기능 산출 및 개발 계획
채팅 기능을 개발할 때 저희가 흔하게 아는 Rest API로는 개발을 할 수가 없습니다.
그 이유는 HTTP 통신은 단방향 통신이기 때문입니다. 다른 유저가 나에게 메시지를 보낸 경우 Server에서 Client 단으로 보내야 하기 때문에 양방향 통신이 가능한 WebSocket을 사용해서 구현해야 합니다.
❓ 채팅방은 구현하려면 왜 WebSocket을 사용해야 할까?
HTTP와 차이점
HTTP 프로토콜은 클라이언트가 서버에 요청을 보내고, 서버가 응답을 반환하는 단방향 통신입니다. 이는 페이지를 로드하거나 데이터를 가져오는 등의 작업에는 적합하지만, 실시간으로 데이터를 주고받아야 하는 경우에는 한계가 있습니다.
예를 들어, 채팅 애플리케이션에서 다른 사용자가 메시지를 보냈을 때 이 메시지를 받기 위해서는 클라이언트가 계속해서 서버에 "새로운 메시지가 있나요?"라고 묻는 폴링(polling) 방식을 사용해야 합니다. 이 방식은 불필요한 네트워크 트래픽과 지연 시간(latency)을 초래할 수 있습니다.
Websocket의 등장
Websocket은 웹 브라우저와 서버 간에 소켓 연결을 열어 두고 양방향 통신할 수 있는 프로토콜입니다. 이렇게 함으로써 클라이언트와 서버 사이에서 실시간으로 데이터를 주고받는 것이 가능해집니다.
Websocket 연결은 HTTP 연결 위에서 핸드셰이크 과정을 거치며 생성되며, 한 번 연결되면 그 연결 위에서 양방향 통신하게 됩니다. 따라서 새로운 메시지나 업데이트 정보 등 클라이언트나 서버 어디에서든 초기화할 수 있습니다.
채팅방과 Websocket
채팅방은 사용자가 실시간으로 메시지를 주고받는 공간입니다. 따라서 한 사용자가 메시지를 보내면, 이 메시지는 즉시 다른 사용자들에게 전달되어야 합니다. 이러한 요구 사항은 Websocket이 제공하는 실시간 양방향 통신 기능과 완벽하게 일치합니다.
Websocket을 사용하면 서버는 새로운 메시지를 받자마자 해당 채팅방의 모든 클라이언트에게 이를 전달할 수 있습니다. 이렇게 하면 각 클라이언트는 별도의 요청 없이 실시간으로 새로운 메시지를 받아올 수 있습니다.
결론적으로, Websocket은 채팅 애플리케이션에서 빠르고 효율적인 실시간 통신을 가능하게 하는 핵심 기술입니다
제가 개발하고 있는 서비스에서 채팅방에서 제공하는 기능이 채팅을 제외하고도 더 존재하지만 글의 초점에 맞게 채팅 기능에 초점을 맞춰 작성하도록 하겠습니다.
기능은 다음과 같습니다.
- 채팅방 목록 불러오기
- 채팅 내용 불러오기
- 채팅 보내기
- 채팅받기
이렇게 크게 4가지로 분리할 수 있을 것 같습니다.
WebSocket의 서버는 1개이고 관리해야 할 곳은
- 채팅방 목록
- 채팅방
2곳이었기에 WebSocket을 연결한 Container를 하나 만들어 관리하면 편할 것이라고 생각하여 그런 구조로 진행했습니다.
또한 다른 이유로는 현재 저희는 JWT를 사용하고 있습니다.
Rest API를 요청하다 액세스 토큰이 만료가 되어 에러를 받게 된다면 인터셉터를 활용하여 처리를 하고 있듯이
WebSocket을 이용하는 부분도 액세스 토큰 만료 시 에러를 똑같이 처리를 하고 싶었기에 Container에서 에러 처리를 하는 방식으로 구현하고자 했습니다.
즉 기존 Rest API를 요청하기 위해 사용하는 axios의 인터셉터의 역할을 제가 개발할 WebSocket Container의 error에서 담당을 하게 된다는 것입니다.
😵💫 문제 발생
개발 시 첫 번째로 발견된 문제는 백엔드 분과 소통의 창구인 스펙 문서였습니다.
백엔드 분도 어떤 식으로 작성해야 제가 편한지에 대해 정답을 모르셨고
저도 갈피를 잡지 못하여 이런 식으로 작성하면 보기 편할 것 같습니다라고 전달을 드리지 못하는 상황이었습니다.
기존의 저희는 GitBook을 사용하여 API 스펙 문서로 사용을 하고 있는 상태였으며
기존에 Rest API에 대한 스펙은
이런 식으로 정리하여 제가 API 스펙 문서를 확인하며 개발하는 방식이었습니다.
하지만 WebSocket 스펙에 대한 문서는 갈피를 잡지 못하여 초반에는 실제 대화로 많이 나누면서 스펙을 파악하는데 알아가는 시간이 꽤 많이 소요가 됐습니다.
그로 인해 다른 Rest API를 연결하는 작업보다 체력 소모가 더 심한 편이었습니다.
같이 프로젝트를 진행하는 백엔드 개발자 분도 이 문제에 대해 인지를 하고 계셨기에 바로 스펙을 어떤 식으로 정리하면 좋을까? 에 대해 서로가 편한 방향과 시간이 흐른 후 다시 봐도 바로 쉽게 파악할 수 있을 방법 등에 의견을 나누며 방향성을 잡아갔습니다.
이런 식으로 emit을 사용하는 것과 그에 대한 응답을 받을 수 있는 것을 on을 사용하여 저희 나름대로 직관적인 룰을 만들어 작성하기 시작했고
그 후부터 스펙 파악이 빨라져 개발 속도에 불이 붙기 시작했습니다.
👀 채팅방 개발(CODE)
socket.io-client
라이브러리를 사용했습니다.
npm i socket.io-client
WebSocketContainer.tsx
import React, { createContext, useEffect, useRef, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Socket, io } from 'socket.io-client';
import { UserState } from '../store/atoms';
interface Props {
children?: React.ReactNode;
}
export const WebSocketContext = createContext<Socket | null>(null);
const WebSocketContainer = ({ children }: Props) => {
const [webSocket, setWebSocket] = useState<Socket | null>(null);
const { refreshToken } = useRecoilValue(UserState);
const [user, setUser] = useRecoilState(UserState);
useEffect(() => {
if (!!refreshToken) {
const socket = io(`${process.env.API_URL}/chat`, {
transports: ['websocket'],
});
socket.on('error', (object) => {
const { data, url, accessToken } = object;
setUser((prev) => ({ ...prev, accessToken: accessToken }));
socket.emit(`${url}`, { ...data, token: accessToken });
});
setWebSocket(socket);
return () => {
if (socket) {
socket.disconnect();
}
};
}
}, [refreshToken]);
return (
<WebSocketContext.Provider value={webSocket}>
{children}
</WebSocketContext.Provider>
);
};
export default WebSocketContainer;
코드 설명
WebSocketContext를 생성하여, 이 콘텍스트를 통해 애플리케이션의 다른 부분에서 WebSocket 객체에 접근할 수 있게 합니다.
useEffect 훅 내에서 Socket.IO 클라이언트 인스턴스를 생성하고, 이것을 상태 변수인 webSocket에 저장합니다.
에러가 발생하면 서버로부터 에러 정보(object)가 전달됩니다. 그런 다음 accessToken을 업데이트하고, 해당 URL로 다시 emit 하는 작업을 수행합니다.
저희 환경처럼 JWT에 대한 Error 처리를 하지 않으신다면 원하시는 Error 처리를 하시면 될 것 같습니다.
저희는 WebSocket 관련 Error 시 socket의 error로 전달해 주기로 규칙을 정했기에 저런 식으로 처리가 가능했다는 것도 인지해주시면 좋을 것 같습니다.
Chatting.tsx (채팅방 목록)
import React, { useContext, useEffect, useState } from 'react';
import { FlatList, Text, View } from 'react-native';
import TYPOS from '../../components/ui/typo';
import ChatRoomItem from '../../components/ui/ChatRoomItem';
import Color from '../../constants/color';
import EmptyList from '../../components/chat/EmptyList';
import { WebSocketContext } from '../../components/WebSocketContainer';
import { useRecoilValue } from 'recoil';
import { UserState } from '../../store/atoms';
import axios from 'axios';
import useOverlay from '../../hooks/overlay/useOverlay';
import Dialog from '../../components/ui/Dialog';
interface RoomData {
id: string;
title: string;
lastChat: string;
lastChatAt: string;
isAlarm: boolean;
isPinned: boolean;
isPetMate?: boolean;
image?: string;
region: string;
productImage?: string;
}
const Chatting = () => {
const [rooms, setRooms] = useState<RoomData[]>([]);
const socket = useContext(WebSocketContext);
const { accessToken } = useRecoilValue(UserState);
const overlay = useOverlay();
useEffect(() => {
if (!socket) return;
const handleGetChatList = () => {
socket.emit('room-list', {
token: accessToken,
});
socket.on('room-list', ({ data: { chatRoomList } }) => {
setRooms(chatRoomList);
});
};
handleGetChatList();
return () => {
socket.off('room-list');
};
}, [socket]);
const onPinPressHandler = async (id: string) => {
try {
const {
data: { chatRoomList },
} = await axios.patch(`/chat/pinned/${id}`);
setRooms(chatRoomList);
} catch (error) {
console.log(error);
}
};
const onExitPressHandler = async (id: string) => {
overlay.open(
<Dialog isOpened={true}>
<Dialog.Content content='채팅방을 나가면 대화 내용이 모두 삭제되며 복구할 수 없어요. 채팅방을 나갈까요?' />
<Dialog.Buttons
buttons={[
{
label: '취소',
onPressHandler: overlay.close,
},
{
label: '나가기',
onPressHandler: async () => {
try {
const {
data: { chatRoomList },
} = await axios.patch(`/chat/leave/${id}`);
setRooms(chatRoomList);
} catch (error) {
console.log(error);
} finally {
overlay.close();
}
},
},
]}
/>
</Dialog>
);
};
const onToggleNotificationHandler = async (id: string) => {
try {
const {
data: { chatRoomList },
} = await axios.patch(`/chat/alarm/${id}`);
setRooms(chatRoomList);
} catch (error) {
console.log(error);
}
};
return (
<>
<View
style={{
paddingHorizontal: 24,
paddingVertical: 16,
backgroundColor: Color.white,
}}
>
<Text style={[TYPOS.headline3, { color: Color.black }]}>채팅</Text>
</View>
<FlatList
contentContainerStyle={{
backgroundColor: Color.white,
flex: 1,
}}
keyExtractor={(item) => item.id}
data={rooms}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<ChatRoomItem
roomId={item.id}
image={item.image}
roomName={item.title}
region={item.region}
timeStamp={item.lastChatAt}
content={item.lastChat}
isPinned={item.isPinned}
isNotificationEnabled={item.isAlarm}
onPinPressHandler={() => {
onPinPressHandler(item.id);
}}
onExitPressHandler={() => {
onExitPressHandler(item.id);
}}
onToggleNotificationHandler={() => {
onToggleNotificationHandler(item.id);
}}
/>
)}
ListEmptyComponent={<EmptyList />}
/>
</>
);
};
export default Chatting;
코드 설명
useEffect 내에서 웹소켓의 'room-list' 이벤트를 설정합니다. 이 이벤트는 서버에 채팅방 목록을 요청하고 응답을 받아 상태를 업데이트하는 역할을 합니다.
각각의 핸들러 함수가 HTTP PATCH 요청을 보내어 해당 채팅방의 정보(핀 상태, 알림 상태 등)를 변경하고 결과로 받은 새로운 채팅방 목록으로 상태를 업데이트합니다.
ChatRoomItem은 지난번에 작성한 컴포넌트를 사용했습니다.
만약 저희처럼 핀 상태, 알림 상태, 채팅방 나가기 기능을 제공할 필요가 없으시다면
- onPinPressHandler
- onExitPressHandler
- onToggleNotificationHandler
3가지 함수는 생략하셔도 괜찮습니다.
ChatRoom.tsx (채팅방)
import React, { useContext, useEffect, useRef, useState } from 'react';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParamList } from '../types/navigation';
import AppBar from '../components/ui/AppBar';
import { View, ScrollView } from 'react-native';
import DateDisplay from '../components/chat/room/DateDisplay';
import ChatBubble from '../components/ui/ChatBubble';
import Input from '../components/chat/room/Input';
import { useRecoilValue } from 'recoil';
import { WebSocketContext } from '../components/WebSocketContainer';
import { UserState } from '../store/atoms';
export type ChatRoomScreenProps = StackScreenProps<
RootStackParamList,
'ChatRoom'
>;
type Message = {
id: string;
content: string;
timestamp?: string;
timeOfDay?: string;
isMe?: boolean;
};
type ChatData = {
[key: string]: Array<Message>;
};
const ChatRoom = ({ navigation, route }: ChatRoomScreenProps) => {
const socket = useContext(WebSocketContext);
const { accessToken } = useRecoilValue(UserState);
const { roomId } = route.params;
const [chatData, setChatData] = useState<ChatData>({});
const scrollViewRef = useRef<ScrollView | null>(null);
const scrollToBottom = () => {
scrollViewRef.current?.scrollToEnd({ animated: true });
};
useEffect(() => {
scrollToBottom();
}, [chatData]);
useEffect(() => {
if (!socket) return;
const handleGetChatList = () => {
socket.emit('chat-list', {
token: accessToken,
chatRoomId: roomId,
});
socket.on('chat-list', (data) => {
setChatData(data.data.chatList);
});
};
handleGetChatList();
return () => {
socket.off('chat-list');
};
}, [socket]);
const onPostMessageHandler = (message: string) => {
if (!socket || !message) {
return;
}
socket.emit('message', {
token: accessToken,
chatRoomId: roomId,
message,
});
};
return (
<>
<AppBar />
<ScrollView
contentContainerStyle={{
paddingHorizontal: 16,
}}
ref={scrollViewRef}
>
{Object.entries(chatData).map(([date, children], i) => {
const contents = children.map((child, index) => {
const messageChild = child;
return (
<React.Fragment key={messageChild.id + index + 'Fragment'}>
<ChatBubble
key={messageChild.id}
message={messageChild.content}
isSentByMe={!!messageChild.isMe}
{...(!!messageChild.timeOfDay &&
!!messageChild.timestamp && {
timeStamp: `${messageChild.timeOfDay} ${messageChild.timestamp}`,
})}
/>
<View
style={{ paddingBottom: 4 }}
key={messageChild.id + index}
/>
</React.Fragment>
);
});
return (
<React.Fragment key={date + i + 'Fragment'}>
<View style={{ paddingTop: 16 }} key={date + i + 'up'} />
<DateDisplay key={date} timestamp={date} />
<View style={{ paddingBottom: 16 }} key={date + i + 'down'} />
{contents}
</React.Fragment>
);
})}
</ScrollView>
<Input onPostMessageHandler={onPostMessageHandler} />
</>
);
};
export default ChatRoom;
코드 설명
useEffect 내에서 웹소켓의 'chat-list' 이벤트를 설정합니다. 이 이벤트는 서버에 해당 채팅방의 채팅 내용을 요청하고 응답을 받아 상태를 업데이트하는 역할을 합니다.
onPostMessageHandler 입력된 메시지를 서버에 전송하는 함수입니다.
ScrollView 컴포넌트 안에서 각각의 DateDisplay와 그 날짜에 해당하는 모든 ChatBubble(즉, 각각의 메시지) 컴포넌트들이 렌더링 됩니다.
💎 그 외 사용된 컴포넌트(CODE)
DateDisplay.tsx (날짜 표시)
import React from 'react';
import { View, Text } from 'react-native';
import TYPOS from '../../ui/typo';
import Color from '../../../constants/color';
interface Props {
timestamp: string;
}
const DateDisplay = ({ timestamp }: Props) => {
return (
<View style={{ alignItems: 'center' }}>
<Text style={[TYPOS.body3, { color: Color.neutral3 }]}>{timestamp}</Text>
</View>
);
};
export default DateDisplay;
컴포넌트 이미지
ChatBubble.tsx (메시지 표시)
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import TYPOS from './typo';
import Color from '../../constants/color';
interface ChatBubbleProps {
message: string;
isSentByMe: boolean;
timeStamp?: string;
}
const ChatBubble = ({ message, isSentByMe, timeStamp }: ChatBubbleProps) => {
const bubbleStyles = isSentByMe ? styles.sentBubble : styles.receivedBubble;
const textStyles = isSentByMe ? styles.sentText : styles.receivedText;
const processMessage = (text: string) => {
const lines = text.split('\n');
return lines.map((line, index) => (
<Text key={index} style={[textStyles, TYPOS.body2]}>
{line}
</Text>
));
};
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: isSentByMe ? 'flex-end' : 'flex-start',
}}
>
{isSentByMe && (
<Text style={[TYPOS.body3, { color: Color.neutral3, marginRight: 4 }]}>
{timeStamp}
</Text>
)}
<View style={[styles.bubbleContainer, bubbleStyles]}>
{processMessage(message)}
</View>
{!isSentByMe && (
<Text style={[TYPOS.body3, { color: Color.neutral3, marginLeft: 4 }]}>
{timeStamp}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
bubbleContainer: {
maxWidth: '80%',
borderRadius: 20,
paddingVertical: 8,
paddingHorizontal: 12,
},
sentBubble: {
backgroundColor: Color.primary700,
},
receivedBubble: {
backgroundColor: Color.neutral5,
},
sentText: {
color: Color.white,
},
receivedText: {
color: Color.neutral1,
},
});
export default ChatBubble;
컴포넌트 이미지
✨ 완성된 채팅방
채팅방 목록
채팅방
평소에는 GIF로 좀 더 생동감(?) 있게 완성 결과를 보여드리는 편인데 GIF로 준비하지 못한 부분 죄송합니다.
😁 글 작성 후기
기존에 WebSocket에 대한 배경 지식은 어느 정도 있는 편이었으나 직접 활용해 볼 기회가 없었습니다.
마침 이번에 진행하는 토이 프로젝트에서 제가 쌓아온 WebSocket 지식을 접목해 볼 기회가 와서 좋은 경험이었다고 생각합니다.
관련 예제를 찾을 때도 채팅 기능을 구현하신 예제는 좋은 자료들로 가득했지만 채팅방 목록부터 방까지 구현한 예시는 쉽게 찾기 어려웠어서 제가 직접 이렇게 글을 작성할 수 있는 경험도 같이 따라온 것 같습니다.
현재 진행 중인 토이 프로젝트에서 기능 단위로 feature를 나눠서 일정을 계획하고 개발 진행을 하는데 가장 오래 걸린 기능 개발이었습니다.
저도 그렇고 백엔드 분도 그렇고 채팅방 구현 자체가 처음이었고 기존 Rest API가 아닌 WebSocket을 사용한다는 점에서 서로 맞춰갈 부분도 많았기에 시간을 많이 사용하지 않았나 싶습니다.
그래도 이번에 사용한 시간 덕에 "WebSocket을 잘 알고 있나요?" 라는 질문을 받게 된다면 "찍어는 먹어봤습니다."
정도로 답을 할 수는 있을 것 같네요
채팅방 구현이 WebSocket을 사용한 점도 있지만 저에겐 의미가 큰 기능 개발이었습니다.
제가 경력이 긴 편은 아니지만 어느덧 생각해 보면 항상 비슷한 기능만 개발을 하고 있어서 아쉬움이 마음 한편에 남아있었는데
정말 색다른 걸 하니 즐거웠네요
긴 글 읽어주셔서 감사합니다 질문 또는 피드백 댓글은 언제나 환영입니다.
읽어주셔서 감사합니다.
'개발' 카테고리의 다른 글
고차 컴포넌트 구현하기 (2) | 2023.10.27 |
---|---|
채팅방 목록에 새로운 기능을 추가해보자 (1) | 2023.10.19 |
React Native Picker (2) | 2023.09.19 |
useAsyncWithLoading custom hook (0) | 2023.09.12 |
React Native RadioButton 구현하기 (2) | 2023.09.01 |