From be7a8fe8b4ce43d9944c7e27a1eae9baaebcc06f Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 4 Jun 2026 16:21:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/contexts/TradeNotificationContext.tsx | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index 644717a..034f655 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -151,6 +151,12 @@ export const TradeNotificationProvider: React.FC = ({ const [allNotifications, setAllNotifications] = useState([]); const allNotificationsRef = useRef(allNotifications); allNotificationsRef.current = allNotifications; + /** + * addNotification 에서 수신된 모든 알림 ID 를 동기적으로 누적. + * dismissAllToasts 가 호출될 때 allNotificationsRef 가 stale 한 경우에도 + * 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다. + */ + const allSeenIdsRef = useRef>(new Set()); const [detailSignal, setDetailSignal] = useState(null); const [detailCentered, setDetailCentered] = useState(false); @@ -237,10 +243,12 @@ export const TradeNotificationProvider: React.FC = ({ * 앱 설정이 DB 에서 로드 완료된 시점에 readIds 를 즉시 동기화한다. * STOMP 가 앱 시작 직후 구독 시 readIds 가 아직 빈 상태여서 이미 닫은 * 알림이 다시 팝업으로 뜨는 타이밍 버그를 방지한다. + * 세션 전환 시 allSeenIdsRef 도 초기화해 이전 세션 알림 ID 가 잔류하지 않게 한다. */ + allSeenIdsRef.current = new Set(); const dbRead = loadReadIds(); - if (dbRead.size > 0) { - const merged = new Set([...readIdsRef.current, ...dbRead]); + const merged = new Set([...readIdsRef.current, ...dbRead]); + if (merged.size !== readIdsRef.current.size) { readIdsRef.current = merged; setReadIds(merged); } @@ -265,25 +273,27 @@ export const TradeNotificationProvider: React.FC = ({ const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => { const id = makeId(signal); + // 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조) + allSeenIdsRef.current.add(id); + /** * 읽음 여부 3중 확인: * 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref * 2. loadReadIds() — DB 캐시 기반 값 - * 3. allNotificationsRef — dismissAllToasts 가 functional updater 로 isRead:true - * 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우를 커버 - * (STOMP/WebSocket 재연결 직후 동일 신호 재수신 시 팝업 방지) + * 3. allNotificationsRef — dismissAllToasts 의 functional updater 가 + * isRead:true 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우 커버 */ const alreadyRead = readIdsRef.current.has(id) || loadReadIds().has(id) || (allNotificationsRef.current.find(n => n.id === id)?.isRead === true); - // allNotifications 에는 isRead:true 이지만 readIds 에 빠진 경우 보정 + // readIds 에 누락된 읽음 항목 보정 (다음 STOMP/폴링에서 즉시 필터링) if (alreadyRead && !readIdsRef.current.has(id)) { const next = new Set(readIdsRef.current); next.add(id); readIdsRef.current = next; - saveReadIds(next); // 디바운스 저장 (즉시 저장 불필요) + saveReadIds(next); } setAllNotifications(prev => { @@ -364,48 +374,44 @@ export const TradeNotificationProvider: React.FC = ({ const dismissAllToasts = useCallback(() => { /** - * 전체 닫기 — 현재 표시된 모든 알림을 읽음 처리하고 즉시 DB 저장. + * 전체 닫기 — 모든 알림을 읽음 처리하고 즉시 DB 저장. * - * 핵심 원칙: - * 1. setAllNotifications 의 functional updater 안에서 nextRead 를 구성한다. - * updater 의 prev 는 같은 배치 내 addNotification 의 setAllNotifications 까지 - * 포함한 최신 상태이므로, dismissAllToasts 직전에 도착한 STOMP 신호도 - * readIds 에 포함된다 (stale ref 문제 해결). - * 2. immediate=true 로 400ms 디바운스 없이 즉시 저장해 이후 폴링과의 - * 경쟁 조건(readIds 가 아직 DB 에 없는 상태)을 제거. + * allSeenIdsRef: addNotification 에서 동기적으로 누적된 ID 집합. + * - allNotificationsRef.current 가 stale 하더라도(배치 미반영) + * 동일 틱에 수신된 STOMP 신호 ID 도 포함 → readIds 에서 누락 없음. + * side-effect(readIdsRef, saveReadIds)를 functional updater 밖에서 실행하여 + * React concurrent 렌더링에서의 중복 실행 문제를 방지. */ - const baseRead = new Set(readIdsRef.current); - toastNotificationsRef.current.forEach(n => baseRead.add(n.id)); + const nextRead = new Set([ + ...readIdsRef.current, + ...toastNotificationsRef.current.map(n => n.id), + ...allNotificationsRef.current.map(n => n.id), + ...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID + ]); - setAllNotifications(prev => { - // prev = 현재 배치의 최신 allNotifications (addNotification 반영 포함) - const nextRead = new Set(baseRead); - prev.forEach(n => nextRead.add(n.id)); + // 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조) + readIdsRef.current = nextRead; - // 인메모리 ref 즉시 갱신 (addNotification 이 다음 틱에 확인할 때 참조) - readIdsRef.current = nextRead; - - // 캐시 갱신 + 즉시 DB 저장 - saveReadIds(nextRead, true); - - return prev.map(n => ({ ...n, isRead: true })); - }); - - setReadIds(() => readIdsRef.current); + // 2) React state 갱신 + setReadIds(nextRead); + setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setToastNotifications([]); + + // 3) 인메모리 캐시 + 즉시 DB 저장 (immediate=true) + saveReadIds(nextRead, true); }, []); const markAllAsRead = useCallback(() => { - const baseRead = new Set(readIdsRef.current); - setAllNotifications(prev => { - const nextRead = new Set(baseRead); - prev.forEach(n => nextRead.add(n.id)); - readIdsRef.current = nextRead; - saveReadIds(nextRead, true); - return prev.map(n => ({ ...n, isRead: true })); - }); - setReadIds(() => readIdsRef.current); + const nextRead = new Set([ + ...readIdsRef.current, + ...allNotificationsRef.current.map(n => n.id), + ...allSeenIdsRef.current, + ]); + readIdsRef.current = nextRead; + setReadIds(nextRead); + setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setToastNotifications([]); + saveReadIds(nextRead, true); }, []); const purgeNotifications = useCallback((ids: string[]) => {