반응형
개발 배경설명
사이드 프로젝트로 데이팅앱을 개발 중.
네이티브 앱에 대한 지식은 없어서 익숙한 리액트를 사용하여 React Native으로 하이브리드 앱 선택.
일부 기능 웹뷰를 띄워 앱과 웹뷰 혼합형태로 제작.
처음 사용해보는 RN, 웹소켓 통신 기술 --> 내가 얼마나 빠르게 새로운 기능 구현을 해볼수있을까 셀프 챌린지
백엔드와 협력해서 대략 이틀만에 기능 구현(하루당 3시간?정도 소모)
기술 스택
- App: React Native, Expo, TypeScript, React-native-paper (빠른 생산성을 위해 UI 컴포넌트 직접구현 X)
- Web: Next v14, React v18, TypeScript
채팅 기능을 위해 소켓서버(redis)을 올리고 클라이언트에서 로그인인증 후 소켓연결.
newMessage
이벤트 리스너를 등록하여 현재 접속 중인 채팅방 id에 해당하는 메세지만 화면에 업데이트한다.
- 주의: 본인이 emit으로 전송한 메세지도 newMessage 이벤트를 통해 받은 뒤 화면에 업데이트하는 구조
소켓통신 구현
실시간 연결이 필요한 채팅을 위해 소켓통신 사용
- 단방향 HTTP 프로토콜이 아닌 양방향 Socket 통신
- 실시간 연결. 서버가 응답하고 끊어지는 http 통신과 달리 연결을 유지하고 있음
소켓에 다양한 이벤트 리스너가 등록되어 있으므로, UI 구조에 필요한 이벤트와 데이터를 잘 구조화하는게 중요함.
- 리액트에서 구현하기 위해 SocketContext, ChatContext를 만들어 프로바이더 패턴을 구현
- Socket프로바이더를 기반으로 Chat프로바이더가 동작하므로 Chat프로바이더가 응용레벨이므로 상위에 위치한다.
- Chat프로바이더는 useSelector를 사용하여 redux의 flux 패턴으로 구현하여 useChat()을 제공
- 소켓프로바이더의 각 이벤트 핸들러에서 이벤트에 맞게 useChat()에서 제공하는 dispatch() 함수를 호출함
- dispatch()가 호출되면 reducer에서 각 action type에 맞게 state에 저장함.
- ChatContext가 제공하는 state가 필요한 컴포넌트에서 state를 보고있다가 변경되면 필요한 UI를 업데이트함
// chatSocket.ts
import {io, Socket} from "socket.io-client";
import {SOCKET_SERVER_URL} from "../utils/constants";
class SocketService {
private static instance: SocketService;
private socket: Socket | null = null;
private constructor() {}
public static getInstance(): SocketService {
if (!SocketService.instance) {
SocketService.instance = new SocketService();
}
return SocketService.instance;
}
public connect(): Promise<Socket> {
return new Promise((resolve, reject) => {
this.socket = io(SOCKET_SERVER_URL, {
reconnection: true,
reconnectionDelay: 1000,
});
this.socket.on("connect", () => {
console.log("Socket connected");
resolve(this.socket!);
});
this.socket.on("connect_error", (error) => {
console.error("Socket connection error:", error);
reject(error);
});
});
}
public getSocket(): Socket | null {
return this.socket;
}
public disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
}
export default SocketService;
// ChatContext.tsx
import React, {createContext, useContext, useReducer} from "react";
interface Room {
roomId: number;
user1Id: number;
user2Id: number;
}
interface ChatState {
newRoom: Room[];
message: Message[];
}
interface Message {
chatId: number;
roomId: number;
senderId: number;
message: string;
createdAt: Date;
}
type ChatAction =
| {type: "ADD_ROOM"; payload: Room}
| {type: "ADD_ROOM_BY_OTHER"; payload: Room}
| {type: "ADD_MESSAGE"; payload: Message}
| {type: "REMOVE_MESSAGE_BY_ROOM_ID"; payload: number};
const initialState: ChatState = {
newRoom: [],
message: [],
};
const chatReducer = (state: ChatState, action: ChatAction): ChatState => {
switch (action.type) {
case "ADD_ROOM":
return {
...state,
newRoom: [...state.newRoom, action.payload],
};
case "ADD_ROOM_BY_OTHER":
return {
...state,
newRoom: [...state.newRoom, action.payload],
};
case "ADD_MESSAGE":
return {
...state,
message: [...state.message, action.payload],
};
case "REMOVE_MESSAGE_BY_ROOM_ID":
const deletedRoom = action.payload;
const newMessage = state.message.filter((message) => message.roomId !== deletedRoom);
return {
...state,
message: newMessage,
};
default:
return state;
}
};
// Context 생성
const ChatContext = createContext<{
state: ChatState;
dispatch: React.Dispatch<ChatAction>;
} | null>(null);
// Provider 컴포넌트
export const ChatProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
const [state, dispatch] = useReducer(chatReducer, initialState);
return <ChatContext.Provider value={{state, dispatch}}>{children}</ChatContext.Provider>;
};
// Custom Hook
export const useChat = () => {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
};
// SocketContext.tsx
import React, {createContext, useContext, useEffect, useState} from "react";
import {Socket} from "socket.io-client";
import SocketService from "../chat/chatSocket";
import {useChat} from "./ChatContext";
interface SocketContextType {
socket: Socket | null;
isConnected: boolean;
connect: () => Promise<void>;
disconnect: () => void;
}
const SocketContext = createContext<SocketContextType | null>(null);
export const SocketProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const socketService = SocketService.getInstance();
const {dispatch} = useChat();
const connect = async () => {
try {
const socket = await socketService.connect();
setSocket(socket);
setIsConnected(true);
} catch (error) {
console.error("Socket connection failed:", error);
setIsConnected(false);
}
};
const disconnect = () => {
socketService.disconnect();
setSocket(null);
setIsConnected(false);
};
useEffect(() => {
if (socket) {
socket.on("login-success", (data) => {
console.log("login-success", data);
});
socket.on("newRoom", (data) => {
dispatch({type: "ADD_ROOM", payload: data});
});
socket.on("newMessage", (data) => {
dispatch({type: "ADD_MESSAGE", payload: data});
});
socket.on("error", (error) => {
console.error("☠️ Socket error:", error);
});
}
}, [socket?.id]);
useEffect(() => {
return () => {
disconnect();
};
}, []);
return <SocketContext.Provider value={{socket, isConnected, connect, disconnect}}>{children}</SocketContext.Provider>;
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
};
주의 사항
- 처음 로그인 성공하면 소켓 인스턴스를 만들어서 앱 전체에서 싱글톤으로 사용한다. (한 번 연결되기만 하면 됨)
- 소켓 인스턴스에 이벤트 리스너가 중복으로 등록되지 않도록 주의한다. `useEffect`로 이벤트를 등록해야함. 처음에 이렇게 안했다가 같은 이벤트 리스너가 계속해서 등록되게 되어, dispatch()가 중복호출되는 버그 발생
반응형
'Development' 카테고리의 다른 글
웹 성능 최적화 과정 - Nextjs Image, Script 태그 잘 사용하기 (0) | 2025.01.19 |
---|---|
웹 성능 최적화에 앞서 알아야할 내용 (0) | 2025.01.05 |
🚧 Nestjs 에러 로깅 (0) | 2024.11.26 |
어? 이거 설치한적없는데? 👻 패키지 매니저 알아보기 - npm, yarn, yarn berry, pnpm (2) | 2024.11.24 |
Vanilla-extract 라이브러리 번들사이즈 60% 최적화하기(feat. 내가 Anti-pattern이라니..) (3) | 2024.11.10 |