Skip to content

Commit 0d220e0

Browse files
RookieANDclaude
andauthored
feat: SSE 기반 실시간 모임 현황 업데이트 구현 (#87)
* feat: SSE 범용 훅 및 타입 정의 추가 - useServerSentEvent 범용 훅 구현 - SSE 이벤트 타입 정의 (ParticipantCountEvent, GatheringFullEvent) - 이벤트 타입 동적 등록 지원 - readyState, close, reconnect 기능 제공 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: useGetGatheringCapacity 폴링 제거 - 10초 폴링 제거하여 SSE 기반 실시간 업데이트로 전환 - 초기 데이터 로드만 수행 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * feat: OpinionFormView에 SSE 통합 - gathering-full 이벤트 감지 - 모임 마감 시 자동으로 결과 페이지로 이동 - useServerSentEvent 훅 사용 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * feat: PendingViewContainer에 SSE 통합 - participant-count 이벤트로 실시간 참여자 수 업데이트 - gathering-full 이벤트로 자동 Complete 페이지 이동 - React Query 캐시 동기화 - SSE 데이터 우선, 실패 시 초기 데이터 사용 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: PendingViewContainer에서 불필요한 React Query 캐시 업데이트 제거 - queryClient.setQueryData 호출 제거 - useQueryClient, gatheringKeys import 제거 - SSE 데이터를 로컬 상태로만 관리하도록 단순화 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: SSE 이벤트 핸들러에서 파싱된 데이터 직접 전달 - events 핸들러가 MessageEvent 대신 파싱된 데이터를 직접 받도록 변경 - useServerSentEvent 내부에서 JSON.parse 처리 - 각 핸들러에서 명시적으로 타입 지정 가능 - 에러 핸들링 내장 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: SSE 이벤트 핸들러가 MessageEvent를 직접 받도록 변경 - events 핸들러가 MessageEvent를 직접 전달받음 - 사용자가 MessageEvent<T>로 타입 지정 가능 - JSON.parse는 사용자가 직접 수행 - 제네릭 제거로 더 단순하고 표준에 가까운 API Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: SSE 타입 구조 개선 - types.ts → events.ts 리네임 (이벤트 타입임을 명확히) - Hook Options/Return 타입은 useServerSentEvent.ts에 위치 - events.ts는 순수 이벤트 데이터 타입만 포함 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * fix: Prettier 및 TypeScript 오류 수정 - Prettier 포맷팅 적용 - MessageEvent 타입 캐스팅 수정 (event.data를 string으로 파싱) - useMemo 제거하여 capacity를 단순 객체로 변경 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: SSE 재연결 문제 해결 및 Hook 개선 - useServerSentEvent 의존성 배열에서 콜백 제거하여 불필요한 재연결 방지 - useGetGatheringCapacity를 useQuery로 변경하고 enabled 옵션 추가 - reconnect, close 함수 제거로 Hook 단순화 - baseURL 처리를 Hook 내부로 이동하여 일관성 확보 - SSR 호환성을 위해 EventSource 상수 대신 숫자 리터럴 사용 - 디버깅을 위한 콘솔 로그 추가 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: PendingViewContainer SSR 적용 및 SSE Hook 개선 - PendingViewContainer에서 초기 데이터를 서버 컴포넌트에서 가져와 props로 전달 - useGetGatheringCapacity를 useSuspenseQuery로 변경 - SSE 이벤트 핸들러를 useMemo로 최적화하여 불필요한 재생성 방지 - SSE 훅에서 이벤트 리스너 정리 로직 추가 - 불필요한 autoReconnect, reconnectInterval 옵션 제거 - console.log 정리 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: OpinionFormView SSE 이벤트 핸들러 useMemo 최적화 - eventHandlers를 useMemo로 메모이제이션하여 불필요한 재생성 방지 - accessKey와 router 변경 시에만 이벤트 핸들러 재생성 - PendingViewContainer와 동일한 최적화 패턴 적용 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * chore: prettier 포맷에 맞지 않았던 코드 수정 * feat: participantCount Event Data 에 대한 유효성 검증 및 Parsing 로직 추가: * fix: 의견 수합 폼 내에서는 SSE 로 정원 초과 여부를 파악하지 않도록 소거 * fix: 코드 리뷰 반영하여 수정 --------- Co-authored-by: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com>
1 parent a406ad4 commit 0d220e0

File tree

9 files changed

+145
-29
lines changed

9 files changed

+145
-29
lines changed

app/gathering/[accessKey]/opinion/pending/PendingViewContainer.tsx

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,69 @@
11
"use client";
22

3-
import { useEffect } from "react";
4-
import { useParams, redirect } from "next/navigation";
3+
import { useEffect, useMemo, useState } from "react";
4+
import { redirect } from "next/navigation";
55

66
import { trackViewPage, trackShareClick } from "#/components/analytics";
77
import { Button } from "#/components/button";
88
import { Layout } from "#/components/layout";
9+
import { Toaster } from "#/components/toast";
10+
import { useServerSentEvent } from "#/hooks/sse";
911
import {
1012
PendingView,
1113
SubmissionBottomSheet,
1214
} from "#/pageComponents/gathering/opinion";
13-
import { useGetGatheringCapacity } from "#/hooks/apis/gathering";
1415
import { share } from "#/utils/share";
15-
import { Toaster } from "#/components/toast";
16+
import { participantCountSchema } from "#/schemas/sse/participantCount.schema";
1617

1718
const PAGE_ID = "의견수합_대기";
1819

19-
export function PendingViewContainer() {
20-
const { accessKey } = useParams<{ accessKey: string }>();
21-
const { data: capacity } = useGetGatheringCapacity(accessKey);
20+
interface PendingViewContainerProps {
21+
accessKey: string;
22+
initialMaxCount: number;
23+
initialCurrentCount: number;
24+
}
25+
26+
export function PendingViewContainer({
27+
accessKey,
28+
initialMaxCount,
29+
initialCurrentCount,
30+
}: PendingViewContainerProps) {
31+
const [currentCount, setCurrentCount] =
32+
useState<number>(initialCurrentCount);
33+
const [maxCount, setMaxCount] = useState<number>(initialMaxCount);
2234

23-
const isComplete = capacity.currentCount >= capacity.maxCount;
35+
const eventHandlers = useMemo(
36+
() => ({
37+
"participant-count": (event: MessageEvent) => {
38+
const { data, success } = participantCountSchema.safeParse(
39+
JSON.parse(event.data),
40+
);
2441

25-
if (isComplete) {
26-
redirect(`/gathering/${accessKey}/opinion/complete`);
27-
}
42+
// TODO : 응답이 유효하지 않을 경우에 대한 후속 에러 조치 시행 필요
43+
if (success) {
44+
setCurrentCount(data.currentCount);
45+
setMaxCount(data.maxCount);
46+
}
47+
},
48+
"gathering-full": () => {
49+
redirect(`/gathering/${accessKey}/opinion/complete`);
50+
},
51+
}),
52+
[accessKey],
53+
);
54+
55+
useServerSentEvent({
56+
url: `/gatherings/${accessKey}/subscribe`,
57+
events: eventHandlers,
58+
});
59+
60+
const capacity = useMemo(
61+
() => ({
62+
currentCount,
63+
maxCount,
64+
}),
65+
[currentCount, maxCount],
66+
);
2867

2968
const handleShare = () => {
3069
trackShareClick({ page_id: PAGE_ID, share_location: "Footer" });
Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
HydrationBoundary,
3-
QueryClient,
4-
dehydrate,
5-
} from "@tanstack/react-query";
6-
7-
import { gatheringQueryOptions } from "#/apis/gathering";
1+
import { getGatheringCapacity } from "#/apis/gathering";
82
import { PendingViewContainer } from "./PendingViewContainer";
93

104
interface OpinionPendingPageProps {
@@ -15,19 +9,19 @@ interface OpinionPendingPageProps {
159

1610
/**
1711
* 의견 수렴 대기 페이지 (서버 컴포넌트)
18-
* - gathering capacity 데이터를 서버에서 prefetch하여 무한 렌더링 방지
1912
*/
2013
export default async function OpinionPendingPage({
2114
params,
2215
}: OpinionPendingPageProps) {
2316
const { accessKey } = await params;
24-
const queryClient = new QueryClient();
2517

26-
await queryClient.prefetchQuery(gatheringQueryOptions.capacity(accessKey));
18+
const { data: initialCapacity } = await getGatheringCapacity(accessKey);
2719

2820
return (
29-
<HydrationBoundary state={dehydrate(queryClient)}>
30-
<PendingViewContainer />
31-
</HydrationBoundary>
21+
<PendingViewContainer
22+
accessKey={accessKey}
23+
initialMaxCount={initialCapacity.maxCount}
24+
initialCurrentCount={initialCapacity.currentCount}
25+
/>
3226
);
3327
}

src/hooks/apis/gathering/useGetGatheringCapacity.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,9 @@ import { useSuspenseQuery } from "@tanstack/react-query";
22

33
import { gatheringQueryOptions } from "#/apis/gathering";
44

5-
/**
6-
* 모임 참여자 현황 조회 query hook
7-
* - 10초마다 자동으로 refetch
8-
*/
95
export const useGetGatheringCapacity = (accessKey: string) => {
106
return useSuspenseQuery({
117
...gatheringQueryOptions.capacity(accessKey),
128
select: (response) => response.data,
13-
refetchInterval: 1000 * 10,
149
});
1510
};

src/hooks/sse/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useServerSentEvent } from "./useServerSentEvent";
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
5+
export interface ServerSentEventOptions {
6+
url: string;
7+
enabled?: boolean;
8+
withCredentials?: boolean;
9+
onOpen?: () => void;
10+
onError?: (error: Event) => void;
11+
onMessage?: (event: MessageEvent) => void;
12+
events?: Record<string, (event: MessageEvent) => void>;
13+
}
14+
15+
export const useServerSentEvent = ({
16+
url,
17+
enabled = true,
18+
withCredentials = false,
19+
onOpen,
20+
onError,
21+
onMessage,
22+
events = {},
23+
}: ServerSentEventOptions) => {
24+
const eventSourceRef = useRef<EventSource | null>(null);
25+
26+
const [readyState, setReadyState] = useState<number>(0);
27+
28+
useEffect(() => {
29+
if (!enabled) return;
30+
31+
const baseUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1`;
32+
const fullUrl = `${baseUrl}${url}`;
33+
const eventSource = new EventSource(fullUrl, { withCredentials });
34+
35+
eventSourceRef.current = eventSource;
36+
37+
eventSource.addEventListener("open", () => {
38+
setReadyState(1);
39+
onOpen?.();
40+
});
41+
42+
eventSource.addEventListener("error", (error) => {
43+
setReadyState(eventSource.readyState);
44+
onError?.(error);
45+
});
46+
47+
if (onMessage) {
48+
eventSource.addEventListener("message", onMessage);
49+
}
50+
51+
Object.entries(events).forEach(([eventType, handler]) => {
52+
eventSource.addEventListener(eventType, handler);
53+
});
54+
55+
return () => {
56+
Object.entries(events).forEach(([eventType, handler]) => {
57+
eventSource.removeEventListener(eventType, handler);
58+
});
59+
60+
eventSource.close();
61+
eventSourceRef.current = null;
62+
setReadyState(2);
63+
};
64+
// NOTE : events, onMessage, onOpen, onError는 의도적으로 의존성에서 제외
65+
// 재연결이 필요한 경우(url, enabled, withCredentials 변경)에만 재연결
66+
// eslint-disable-next-line react-hooks/exhaustive-deps
67+
}, [url, enabled, withCredentials]);
68+
69+
return {
70+
readyState,
71+
};
72+
};

src/pageComponents/gathering/opinion/OpinionFormView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import { useParams, useRouter } from "next/navigation";
45
import { FormProvider } from "react-hook-form";
56

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import z from "zod";
2+
3+
export const gatheringFullSchema = z.object({
4+
maxCount: z.number().positive(),
5+
currentCount: z.number().positive(),
6+
});

src/schemas/sse/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { participantCountSchema } from "./participantCount.schema";
2+
export { gatheringFullSchema } from "./gatheringFull.schema";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import z from "zod";
2+
3+
export const participantCountSchema = z.object({
4+
maxCount: z.number().positive(),
5+
currentCount: z.number().positive(),
6+
});

0 commit comments

Comments
 (0)