유리의 개발새발
[RN] 채팅 기능 (socket.io) 본문
반응형
자, 오늘은 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 |