Development

(작성중) [RN] 소켓 통신으로 채팅 기능만들기

RED BEAN 2025. 2. 12. 14:11
반응형

개발 배경설명

사이드 프로젝트로 데이팅앱을 개발 중.

네이티브 앱에 대한 지식은 없어서 익숙한 리액트를 사용하여 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 구조에 필요한 이벤트와 데이터를 잘 구조화하는게 중요함.

  1. 리액트에서 구현하기 위해 SocketContext, ChatContext를 만들어 프로바이더 패턴을 구현
  • Socket프로바이더를 기반으로 Chat프로바이더가 동작하므로 Chat프로바이더가 응용레벨이므로 상위에 위치한다.
  • Chat프로바이더는 useSelector를 사용하여 redux의 flux 패턴으로 구현하여 useChat()을 제공
  1. 소켓프로바이더의 각 이벤트 핸들러에서 이벤트에 맞게 useChat()에서 제공하는 dispatch() 함수를 호출함
  2. dispatch()가 호출되면 reducer에서 각 action type에 맞게 state에 저장함.
  3. 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()가 중복호출되는 버그 발생
반응형