FE/React Native

[React Native] WebSocket으로 구현하는 실시간 채팅

ardoh 2024. 11. 21. 23:26

💠 Intro 💠

1. 학습 목표

     이번 글에서는 React Native와 TypeScript를 사용해 WebSocket 기반의 채팅 기능 단계별로 구현하는 방법을 소개하려고 한다. 현재 필자가 진행하고 있는 프로젝트인 FoodieBuddy를 활용해 실시간 채팅 시스템이 어떻게 동작하는지 설명해보겠다. 직접 실습을 하며 따라해 볼 수 있도록 과정을 단계별로 정리하였다.

 

실습 과정은 아래와 같은 단계를 따른다. 마지막까지 따라가면 채팅 앱 서비스가 완성된다:)

  • Step 1: 기본 UI 설계 및 구현
  • Step 2: 메시지 상태 관리 및 화면 렌더링
  • Step 3: WebSocket 기본 코드 작성
  • Step 4~5: WebSocket을 활용한 실시간 통신 추가
  • Step 6~7: 텍스트와 이미지 데이터 처리 구현

 

2. 프로젝트 설명

     Dietary Restrictions을 가진 외국인들을 위한 생성형 AI를 이용한 맞춤형 한식 정보 제공 채팅 앱 서비스 FoodieBuddy를 개발하고 있다. 사용자는 앱 초기 설정 단계에서 자신의 dietary restrictions(예: 채식주의자, 글루텐 프리, 땅콩 알레르기 등)를 입력한다. 입력된 정보는 데이터베이스에 저장되며, 이후 모든 기능의 AI 모델 프롬프트와 데이터 처리에 반영된다. 해당 정보를 기반으로 모든 채팅이 이루어진다. 그 외에도 여러 가지 기능이 있지만 오늘은 WebSocket을 활용한 채팅 기능만 집중해서 글을 작성하겠다.

3. 채팅 기능 설명

     FoodieBuddy 서비스에는 크게 3가지 채팅 기능이 있다. (1. 한식 메뉴 추천 / 2. 메뉴판 설명 / 3. 밑반찬 설명) 각 기능은 데이터의 형태와 처리 방식이 다르기 때문에 3가지의 서로 다른 웹소켓 URL 을 사용할 예정이다.

  1. 메뉴 추천 2. 메뉴판 설명 3. 밑반찬 설명
input 텍스트 이미지 이미지
output 이미지 + 텍스트  텍스트 텍스트
기술 - 농촌진흥청 식재료 데이터 API를 통해 각 메뉴의 재료와 성분 정보를 분석하여 GPT-4o의 프롬프트에 반영
- Stable Diffusion으로 이미지 생성
OCR로 메뉴판 이미지에서 텍스트 추출 Computer Vision 으로 밑반찬 이미지 분석 및 주요 성분 식별

 

 

💠 Step 1 : 기본 UI 세팅 💠

가장 먼저 기본 UI를 설계하고 구현한다. 아래 코드를 복사해서 실습을 시작하면 된다. 

1.1 ChatScreen

import React, {useState} from 'react';
import {
  View,
  FlatList,
  StyleSheet,
  Text,
  TouchableOpacity,
  ActivityIndicator,
  SafeAreaView,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import MessageInput from '../../components/chat/MessageInput';
import MessageItem from '../../components/chat/MessageItem';


const ChatScreen: React.FC = () => {
  const [messages, setMessages] => ([]);

  const buttons = [
    {
      icon: 'question-mark',
      text: 'Recommend Food',
    },
    {
      icon: 'menu-book',
      text: 'Explain\nMenu Board',
    },
    {
      icon: 'egg-alt',
      text: 'Explain\nSide Dish',
    },
  ];

  const renderButtons = () => (
    <View style={styles.buttonRow}>
      {buttons.map((button, index) => (
        <View key={index} style={styles.buttonWrapper}>
          <TouchableOpacity style={styles.button}>
            <Icon name={button.icon} size={28} color="white" />
          </TouchableOpacity>
          <Text style={styles.buttonText}>{button.text}</Text>
        </View>
      ))}
    </View>
  );

  return (
    <SafeAreaView style={styles.safeArea}>
      {/* 헤더 */}
      <View style={styles.header}>
        <Text style={styles.headerText}>Chat</Text>
      </View>

      <View style={styles.container}>
        {/* 안내 버튼 */}
        {renderButtons()}

        {/* 메시지 입력 */}
        <MessageInput
          onSend={message => {
            const newMessage: MessageItemType = {
              text: message,
              sentByUser: true,
            };
            setMessages(prevMessages => [...prevMessages, newMessage]);
          }}
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  safeArea: {
    flex: 1,
    backgroundColor: 'white',
  },
  header: {
    height: 60,

    justifyContent: 'center',
    alignItems: 'center',
    borderBottomWidth: 1,
    borderBottomColor: '#ddd',
  },
  headerText: {
    fontSize: 30,
    fontWeight: 'bold',
    color: '#F27059',
  },
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: 'white',
  },
  flatListContent: {
    flexGrow: 1,
    justifyContent: 'flex-start',
  },
  buttonRow: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    alignItems: 'center',
    marginVertical: 10,
  },
  buttonWrapper: {
    alignItems: 'center',
    maxWidth: 100,
  },
  button: {
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: '#FFA500',
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 5,
  },
  buttonText: {
    fontSize: 12,
    textAlign: 'center',
    color: 'black',
    lineHeight: 15,
  },
  loadingContainer: {
    justifyContent: 'center',
    alignItems: 'center',
    marginVertical: 10,
  },
  loadingText: {
    marginTop: 10,
    fontSize: 16,
    color: 'gray',
  },
});

export default ChatScreen;

 

1.2 MessageInput.tsx

import React, {useState} from 'react';
import {
  View,
  TextInput,
  Button,
  StyleSheet,
  TouchableOpacity,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import {colors} from '../../constants';

interface MessageInputProps {
  onSend: (message: string) => void;
  onImagePress?: () => void;
}

const MessageInput: React.FC<MessageInputProps> = ({onSend, onImagePress}) => {
  const [message, setMessage] = useState('');

  const handleSend = () => {
    if (message.trim()) {
      onSend(message);
      setMessage('');
    }
  };

  return (
    <View style={styles.container}>
      {/* 이미지 아이콘 */}
      <TouchableOpacity onPress={onImagePress} style={styles.iconButton}>
        <Icon name="image" size={28} color="#F27059" />
      </TouchableOpacity>

      {/* 메시지 입력 필드 */}
      <TextInput
        style={styles.input}
        placeholder="Ask me anything"
        value={message}
        onChangeText={setMessage}
      />

      {/* 전송 버튼 */}
      <Button title="Send" onPress={handleSend} color="#F27059" />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 10,
    marginTop: 10,
  },
  iconButton: {
    marginRight: 10,
    padding: 5,
  },
  input: {
    flex: 1,
    borderColor: colors.ORANGE_800,
    borderWidth: 2,
    paddingHorizontal: 10,
    height: 40,
    borderRadius: 20,
    marginRight: 10,
  },
});

export default MessageInput;

 

💠 Step 2 : 메시지 입력과 화면 렌더링 💠

     

     이제 기본 UI 화면에서 메시지를 입력하면 채팅화면에 말풍선이 뜨도록 해보자. 채팅 애플리케이션의 핵심은 사용자가 입력한 메시지를 실시간으로 화면에 표시하는 기능이다. React Native를 사용하면 상태 관리컴포넌트 기반 구조를 활용하여 이러한 기능을 효율적으로 구현할 수 있다. 사용자가 메시지를 입력하면 화면에 표시되도록 데이터 상태를 관리하고, 이를 MessageItem 컴포넌트를 통해 렌더링하는 방법을 단계별로 알아보자.

 

2.1 UseState 사용해 message 상태 정의

     React의 useState 훅을 사용해 messages 상태를 정의한다. 이 상태는 문자열 배열로, 채팅 메시지를 저장한다. 상태를 업데이트하기 위해 setMessages라는 함수를 이용한다. 아래의 코드를 통해 messages에 저장된 데이터를 기반으로 화면을 업데이트할 수 있다. 

 

 

const [messages, setMessages] = useState<string[]>([]); // 메시지를 문자열 배열로 관리

 

2.2 메시지 추가 로직

     MessageInput 컴포넌트에서 메시지를 입력받는다. onSend 이벤트가 발생하면, 새 메시지가 messages 배열에 추가된다. 이 과정에서 기존 배열을 복사하고, 새로운 메시지를 배열 끝에 덧붙인다.

  • prevMessages: 기존 메시지 배열을 나타낸다.
  • ...prevMessages: 기존 배열을 복사한다.
  • message: 새로 입력된 메시지를 배열 끝에 추가한다.
<MessageInput
  onSend={message => {
    setMessages(prevMessages => [...prevMessages, message]); // 새 메시지를 배열에 추가
  }}
/>

2.3 MessageItem의 기능

     MessageItem은 개별 메시지를 화면에 렌더링하는 역할을 한다. 또한, 메시지와 관련된 시각적 표현과 동작을 관리하여 사용자가 대화를 쉽게 이해할 수 있도록 돕는다. 단순히 데이터를 출력하는 데 그치지 않고, 메시지의 성격에 따라 동적으로 스타일을 적용하거나 특정 동작을 처리하는 데도 기여한다. 주요 기능들을 코드와 함께 살펴보자. 

 

a) 데이터 기반 렌더링

     MessageItem은 부모 컴포넌트인 ChatScreen으로부터 props를 통해 메시지 데이터를 전달받아 렌더링한다. 전달받은 데이터에는 메시지의 텍스트, 이미지, 발신자 정보 등이 포함된다.

// MessageItemProps 인터페이스 정의: 각 메시지 항목의 데이터 구조를 설명
interface MessageItemProps {
  item: {
    text?: string; // 메시지 텍스트 (선택적)
    sentByUser?: boolean; // 메시지 발신자 정보 (사용자가 보낸 메시지인지 여부)
  };
  
  // MessageItem 컴포넌트: 개별 메시지를 렌더링하는 역할
const MessageItem: React.FC<MessageItemProps> = ({item}) => {
  // 메시지 텍스트가 없는 경우 아무것도 렌더링하지 않음
  if (!item.text) {
    return null;
  }

 

b) 조건부 스타일링

     메시지의 발신자에 따라 스타일을 다르게 적용한다. 사용자가 보낸 메시지는 오른쪽에 정렬하고, 다른 사람이 보낸 메시지는 왼쪽에 정렬해 대화의 흐름을 시각적으로 구분한다. 이러한 스타일링은 대화창이 더 직관적이고 읽기 편하도록 만들며, 대화 상대와 자신의 메시지를 명확히 식별할 수 있게 한다.

<View
  style={[
    styles.messageContainer,
    item.sentByUser ? styles.sentMessage : styles.receivedMessage, // 발신자에 따라 스타일 적용
  ]}>

 

MessageItem.tsx 전체 코드

import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {colors} from '../../constants';

// MessageItemProps 인터페이스 정의: 각 메시지 항목의 데이터 구조를 설명
interface MessageItemProps {
  item: {
    text?: string; // 메시지 텍스트 (선택적)
    sentByUser?: boolean; // 메시지 발신자 정보 (사용자가 보낸 메시지인지 여부)
  };
}

// MessageItem 컴포넌트: 개별 메시지를 렌더링하는 역할
const MessageItem: React.FC<MessageItemProps> = ({item}) => {
  // 메시지 텍스트가 없는 경우 아무것도 렌더링하지 않음
  if (!item.text) {
    return null;
  }

  return (
    <View
      style={[
        styles.messageContainer,
        item.sentByUser ? styles.sentMessage : styles.receivedMessage, // 발신자에 따라 스타일 적용
      ]}>
      {/* 메시지 텍스트 렌더링 */}
      <Text style={styles.messageText}>{item.text}</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  messageContainer: {
    marginVertical: 10,
    padding: 10,
    borderRadius: 10,
    maxWidth: '70%',
  },
  sentMessage: {
    backgroundColor: colors.YELLOW_200,
    alignSelf: 'flex-end',
  },
  receivedMessage: {
    backgroundColor: colors.GRAY_200,
    alignSelf: 'flex-start',
  },
  messageText: {
    fontSize: 16,
    color: colors.BLACK,
  },
});

export default MessageItem;

💠 잠깐! 웹소켓이란? 💠

1. WebSocket 개념

     WebSocket은 클라이언트와 서버 간에 양방향 통신을 가능하게 하는 통신 프로토콜이다. 따라서 클라이언트와 서버가 서로 데이터를 주고받을 수 있다.일반적인 HTTP 요청-응답 방식과 달리, 단일 연결을 통해 데이터를 지속적으로 주고받을 수 있다. 이는 실시간 데이터 전송이 필요한 애플리케이션에 적합하다. 데이터를 요청하지 않아도 서버가 클라이언트에 데이터를 푸시할 수 있기 때문이다.

 

  HTTP  WebSocket
통신 방식 요청-응답 기반 (클라이언트가 요청해야 서버가 응답) 양방향 통신 (서버가 클라이언트에 데이터 푸시 가능)
연결 유지 요청-응답 사이클마다 연결 생성 초기 연결 후 지속적인 연결 유지
헤더 오버헤드 요청/응답마다 HTTP 헤더 전송 초기 핸드셰이크 이후 추가 헤더 없음

 

2. WebSocket 통신 흐름

WebSocket의 통신 과정은 초기 핸드셰이크부터 연결 종료까지 단계적으로 이루어진다. 아래는 주요 과정이다.

 

a) 초기 핸드셰이크(Handshake)

클라이언트는 HTTP 요청으로 WebSocket 연결을 요청한다. 서버는 이를 승인하고 응답을 반환한다. 이후에는 HTTP 대신 WebSocket 프로토콜로 통신이 진행된다.

 

b) 연결 유지

핸드셰이크 후 연결은 지속적으로 유지된다. 추가 연결 없이 데이터를 주고받을 수 있으며, 한쪽에서 종료 요청을 보내기 전까지 연결이 끊기지 않는다.

 

c) 데이터 전송

클라이언트와 서버는 onmessage 이벤트를 통해 데이터를 주고받는다. 텍스트, 바이너리 데이터 등 다양한 형식이 지원되며, 양방향 통신이 가능하다.

 

d) 연결 종료:

클라이언트 또는 서버에서 WebSocket 연결을 종료할 수 있다. 종료 요청은 onclose 이벤트로 감지된다.

3. 클라이언트 작동 원리

    WebSocket 통신 과정은 클라이언트 측과 서버 측 각각의 역할로 나뉜다. 그 중에서 클라이언트 측의 작동 방식에 대해 알아보자.

클라이언트는 브라우저나 애플리케이션에서 WebSocket 객체를 생성하여 서버와의 연결을 시작한다. 클라이언트는 연결 상태를 관리하고, 데이터를 전송하거나 수신하는 작업을 수행한다. 주요 메서드와 이벤트는 다음과 같다.

메서드 설명
new WebSocket(url) 클라이언트가 서버 URL을 입력하여 WebSocket 객체를 생성한다. 이를 통해 연결이 시작된다.
ws.onopen 서버와의 연결이 성공적으로 성립되었을 때 호출되는 이벤트이다. 연결 성공 후 데이터를 보낼 준비가 된다.
ws.onmessage 서버로부터 메시지를 수신했을 때 호출된다. 수신된 데이터는 이벤트 핸들러에서 처리하며, 텍스트 또는 바이너리 데이터 형식일 수 있다.
ws.send(data) 클라이언트가 데이터를 서버로 전송할 때 사용하는 메서드이다. 전송할 데이터는 문자열 또는 바이너리 형식일 수 있다.
ws.onclose WebSocket 연결이 종료되었을 때 호출된다. 정상 종료 또는 오류로 인한 종료 여부를 확인할 수 있다.
ws.onerror 통신 중 오류가 발생했을 때 호출된다. 네트워크 장애나 서버 오류 등 문제를 처리하기 위해 사용된다.

💠 Step 3 : WebSocket 설계 💠 

     WebSocket 기반의 실시간 애플리케이션은 데이터 송수신, 상태 관리, UI 업데이트를 통합적으로 처리해야 한다. 이를 효과적으로 구현하기 위해 애플리케이션을 네 가지 파일로 역할을 분리하여 설계할 수 있다. websocketActions.ts와 websocketActionTypes.ts는 WebSocket 동작과 Redux 액션 관리를 책임진다. websocketHandler.ts는 React와 WebSocket 간의 연결을 효율적으로 처리한다. websocketReducer.ts는 상태를 중앙에서 관리하며 애플리케이션의 일관성을 보장한다.

 

├── websocketActions.ts        // 웹소켓 동작 제어 (연결, 메시지 전송 등)
├── websocketActionTypes.ts    // 액션 타입을 정의하여 전체 구조를 통일
├── websocketHandler.ts        // React 컴포넌트와 웹소켓 로직 연결
└── websocketReducer.ts        // Redux를 통한 상태 관리

 

3.1 WebSocket 동작 제어 (websocketActions.ts)

이 파일은 WebSocket의 연결, 데이터 송수신, 연결 종료와 같은 동작을 제어하는 Redux 액션을 정의한다.

 

a) connectWebSocket
     지정된 URL로 WebSocket을 연결하고, onopen, onmessage, onclose 이벤트를 처리한다. 서버에서 받은 데이터를 Redux 상태에 반영하며, 필요 시 기존 연결을 종료하고 새 연결을 생성한다.

export const connectWebSocket = (url: string): ThunkAction<void, RootState, unknown, any> => {
  return dispatch => {
    if (websocket) websocket.close(); // 기존 연결 종료
    websocket = new WebSocket(url); // 새로운 WebSocket 연결 생성

    websocket.onopen = () => {
      dispatch({type: WEBSOCKET_CONNECT}); // 연결 상태 업데이트
      console.log(`WebSocket connected to ${url}`);
    };

    websocket.onmessage = event => {
      const message = typeof event.data === 'string'
        ? {text: event.data, sentByUser: false}
        : {imageUri: URL.createObjectURL(event.data), sentByUser: false};
      dispatch({type: WEBSOCKET_MESSAGE, payload: message}); // 메시지 수신
    };

    websocket.onclose = () => {
      dispatch({type: WEBSOCKET_DISCONNECT}); // 연결 해제
      console.log('WebSocket disconnected');
    };
  };
};

 

b) sendWebSocketMessage
     WebSocket 연결 상태를 확인한 뒤, 연결이 열려 있는 경우 서버로 메시지를 전송한다. 데이터 형식(문자열 또는 바이너리)을 확인하여 적절히 처리한다.

export const sendWebSocketMessage = (message: any): ThunkAction<void, RootState, unknown, any> => {
  return () => {
    if (websocket && websocket.readyState === WebSocket.OPEN) {
      if (message instanceof ArrayBuffer) websocket.send(message); // 바이너리 데이터 전송
      else websocket.send(JSON.stringify(message)); // 문자열 데이터 전송
    } else {
      console.error('WebSocket is not connected');
    }
  };
};

 

sconnectWebSocket:

export const disconnectWebSocket = (): ThunkAction<void, RootState, unknown, any> => {
  return dispatch => {
    if (websocket) {
      websocket.close();
      websocket = null;
    }
    dispatch({type: WEBSOCKET_DISCONNECT}); // 상태 업데이트
  };
};

 

websocketHandler.ts 전체코드

import {ThunkAction} from '@reduxjs/toolkit';
import {RootState} from '../states/store';
import {
  WEBSOCKET_CONNECT,
  WEBSOCKET_DISCONNECT,
  WEBSOCKET_MESSAGE,
} from './websocketActionTypes';
import {MessageItem} from './websocketReducer';

let websocket: WebSocket | null = null;

// WebSocket 연결 액션
export const connectWebSocket = (
  url: string,
): ThunkAction<void, RootState, unknown, any> => {
  return dispatch => {
    if (websocket) {
      websocket.close();
    }

    websocket = new WebSocket(url);
    websocket.binaryType = 'blob';

    websocket.onopen = () => {
      console.log(`WebSocket connected to ${url}`);
      dispatch({type: WEBSOCKET_CONNECT});
    };

    websocket.onmessage = event => {
      const data = event.data;

      console.log('[WebSocket] Received data:', data); // 데이터 로깅
      console.log('[WebSocket] Data type:', typeof data);

      // 메시지 유형에 따라 MessageItem 생성
      const message: MessageItem =
        typeof data === 'string'
          ? {text: data, sentByUser: false} // 텍스트 메시지
          : {imageUri: URL.createObjectURL(data), sentByUser: false}; // 이미지 메시지

      console.log('[WebSocket] MessageItem created:', message);

      // Redux에 메시지 디스패치
      dispatch({type: WEBSOCKET_MESSAGE, payload: message});
    };

    websocket.onclose = () => {
      console.log('WebSocket disconnected');
      dispatch({type: WEBSOCKET_DISCONNECT});
      websocket = null;
    };
  };
};

// WebSocket 메시지 전송 액션
export const sendWebSocketMessage = (
  message: any,
): ThunkAction<void, RootState, unknown, any> => {
  return () => {
    if (websocket && websocket.readyState === WebSocket.OPEN) {
      if (message instanceof ArrayBuffer) {
        websocket.send(message); // 바이너리 데이터 전송
      } else {
        websocket.send(JSON.stringify(message)); // 문자열/JSON 전송
      }
    } else {
      console.error('WebSocket is not connected');
    }
  };
};

// WebSocket 연결 종료 액션
export const disconnectWebSocket = (): ThunkAction<
  void,
  RootState,
  unknown,
  any
> => {
  return dispatch => {
    if (websocket) {
      websocket.close();
      websocket = null;
    }
    dispatch({type: WEBSOCKET_DISCONNECT});
  };
};

3.2 WebSocket 동작 제어 (websocketActionsTypes.ts)

액션 타입을 상수로 정의하여 코드의 가독성을 높이고 하드코딩을 방지한다.

export const WEBSOCKET_CONNECT = 'WEBSOCKET_CONNECT'; // WebSocket 연결
export const WEBSOCKET_DISCONNECT = 'WEBSOCKET_DISCONNECT'; // WebSocket 해제
export const WEBSOCKET_MESSAGE = 'WEBSOCKET_MESSAGE'; // 메시지 수신

 

 

3.3 React와 WebSocket 통합 (websocketHandler.ts)

React 컴포넌트에서 WebSocket 로직을 효율적으로 처리하기 위해 커스텀 Hook을 제공한다. 이 Hook은 WebSocket 연결을 관리하고, 메시지를 송수신하며, 상태를 추적한다.

export function useWebSocket(
  url: string,
  onMessageReceived: (data: any) => void,
) {
  const wsRef = useRef<WebSocket | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    wsRef.current = new WebSocket(url); // WebSocket 연결 생성

    wsRef.current.onopen = () => {
      console.log(`WebSocket connected to ${url}`);
      setIsConnected(true);
    };

    wsRef.current.onmessage = event => {
      onMessageReceived(event.data); // 수신된 메시지 처리
    };

    wsRef.current.onclose = () => {
      console.log('WebSocket disconnected');
      setIsConnected(false);
    };

    return () => wsRef.current?.close(); // 컴포넌트 언마운트 시 연결 종료
  }, [url]);

  const sendMessage = (message: any) => {
    if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message)); // 메시지 전송
    }
  };

  return {isConnected, sendMessage};
}

 

3.4 상태 관리와 Redux (websocketReducer.ts)

    상태 관리는 애플리케이션의 동적인 데이터를 추적하고 이를 적절히 업데이트하는 과정을 말한다. Redux는 상태 관리를 위한 라이브러리이다. 애플리케이션의 상태를 단일 스토어(store)에 저장하고, 이를 명확하게 업데이트하기 위해 액션(actions)과 리듀서(reducers)라는 개념을 도입한다. Redux를 사용하면 여러 컴포넌트 간에 상태를 공유하거나 전역 상태를 관리하는 일이 쉬워진다. 리듀서를 통해 WebSocket 연결 상태와 수신 메시지를 중앙 집중적으로 관리하는 방법을 알아보자. 

 

a) 리듀서 초기 상태 및 구조
WebSocket 연결 상태와 메시지를 관리하기 위한 초기 상태와 리듀서 구조는 다음과 같다.

const initialState: WebSocketState = {
  isConnected: false, // 연결 상태
  messages: [], // 수신 메시지 목록
};

export const websocketReducer = (
  state = initialState,
  action: {type: string; payload?: any},
): WebSocketState => {
  switch (action.type) {
    case WEBSOCKET_CONNECT:
      return {...state, isConnected: true};
    case WEBSOCKET_DISCONNECT:
      return {...state, isConnected: false};
    case WEBSOCKET_MESSAGE:
      return {...state, messages: [...state.messages, action.payload]};
    default:
      return state;
  }
};

 

b) WEBSOCKET_MESSAGE
서버에서 수신된 메시지를 상태에 추가한다. 기존 메시지 배열을 유지하면서 새로운 메시지를 덧붙인다.

case WEBSOCKET_MESSAGE:
  return {
    ...state, // 기존 상태 유지
    messages: [...state.messages, action.payload], // 새로운 메시지 추가
  };

 

WebsocketReducer.ts 전체코드

import {
  WEBSOCKET_CONNECT,
  WEBSOCKET_DISCONNECT,
  WEBSOCKET_MESSAGE,
} from './websocketActionTypes';

// MessageItem 타입 정의
export type MessageItem = {
  text?: string; // 선택적 속성으로 변경
  sentByUser: boolean;
  imageUri?: string;
};

// WebSocketState 타입 정의
interface WebSocketState {
  isConnected: boolean;
  messages: MessageItem[];
}

// 초기 상태
const initialState: WebSocketState = {
  isConnected: false,
  messages: [],
};

// WebSocket 리듀서
export const websocketReducer = (
  state = initialState,
  action: {type: string; payload?: any},
): WebSocketState => {
  switch (action.type) {
    case WEBSOCKET_CONNECT:
      return {
        ...state,
        isConnected: true,
      };

    case WEBSOCKET_DISCONNECT:
      return {
        ...state,
        isConnected: false,
      };

    case WEBSOCKET_MESSAGE:
      return {
        ...state,
        messages: [...state.messages, action.payload],
      };

    default:
      return state;
  }
};

 

💠 Step 4 :  WebSocket 데이터를 화면에 통합하기 💠

     이제, WebSocket을 통해 받은 데이터를 화면에 렌더링하고, 사용자 입력을 서버와 주고받는 과정을 구현할 차례다. 설계한 WebSocket 로직과 컴포넌트를 활용하여 실시간 메시징 기능을 화면에 통합한다. 이 단계에서는 ChatScreen에서 상태(messages)를 관리하고, WebSocket을 통해 받은 데이터를 동적으로 표시하며, 사용자 입력을 서버로 전송하는 흐름을 구현한다.

 

4.1 WebSocket을 통해 수신된 데이터 추가

     수신한 메시지는 onmessage 이벤트 핸들러에서 처리된다. 현재 코드에서 수신한 메시지는 다음과 같이 setMessages를 통해 상태에 추가된다. 이 코드는 event.data(서버에서 수신된 데이터)를 기반으로 새 메시지를 생성하고, 이전 메시지 목록(prevMessages)에 덧붙인다. 이를 통해 실시간으로 새로운 메시지가 렌더링된다.

// 서버로부터 메시지 수신
ws.onmessage = event => {
  console.log('Message received:', event.data);

  const newMessage: MessageItemType = {
    text: event.data, // 서버에서 받은 텍스트
    sentByUser: false, // 서버가 보낸 메시지
  };

  // 기존 메시지에 새로운 메시지 추가
  setMessages(prevMessages => [...prevMessages, newMessage]);
};

4.2 메시지 렌더링

FlatList를 사용하여 messages 배열의 데이터를 렌더링한다. 각 메시지는 MessageItem 컴포넌트를 통해 스타일링되고 표시된다.

  • data: messages 상태로 메시지 목록을 렌더링한다.
  • renderItem: 각 메시지를 MessageItem 컴포넌트로 렌더링한다.
  • keyExtractor: 배열의 인덱스를 메시지의 고유 키로 사용한다.
<FlatList
  data={messages} // 메시지 데이터
  renderItem={({item}) => <MessageItem item={item} />} // 메시지 렌더링
  keyExtractor={(_, index) => index.toString()} // 고유 키 설정
  contentContainerStyle={styles.flatListContent}
/>

4.3 MessageItem 컴포넌트에서 메시지 표시

MessageItem은 발신자(sentByUser)에 따라 스타일을 변경하며, 메시지 텍스트를 화면에 출력한다.

const MessageItem: React.FC<MessageItemProps> = ({item}) => {
  if (!item.text) return null; // 메시지 텍스트가 없는 경우 렌더링하지 않음

  return (
    <View
      style={[
        styles.messageContainer,
        item.sentByUser ? styles.sentMessage : styles.receivedMessage, // 발신자에 따라 스타일 변경
      ]}>
      <Text style={styles.messageText}>{item.text}</Text>
    </View>
  );
};

4.4 연결 상태 시각화

웹소켓 연결 상태(isConnected)를 사용하여 연결 여부를 사용자에게 표시한다.

{!isConnected && <Text style={styles.statusText}>Connecting...</Text>}

4.5 메시지 전송

MessageInput을 사용하여 사용자가 입력한 메시지를 서버로 전송하고, 화면에 표시한다. 입력한 메시지는 sendMessage를 통해 서버로 전송되며, messages 배열에도 추가된다.

<MessageInput
  onSend={message => {
    sendMessage(message); // 서버로 메시지 전송
  }}
/>

4.6 결과

 

     WebSocket을 활용한 실시간 메시지 송수신 기능을 성공적으로 구현하였다. 현재는 3개의 버튼 중 첫 번째 기능(Recommend Food)만 WebSocket과 연결하여 작동하도록 설정하였다. 서버로부터 메시지를 수신하면 onmessage 이벤트 핸들러를 통해 messages 상태에 반영되고, FlatList와 MessageItem 컴포넌트를 사용하여 화면에 실시간으로 메시지가 렌더링된다. 사용자가 메시지를 입력하면, 해당 메시지는 MessageInput을 통해 서버로 전송되며, 동시에 클라이언트 화면에도 표시된다.

 

     

 

 

💠 Step 5 : WebSocket 연결 확장 - 3가지 기능 구현 💠

     이 단계에서는 각 버튼을 클릭했을 때, 3개의 다른 WebSocket API URL에 연결되도록 구현한다. 버튼별로 고유한 apiUrl이 정의되어 있으며, 이를 기반으로 WebSocket 연결을 동적으로 변경한다. 사용자가 특정 버튼을 누르면 해당 API와 연결되어 실시간 데이터를 주고받을 수 있도록 설정할 것이다. 이번 단계의 목표는 버튼 클릭 이벤트에 따라 WebSocket URL을 설정하고, 연결된 API로부터 수신한 데이터를 화면에 동적으로 반영하는 것이다. 아래에서는 버튼 데이터 정의, 클릭 이벤트 처리, WebSocket 연결 초기화, 그리고 버튼 렌더링 순서로 세부 구현 방법을 설명한다.

 

5.1 버튼 데이터 정의

 

각 버튼은 고유한 apiUrl과 텍스트, 아이콘 정보를 가지고 있다. 이 정보는 buttons 배열에 정의되어 있다. 버튼 클릭 시 해당 URL로 연결을 시도한다.

const buttons = [
  {
    icon: 'question-mark',
    text: 'Recommend Food',
    apiUrl: `ws://YOUR api Url 1`,
  },
  {
    icon: 'menu-book',
    text: 'Explain\nMenu Board',
    apiUrl: `ws://YOUR api Url 2`,
  },
  {
    icon: 'egg-alt',
    text: 'Explain\nSide Dish',
    apiUrl: `YOUR api Url 3`,
  },
];

 

5.2 클릭 이벤트 처리

handleInstructionButtonPress 함수는 클릭된 버튼의 apiUrl 값을 받아 currentUrl 상태를 업데이트한다. 이를 통해 WebSocket 연결이 재설정된다.

const handleInstructionButtonPress = (apiUrl: string) => {
  setCurrentUrl(apiUrl); // WebSocket URL 업데이트
  
};

5.3 WebSocket 연결 관리

useWebSocket 훅은 currentUrl 값을 이용해 WebSocket 연결을 초기화한다. onMessageReceived 콜백을 통해 수신된 데이터를 처리하며, 연결 상태(isConnected)를 반환한다.

const {isConnected, sendMessage} = useWebSocket(
  currentUrl || '', // 현재 설정된 WebSocket URL로 연결
  (data: any) => {
    
    const parsedMessage: MessageItemType =
      typeof data === 'string'
        ? {text: data, sentByUser: false}
        : {text: '', sentByUser: false};
    dispatch({type: 'WEBSOCKET_MESSAGE', payload: parsedMessage}); // 수신 데이터 디스패치
  },
);

5.4 버튼 렌더링과 사용자 입력 관리

renderButtons 함수는 buttons 배열을 기반으로 버튼 컴포넌트를 렌더링한다. 각 버튼 클릭 시 handleInstructionButtonPress가 호출되어 WebSocket URL을 설정한다.

const renderButtons = () => (
  <View style={styles.buttonRow}>
    {buttons.map((button, index) => (
      <View key={index} style={styles.buttonWrapper}>
        <TouchableOpacity
          style={styles.button}
          onPress={() => handleInstructionButtonPress(button.apiUrl)} // 클릭 시 URL 설정
        >
          <Icon name={button.icon} size={28} color="white" />
        </TouchableOpacity>
        <Text style={styles.buttonText}>{button.text}</Text>
      </View>
    ))}
  </View>
);

 

5.5 결과

각각의 버튼을 누르면 챗봇의 안내 메시지를 수신한다. 

 

💠 Step 6 : 이미지 수신(1번 기능) 💠

     1번 기능(Recommend Food)에서는 WebSocket을 통해 이미지 데이터를 서버로부터 수신하고, 이를 메시지 상태에 반영하는 로직이 포함되어 있다. WebSocket을 통해 이미지를 바이너리 타입(Blob)으로 받아오는 로직은 서버가 데이터를 바이너리 형식으로 전송할 때 활용된다. Blob(Binary Large Object)은 파일, 이미지, 비디오 등 크기가 큰 바이너리 데이터를 다루기 위한 객체로, WebSocket에서 바이너리 데이터 전송 시 자주 사용된다.

 

6.1 WebSocket으로 이미지 데이터 수신

     WebSocket 객체의 binaryType을 'blob'으로 설정하면, 서버에서 전송된 바이너리 데이터를 Blob 형식으로 수신할 수 있다. 수신된 Blob 데이터는 클라이언트에서 이미지로 변환하여 렌더링된다.

websocket.binaryType = 'blob'; // WebSocket 바이너리 데이터 타입을 Blob으로 설정

   

     WebSocket 연결을 통해 서버에서 이미지 데이터를 전송하면 onMessageReceived 콜백이 호출된다. 이 콜백은 수신된 데이터의 type 필드를 확인하여, 데이터가 'image'인 경우 이를 이미지 데이터로 간주한다. 이후, dispatch를 사용해 Redux 상태에 이미지 메시지를 추가하고, 메시지가 messages 배열로 동기화되어 화면에 반영될 수 있도록 처리한다.

const {isConnected, sendMessage} = useWebSocket(
  currentUrl || '', // 현재 WebSocket URL
  (data: any) => {
    setLoading(false);

    if (data.type === 'image') {
      // 이미지 데이터 처리
      const imageMessage: MessageItemType = {
        imageUri: data.imageUrl, // 서버에서 받은 이미지 URL
        sentByUser: false,
      };
      dispatch({type: 'WEBSOCKET_MESSAGE', payload: imageMessage}); // Redux 상태 업데이트
    }
  },
);

6.2 Redux 상태와 화면 동기화

     WebSocket에서 받은 이미지 데이터는 Redux를 통해 messages 상태에 저장되며, 이는 ChatScreen에서 동기화되어 화면에 표시된다. 이 과정에서 FlatList를 사용해 최신 메시지 배열을 렌더링하며, 각 메시지는 data 속성을 통해 messages 상태로부터 가져온다. renderItem은 배열의 각 메시지를 MessageItem 컴포넌트에 전달해 화면에 표시한다.

<FlatList
  data={messages} // Redux 상태로부터 메시지 배열 가져오기
  renderItem={({item}) => <MessageItem item={item} />} // 메시지 컴포넌트 렌더링
  keyExtractor={(_, index) => index.toString()} // 고유 키 설정
  contentContainerStyle={styles.flatListContent}
/>

6.3 MessageItem에서 이미지 렌더링

     MessageItem 컴포넌트는 수신된 이미지 데이터를 로드하여 화면에 표시한다. 이 과정에서 로딩 상태와 오류를 처리한다. 이미지를 로드하는 동안에는 ActivityIndicator를 표시하여 사용자에게 시각적으로 알려준다. 로드가 실패할 경우 오류 메시지를 출력한다. 로드가 성공적으로 완료되면 이미지를 화면에 렌더링하여 사용자에게 표시한다.

{item.imageUri && (
  <View style={styles.imageContainer}>
    {isImageLoading && (
      <ActivityIndicator size="small" color={colors.GRAY_500} />
    )}
    {imageLoadError ? (
      <Text style={styles.errorText}>이미지를 로드할 수 없습니다.</Text>
    ) : (
      <Image
        source={{uri: item.imageUri}} // 이미지 URI를 기반으로 렌더링
        style={styles.image}
        onLoad={handleImageLoad} // 로드 완료 처리
        onError={handleImageError} // 로드 실패 처리
      />
    )}
  </View>
)}

 

 

6.4 결과

AI 가 생성한 이미지가 화면에 표시된다. 

 

6.5 보너스! text 스타일링

챗봇이 생성한 텍스트를 보면 ** ** 나 # 같은 특수 문자들이 포함되어 있다. 텍스트 스타일링을 하기 위해서 의도적으로 프롬프팅을 해두었다. 이를 활용하여 말풍선 속에 있는 텍스트를 꾸며보자. 

// 정규식을 기준으로 텍스트를 분리
const boldAndHashtagRegex = /(\*\*(.*?)\*\*|#[^\s]+)/g; // **볼드체** 또는 #해시태그 추출
const parts = sanitizedMessage
  .split(boldAndHashtagRegex) // 메시지를 정규식을 기준으로 분리
  .filter(part => part); // 빈 문자열 제거

return parts.map((part, index) => {
  if (part.startsWith('**') && part.endsWith('**')) {
    // **볼드체 텍스트**
    const content = part.slice(2, -2).trim(); // **를 제거하고 내용만 가져옴
    return (
      <Text key={index} style={styles.boldText}>
        {content}
      </Text>
    );
  } else if (part.startsWith('#')) {
    // #해시태그
    return (
      <Text key={index} style={styles.hashtagText}>
        {part}
      </Text>
    );
  } else {
    // 일반 텍스트
    return (
      <Text key={index} style={styles.normalText}>
        {part.trim()}
      </Text>
    );
  }
});

const styles = StyleSheet.create({
  boldText: {
    fontWeight: 'bold',
    color: colors.BLACK,
  },
  normalText: {
    fontWeight: 'normal',
    color: colors.BLACK,
  },
  hashtagText: {
    color: colors.ORANGE_800,
    fontWeight: 'bold',
  },
});

 

그러면 이렇게 볼드체와 해시태그 부분의 색상 변경이 된다. 

 

💠 Step 7 : 이미지 전송(2,3번 기능) 💠

2,3 번 기능은 사용자가 이미지를 업로드한다. 이미지를 전송하는 과정은 이미지 선택, 이미지 미리보기 및 상태 관리, 그리고 이미지 전송의 세 단계로 이루어진다. 사용자가 이미지를 선택하면 해당 이미지의 URI와 바이너리 데이터가 상태로 저장되고, 미리보기 화면을 통해 확인할 수 있다. 이후 "Send" 버튼을 누르면 이미지와 텍스트 메시지가 함께 서버로 전송된다.

7.1 이미지 선택

     ImageInput 컴포넌트는 react-native-image-picker와 같은 라이브러리를 활용해 사용자로부터 이미지를 선택하도록 한다. 선택된 이미지의 URI와 ArrayBuffer 형식의 바이너리 데이터는 onChange 콜백을 통해 부모 컴포넌트로 전달된다.

<ImageInput
  onChange={(uri, data) => {
    setImageUri(uri); // 이미지 URI 상태 업데이트
    setBinaryData(data); // 바이너리 데이터 상태 업데이트
  }}
/>

7.2 이미지 미리보기 및 상태 관리

     이미지 선택 후, 사용자가 전송하기 전에 선택된 이미지를 미리 보여준다. 이 과정에서 이미지 삭제 버튼도 제공하여 선택을 취소할 수 있다. imageUri 상태가 존재하면 선택된 이미지는 화면에 미리보기 형태로 표시된다. 이를 위해 Image 컴포넌트의 source 속성에 {uri: imageUri}를 전달하여, 해당 URI를 기반으로 이미지를 렌더링한다. 만약 사용자가 미리보기를 취소하고 싶다면, X 버튼을 눌러 handleRemoveImage 함수를 호출할 수 있다. 이 함수는 선택된 이미지의 URI와 바이너리 데이터를 초기화하여 상태를 정리한다.

 

{imageUri && (
  <View style={styles.previewContainer}>
    <Image source={{uri: imageUri}} style={styles.imagePreview} />
    <TouchableOpacity
      onPress={handleRemoveImage}
      style={styles.removeButton}>
      <Text style={styles.removeButtonText}>X</Text>
    </TouchableOpacity>
  </View>
)}
const handleRemoveImage = () => {
  setImageUri(null); // 이미지 URI 초기화
  setBinaryData(null); // 바이너리 데이터 초기화
};

7.3 이미지 전송

     이미지와 메시지는 "Send" 버튼을 통해 전송된다. 버튼 클릭 시, onSend 콜백을 호출하여 상위 컴포넌트(ChatScreen)로 메시지와 이미지를 전달한다.

const handleSend = () => {
  if (message.trim() || imageUri) {
    onSend(message, imageUri, binaryData); // 상위 컴포넌트로 데이터 전달
    setMessage(''); // 메시지 초기화
    setImageUri(null); // 이미지 URI 초기화
    setBinaryData(null); // 바이너리 데이터 초기화
  }
};

 

 

💠 Outro 💠

    이번 글에서는 React Native와 WebSocket을 활용해 실시간 채팅 기능을 구현하는 방법을 다루었다. 특히 3가지 WebSocket URL을 동적으로 연결하여 각각의 기능을 별도로 구현했다. 또한, 이미지를 서버로 전송하고 수신하여 사용자와의 실시간 데이터 상호작용을 가능하게 했다. 실시간 채팅 시스템의 설계와 구현이 필요한 사람에게 도움이 되었으면 좋겠다😊

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

React Native 아이콘 넣기  (0) 2024.08.27
Stack Navigation  (0) 2024.08.21
[RN] 프로그램 만들기 / 깃허브 연결  (0) 2024.05.26