🖥️ 실시간 데이터 핸들링
🔗 SSE(Server Sent Event)
✨ SSE란 무엇일까?
-
SSE는 서버에서 실시간으로 이벤트를 전달하는 웹 기술을 의미한다.
HTTP의 기본적인 특징은 지속적으로 연결을 유지하지 않는다는 것.따라서
Polling이나Long-Polling을 통해 지속적으로 요청을 보내 서버의 변경된 데이터를 실시간으로 가져오는 것 처럼 구현한다.
🤔 결국 위와 같은 방식은 실시간으로 데이터를 받는다고 보기 어렵지 않나??
-
따라서 실시간 데이터를 핸들링 하는 경우,
SSE또는WebSocket을 사용한다.여기서 내가 사용한
SSE는WebSocket과 달리 단방향 통신(데이터를 보내고 받는 쪽이 고정된)을 위한 기술이다.
또한 이는 클라이언트가 별도로 요청하지 않아도 서버 측에서 데이터를 실시간으로 보내줄 수 있다는 특징을 가진다.
🤔 그렇다면 이걸 어떤 때에 사용해야 하는거지?
-
이번에 내가 진행했던 프로젝트는 실시간 이벤트를 통해 매칭을 해야하는 서비스였다.
택시 동승을 위해 가까운 출발지와 같은 목적지를 가진 학생들의 택시 비용 부담을 함께 나눌 수 있도록 해주는 서비스여서 실시간 이벤트 사용이 불가피했다.
🚫 하지만 서버에 지속적으로 요청을 보내는
Polling형태의 방식은 실시간처럼 말그대로 '보이게' 하는 것이며, 지속적인 요청으로 서버에 부하를 줄 수 있어 긍정적인 방안은 아니었다.때문에 실시간으로 데이터는 받지만, 서버에 데이터를 보내줄 필요는 없는 SSE 를 채택하여 사용하게 되었다.
💡 SSE는 어떻게 사용해야 할까
-
기본적으로 리액트와 함께 사용하는 만큼,
useState와 같은 상태 변경 제어 훅의 사용이 강제된다.실시간 이벤트는 말그대로 서버가 보내주는
String데이터를 가져오기만 할뿐, 이걸 핸들링하는 것은 클라이언트의 몫이기 떄문이다.✅ 내 프로젝트 같은 경우, 인증에 필요한 엑세스 토큰을 응답 헤더로 받는 방식을 채택하고 있어서 자바스크립트의 자체적인
EventSource객체가 아닌EventSourcePolyfill이라는 헤더 설정이 가능한 라이브러리를 사용해야 했다.
useEffect(() => {
const eventSource = new EventSourcePolyfill('/api/stream', {
headers: {
'Authorization': 'Bearer your-token', // 헤더 설정 가능
}
});
// 이벤트에 담겨오는 메세지 처리
eventSource.onmessage = (event) => {
console.log('New message received:', event.data);
};
// 에러 발생 시 제어
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
};
// 컴포넌트 언마운트 클린업
return () => {
eventSource.close();
};
}, []);위와 같은 코드가 기본적인 사용 방법이지만, 우리 프로젝트의 경우 썸네일과 같이 매칭 대기 와 매칭 완료 후 화면이 각각 다르고 채팅방까지 연결해야 했기 떄문에 다른 방식으로 사용하게 되었다.
🔗 SSE 객체를 전역적으로 사용하기
컴포넌트는 사용되는 페이지 내에서만 렌더링되므로, 대기화면이 URL에 따라 변경되는 우리 서비스는 SSE 객체를 전역적으로 만들어 사용할 필요가 있었다.
- 따라서
Zustand라는 상태 관리 라이브러리를 사용하여 해당 객체를 전역으로 만들고, 이를 각 페이지에서 상황에 맞게 사용할 수 있게 되었다. (사용법은 공식 사이트를 참고하자.)
⬇️ 다음은 실제로 사용한 SSE 전역 객체이다.
import { EventSourcePolyfill } from '@/utils/EventSourcePolyfill';
import { MatchingEvent, MessagesArray, EventType } from 'gachTaxi-types';
import { create } from 'zustand';
interface SSEState {
sse: EventSourcePolyfill | null;
messages: MessagesArray[];
initializeSSE: () => void;
closeSSE: () => void;
}
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
const useSSEStore = create<SSEState>((set, get) => ({
sse: null,
messages: [],
initializeSSE: () => {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
console.error('❌ 엑세스 토큰이 없습니다! SSE를 시작할 수 없습니다.');
return;
}
set((state): Partial<SSEState> => {
if (state.sse) {
console.log('🔄 이미 SSE 구독 중이므로 재구독을 방지합니다.');
return state;
}
const sse = new EventSourcePolyfill(
`${BASE_URL}/api/matching/auto/subscribe`,
{
headers: { Authorization: `Bearer ${accessToken}` },
withCredentials: true,
},
);
sse.onmessage = (event: MessageEvent) => {
const rawData = event.data.trim();
const eventLines = rawData.split('\n');
let eventType: EventType = 'init'; // 기본값 설정
let jsonData = '';
eventLines.forEach((line: string) => {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim() as EventType;
} else if (line.startsWith('data:')) {
jsonData = line.slice(5).trim();
}
});
if (!jsonData) return;
try {
const parsedData: MatchingEvent = JSON.parse(jsonData);
set((state) => ({
messages: [...state.messages, { eventType, message: parsedData }],
}));
} catch (error) {
console.error('⚠️ JSON 파싱 오류 발생:', error);
}
};
sse.onerror = () => {
console.error(
'🚨 SSE 연결 오류 발생! 연결을 종료하고 5초 후 재연결을 시도합니다.',
);
sse.close();
set({ sse: null });
setTimeout(() => {
get().initializeSSE();
}, 5000);
};
return { sse };
});
console.log('✅ SSE 구독 시작');
},
closeSSE: () => {
set((state) => {
if (state.sse) {
console.log('🔌 SSE 연결 종료');
state.sse.close();
}
return { sse: null, messages: [] }; // messages 초기화 유지 필요 시 수정 가능
});
},
}));
export default useSSEStore;- 위에서처럼 전역적으로 SSE 객체를 만들고, 이를 필요한 페이지에서 사용한다.
🔗 실제 사용 예시
// 전역 객체로부터 SSE 메세지와 구독 시작 로직 가져오기
const { initializeSSE, messages } = useSSEStore();
// 채팅방 넘버 저장
const { chattingRoomId, setChattingRoomId } = useChattingRoomIdStore();
// 방 인원 핸들링
const [roomCapacity, setRoomCapacity] = useState<number>(0);
const [maxCapacity, setMaxCapacity] = useState<number>(0);
// 매칭중, 완료에 따른 상태값 제어
const [roomStatus, setRoomStatus] = useState<'searching' | 'matching'>(
'searching',
);
// 메세지의 마지막 부분만 가져와 비교하여 중복 방지
const [lastProcessedMessageId, setLastProcessedMessageId] = useState<
string | null
>(null);
useEffect(() => {
initializeSSE();
}, [initializeSSE]);
useEffect(() => {
if (messages.length === 0) return;
const latestMessage: MessagesArray = messages[messages.length - 1];
if (latestMessage.message.topic === lastProcessedMessageId) return;
switch (latestMessage.message.topic) {
case 'match_member_joined':
setRoomCapacity(latestMessage.message.nowMemberCount); // 최대 4명 제한
setChattingRoomId(latestMessage.message.roomId.toString());
setMaxCapacity(latestMessage.message.maxCapacity);
setRoomStatus('matching');
break;
case 'match_member_cancelled':
setRoomCapacity((prev) => Math.max(prev - 1, 0)); // 최소 0명 제한
break;
case 'match_room_created':
setRoomCapacity((prev) => Math.min(prev + 1, 4));
setChattingRoomId(latestMessage.message.roomId.toString());
setMaxCapacity(latestMessage.message.maxCapacity);
setRoomStatus('matching');
break;
default:
break;
}
setLastProcessedMessageId(latestMessage.message.topic); // 처리한 메시지 ID 저장
}, [messages, setChattingRoomId, lastProcessedMessageId]);-
위와 같이 사용하여 매칭 완료 후, 채팅방 아이디 및 현재 인원 수를 실시간으로 가져와 UI에 반영할 수 있게 되었다.
매칭중 페이지 말고도 채팅방이나 홈 등 전역적으로 객체를 사용해야 할 부분이 많아서 SSE 객체를 전역적으로 뺸 건 좋은 선택이었다고 생각한다.
🤔 근데 그냥 전역적으로 뺼 필요 없이 한 페이지에서 다 처리하면 되는 것 아닌가?
-
이렇게 생각할 수도 있지만... 하나의 페이지가 너무 많은 로직을 처리하면 성능 저하가 필연적일 것이라고 생각했고, 더불어 각각의 컴포넌트를 제어할 상태값이 점점 많아지는 것만으로도 이미 쉽지 않을 것 같았다..
😁 직접적인 성능 비교는 들어가보지 못했지만 문제없이 작동하고, 구글 lightHouse 등에서도 별다른 문제가 감지되지 않는 것으로 보아 나쁘지 않은 듯 하다!