
프로젝트를 진행하면서 실시간 이벤트를 만들어 볼 기회가 생겼다.
이벤트가 활성화된 동안 사용자는 일정 금액을 결제할 수 있고, 이벤트가 종료 후 결제자 중 랜덤으로 당첨자를 1명 선별해 모인 금액을 받을 수 있는 이벤트이다.
이벤트가 종료되면 실시간으로 모든 사용자에게 당첨자가 누구인지 토스트로 알려주는 방식을 사용했고, 실시간 토스트를 화면에 출력하기 위해 소켓을 사용했다.
추가로 이벤트 시작과 종료도 즉시 모든 사용자에게 적용되어야 한다. 이벤트를 종료했는데도 불구하고 사용자에겐 아직 미적용 상태여서 이벤트가 활성화된 채로 표시된다면 결제를 했지만 참여자는 되지 못하는 문제가 발생할 수 있다. 그래서 이벤트 활성 상태도 소켓을 통해 모든 사용자에게 실시간으로 적용되도록 하였다.
이벤트 활성 상태를 서버로부터 실시간으로 받아오기
// eventstore.ts
import { Socket, io } from 'socket.io-client';
import { create } from 'zustand';
const SOCKET_SERVER_URL = 'https://api.pqsoft.net:3000';
interface EventState {
socket: Socket | null;
isEventActive: boolean | null;
initializeSocket: () => void;
disconnectSocket: () => void;
}
export const useEventStore = create<EventState>((set, get) => ({
socket: null,
isEventActive: null,
initializeSocket: () => {
try {
const socket = io(SOCKET_SERVER_URL);
socket.on('event_status', (status) => {
set({ isEventActive: status });
console.log('Event status updated:', status);
});
set({ socket });
} catch (err) {
console.error('Socket initialization failed:', err);
}
},
disconnectSocket: () => {
const { socket } = get();
if (socket) {
socket.off('event_status');
socket.disconnect();
set({ socket: null });
}
},
}));
export default useEventStore;
마찬가지로 서버로부터 실시간으로 전달되는 토스트
// toastStore.ts
import { create } from 'zustand';
import { io, Socket } from 'socket.io-client';
const SOCKET_SERVER_URL = 'https://api.pqsoft.net:3000';
export interface Toast {
id: number;
message: string;
}
export interface ToastState {
socket: Socket | null;
toasts: Toast[];
addToast: (message: string) => void;
removeToast: (id: number) => void;
initializeSocket: () => void;
disconnectSocket: () => void;
}
const useToastStore = create<ToastState>((set, get) => ({
socket: null,
toasts: [],
addToast: (message) => {
const newToast = { id: Date.now(), message };
set((state) => ({
toasts: [...state.toasts, newToast],
}));
setTimeout(() => {
get().removeToast(newToast.id);
}, 3000); // 3초 후에 토스트 자동 삭제
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
initializeSocket: () => {
try {
const socket = io(SOCKET_SERVER_URL);
socket.on('receive_toast', (message) => {
get().addToast(message);
});
set({ socket });
} catch (err) {
console.error('Socket initialization failed:', err);
}
},
disconnectSocket: () => {
const { socket } = get();
if (socket) {
socket.off('receive_toast');
socket.disconnect();
set({ socket: null });
}
},
}));
export default useToastStore;
토스트 ui
// toast.tsx
import useToastStore from 'src/store/toastStore';
import styled, { keyframes } from 'styled-components';
export default function Toasts() {
const { toasts, removeToast } = useToastStore();
return (
<ToastsContainer>
{toasts.map((toast) => (
<Toast key={toast.id} onClick={() => removeToast(toast.id)}>
{toast.message}
</Toast>
))}
</ToastsContainer>
);
}
이벤트와 관련된 데이터를 따로 처리하기 위해 React Hook으로 분리해주었다.
// useEventWinner.ts
import { useEffect, useState } from 'react';
import { useQueryGet } from 'src/apis/service/service';
import { EventResponse } from 'src/components/modal/contents/SubscriptionModal/_type/subscriptionType';
import { USER_URL } from 'src/constants/apiUrl';
import { PLAN } from 'src/constants/plan';
import { EventPaymentsResponse } from 'src/pages/payments/_type/type';
import useEventStore from 'src/store/eventStore';
export const useEventWinner = () => {
const [participants, setParticipants] = useState<EventPaymentsResponse>(null);
const [accumulatedAmount, setAccumulatedAmount] = useState<number>(0);
const { isEventActive } = useEventStore(); // 이벤트 상태 가져오기
// 누적 금액 데이터
const { data: eventAmount, refetch: refetchEventAmount } = useQueryGet<EventResponse>(
'getEventAmount',
`${USER_URL.PAYMENTS}/event`,
);
// 결제 데이터
const { data: eventPayments, refetch: refetchEventPayments } = useQueryGet<EventPaymentsResponse>(
'getEventPayments',
`${USER_URL.PAYMENTS}/plan/${PLAN.EVENT.id}`,
);
// 누적 금액 업데이트
useEffect(() => {
if (eventAmount) {
setAccumulatedAmount(eventAmount.amount);
}
}, [eventAmount]);
// 이벤트 활성화 상태 감지
useEffect(() => {
if (isEventActive) {
refetchEventAmount();
}
}, [isEventActive, refetchEventAmount]);
// 참여자 필터링
useEffect(() => {
if (eventPayments && eventAmount) {
const filteredParticipants = eventPayments.filter((payment) => {
return new Date(payment.createdAt) > new Date(eventAmount.createdAt) && payment.status === 'DONE';
});
setParticipants(filteredParticipants);
}
}, [eventAmount, eventPayments]);
return { eventAmount, refetchEventAmount, eventPayments, refetchEventPayments, participants, accumulatedAmount };
};
이벤트 컴포넌트는 이벤트를 시작하거나 종료하고, 참가자 중 랜덤으로 당첨자를 선정하는 로직을 포함한다.
const { mutate: startEvent, isPending: isStarting } = useMutationPost(
`${USER_URL.PAYMENTS}/event`,
{
onSuccess: () => {
socketRef.current?.emit('update_event_status', true);
socketRef.current?.emit('send_toast', `이벤트가 시작되었습니다!`);
},
onError: (err: AxiosError) => {
if (err.response?.status === 400) {
alert('이미 이벤트가 진행 중입니다.');
return;
}
alert(`${err?.message}`);
},
}
);
const { mutate: endEvent, isPending: isEnding } = useMutationDelete(
`${USER_URL.PAYMENTS}/event`,
{
onSuccess: () => {
socketRef.current?.emit('update_event_status', false);
socketRef.current?.emit('send_toast', `이벤트가 종료되었습니다.`);
},
onError: (err: AxiosError) => {
if (err.response?.status === 500) {
alert('이벤트가 진행 중이 아닙니다.');
return;
}
alert(`${err?.message}`);
},
}
);
const handleStartEvent = () => {
if (isStarting) return;
if (Array.isArray(eventAmount) && eventAmount.length > 0) {
return alert('이미 이벤트가 진행 중입니다.');
}
startEvent({ amount: 0 }); // 초기 누적 금액 0으로 이벤트 시작
};
const handleEndEvent = async () => {
if (isEnding) return;
await refetchEventAmount(); // 최신 데이터로 갱신
await refetchEventPayments();
endEvent(); // 이벤트 종료
};
const selectWinner = async () => {
if (!participants || participants.length === 0)
return alert('이벤트 참가자가 없습니다.');
const randomIndex = Math.floor(Math.random() * participants.length);
const selectedWinner = participants[randomIndex];
setWinnerId(selectedWinner.userId);
socketRef.current?.emit(
'send_toast',
`${userData?.nickname} 님이 ${accumulatedAmount}원에 당첨되셨습니다!`
);
};
useEffect(() => {
if (isEventActive === false) {
selectWinner(); // 이벤트가 비활성화되면 당첨자 선정
}
}, [eventPayments, isEventActive]);
// Event.tsx
import { AxiosError } from 'axios';
import { useEffect, useRef, useState } from 'react';
import { Socket, io } from 'socket.io-client';
import { useMutationDelete, useMutationPost, useQueryGet } from 'src/apis/service/service';
import { USER_URL } from 'src/constants/apiUrl';
import { useEventWinner } from 'src/hooks/useEventWinner';
import useEventStore from 'src/store/eventStore';
import { UserInfo } from 'src/types/userType';
const SOCKET_SERVER_URL = 'https://api.pqsoft.net:3000';
export default function Event() {
const [winnerId, setWinnerId] = useState<number | null>(null);
const socketRef = useRef<Socket | null>(null);
const { isEventActive } = useEventStore();
const { eventAmount, refetchEventAmount, eventPayments, refetchEventPayments, participants, accumulatedAmount } =
useEventWinner();
// 당첨자 정보 조회
const { data: userData } = useQueryGet<UserInfo | null>('getUserData', `${USER_URL.USER}/${winnerId}`, {
enabled: !!winnerId,
});
// 이벤트 시작: 누적 금액 테이블 추가
const { mutate: startEvent, isPending: isStarting } = useMutationPost(`${USER_URL.PAYMENTS}/event`, {
onSuccess: () => {
socketRef.current?.emit('update_event_status', true);
socketRef.current?.emit('send_toast', `이벤트가 시작되었습니다!`);
},
onError: (err: AxiosError) => {
if (err.response?.status === 400) {
alert('이미 이벤트가 진행 중입니다.');
return;
}
alert(`${err?.message}`);
},
});
// 이벤트 종료: 누적 금액 테이블 삭제
const { mutate: endEvent, isPending: isEnding } = useMutationDelete(`${USER_URL.PAYMENTS}/event`, {
onSuccess: () => {
socketRef.current?.emit('update_event_status', false);
socketRef.current?.emit('send_toast', `이벤트가 종료되었습니다.`);
},
onError: (err: AxiosError) => {
if (err.response?.status === 500) {
alert('이벤트가 진행 중이 아닙니다.');
return;
}
alert(`${err?.message}`);
},
});
// 당첨자 선정
const selectWinner = async () => {
if (!participants || participants.length === 0) return alert('이벤트 참가자가 없습니다.');
const randomIndex = Math.floor(Math.random() * participants.length);
const selectedWinner = participants[randomIndex];
setWinnerId(selectedWinner.userId);
socketRef.current?.emit('send_toast', `${userData?.nickname} 님이 ${accumulatedAmount}원에 당첨되셨습니다!`);
};
const handleStartEvent = () => {
if (isStarting) return;
if (Array.isArray(eventAmount) && eventAmount.length > 0) {
return alert('이미 이벤트가 진행 중입니다.');
}
startEvent({ amount: 0 });
};
const handleEndEvent = async () => {
if (isEnding) return;
await refetchEventAmount();
await refetchEventPayments();
endEvent();
};
useEffect(() => {
if (isEventActive === false) {
selectWinner();
}
}, [eventPayments, isEventActive]);
useEffect(() => {
socketRef.current = io(SOCKET_SERVER_URL);
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
}
};
}, []);
return (
<div>
<h1>Event</h1>
<button onClick={handleStartEvent}>이벤트 시작</button>
<button onClick={handleEndEvent}>이벤트 종료</button>
</div>
);
}