Notice
Recent Posts
Recent Comments
Link
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

유리의 개발새발

[RN] 채팅 기능 (socket.io) 본문

React Native

[RN] 채팅 기능 (socket.io)

yuri_ 2025. 9. 7. 20:16
반응형

자, 오늘은 RN에서 채팅 기능을 구현해 보겠습니다.

채팅 기능은 예전에 한 번 다뤄본 기억이 있는데, 그때는 소켓을 클래스로 만들어서 관리했었습니다.

제가 한 건 아니고, 회사에서 이미 클래스로 해두었더군요. 네 뭐 그랬습니다.

아. 무. 튼 이번에는 커스텀 훅으로 만들 예정입니다.

 

우선 라이브러리부터 설치하죠.

npm i @react-native-community/netinfo // 네트워크 연결 여부 확인, 이벤트 리스너(addEventListener)로 네트워크 상태가 바뀔 때마다 콜백을 실행할 수 있음.
npm i socket.io-client

 

커스텀 훅을 만들까요?

import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { io, Socket } from 'socket.io-client';

type Message = { id: string; user: string; text: string; ts: string };
type UseSocketOptions = {
  roomId: string;
  user: string;
  baseUrl?: string; // iOS: http://localhost:3000, Android 에뮬: http://10.0.2.2:3000
  autoJoin?: boolean; // 연결 후 자동 join
};

export function useSocket({
  roomId,
  user,
  baseUrl,
  autoJoin = true,
}: UseSocketOptions) {
  const [connected, setConnected] = useState(false);
  const [reconnecting, setReconnecting] = useState(false);
  const [lastError, setLastError] = useState<string | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);

  const socketRef = useRef<Socket | null>(null);
  const appStateRef = useRef<AppStateStatus>(AppState.currentState);
  const offlineQueue = useRef<{ text: string }[]>([]);

  const URL =
    baseUrl ??
    (__DEV__
      ? Platform.select({
          ios: 'http://localhost:3000',
          android: 'http://10.0.2.2:3000', // 안드 에뮬레이터 → 호스트 루프백(10.0.2.2) 특수 주소
          default: 'http://localhost:3000',
        })!
      : 'https://your-prod-domain');

  const connect = useCallback(() => {
    // ✅ 이미 인스턴스가 있다면 "재생성 금지" (중복 리스너 방지)
    if (socketRef.current) {
      if (!socketRef.current.connected) socketRef.current.connect();
      return;
    }

    const s = io(URL, {
      transports: ['websocket'], // RN에선 websocket 우선 권장
      autoConnect: false, // 직접 connect() 호출
      reconnection: true,
      reconnectionAttempts: Infinity,
      reconnectionDelay: 500,
      reconnectionDelayMax: 5000,
      timeout: 10000,
    });
    socketRef.current = s;

    s.on('connect', () => {
      setConnected(true);
      setReconnecting(false);
      setLastError(null);
      if (autoJoin) s.emit('join', { roomId, user });
      if (offlineQueue.current.length) {
        offlineQueue.current.forEach(({ text }) =>
          s.emit('message', { roomId, user, text }),
        );
        offlineQueue.current = [];
      }
    });
    s.on('disconnect', () => setConnected(false));
    s.on('connect_error', (err: any) =>
      setLastError(String(err?.message ?? err)),
    );

    s.io.on('reconnect_attempt', () => setReconnecting(true));
    s.io.on('reconnect', () => setReconnecting(false));

    s.on('message', (m: Message) => setMessages(prev => [...prev, m]));
    s.on('system', (text: string) =>
      setMessages(prev => [
        ...prev,
        {
          id: String(Date.now()),
          user: 'system',
          text,
          ts: new Date().toISOString(),
        },
      ]),
    );

    s.connect(); // 실제 연결 시작
  }, [URL, roomId, user, autoJoin]);

  useEffect(() => {
    // 온라인/오프라인에 따라 연결/해제
    const unsubNet = NetInfo.addEventListener(state => {
      if (state.isConnected) {
        if (!socketRef.current?.connected) connect();
      } else {
        socketRef.current?.disconnect();
      }
    });

    // 포그라운드에서만 연결 유지
    const unsubApp = AppState.addEventListener('change', state => {
      appStateRef.current = state;
      if (state === 'active') connect();
      else socketRef.current?.disconnect();
    });

    connect(); // 마운트 시 1회

    return () => {
      unsubNet();
      unsubApp.remove();
      if (socketRef.current) {
        if (autoJoin) socketRef.current.emit('leave', { roomId, user });
        socketRef.current.removeAllListeners();
        socketRef.current.disconnect();
        socketRef.current = null;
      }
    };
  }, [connect, roomId, user, autoJoin]);

  const sendMessage = useCallback(
    (text: string) => {
      const t = text.trim();
      if (!t) return;
      const s = socketRef.current;
      if (s?.connected) {
        s.emit('message', { roomId, user, text: t }, (_ack: any) => {});
      } else {
        offlineQueue.current.push({ text: t }); // 오프라인이면 큐에 저장 → 다음 connect 시 flush
      }
    },
    [roomId, user],
  );

  const sendTyping = useCallback(() => {
    const s = socketRef.current;
    if (s?.connected) s.emit('typing', { roomId, user });
  }, [roomId, user]);

  return {
    connected,
    reconnecting,
    lastError,
    messages,
    sendMessage,
    sendTyping,
  };
}

 

쓸 때는 어떻게 쓰는가 하면

import React, { useEffect, useRef, useState } from 'react';
import {
  Button,
  FlatList,
  KeyboardAvoidingView,
  Platform,
  SafeAreaView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import { useSocket } from '../hooks/useSocket';

// (선택) 메시지 타입 정의: 훅에서 내려오는 형태와 동일하게 맞춰두면 IDE 자동완성에 도움됨
type Message = { id: string; user: string; text: string; ts: string };

export default function Chat() {
  // 1) 입력창 상태
  const [input, setInput] = useState('');

  // 2) 소켓 훅 사용
  // - connected: 현재 연결 여부
  // - reconnecting: 자동 재연결 중 표시
  // - messages: 수신된 메시지 목록(가장 단순한 형태)
  // - sendMessage(text): 메시지 보내기
  // - sendTyping(): "입력 중" 신호 보내기
  const { connected, reconnecting, messages, sendMessage, sendTyping } =
    useSocket({
      roomId: 'general',
      user: '사용자이름', // 실제 서비스에선 로그인 유저명/ID로 대체
      baseUrl: 'http://192.168.0.17:3000', // (실기기) 개발PC의 로컬 IP
    });

  // 3) 리스트 참조: 새 메시지가 오면 "아래로" 자동 스크롤하기 위해 필요
  const listRef = useRef<FlatList<Message>>(null);

  // 4) 새 메시지 수신 시, 자동으로 리스트 맨 아래로 스크롤
  useEffect(() => {
    if (messages.length > 0) {
      // 살짝 지연을 두면 레이아웃 계산이 끝난 뒤 스크롤되어 튀는 현상이 적음
      const t = setTimeout(
        () => listRef.current?.scrollToEnd({ animated: true }),
        50,
      );
      return () => clearTimeout(t);
    }
  }, [messages.length]);

  // 5) '전송' 공통 핸들러 (버튼/키보드 제출 둘 다 여기로)
  const handleSend = () => {
    const text = input.trim();
    if (!text) return; // 빈 문자열은 전송 X
    sendMessage(text); // 연결돼 있으면 즉시 전송, 아니면 훅 내부 큐에 저장됨
    setInput(''); // 입력창 비우기
  };

  return (
    // iOS에서 키보드가 입력창을 가리지 않도록 하는 래퍼(안드로이드는 기본적으로 괜찮음)
    <KeyboardAvoidingView
      style={{ flex: 1, alignSelf: 'stretch' }}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={0} // 네비게이션 헤더가 있으면 그 높이만큼 보정
    >
      <SafeAreaView style={styles.root}>
        {/* 상단: 방 정보 + 연결 상태 */}
        <View style={styles.header}>
          <Text style={styles.title}>Room: general</Text>
          <Text style={{ color: connected ? 'lightgreen' : 'tomato' }}>
            {/* 연결 상태 안내:
               - ONLINE: 정상 연결
               - RECONNECTING...: 연결이 끊겨 재시도 중
               - OFFLINE: 현재 오프라인(네트워크 끊김 등) */}
            {connected
              ? reconnecting
                ? 'RECONNECTING...'
                : 'ONLINE'
              : 'OFFLINE'}
          </Text>
        </View>

        {/* 메시지 리스트
            - contentContainerStyle: 아이템들 주위 패딩
            - keyboardShouldPersistTaps: 리스트를 터치해도 키보드가 바로 닫히지 않게 */}
        <FlatList
          ref={listRef}
          data={messages}
          keyExtractor={it => it.id}
          contentContainerStyle={{ padding: 12 }}
          keyboardShouldPersistTaps="handled"
          // (선택) 비어 있을 때 안내 문구
          ListEmptyComponent={
            <Text style={{ color: '#777', textAlign: 'center', marginTop: 24 }}>
              아직 메시지가 없습니다. 먼저 인사해 보세요!
            </Text>
          }
          renderItem={({ item }) => (
            <View
              style={[
                styles.bubble,
                item.user === 'jeonyul' ? styles.me : styles.other, // 내 메시지면 오른쪽, 아니면 왼쪽
              ]}
            >
              <Text style={styles.user}>{item.user}</Text>
              <Text style={styles.text}>{item.text}</Text>
            </View>
          )}
          // 레이아웃 계산이 끝날 때도 하단으로 붙이기
          onLayout={() => listRef.current?.scrollToEnd({ animated: false })}
          // 데이터 양이 급증해도 성능 저하가 적도록 (필요시) getItemLayout 구현을 검토하세요
        />

        {/* 하단: 입력창 + 전송 버튼 */}
        <View style={styles.inputRow}>
          <TextInput
            style={styles.input}
            value={input}
            onChangeText={t => {
              setInput(t);
              sendTyping(); // 타이핑 중 신호(상대에게 "입력 중..." 표시용)
            }}
            placeholder="메시지를 입력하세요"
            placeholderTextColor="#777"
            returnKeyType="send"
            blurOnSubmit={false} // 엔터 눌러도 키보드 유지(연속 입력 편의)
            onSubmitEditing={handleSend} // 엔터(전송) 처리
          />
          <Button title="전송" onPress={handleSend} />
        </View>
      </SafeAreaView>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#111', alignSelf: 'stretch' },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',

    padding: 12,
  },
  title: { color: '#fff', fontSize: 16, fontWeight: '600' },

  // 말풍선 공통 스타일
  bubble: {
    padding: 10,
    borderRadius: 8,
    marginVertical: 6,
    maxWidth: '85%',
  },
  // 내 메시지: 오른쪽 정렬 + 파란 배경
  me: { alignSelf: 'flex-end', backgroundColor: '#2d6cdf' },
  // 상대 메시지: 왼쪽 정렬 + 짙은 회색 배경
  other: { alignSelf: 'flex-start', backgroundColor: '#333' },

  user: { fontSize: 10, color: '#ddd', marginBottom: 2 },
  text: { fontSize: 14, color: '#fff' },

  // 하단 입력 영역
  inputRow: {
    flexDirection: 'row',
    gap: 8,
    padding: 12,
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: '#333',
  },
  input: {
    flex: 1,
    padding: 10,
    backgroundColor: '#222',
    color: '#fff',
    borderRadius: 8,
  },
});

 

자, 이제 설명을 하겠습니다.

---

customHooks 부터 보져.

소켓을 연결하는 함수(connect)를 먼저 구현하고, 이를 useEffect에서 호출합니다.

  const connect = useCallback(() => {
    // ✅ 이미 인스턴스가 있다면 "재생성 금지" (중복 리스너 방지)
    if (socketRef.current) {
      if (!socketRef.current.connected) socketRef.current.connect();
      return;
    }

    const s = io(URL, {
      transports: ['websocket'], // RN에선 websocket 우선 권장
      autoConnect: false, // 직접 connect() 호출
      reconnection: true,
      reconnectionAttempts: Infinity,
      reconnectionDelay: 500,
      reconnectionDelayMax: 5000,
      timeout: 10000,
    });
    socketRef.current = s;

    s.on('connect', () => {
      setConnected(true);
      setReconnecting(false);
      setLastError(null);
      if (autoJoin) s.emit('join', { roomId, user });
      if (offlineQueue.current.length) {
        offlineQueue.current.forEach(({ text }) =>
          s.emit('message', { roomId, user, text }),
        );
        offlineQueue.current = [];
      }
    });
    s.on('disconnect', () => setConnected(false));
    s.on('connect_error', (err: any) =>
      setLastError(String(err?.message ?? err)),
    );

    s.io.on('reconnect_attempt', () => setReconnecting(true));
    s.io.on('reconnect', () => setReconnecting(false));

    s.on('message', (m: Message) => setMessages(prev => [...prev, m]));
    s.on('system', (text: string) =>
      setMessages(prev => [
        ...prev,
        {
          id: String(Date.now()),
          user: 'system',
          text,
          ts: new Date().toISOString(),
        },
      ]),
    );

    s.connect(); // 실제 연결 시작
  }, [URL, roomId, user, autoJoin]);

요기서 .emit는 서버에게 이벤트명과 데이터를 보냅니다., 반대로 .on은서버 쪽에서 프론트로 이벤트명과, 데이터(콜백함수)를 반환합니다.

 

  • emit: 이벤트 보내기 (클라이언트 → 서버, 또는 서버 → 클라이언트)
  • on: 이벤트 받기 (서버 → 클라이언트, 또는 클라이언트 → 서버

그리고 useEffect를 구현하는데, 반.드.시 클린업 하셔야합니다.

  useEffect(() => {
    // 온라인/오프라인에 따라 연결/해제
    const unsubNet = NetInfo.addEventListener(state => {
      if (state.isConnected) {
        if (!socketRef.current?.connected) connect();
      } else {
        socketRef.current?.disconnect();
      }
    });

    // 포그라운드에서만 연결 유지
    const unsubApp = AppState.addEventListener('change', state => {
      appStateRef.current = state;
      if (state === 'active') connect();
      else socketRef.current?.disconnect();
    });

    connect(); // 마운트 시 1회

    return () => {
      unsubNet();
      unsubApp.remove();
      if (socketRef.current) {
        if (autoJoin) socketRef.current.emit('leave', { roomId, user });
        socketRef.current.removeAllListeners();
        socketRef.current.disconnect();
        socketRef.current = null;
      }
    };
  }, [connect, roomId, user, autoJoin]);

 

아.. 근데 주석으로 다 써놔서 뭘 설명해야 할 지 모르겠습니다...

반응형

'React Native' 카테고리의 다른 글

[RN] 프로젝트 초기에 할 것  (0) 2025.12.24
[RN] Native Module (legacy)  (0) 2025.09.04
[RN] Firebase FCM 02  (4) 2025.08.26
[RN] Firebase FCM  (7) 2025.08.26
[RN] react-native-seoul/kakao-login issue  (0) 2025.02.15