Repository: lyra-j/uuno
Pull request #377: [FEAT]#340 카드 조회수 로직 수정
Author: login: sum529-create
Labels: name: ✨Feature
description: 기능 개발
name: 🌸 수민
description: 담당자 노수민
Created: 2025-04-29T21:56:08Z | Merged: 2025-04-30T08:15:27Z

## #️⃣ 연관된 이슈

- close #340

<br>

## 📝 작업 내용

- 새로고침 시 동일한 세션에서는 조회수가 증가하지 않음 (중복 Insert 방지)
- 5분 이후 새로고침 시 세션 타임아웃으로 새 세션이 생성되어 조회수 증가 ([추후 30분으로 변경 예정](https://support.google.com/analytics/answer/9191807?hl=ko))
- 브라우저 탭이 닫히면 세션이 종료되고 새 탭에서 열 때 새 세션으로 간주하여 조회수 증가
- 세션 스토리지와 로컬 스토리지 사용 목적 명확화 (세션 스토리지: 세션 정보, 로컬 스토리지: 마지막 활동 시간)

<br>

## 🖼 스크린샷


https://github.com/user-attachments/assets/31b2c03f-ffe9-4233-9acd-4c6b57feba0b



<br>

## 💬 리뷰 요구사항

> 없습니다...


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **신규 기능**
    - 세션 타임아웃(5분) 및 세션 관리 기능이 도입되어, 일정 시간 비활성 시 세션이 자동 종료됩니다.
    - 세션 시작, 활동, 종료 시점이 보다 명확하게 추적됩니다.

- **버그 수정**
    - IP 주소 응답값의 유효성을 추가로 검사하여, 잘못된 API 응답에 대한 오류 처리가 강화되었습니다.

- **리팩터링**
    - 세션 관리 로직이 유틸리티 함수로 통합되어, 오류 처리와 스토리지 사용 가능성 확인이 개선되었습니다.
    - 카드 소유자 여부 및 인터랙션 로깅 조건이 명확하게 개선되었습니다.
    - 세션 초기화 및 활동 추적 로직이 React 상태 관리와 연동되어 안정성이 향상되었습니다.
    - 세션 관련 이벤트 리스너 및 상태 관리가 세분화되고 최적화되었습니다.

- **상수 추가**
    - 세션 타임아웃 값을 상수로 분리하여 관리합니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #340: [FEAT] 명함 통계 체류시간을 월 클릭수로 변경 및 조회 수 로직 보완
# ✨ Feature

### 1. 어떤 기능인가요?

- 사용자 UT의 명함 조회 체류시간의 필요성의 의문에 따라 클릭수로 변경
- 세션 및 인터랙션 트래킹 로직 보완

<br>

### 2. 작업 상세 내용

- [x] 월간 클릭수 로직 추가
- [x] 월간 조회수, 저장수, 조회수 로직 파일 정리
- [x] 조회수 로직 보완

<br>

### 3. 참고할만한 자료 (선택)


Diff:
diff --git a/src/apis/interaction.ts b/src/apis/interaction.ts
index 7fb99c0e..88ce4667 100644
--- a/src/apis/interaction.ts
+++ b/src/apis/interaction.ts
@@ -24,6 +24,9 @@ export const getIpAddress = async (): Promise<string> => {
       throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
     }
     const data = await res.json();
+    if (!data || !data.ip) {
+      throw new Error('IP 주소가 응답에 포함되어 있지 않습니다.');
+    }
     return data.ip;
   } catch (error) {
     console.error('IP 주소 조회 중 오류 발생:', error);
diff --git a/src/app/[...slug]/page.client.tsx b/src/app/[...slug]/page.client.tsx
index bbf75212..f12a2a9e 100644
--- a/src/app/[...slug]/page.client.tsx
+++ b/src/app/[...slug]/page.client.tsx
@@ -8,12 +8,14 @@ import { useIpAddressQuery } from '@/hooks/queries/use-ip-address';
 import { useInteractionTracker } from '@/hooks/use-interaction-tracker';
 import { useIsMobile } from '@/hooks/use-is-mobile';
 import { authStore } from '@/store/auth.store';
-import { Cards } from '@/types/supabase.type';
+import { Cards, CardViews } from '@/types/supabase.type';
+import { getEffectiveSessionId } from '@/utils/interaction/session-util';
 import { toastComingSoonAlert } from '@/utils/common/sweet-coming-soon-alert';
+
 import clsx from 'clsx';
 import Link from 'next/link';
 import { useSearchParams } from 'next/navigation';
-import { useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
 
 interface SlugClientPageParams {
   initialData: Cards & {
@@ -39,19 +41,72 @@ const SlugClientPage = ({ initialData }: SlugClientPageParams) => {
 
   const { handleSaveImg, updateActivity, handleSaveVCard } =
     useInteractionTracker({
+      isDetail: false,
       slug,
       source,
       startedAt: new Date(),
     });
+
   const { data: ip } = useIpAddressQuery();
 
   const logInteractionMutation = useLogInteractionMutation(
     id || '',
-    ip,
+    ip || '',
     source,
     new Date()
   );
 
+  const [hasInitialViewId, setHasInitialViewId] = useState<number | null>(null);
+  const isMyCard = initialData.user_id === userId;
+
+  // 무한 루프 방지를 위한 처리 상태 추적 ref
+  const initialViewProcessedRef = useRef(false);
+
+  useEffect(() => {
+    // 내 카드이거나, 이미 처리했거나, IP가 없으면 실행하지 않음
+    if (isMyCard || initialViewProcessedRef.current || !ip) {
+      return;
+    }
+
+    // 처리 시작을 표시 - 이렇게 하면 이 useEffect가 여러 번 실행되더라도
+    // 조회수 증가 로직은 한 번만 실행됨
+    initialViewProcessedRef.current = true;
+
+    // 조회 여부 확인
+    const viewKey = `viewed_${id}`;
+    const hasViewed = sessionStorage.getItem(viewKey);
+
+    if (!hasViewed) {
+      // 아직 조회하지 않은 경우에만 API 호출
+      logInteractionMutation.mutate(
+        { elementName: null, type: null },
+        {
+          onSuccess: (data: CardViews | null) => {
+            if (data) {
+              // 조회 기록 표시
+              sessionStorage.setItem(viewKey, 'true');
+              // 세션 ID 상태 업데이트
+              setHasInitialViewId(
+                data.session_id ? Number(data.session_id) : null
+              );
+            }
+          },
+          onError: (error: Error) => {
+            // 에러 발생 시 다시 시도할 수 있도록 플래그 리셋
+            initialViewProcessedRef.current = false;
+            console.error('Error inserting initial view:', error);
+          },
+        }
+      );
+    } else {
+      // 이미 조회한 경우 API 호출 없이 상태만 업데이트
+      const sessionId = getEffectiveSessionId();
+      if (sessionId) {
+        setHasInitialViewId(Number(sessionId));
+      }
+    }
+  }, [isMyCard, ip, id, logInteractionMutation]); // hasInitialViewId를 의존성 배열에서 제거
+
   // 이미지 저장 핸들러
   const handleImageSave = async () => {
     try {
@@ -88,7 +143,7 @@ const SlugClientPage = ({ initialData }: SlugClientPageParams) => {
       {/* 헤더 영역 */}
       <div className='absolute left-0 top-0 z-50 flex h-[52px] w-full items-center justify-center border-b border-gray-10 bg-white shadow-sm md:h-[80px]'>
         <div className='relative flex h-full w-full items-center justify-center px-[20px] py-[14px] text-label1-semi md:justify-start md:px-[22px] md:py-5 md:text-title-bold'>
-          {initialData.user_id === userId && (
+          {isMyCard && (
             <Link
               href={`${ROUTES.MYCARD}/${initialData.id}`}
               className='absolute left-[20px] cursor-pointer md:static md:mr-[14px]'
diff --git a/src/components/card/flip-card.tsx b/src/components/card/flip-card.tsx
index a7a2f65e..63df21d3 100644
--- a/src/components/card/flip-card.tsx
+++ b/src/components/card/flip-card.tsx
@@ -132,10 +132,19 @@ const FlipCard = forwardRef<FlipCardRef, FlipCardParam>(({ isDetail }, ref) => {
 
   // 인터랙션 트래커 설정
   const { handleClick } = useInteractionTracker({
+    isDetail: !!isDetail,
     slug: isDetail ? slug || '' : pathArray,
     source,
     startedAt,
-  });
+  }) as {
+    handleClick: ({
+      url,
+      elementName,
+    }: {
+      url: string;
+      elementName: string;
+    }) => Promise<void>;
+  };
 
   // 카드 콘텐츠 가져오기
   const { data, isPending, error } = useCardContent(
diff --git a/src/components/card/save-share-modal.tsx b/src/components/card/save-share-modal.tsx
index e6136b84..d67b0601 100644
--- a/src/components/card/save-share-modal.tsx
+++ b/src/components/card/save-share-modal.tsx
@@ -77,6 +77,7 @@ const SaveShareModal = () => {
 
   const canvasRef = useRef<HTMLDivElement>(null);
   const qrUrl = slug ? `${BASE_URL.UUNO}/${slug}?source=qr` : '';
+  const [origin, setOrigin] = useState<string>('');
 
   // 카카오 SDK 로드
   useEffect(() => {
@@ -99,6 +100,8 @@ const SaveShareModal = () => {
       script.onload = loadKakaoSDK;
       document.body.appendChild(script);
     }
+
+    if (typeof window !== 'undefined') setOrigin(window.location.origin);
   }, []);
 
   // 모달 열기/닫기 처리
@@ -372,7 +375,8 @@ const SaveShareModal = () => {
   };
 
   const handleTagCopy = () => {
-    const htmlCode = `<a href="${linkUrl}" target="_blank"><img src="${imageUrl}" alt="${title}"/></a>`;
+    const currentOrigin = origin || window.location.origin || BASE_URL.UUNO;
+    const htmlCode = `<a href="${currentOrigin}/${slug}?source=tag" target="_blank"><img src="${imageUrl}" alt="${title}"/></a>`;
     try {
       navigator.clipboard.writeText(htmlCode);
       sweetAlertUtil.success(
diff --git a/src/constants/session.constant.ts b/src/constants/session.constant.ts
new file mode 100644
index 00000000..0946985a
--- /dev/null
+++ b/src/constants/session.constant.ts
@@ -0,0 +1,2 @@
+// 세션 타임아웃 확인
+export const SESSION_TIMEOUT = 5 * 60 * 1000; // 추후 수정 예정 :30분, 테스트 용 5분
diff --git a/src/hooks/mutations/use-init-session.ts b/src/hooks/mutations/use-init-session.ts
index da9280fc..7def5811 100644
--- a/src/hooks/mutations/use-init-session.ts
+++ b/src/hooks/mutations/use-init-session.ts
@@ -6,6 +6,12 @@ import {
 } from '@/apis/interaction';
 import { useMutation } from '@tanstack/react-query';
 import { formatToDateString } from '@/utils/interaction/format-date';
+import {
+  checkSessionTimeout,
+  getEffectiveSessionId,
+  initSession,
+  updateSessionActivity,
+} from '@/utils/interaction/session-util';
 
 interface SessionData {
   sessionId: string;
@@ -18,35 +24,21 @@ interface SessionData {
  * @param startedAt 세션 시작 시간
  * @returns
  */
-export const useInitSessionMutation = (startedAt: Date | null) => {
+export const useInitSessionMutation = (startedAt: Date) => {
   return useMutation({
-    mutationFn: async (): Promise<SessionData> => {
-      const existingSessionId =
-        sessionStorage.getItem('currentSessionId') ||
-        localStorage.getItem('backup_session_id') ||
-        null;
-
-      if (!startedAt) {
-        throw new Error('startedAt is required to initialize a session');
-      }
-
-      if (existingSessionId) {
-        return {
-          sessionId: existingSessionId,
-          startedAt:
-            sessionStorage.getItem('session_started_at') ||
-            formatToDateString(startedAt),
-        };
-      }
-
-      const startTime = formatToDateString(startedAt);
-      const uuid = crypto.randomUUID();
+    mutationFn: async (params: {
+      cardId: string;
+      viewerIp: string;
+      source: 'direct' | 'qr' | 'link' | 'tag' | null;
+    }): Promise<SessionData> => {
+      if (!startedAt) throw new Error('시작 시간이 필요합니다.');
 
-      sessionStorage.setItem('currentSessionId', uuid);
-      sessionStorage.setItem('session_started_at', startTime);
-      localStorage.setItem('backup_session_id', uuid);
+      const result = initSession(startedAt);
 
-      return { sessionId: uuid, startedAt: startTime };
+      return {
+        sessionId: result.sessionId,
+        startedAt: result.startedAt,
+      };
     },
   });
 };
@@ -79,7 +71,7 @@ export const useEndSessionMutation = () => {
  */
 export const useLogInteractionMutation = (
   cardId: string,
-  viewerIp: string | undefined,
+  viewerIp: string,
   source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined,
   startedAtDate: Date | null
 ) => {
@@ -93,15 +85,19 @@ export const useLogInteractionMutation = (
     }) => {
       if (!viewerIp || !cardId) throw new Error('Missing required data');
 
-      const effectiveSessionId =
-        sessionStorage.getItem('currentSessionId') ||
-        localStorage.getItem('backup_session_id') ||
-        null;
       if (!startedAtDate) {
         throw new Error('startedAt is required to initialize a session');
       }
 
-      if (!effectiveSessionId) throw new Error('No session ID available');
+      const sessionId = getEffectiveSessionId();
+
+      if (!sessionId) throw new Error('No session ID available');
+
+      if (checkSessionTimeout()) {
+        throw new Error('Session timed out');
+      } else {
+        updateSessionActivity();
+      }
 
       return logInteraction({
         cardId,
@@ -109,7 +105,7 @@ export const useLogInteractionMutation = (
         type,
         startedAt: formatToDateString(startedAtDate),
         viewerIp,
-        sessionId: effectiveSessionId,
+        sessionId: sessionId,
         source: source as 'direct' | 'qr' | 'link' | 'tag' | null,
       });
     },
diff --git a/src/hooks/use-interaction-tracker.ts b/src/hooks/use-interaction-tracker.ts
index 0d084317..75e2ec9b 100644
--- a/src/hooks/use-interaction-tracker.ts
+++ b/src/hooks/use-interaction-tracker.ts
@@ -1,19 +1,22 @@
-import { useEffect, useRef } from 'react';
-import { useIpAddressQuery } from './queries/use-ip-address';
+import { useEffect, useRef, useState } from 'react';
+import { useIpAddressQuery } from '@/hooks/queries/use-ip-address';
 import {
   useEndSessionMutation,
   useInitSessionMutation,
   useLogInteractionMutation,
-} from './mutations/use-init-session';
+} from '@/hooks/mutations/use-init-session';
 import {
   getEffectiveSessionId,
   storePendingSessionEnd,
+  updateSessionActivity,
 } from '@/utils/interaction/session-util';
 import { useCardInteraction } from '@/hooks/queries/use-card-interaction';
-import { useImageDownloader } from './use-Image-downloader';
-import { useVCardSaver } from './use-vcard-saver';
+import { useImageDownloader } from '@/hooks/use-Image-downloader';
+import { useVCardSaver } from '@/hooks/use-vcard-saver';
+import { SESSION_TIMEOUT } from '@/constants/session.constant';
 
 interface InteractionProps {
+  isDetail: boolean;
   slug: string;
   source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined;
   startedAt: Date | null;
@@ -28,54 +31,105 @@ interface SocialLink {
  * 인터랙션 추적 훅
  */
 export const useInteractionTracker = ({
+  isDetail,
   slug,
   source,
   startedAt,
 }: InteractionProps) => {
+  // isDetail 일 경우 해당 로직 생략
+  if (isDetail) {
+    return {
+      updateActivity: () => {},
+      handleClick: () => {},
+      handleSaveImg: () => {},
+      handleSaveVCard: () => {},
+      isLoading: false,
+      isError: false,
+    };
+  }
+
   const { data: ip } = useIpAddressQuery();
+  const { data: cardId } = useCardInteraction(slug);
+
+  // 세션 초기화
+  const {
+    mutate: initSession,
+    isPending,
+    isError,
+  } = useInitSessionMutation(startedAt || new Date());
 
-  const sessionInitMutation = useInitSessionMutation(startedAt);
+  // 세션 종료
   const endSessionMutation = useEndSessionMutation();
-  const { data } = useCardInteraction(slug);
-  const cardId = data;
 
+  // 인터렉션 로깅 - 조건부 생성에서 항상 생성으로 변경
   const logInteractionMutation = useLogInteractionMutation(
     cardId || '',
-    ip,
+    ip || '',
     source,
     startedAt
   );
 
+  // 안전한 로깅 함수 - 필요한 데이터가 있는지 확인 후 호출
+  const safeLogInteraction = (data: {
+    elementName: string | null;
+    type: 'click' | 'save' | null | undefined;
+  }) => {
+    if (!ip || !cardId) {
+      console.log('필요한 데이터가 아직 준비되지 않았습니다:', { ip, cardId });
+      return Promise.resolve();
+    }
+    return logInteractionMutation.mutate(data);
+  };
+
+  // 활동 추적
   const inactivityTimerRef = useRef<NodeJS.Timeout | null>(null);
   const lastActivityRef = useRef<Date | null>(null);
+  const [sessionInitialized, setSessionInitialized] = useState(false);
 
   // 세션 초기화
   useEffect(() => {
-    if (!ip || !cardId) return;
-    sessionInitMutation.mutate();
-  }, [ip, cardId]);
+    if (!ip || !cardId || sessionInitialized) return;
+
+    initSession(
+      { cardId, viewerIp: ip, source: source || null },
+      {
+        onSuccess: () => {
+          setSessionInitialized(true);
+        },
+      }
+    );
+  }, [ip, cardId, source, sessionInitialized, initSession]);
 
   /**
    * 활동 업데이트
    */
   const updateActivity = () => {
     lastActivityRef.current = new Date();
+
+    // 세션 활동 시간 업데이트
+    updateSessionActivity();
+
     if (inactivityTimerRef.current) {
       clearTimeout(inactivityTimerRef.current);
     }
-    inactivityTimerRef.current = setTimeout(
-      () => {
-        const sessionId = getEffectiveSessionId();
-        if (sessionId) {
-          endSessionMutation.mutate({ reason: 'inactive', sessionId });
-        }
-      },
-      30 * 60 * 1000
-    );
+    // 비활동 타임아웃
+    inactivityTimerRef.current = setTimeout(() => {
+      const sessionId = getEffectiveSessionId();
+      if (sessionId) {
+        // 타임아웃 처리
+        endSessionMutation.mutate({ reason: 'inactive', sessionId });
+
+        // 세션 초기화
+        setSessionInitialized(false);
+      }
+    }, SESSION_TIMEOUT);
   };
 
   // 활동 이벤트 설정
   useEffect(() => {
+    // 세션 초기화 확인
+    if (!sessionInitialized) return;
+
     const sessionId = getEffectiveSessionId();
     if (!sessionId) return;
 
@@ -93,13 +147,14 @@ export const useInteractionTracker = ({
 
     updateActivity();
 
+    // 페이지 언로드 처리
     const handleBeforeUnload = () => {
-      const currentSessionId = getEffectiveSessionId();
-      if (currentSessionId) {
-        storePendingSessionEnd(currentSessionId);
+      const current_session_id = getEffectiveSessionId();
+      if (current_session_id) {
+        storePendingSessionEnd(current_session_id);
         endSessionMutation.mutate({
           reason: 'page-exit',
-          sessionId: currentSessionId,
+          sessionId: current_session_id,
         });
       }
     };
@@ -115,15 +170,15 @@ export const useInteractionTracker = ({
         clearTimeout(inactivityTimerRef.current);
       }
 
-      const currentSessionId = getEffectiveSessionId();
-      if (currentSessionId) {
+      const current_session_id = getEffectiveSessionId();
+      if (current_session_id) {
         endSessionMutation.mutate({
           reason: 'route-change',
-          sessionId: currentSessionId,
+          sessionId: current_session_id,
         });
       }
     };
-  }, []);
+  }, [sessionInitialized, endSessionMutation]);
 
   /**
    * 이미지 저장 처리
@@ -134,7 +189,7 @@ export const useInteractionTracker = ({
    * vCard 저장 처리
    */
   const { handleSaveVCard } = useVCardSaver(() => {
-    logInteractionMutation.mutate({ elementName: 'vcard', type: 'save' });
+    safeLogInteraction({ elementName: 'vcard', type: 'save' });
     updateActivity();
   });
 
@@ -142,7 +197,7 @@ export const useInteractionTracker = ({
    * 소셜 링크 클릭 처리
    */
   const handleClick = async ({ url, elementName }: SocialLink) => {
-    logInteractionMutation.mutate({ elementName, type: 'click' });
+    safeLogInteraction({ elementName, type: 'click' });
     window.open(url, '_blank');
     updateActivity();
   };
@@ -152,7 +207,7 @@ export const useInteractionTracker = ({
     handleClick,
     handleSaveImg,
     handleSaveVCard,
-    isLoading: !ip || sessionInitMutation.isPending,
-    isError: sessionInitMutation.isError || logInteractionMutation.isError,
+    isLoading: !ip || isPending || !sessionInitialized,
+    isError: isError || logInteractionMutation.isError,
   };
 };
diff --git a/src/utils/interaction/session-util.ts b/src/utils/interaction/session-util.ts
index 4d28e91a..6562f78c 100644
--- a/src/utils/interaction/session-util.ts
+++ b/src/utils/interaction/session-util.ts
@@ -1,14 +1,49 @@
+import { SESSION_TIMEOUT } from '@/constants/session.constant';
 import { formatToDateString } from './format-date';
 
 /**
  * 유효한 세션 ID를 가져옴
+ * 세션 우선, 없으면 로컬
  */
 export const getEffectiveSessionId = (): string | null => {
-  return (
-    sessionStorage.getItem('currentSessionId') ||
-    localStorage.getItem('backup_session_id') ||
-    null
-  );
+  try {
+    if (isStorageAvailable('sessionStorage')) {
+      const sessionId = sessionStorage.getItem('current_session_id');
+      if (sessionId) return sessionId;
+    }
+    if (isStorageAvailable('localStorage')) {
+      return localStorage.getItem('last_session_id');
+    }
+    return null;
+  } catch (error) {
+    console.error('세션 ID 가져오기 오류: ', error);
+    return null;
+  }
+};
+
+/**
+ * 세션 타임아웃 확인
+ */
+export const checkSessionTimeout = (): boolean => {
+  try {
+    if (!isStorageAvailable('localStorage')) return false;
+
+    const lastActivity = localStorage.getItem('last_activity');
+    if (!lastActivity) return false;
+
+    const isTimedOut = Date.now() - parseInt(lastActivity) > SESSION_TIMEOUT;
+
+    if (isTimedOut && isStorageAvailable('sessionStorage')) {
+      // 타임아웃된 경우 세션 스토리지 초기화
+      sessionStorage.removeItem('current_session_id');
+      sessionStorage.removeItem('session_started_at');
+    }
+
+    return isTimedOut;
+  } catch (e) {
+    console.error('세션 타임아웃 확인 오류:', e);
+    return false;
+  }
 };
 
 /**
@@ -29,6 +64,115 @@ export const storePendingSessionEnd = (sessionId: string): void => {
  * 세션 데이터 삭제
  */
 export const clearSessionData = (): void => {
-  sessionStorage.removeItem('currentSessionId');
+  sessionStorage.removeItem('current_session_id');
   sessionStorage.removeItem('session_started_at');
 };
+
+/**
+ * 스토리지 가용성 확인 함수
+ *
+ * @param type localStorage 또는 sessionStorage
+ * @returns 스토리지 사용 가능 여부
+ */
+export function isStorageAvailable(type: 'localStorage' | 'sessionStorage') {
+  try {
+    if (typeof window === 'undefined') return false;
+
+    const storage = window[type];
+    const x = '__storage_test__';
+    storage.setItem(x, x);
+    storage.removeItem(x);
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
+
+/**
+ * 세션 활동 시간 업데이트
+ */
+export const updateSessionActivity = () => {
+  try {
+    if (isStorageAvailable('localStorage')) {
+      localStorage.setItem('last_activity', Date.now().toString());
+    }
+  } catch (error) {
+    console.error('세션 활동 시간 업데이트 오류: ', error);
+  }
+};
+
+/**
+ * 세션 초기화
+ */
+export const initSession = (startedAt: Date) => {
+  const hasSessionStorage = isStorageAvailable('sessionStorage');
+  const hasLocalStorage = isStorageAvailable('localStorage');
+
+  // 기존 세션 확인
+  let sessionId = null;
+  let sessionStartTime = null;
+  let isNewSession = false;
+
+  try {
+    // 세션 스토리지 우선 확인
+    if (hasSessionStorage) {
+      sessionId = sessionStorage.getItem('current_session_id');
+      sessionStartTime = sessionStorage.getItem('session_started_at');
+    }
+
+    // 세션 스토리지에 없다면 로컬 스토리지를 확인
+    // 이 경우 세 세션으로 간주 (새탭)
+    if (!sessionId && hasLocalStorage) {
+      const lastSessionId = localStorage.getItem('last_session_id');
+
+      if (lastSessionId) {
+        isNewSession = true;
+      }
+    }
+
+    if (!isNewSession && sessionStartTime) {
+      const lastActivity = localStorage.getItem('last_activity');
+      if (lastActivity && Date.now() - Number(lastActivity) > SESSION_TIMEOUT) {
+        isNewSession = true;
+        sessionId = null;
+
+        if (hasSessionStorage) {
+          clearSessionData();
+        }
+      }
+    }
+
+    // 새 세션 생성이 필요한 경우
+    if (!sessionId || isNewSession) {
+      sessionId = crypto.randomUUID();
+      sessionStartTime = formatToDateString(startedAt);
+
+      if (hasSessionStorage) {
+        sessionStorage.setItem('current_session_id', sessionId);
+        sessionStorage.setItem('session_started_at', sessionStartTime);
+      }
+
+      // 시크릿 모드 (세션 스토리지 사용 불가시)
+      if (hasLocalStorage) {
+        localStorage.setItem('last_session_id', sessionId);
+        localStorage.setItem('last_activity', Date.now().toString());
+      }
+    } else {
+      // 기존 세션 활동 시간 업데이트
+      updateSessionActivity();
+    }
+    return {
+      sessionId,
+      startedAt: sessionStartTime || formatToDateString(startedAt),
+      isNewSession,
+    };
+  } catch (error) {
+    // 오류 발생시 임시 세션 발급
+    const tempSessionId = crypto.randomUUID();
+    return {
+      sessionId: tempSessionId,
+      startedAt: formatToDateString(startedAt),
+      isNewSession: true,
+    };
+  }
+};


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 7**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/app/[...slug]/page.client.tsx (1)</summary><blockquote>

`59-97`: **`useEffect` 의 의존성 배열 누락 가능성 확인**

`hasInitialViewId` 값을 조건 분기에서 사용하지만 의존성 배열에 포함돼 있지 않습니다.  
현재 로직상 `hasInitialViewId` 가 최초 한 번만 세팅되면 다시 계산할 필요가 없긴 하지만,  
미래에 로직이 바뀔 경우 잘못된 캐싱 문제가 생길 수 있습니다. 아래와 같이 명시적으로 포함하거나 ESLint(plugin-react-hooks) 규칙을 사용해 안전하게 관리하는 것을 추천합니다.

```diff
-  }, [isMyCard, ip, id]);
+  }, [isMyCard, ip, id, hasInitialViewId]);
```

기능상 문제는 아니므로 선택 사항입니다.

</blockquote></details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (1)</summary><blockquote>

`30-51`: **불필요한 `startedAt` null 체크**

`startedAt` 파라미터가 이제 필수(`Date`) 타입으로 변경되었는데, `if (!startedAt)` 수식어가 여전히 존재합니다. 타입 시스템으로 이미 보장되므로 해당 런타임 체크는 제거해 코드 간결성을 높일 수 있습니다.

</blockquote></details>
<details>
<summary>src/utils/interaction/session-util.ts (2)</summary><blockquote>

`145-157`: **`crypto.randomUUID()` 호환성 주의**

Safari 14 이하 등 일부 브라우저는 `crypto.randomUUID()`를 지원하지 않습니다. 폴리필을 도입하거나 `uuid` 라이브러리를 사용하는 방안을 고려해 주세요.

---

`36-42`: **타임아웃 시 `last_activity` 미정리**

`checkSessionTimeout`에서 세션이 타임아웃되면 `sessionStorage`만 정리하고 `localStorage`의 `last_activity`는 그대로 남겨둡니다. 새 세션 생성 후에도 이전 `last_activity`가 남아 있으면 다음 타임아웃 계산이 왜곡될 가능성이 있으니 함께 삭제하는 것이 안전합니다.

```diff
if (isTimedOut) {
  ...
+ localStorage.removeItem('last_activity');
}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 536f9b6c73154022b881403341f60817fafb75e8 and 2b47ba7b2b88a14b868a0137534ebfa4db106b09.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `src/apis/interaction.ts` (1 hunks)
* `src/app/[...slug]/page.client.tsx` (3 hunks)
* `src/constants/session.constant.ts` (1 hunks)
* `src/hooks/mutations/use-init-session.ts` (4 hunks)
* `src/hooks/use-interaction-tracker.ts` (5 hunks)
* `src/utils/interaction/session-util.ts` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary>

* `useCardInteraction` (19-25)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (4)</summary>

* `initSession` (105-176)
* `updateSessionActivity` (92-100)
* `getEffectiveSessionId` (8-22)
* `storePendingSessionEnd` (52-61)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (3)</summary>

* `useInitSessionMutation` (30-73)
* `useEndSessionMutation` (78-90)
* `useLogInteractionMutation` (101-142)

</details>
<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>

</blockquote></details>
<details>
<summary>src/app/[...slug]/page.client.tsx (1)</summary><blockquote>

<details>
<summary>src/utils/interaction/session-util.ts (1)</summary>

* `getEffectiveSessionId` (8-22)

</details>

</blockquote></details>
<details>
<summary>src/utils/interaction/session-util.ts (2)</summary><blockquote>

<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>
<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-21)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/constants/session.constant.ts (1)</summary>

`1-2`: **SESSION_TIMEOUT를 환경변수로 분리하는 것을 고려해 주세요.**

현재 5 분(추후 30 분 예정)으로 하드코딩되어 있습니다.  
배포/스테이징/로컬 환경마다 다른 값을 쓰려면 `.env` 혹은 `NEXT_PUBLIC_…` 형태의 환경변수로 빼 두면 코드 수정 없이 운영할 수 있습니다.

```diff
-export const SESSION_TIMEOUT = 5 * 60 * 1000; // 추후 수정 예정 :30분, 테스트 용 5분
+export const SESSION_TIMEOUT =
+  Number(process.env.NEXT_PUBLIC_SESSION_TIMEOUT ?? 5 * 60) * 1000;
```

빌드 시점에 숫자로 파싱되지 않는 경우를 대비해 `Number()` 검사를 넣어 두면 안전합니다.  
[ suggest_optional_refactor ]

</details>
<details>
<summary>src/apis/interaction.ts (1)</summary>

`26-30`: **IP 응답 검증 추가 👍**

`data?.ip` 존재 여부를 확인해 예외를 던지는 로직이 추가돼 예상치 못한 응답을 안전하게 방어합니다. 로깅까지 포함해 디버깅도 용이합니다. 별다른 이슈 없습니다.

</details>
<details>
<summary>src/app/[...slug]/page.client.tsx (1)</summary>

`135-143`: **`isMyCard` 조건부 렌더링 👍**

소유자 카드일 때만 뒤로가기 아이콘을 렌더링하도록 명확하게 분리되어 UX가 개선되었습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-29T21:59:13Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/utils/interaction/session-util.ts (1)</summary><blockquote>

`77-89`: **isStorageAvailable 함수의 SSR 안전성 확보**

스토리지 가용성 체크 함수가 적절하게 구현되었고, 이전 리뷰에서 지적된 SSR 환경에서의 `window` 참조 문제도 해결되었습니다. 테스트 아이템을 저장하고 삭제하는 방식으로 실제 스토리지 사용 가능 여부를 확인하는 점도 좋습니다.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>src/utils/interaction/session-util.ts (2)</summary><blockquote>

`24-47`: **세션 타임아웃 체크 로직 잘 구현됨**

세션 타임아웃을 확인하고 필요한 경우 세션 데이터를 초기화하는 로직이 적절하게 구현되었습니다. 예외 처리도 잘 되어 있습니다.

한 가지 제안: 타임아웃 로직에 추가로 주석을 달아 SESSION_TIMEOUT 값이 얼마인지(현재 5분, 미래에 30분으로 변경 예정)를 명시하면 가독성이 더 좋아질 것 같습니다.

---

`107-178`: **initSession 함수의 복잡성 검토**

세션 초기화 함수는 다양한 상황을 잘 처리하고 있습니다만, 복잡도가 높아 이해하기 어려울 수 있습니다.

1. 세션 체크 로직(118-143줄)과 세션 생성 로직(146-163줄)을 별도 함수로 분리하는 것을 고려해보세요.
2. 주석을 더 상세하게 달아 각 조건문의 목적을 명확히 하면 도움이 될 것 같습니다.
3. 175-176줄에서 항상 `isNewSession: true`를 반환하는데, 이는 오류 발생 시 임시 세션을 발급하는 상황에서 적절합니다.

전반적으로 잘 구현되었지만, 가독성과 유지보수성을 위해 리팩토링을 고려해보세요.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2b47ba7b2b88a14b868a0137534ebfa4db106b09 and b3c5c86d513009a537724fa071f86e82b779b1b3.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/utils/interaction/session-util.ts` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/utils/interaction/session-util.ts (2)</summary><blockquote>

<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>
<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-21)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>src/utils/interaction/session-util.ts (4)</summary>

`1-1`: **세션 타임아웃 상수 임포트 적절히 추가됨**

SESSION_TIMEOUT 상수를 별도 파일에서 임포트하여 중앙화된 설정 관리를 구현한 점이 좋습니다. 코드의 유지보수성을 높이는 좋은 패턴입니다.

---

`8-22`: **getEffectiveSessionId 함수의 안전성 개선**

세션 ID를 가져오는 로직이 크게 개선되었습니다. 스토리지 가용성 체크, 우선순위 로직(세션 스토리지 우선, 로컬 스토리지 폴백), 그리고 예외 처리가 잘 구현되었습니다.

---

`67-67`: **clearSessionData 함수에서 올바른 키 이름 사용**

세션 ID 키 이름이 'current_session_id'로 올바르게 사용되어 일관성이 유지됩니다.

---

`94-102`: **세션 활동 시간 업데이트 함수 구현**

세션 활동 시간을 안전하게 업데이트하는 함수가 잘 구현되었습니다. 스토리지 가용성 체크와 예외 처리도 적절합니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-29T22:11:10Z
user: login: lyra-j
body: 막판에 로직이 많이 바뀌네요..ㅠ
고생많으셨습니다!!
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-30T04:13:08Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (1)</summary><blockquote>

`119-171`: _⚠️ Potential issue_

**이벤트 리스너 관리 및 의존성 개선**

이벤트 리스너 설정의 주요 개선 사항:
1. 세션 초기화 상태 확인으로 불필요한 리스너 등록 방지
2. 세션 ID 유효성 검사로 안전성 강화
3. 페이지 언로드 처리 개선
4. 정리(cleanup) 함수의 종합적인 강화

`sessionInitialized` 상태를 의존성 배열에 추가하여 세션 상태 변화에 따라 적절히 리스너를 관리하는 것은 좋은 개선입니다.

하지만 의존성 배열에 `endSessionMutation`이 누락되어 있습니다. 이는 잠재적으로 오래된 클로저 문제를 일으킬 수 있습니다.


```diff
-  }, [sessionInitialized]);
+  }, [sessionInitialized, endSessionMutation]);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>src/components/card/save-share-modal.tsx (1)</summary><blockquote>

`378-378`: **태그 복사 동작 개선으로 더 유연한 URL 구성**

`handleTagCopy` 함수에서 하드코딩된 URL 대신 `origin` 상태 변수를 사용하여 HTML 태그를 생성하는 방식은 환경에 따라 유연하게 URL을 구성할 수 있게 해줍니다. 또한 `source=tag` 매개변수를 추가하여 세션 추적의 정확성을 높인 점도 좋습니다.


다만, 사용자가 페이지 로드 직후 즉시 태그를 복사하려 할 경우 `origin`이 아직 설정되지 않았을 가능성이 있습니다. 이 경우를 대비한 안전장치를 추가하는 것을 고려해보세요.

```diff
-const htmlCode = `<a href="${origin}/${slug}?source=tag" target="_blank"><img src="${imageUrl}" alt="${title}"/></a>`;
+const currentOrigin = origin || window.location.origin || BASE_URL.UUNO;
+const htmlCode = `<a href="${currentOrigin}/${slug}?source=tag" target="_blank"><img src="${imageUrl}" alt="${title}"/></a>`;
```

</blockquote></details>
<details>
<summary>src/hooks/use-interaction-tracker.ts (1)</summary><blockquote>

`65-74`: **인터랙션 로깅 뮤테이션 메모이제이션 적용**

`useMemo`를 사용하여 인터랙션 로깅 뮤테이션을 메모이제이션한 것은 불필요한 훅 재생성을 방지하는 좋은 최적화입니다. 또한 초기화 실패 시 명시적인 에러 처리도 적절합니다.

하지만 런타임 에러(`throw new Error`)는 사용자 경험에 영향을 줄 수 있습니다. 대신 정상적인 폴백 동작을 제공하거나 에러 로깅만 하는 방식이 더 안전할 수 있습니다.


```diff
-  if (!logInteractionMutation) {
-    throw new Error('Log interaction mutation is not initialized properly.');
-  }
+  const safeLogInteraction = (data: any) => {
+    if (!logInteractionMutation) {
+      console.error('Log interaction mutation is not initialized properly.');
+      return Promise.resolve();
+    }
+    return logInteractionMutation.mutate(data);
+  };
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d12d3fdab96be5091b0d87d9a6e415ac572aff88 and 9b229645118838d047b15c0c325906818c0e08e7.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `src/app/[...slug]/page.client.tsx` (4 hunks)
* `src/components/card/flip-card.tsx` (1 hunks)
* `src/components/card/save-share-modal.tsx` (3 hunks)
* `src/hooks/mutations/use-init-session.ts` (4 hunks)
* `src/hooks/use-interaction-tracker.ts` (6 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* src/hooks/mutations/use-init-session.ts
* src/app/[...slug]/page.client.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary>

* `useCardInteraction` (19-25)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (3)</summary>

* `initSession` (107-178)
* `updateSessionActivity` (94-102)
* `getEffectiveSessionId` (8-22)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (3)</summary>

* `useInitSessionMutation` (30-59)
* `useEndSessionMutation` (64-76)
* `useLogInteractionMutation` (87-128)

</details>
<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (11)</summary><blockquote>

<details>
<summary>src/components/card/flip-card.tsx (1)</summary>

`135-147`: **타입 안전성 향상 및 명시적 불리언 변환 적용**

`useInteractionTracker` 훅 사용 시 `isDetail` 속성에 `!!isDetail`로 명시적 불리언 변환을 적용하고, `handleClick` 함수에 구체적인 타입 정의를 추가한 것은 좋은 개선입니다. 이는 타입 안전성을 높이고 코드의 의도를 더 명확하게 합니다.

</details>
<details>
<summary>src/components/card/save-share-modal.tsx (1)</summary>

`80-80`: **origin 상태 변수 추가로 동적 환경에서의 URL 구성 개선**

`origin` 상태 변수를 추가하여 현재 페이지의 도메인을 저장하는 방식은 적절합니다. 이를 통해 개발/스테이징/프로덕션 환경에 관계없이 동적으로 올바른 URL을 구성할 수 있습니다.



Also applies to: 104-104

</details>
<details>
<summary>src/hooks/use-interaction-tracker.ts (9)</summary>

`1-16`: **의존성 임포트 개선 및 세션 타임아웃 상수 활용**

세션 관리 관련 유틸리티와 상수를 명시적으로 임포트하여 코드의 모듈성과 가독성이 향상되었습니다. 특히 `SESSION_TIMEOUT` 상수를 별도 파일로 분리한 것은 설정 값을 중앙화하여 관리하기에 좋은 접근 방식입니다.

---

`19-19`: **인터페이스에 isDetail 속성 추가**

인터페이스에 `isDetail` 속성을 추가한 것은 디테일 페이지 여부에 따라 인터랙션 추적 동작을 제어할 수 있게 하는 좋은 개선입니다.

---

`39-49`: **isDetail 조건부 로직으로 불필요한 인터랙션 추적 방지**

상세 페이지(`isDetail=true`)에서는 인터랙션 추적을 건너뛰는 얼리 리턴 패턴을 적용한 것은 좋은 최적화입니다. 이는 불필요한 API 호출과 세션 관리를 방지하여 성능을 개선하고 중복 조회수 발생을 방지합니다.

---

`52-52`: **명함 ID 조회 로직 개선**

`useCardInteraction` 훅을 통해 슬러그에서 카드 ID를 조회하는 방식은 의존성을 명확히 하고 코드 흐름을 개선합니다.

---

`54-59`: **세션 초기화 로직 리팩토링**

세션 초기화 로직을 구조 분해 할당으로 더 명확하게 표현한 것은 좋은 개선입니다. `isPending`과 `isError` 상태를 직접 활용하여 코드의 가독성이 향상되었습니다.

---

`77-77`: **세션 초기화 상태 관리 개선**

세션 초기화 상태를 `useRef` 대신 `useState`로 관리하는 방식은 React의 상태 관리 패러다임에 더 적합합니다. 이를 통해 세션 초기화 상태 변경 시 관련 효과가 올바르게 트리거됩니다.

---

`80-91`: **세션 초기화 로직 개선 및 중복 초기화 방지**

세션 초기화 조건과 콜백 처리가 더 명확해졌습니다. IP, 카드 ID 존재 여부와 세션 초기화 상태를 확인하여 중복 초기화를 방지하는 것은 좋은 개선입니다. 성공 콜백에서 상태를 업데이트하는 방식도 적절합니다.

---

`96-116`: **세션 활동 업데이트 및 타임아웃 처리 개선**

세션 활동 업데이트 메커니즘이 크게 개선되었습니다:
1. 중앙화된 `updateSessionActivity` 함수 사용
2. `SESSION_TIMEOUT` 상수를 활용한 타임아웃 설정
3. 세션 타임아웃 시 세션 종료 및 상태 리셋 로직 추가

이러한 변경으로 세션 관리의 일관성과 정확성이 향상되었습니다.

---

`200-201`: **로딩 및 에러 상태 판단 로직 개선**

로딩 및 에러 상태 결정 로직이 더 명확하고 직관적으로 개선되었습니다. 구조 분해된 `isPending`과 `isError`를 활용하는 방식은 코드의 가독성을 높입니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T04:56:22Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

`14-14`: **모듈 import 경로에 일관성 문제가 있습니다.**

파일 경로에 대문자 'I'가 포함되어 있습니다 (`use-Image-downloader`). 케이스 센서티브 파일 시스템에서 문제가 발생할 수 있으며, 프로젝트의 명명 규칙과 일치하지 않습니다.

```diff
-import { useImageDownloader } from '@/hooks/use-Image-downloader';
+import { useImageDownloader } from '@/hooks/use-image-downloader';
```

---

`65-73`: **logInteractionMutation 초기화 패턴에 개선 여지가 있습니다.**

현재 코드는 `useMemo`에서 조건에 따라 `null`을 반환한 후 즉시 null 체크에서 에러를 발생시키고 있어 불필요한 에러 처리 패턴이 될 수 있습니다. 다음과 같이 개선할 수 있습니다:

```diff
-  const logInteractionMutation = useMemo(() => {
-    return cardId && ip
-      ? useLogInteractionMutation(cardId, ip, source, startedAt)
-      : null;
-  }, [cardId, ip, source, startedAt]);
-
-  if (!logInteractionMutation) {
-    throw new Error('Log interaction mutation is not initialized properly.');
-  }
+  // cardId와 ip가 없을 때는 effect에서 early return이 있으므로
+  // 여기서도 early return을 활용하는 것이 더 자연스러움
+  const logInteractionMutation = useMemo(() => {
+    if (!cardId || !ip) {
+      // 필요한 데이터가 없는 경우 noop 함수 반환
+      return {
+        mutate: () => {},
+        isError: false,
+      } as ReturnType<typeof useLogInteractionMutation>;
+    }
+    return useLogInteractionMutation(cardId, ip, source, startedAt);
+  }, [cardId, ip, source, startedAt]);
```

---

`80-91`: **세션 초기화 로직 개선이 잘 이루어졌습니다.**

세션 초기화 로직이 더 명확해졌고, `sessionInitialized` 상태를 활용하여 불필요한 중복 초기화를 방지하는 방식이 좋습니다. 다만 에러 처리가 없어 보입니다.

```diff
  initSession(
    { cardId, viewerIp: ip, source: source || null },
    {
      onSuccess: () => {
        setSessionInitialized(true);
      },
+     onError: (error) => {
+       console.error('세션 초기화 실패:', error);
+       // 선택적으로 재시도 로직 추가 가능
+     },
    }
  );
```

---

`119-171`: **이벤트 리스너 등록 의존성 개선이 이루어졌습니다.**

`sessionInitialized` 상태에 따라 이벤트 리스너를 조건부로 등록하도록 변경하고, 의존성 배열에 추가한 것이 좋습니다. 다만 의존성 배열이 불완전할 수 있습니다.

```diff
-  }, [sessionInitialized]);
+  }, [sessionInitialized, endSessionMutation]);
```

effect 내에서 `endSessionMutation`을 사용하고 있으므로 의존성 배열에 추가하는 것이 React 규칙에 맞습니다.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9b229645118838d047b15c0c325906818c0e08e7 and ce9ed2c1339c88e445a569dc0e20925f86590a35.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/hooks/use-interaction-tracker.ts` (5 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary>

* `useCardInteraction` (19-25)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (3)</summary>

* `initSession` (107-178)
* `updateSessionActivity` (94-102)
* `getEffectiveSessionId` (8-22)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (3)</summary>

* `useInitSessionMutation` (30-59)
* `useEndSessionMutation` (64-76)
* `useLogInteractionMutation` (87-128)

</details>
<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (3)</summary>

`33-49`: **Early return 패턴을 통한 상세 페이지 로직 개선이 잘 이루어졌습니다.**

`isDetail` 파라미터를 활용하여 상세 페이지에서는 조회수 추적 로직을 건너뛰도록 구현한 것이 좋습니다. 이는 PR 목표인 '페이지 새로고침 시 중복 조회수 증가 방지'에 맞게 잘 설계되었습니다.

---

`96-116`: **세션 타임아웃 로직 개선이 잘 이루어졌습니다.**

`SESSION_TIMEOUT` 상수를 활용하고 `updateSessionActivity()` 유틸리티 함수를 사용하여 세션 관리를 중앙화한 것이 좋습니다. 이는 PR 목표인 '세션 타임아웃 관리 개선'에 부합합니다.

---

`200-201`: **로딩 및 에러 상태 반환 개선이 이루어졌습니다.**

`useInitSessionMutation`에서 구조분해한 `isPending`과 `isError`를 사용하여 로딩 및 에러 상태를 관리하는 방식으로 개선된 것이 좋습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T04:59:18Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (1)</summary><blockquote>

`129-181`: **이벤트 리스너 최적화 및 세션 종료 처리**

`sessionInitialized` 상태를 기반으로 이벤트 리스너를 조건부로 추가하는 방식은 불필요한 리소스 사용을 방지합니다. 이는 이전 리뷰에서 지적된 ref.current를 의존성으로 사용하는 문제도 해결했습니다. 페이지 언로드 및 라우트 변경 시 세션 종료 처리도 적절히 구현되었습니다.


`getEffectiveSessionId()`를 여러 번 호출하는 대신, 효과 내에서 한 번만 호출하고 변수에 저장하여 재사용하는 것이 더 효율적일 수 있습니다:

```diff
  useEffect(() => {
    // 세션 초기화 확인
    if (!sessionInitialized) return;

    const sessionId = getEffectiveSessionId();
    if (!sessionId) return;

    // ... 이벤트 리스너 등록 ...

    // 페이지 언로드 처리
    const handleBeforeUnload = () => {
-     const current_session_id = getEffectiveSessionId();
-     if (current_session_id) {
-       storePendingSessionEnd(current_session_id);
+     if (sessionId) {
+       storePendingSessionEnd(sessionId);
        endSessionMutation.mutate({
          reason: 'page-exit',
-         sessionId: current_session_id,
+         sessionId: sessionId,
        });
      }
    };
    // ... 이벤트 리스너 등록 ...

    return () => {
      // ... 정리 로직 ...
      
-     const current_session_id = getEffectiveSessionId();
-     if (current_session_id) {
+     if (sessionId) {
        endSessionMutation.mutate({
          reason: 'route-change',
-         sessionId: current_session_id,
+         sessionId: sessionId,
        });
      }
    };
  }, [sessionInitialized, endSessionMutation]);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ce9ed2c1339c88e445a569dc0e20925f86590a35 and ea51e55c677b4a9a1e51987d4736957c4109a7b2.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/components/card/save-share-modal.tsx` (3 hunks)
* `src/hooks/use-interaction-tracker.ts` (6 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/components/card/save-share-modal.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary>

* `useCardInteraction` (19-25)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (3)</summary>

* `initSession` (107-178)
* `updateSessionActivity` (94-102)
* `getEffectiveSessionId` (8-22)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (3)</summary>

* `useInitSessionMutation` (30-59)
* `useEndSessionMutation` (64-76)
* `useLogInteractionMutation` (87-128)

</details>
<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (9)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (9)</summary>

`1-16`: **모듈 import 개선 및 절대 경로 사용**

모듈 import가 잘 정리되어 있으며, 세션 관련 상수와 유틸리티 함수가 적절히 추가되었습니다. 절대 경로(`@/`)를 사용한 것도 좋습니다.

---

`18-23`: **isDetail 파라미터 추가로 효율적인 조건부 렌더링 지원**

`InteractionProps` 인터페이스에 `isDetail` 불리언 파라미터를 추가하여 상세 페이지에서 불필요한 인터랙션 트래킹을 방지할 수 있게 되었습니다. 이는 효율적인 설계입니다.

---

`33-49`: **상세 페이지에서의 early return 패턴 적용**

상세 페이지(`isDetail=true`)에서 early return 패턴을 적용하여 불필요한 로직 실행을 방지하는 방식은 효율적입니다. no-op 함수와 false 상태값을 반환하여 인터페이스 일관성을 유지하는 것도 좋습니다.

---

`52-53`: **카드 ID 조회 로직 개선**

`useCardInteraction` 훅을 사용하여 slug로부터 직접 cardId를 가져오는 방식은 코드 가독성과 유지보수성을 향상시킵니다.

---

`54-62`: **세션 초기화 및 종료 로직 개선**

세션 초기화 및 종료 로직이 명확하게 분리되었습니다. `useInitSessionMutation`에서 반환되는 상태값을 직접 사용하여 로딩 및 에러 상태를 추적하는 방식이 좋습니다.

---

`89-101`: **세션 초기화 로직 개선**

세션 초기화 로직이 이전 버전의 문제점(setState 후 즉시 값을 사용하려는 시도)을 해결하였습니다. `sessionInitialized` 상태를 추가하고 `onSuccess` 콜백에서 이를 갱신하는 방식은 상태 관리를 명확하게 합니다.

---

`106-126`: **활동 업데이트 로직 및 세션 타임아웃 처리 개선**

`updateSessionActivity` 유틸리티 함수와 `SESSION_TIMEOUT` 상수를 활용한 코드는 가독성과 유지보수성을 높입니다. 타임아웃 발생 시 세션을 명시적으로 종료하고 상태를 초기화하는 로직이 잘 구현되었습니다.

---

`191-203`: **핸들러 함수 개선**

`safeLogInteraction` 함수를 사용하여 인터랙션 로깅을 처리하는 방식은 안전하고 일관성 있습니다. `updateActivity` 호출을 통해 사용자 활동을 지속적으로 추적하는 것도 좋습니다.

---

`205-212`: **반환값 개선**

로딩 및 에러 상태를 뮤테이션 상태로부터 직접 도출하는 방식은 상태 관리를 단순화합니다. IP 주소 로딩 상태를 함께 고려하는 것도 적절합니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T05:11:08Z
user: login: woozizi
body: lgtm!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-30T05:38:08Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (3)</summary><blockquote>

`87-87`: **성능 최적화 제안: sessionInitialized를 useRef로 관리**

`sessionInitialized`를 useState가 아닌 useRef로 관리하면 불필요한 리렌더링을 방지할 수 있습니다. 이 값은 주로 useEffect 내에서만 사용되므로 ref로 충분합니다.

```diff
- const [sessionInitialized, setSessionInitialized] = useState(false);
+ const sessionInitializedRef = useRef(false);

  useEffect(() => {
-   if (!ip || !cardId || sessionInitialized) return;
+   if (!ip || !cardId || sessionInitializedRef.current) return;

    initSession(
      { cardId, viewerIp: ip, source: source || null },
      {
        onSuccess: () => {
-         setSessionInitialized(true);
+         sessionInitializedRef.current = true;
        },
      }
    );
-  }, [ip, cardId, source, sessionInitialized, initSession]);
+  }, [ip, cardId, source, initSession]);

  useEffect(() => {
    // 세션 초기화 확인
-   if (!sessionInitialized) return;
+   if (!sessionInitializedRef.current) return;

    // ... 기존 코드 ...

    if (inactivityTimerRef.current) {
      clearTimeout(inactivityTimerRef.current);
    }

    const current_session_id = getEffectiveSessionId();
    if (current_session_id) {
      endSessionMutation.mutate({
        reason: 'route-change',
        sessionId: current_session_id,
      });
    }
  };
- }, [sessionInitialized, endSessionMutation]);
+ }, [endSessionMutation]);
```

그리고 updateActivity 함수의 타임아웃 콜백에서도 변경이 필요합니다:
```diff
inactivityTimerRef.current = setTimeout(() => {
  const sessionId = getEffectiveSessionId();
  if (sessionId) {
    // 타임아웃 처리
    endSessionMutation.mutate({ reason: 'inactive', sessionId });

    // 세션 초기화
-   setSessionInitialized(false);
+   sessionInitializedRef.current = false;
  }
}, SESSION_TIMEOUT);
```

리턴 부분도 변경해야 합니다:
```diff
return {
  updateActivity,
  handleClick,
  handleSaveImg,
  handleSaveVCard,
- isLoading: !ip || isPending || !sessionInitialized,
+ isLoading: !ip || isPending || !sessionInitializedRef.current,
  isError: isError || logInteractionMutation.isError,
};
```


Also applies to: 129-181

---

`77-80`: **오류 처리 개선 제안**

현재 로깅 시 필요한 데이터가 없으면 단순히 콘솔 로그만 출력하고 있습니다. 필요한 데이터가 언제 도착할지와 필요한 이유를 명확히 설명하는 오류 메시지를 추가하는 것이 좋을 것 같습니다.

```diff
if (!ip || !cardId) {
- console.log('필요한 데이터가 아직 준비되지 않았습니다:', { ip, cardId });
+ console.warn('인터랙션 로깅에 필요한 데이터(IP 또는 카드ID)가 아직 준비되지 않았습니다. 데이터 로딩이 완료되면 자동으로 처리됩니다.', { 
+   ip: ip ? '준비됨' : '준비되지 않음', 
+   cardId: cardId ? '준비됨' : '준비되지 않음' 
+ });
  return Promise.resolve();
}
```

---

`109-110`: **세션 활동 시간 업데이트 주석 추가**

`updateActivity` 함수에서 `updateSessionActivity` 호출 부분에 더 상세한 주석을 추가하면 코드의 명확성이 향상될 것입니다.

```diff
- // 세션 활동 시간 업데이트
+ // 로컬 스토리지에 마지막 활동 시간 업데이트 (세션 타임아웃 계산용)
  updateSessionActivity();
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9dc9e2bbf7fe900b2cc7b89e6825c5a1fb5e88cc and 98fe1fbf89898993a8dcd072d041b9edc155b467.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/app/[...slug]/page.client.tsx` (4 hunks)
* `src/hooks/use-interaction-tracker.ts` (6 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/app/[...slug]/page.client.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/hooks/use-interaction-tracker.ts (4)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary>

* `useCardInteraction` (19-25)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (4)</summary>

* `initSession` (107-178)
* `updateSessionActivity` (94-102)
* `getEffectiveSessionId` (8-22)
* `storePendingSessionEnd` (52-61)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (3)</summary>

* `useInitSessionMutation` (30-59)
* `useEndSessionMutation` (64-76)
* `useLogInteractionMutation` (87-128)

</details>
<details>
<summary>src/constants/session.constant.ts (1)</summary>

* `SESSION_TIMEOUT` (2-2)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (18)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (18)</summary>

`1-16`: **import 구문이 올바르게 개선되었습니다.**

필요한 모듈들이 적절하게 추가되었으며, 특히 `SESSION_TIMEOUT` 상수의 도입으로 하드코딩된 시간값을 제거한 점이 좋습니다.

---

`19-19`: **세부 페이지 로직 분리를 위한 좋은 접근법**

`isDetail` 플래그 추가로 세부 페이지에서는 불필요한 인터랙션 트래킹을 건너뛸 수 있게 되었습니다.

---

`39-49`: **조기 반환 패턴이 훌륭하게 구현되었습니다.**

세부 페이지(`isDetail=true`)에서는 인터랙션 트래킹 로직을 완전히 건너뛰고 빈 함수와 기본 상태값을 반환하는 방식으로 성능을 최적화했습니다.

---

`51-52`: **카드 ID 검색 로직이 개선되었습니다.**

`useCardInteraction(slug)` 훅을 통해 카드 ID를 가져오는 방식으로 변경하여 코드의 일관성과 재사용성이 향상되었습니다.

---

`54-59`: **세션 초기화 코드가 깔끔하게 구조화되었습니다.**

`useInitSessionMutation`의 반환값을 적절히 구조 분해하여 가독성을 높였습니다.

---

`64-70`: **인터랙션 로깅 생성 방식이 개선되었습니다.**

항상 인터랙션 로깅 뮤테이션을 생성하고 나중에 필요한 데이터가 있는지 확인하는 방식은 좋은 접근법입니다. 다만, 빈 문자열을 기본값으로 전달하는 방식이 안전합니다.

---

`72-82`: **안전한 로깅 함수 구현이 효과적입니다.**

필요한 데이터가 있는지 확인 후 뮤테이션을 호출하는 방식으로 안전성을 확보했습니다. 로그 메시지를 통해 디버깅도 용이합니다.

---

`87-87`: **세션 초기화 상태 관리가 잘 구현되었습니다.**

`sessionInitialized` 상태를 통해 세션 초기화 여부를 추적하고 중복 초기화를 방지합니다.

---

`90-101`: **세션 초기화 로직이 적절히 개선되었습니다.**

조건부 초기화와 성공 콜백을 통한 상태 업데이트가 잘 구현되었습니다. 이전 코드리뷰 코멘트에서 지적된 상태 미반영 문제가 해결되었습니다.

---

`106-126`: **활동 업데이트 및 타임아웃 처리가 개선되었습니다.**

중앙 집중화된 `updateSessionActivity` 유틸리티를 사용하고, `SESSION_TIMEOUT` 상수를 활용한 점이 좋습니다. 타임아웃 발생 시 세션 종료 및 초기화 리셋 로직이 잘 구현되었습니다.

---

`129-132`: **이벤트 리스너 설정 조건이 적절히 개선되었습니다.**

세션 초기화 여부를 확인한 후에만 이벤트 리스너를 설정하도록 조건을 추가한 것은 좋은 최적화입니다.

---

`150-160`: **페이지 언로드 처리가 적절히 구현되었습니다.**

페이지 이탈 시 세션 종료 정보를 저장하고 뮤테이션을 호출하는 로직이 잘 구현되었습니다.

---

`173-178`: **라우트 변경 시 세션 종료 처리가 적절히 구현되었습니다.**

라우트 변경 시 명확한 이유와 함께 세션을 종료하는 로직이 잘 구현되었습니다.

---

`181-181`: **의존성 배열에 sessionInitialized가 추가되었습니다.**

이벤트 리스너 설정 useEffect의 의존성 배열에 sessionInitialized를 추가하여 세션 초기화 상태 변경 시 적절히 반응하도록 했습니다.

---

`191-194`: **vCard 저장 처리가 safeLogInteraction을 활용하도록 개선되었습니다.**

안전한 로깅 함수를 사용하여 vCard 저장 이벤트를 기록하도록 변경되었습니다.

---

`199-203`: **소셜 링크 클릭 처리가 safeLogInteraction을 활용하도록 개선되었습니다.**

안전한 로깅 함수를 사용하여 소셜 링크 클릭 이벤트를 기록하도록 변경되었습니다.

---

`210-211`: **로딩 및 에러 상태가 적절히 개선되었습니다.**

세션 초기화 상태를 로딩 상태에 반영하고, 로깅 뮤테이션 에러를 에러 상태에 포함시켜 더 정확한 상태 표시가 가능해졌습니다.

---

`125-125`: <details>
<summary>❓ Verification inconclusive</summary>

**세션 타임아웃 값 검증 필요**

현재 `SESSION_TIMEOUT`은 5분(5 * 60 * 1000)으로 설정되어 있습니다. PR 설명에 따르면 이 값은 향후 30분으로 확장할 계획이 있다고 언급되어 있습니다. 테스트 환경에서 사용 중인 값인지 확인하고, 프로덕션 환경을 위한 계획을 명확히 하는 것이 좋을 것 같습니다.

---

🌐 Web query:

```web
웹 애플리케이션에서 일반적인 세션 타임아웃 시간은 얼마인가요?
```

💡 Result:

## 웹 애플리케이션에서 일반적인 세션 타임아웃 시간

웹 애플리케이션에서 *세션 타임아웃*이란, 사용자가 일정 시간 동안 아무런 활동(요청)이 없으면 서버가 해당 사용자의 세션 정보를 자동으로 만료시키는 시간을 의미합니다. 이 시간은 보안, 자원 관리 등을 위해 중요하게 설정됩니다.

**일반적으로 사용되는 세션 타임아웃 값**
- 다수의 웹 서버와 프레임워크(예: Tomcat, Java EE 등)에서 기본값은 "30분(30 minutes, 1800초)"으로 설정되어 있습니다[3][5][7].
- 또 다른 일반적인 값은 "20분"으로, 일부 시스템에서는 20분이 기본값으로 사용됩니다[2].
- 실제 운영 환경에서는 보안, 사용자 경험, 서비스 성격에 따라 10분에서 60분 이상까지 다양하게 설정할 수 있습니다[5].

**설정 위치와 우선순위**
- 세션 타임아웃은 보통 다음 우선순위로 적용됩니다:
  1. 코드(예: `session.setMaxInactiveInterval()` 등)
  2. 웹 애플리케이션 설정 파일(`WEB-INF/web.xml`의 `<session-timeout>` 태그)
  3. 서버 전체 설정(예: WAS의 기본 설정)
- 가장 먼저 설정된 값이 우선 적용됩니다[5][7].

**요약**

| 구분            | 주요 값 및 특징                |
|----------------|-----------------------------|
| 기본 설정값       | 30분(Tomcat 등), 20분(일부)     |
| 설정 우선순위     | 코드 > 웹.xml(앱) > 서버 전역    |
| 상황에 따른 범위   | 10분 ~ 60분 이상, 필요시 조정    |
| 특별 설정        | -1: 세션 만료 없음(권장 X)     |

10~30분 사이의 값이 가장 널리 사용되며, 대부분의 프레임워크나 서버의 디폴트는 30분입니다[3][5][7][2]. 보안이 중요한 서비스는 더 짧게, 효율성을 중시하는 서비스는 더 길게 설정하기도 합니다.

Citations:

- 1: https://gomean.tistory.com/9
- 2: https://blog.naver.com/hanajava/221888319392
- 3: https://velog.io/@sungmo738/HttpSessionListener-Session-Timeout-%EC%84%A4%EC%A0%95
- 4: https://contradicto-lee.tistory.com/57
- 5: https://blog.naver.com/realmani/222036102804
- 6: https://wwwaloha.oopy.io/40f6c899-a2e1-4efd-8293-c98c4dd2bf25
- 7: https://blog.naver.com/gd8546/222612739117
- 8: https://jylee-dev.tistory.com/27
- 9: https://baejiyoung.tistory.com/160
- 10: https://www.adriel.com/ko/glossary/session-duration

---



</details>

**세션 타임아웃 값 검토 필요**

현재 `src/hooks/use-interaction-tracker.ts` 125번째 줄에서 `SESSION_TIMEOUT`이 5분(5 * 60 * 1000)으로 설정되어 있습니다.  
일반적으로 웹 애플리케이션의 세션 타임아웃 기본값은 20~30분(프레임워크 기본 30분)인 경우가 많으므로, 다음 사항을 확인 부탁드립니다:

- 이 값(5분)이 **테스트 환경 전용**인지, 아니면 프로덕션에도 그대로 적용되는지  
- 프로덕션 환경에서는 10~60분 범위 내에서 적절한 값을 설정할 계획인지  
- 향후 30분으로 확장 예정이라면, 환경변수나 설정 파일 등으로 **외부에서 조정 가능하도록** 변경하는 것이 좋습니다  

위 내용을 확인하신 뒤, 필요 시 `SESSION_TIMEOUT` 값을 환경별로 분리하거나 기본값을 상향 조정해 주세요.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T06:00:56Z

Review comments:
diff_hunk: @@ -33,49 +35,80 @@ export const useInteractionTracker = ({
   startedAt,
 }: InteractionProps) => {
   const { data: ip } = useIpAddressQuery();
+  const { data: cardId } = useCardInteraction(slug);
 
-  const sessionInitMutation = useInitSessionMutation(startedAt);
+  // 세션 초기화
+  const {
+    mutate: initSession,
+    isPending,
+    isError,
+    setSessionParams,
+  } = useInitSessionMutation(startedAt || new Date());
+
+  // 세션 종료
   const endSessionMutation = useEndSessionMutation();
-  const { data } = useCardInteraction(slug);
-  const cardId = data;
 
+  // 인터렉션 로깅
   const logInteractionMutation = useLogInteractionMutation(
     cardId || '',
-    ip,
+    ip || '',
     source,
     startedAt
   );
 
+  // 활동 추적
   const inactivityTimerRef = useRef<NodeJS.Timeout | null>(null);
   const lastActivityRef = useRef<Date | null>(null);
+  const sessionInitializedRef = useRef<boolean>(false);
 
   // 세션 초기화
   useEffect(() => {
-    if (!ip || !cardId) return;
-    sessionInitMutation.mutate();
-  }, [ip, cardId]);
+    if (!ip || !cardId || sessionInitializedRef.current) return;
+
+    // 세션 초기화 파라미터 설정
+    setSessionParams({
+      cardId,
+      viewerIp: ip,
+      source: source || null,
+    });
+
+    initSession(undefined, {
+      onSuccess: () => {
+        sessionInitializedRef.current = true;
+      },
+    });
+  }, [ip, cardId, source]);
path: src/hooks/use-interaction-tracker.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**`setSessionParams` 직후 `initSession` 호출 시 상태 미반영 문제**

`setSessionParams`는 비동기 `setState`를 사용하므로 바로 다음 줄에서 `initSession` 을 호출하면 `cardId`, `viewerIp`, `source` 값이 아직 반영되지 않은 클로저를 사용하게 됩니다. 그 결과 새 세션임에도 조회수 로그가 기록되지 않는 등 의도한 로직이 동작하지 않을 수 있습니다.  

다음과 같이 `setSessionParams`가 완료된 뒤(=다음 렌더) `initSession`을 트리거하도록 분리하거나, 파라미터를 `mutate`에 직접 전달하는 형태로 개선해 주세요.

```diff
- setSessionParams({ cardId, viewerIp: ip, source: source || null });
- initSession();
+ setSessionParams({ cardId, viewerIp: ip, source: source || null });
```

```ts
// 개선 예시 1: 상태 반영을 기다리는 useEffect 분리
useEffect(() => {
  if (!cardId || !ip) return;
  initSession();
}, [cardId, ip]);

// 개선 예시 2: useInitSessionMutation 내부에서 카드/IP를 매개변수로 직접 받도록 인터페이스 변경
```

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 82b079d
created_at: 2025-04-29T21:59:12Z
author_association: NONE
diff_hunk: @@ -33,49 +35,80 @@ export const useInteractionTracker = ({
   startedAt,
 }: InteractionProps) => {
   const { data: ip } = useIpAddressQuery();
+  const { data: cardId } = useCardInteraction(slug);
 
-  const sessionInitMutation = useInitSessionMutation(startedAt);
+  // 세션 초기화
+  const {
+    mutate: initSession,
+    isPending,
+    isError,
+    setSessionParams,
+  } = useInitSessionMutation(startedAt || new Date());
+
+  // 세션 종료
   const endSessionMutation = useEndSessionMutation();
-  const { data } = useCardInteraction(slug);
-  const cardId = data;
 
+  // 인터렉션 로깅
   const logInteractionMutation = useLogInteractionMutation(
     cardId || '',
-    ip,
+    ip || '',
     source,
     startedAt
   );
 
+  // 활동 추적
   const inactivityTimerRef = useRef<NodeJS.Timeout | null>(null);
   const lastActivityRef = useRef<Date | null>(null);
+  const sessionInitializedRef = useRef<boolean>(false);
 
   // 세션 초기화
   useEffect(() => {
-    if (!ip || !cardId) return;
-    sessionInitMutation.mutate();
-  }, [ip, cardId]);
+    if (!ip || !cardId || sessionInitializedRef.current) return;
+
+    // 세션 초기화 파라미터 설정
+    setSessionParams({
+      cardId,
+      viewerIp: ip,
+      source: source || null,
+    });
+
+    initSession(undefined, {
+      onSuccess: () => {
+        sessionInitializedRef.current = true;
+      },
+    });
+  }, [ip, cardId, source]);
 
   /**
    * 활동 업데이트
    */
   const updateActivity = () => {
     lastActivityRef.current = new Date();
+
+    // 세션 활동 시간 업데이트
+    updateSessionActivity();
+
     if (inactivityTimerRef.current) {
       clearTimeout(inactivityTimerRef.current);
     }
-    inactivityTimerRef.current = setTimeout(
-      () => {
-        const sessionId = getEffectiveSessionId();
-        if (sessionId) {
-          endSessionMutation.mutate({ reason: 'inactive', sessionId });
-        }
-      },
-      30 * 60 * 1000
-    );
+    // 비활동 타임아웃
+    inactivityTimerRef.current = setTimeout(() => {
+      const sessionId = getEffectiveSessionId();
+      if (sessionId) {
+        // 타임아웃 처리
+        endSessionMutation.mutate({ reason: 'inactive', sessionId });
+
+        // 세션 초기화
+        sessionInitializedRef.current = false;
+      }
+    }, SESSION_TIMEOUT);
   };
 
   // 활동 이벤트 설정
   useEffect(() => {
+    // 세션 초기화 확인
+    if (!sessionInitializedRef.current) return;
+
     const sessionId = getEffectiveSessionId();
path: src/hooks/use-interaction-tracker.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`useEffect` 의존성에 ref의 `.current` 값 사용 → 효과 미실행**

`useEffect(() => { ... }, [sessionInitializedRef.current])`  
리액트는 ref 변경 시 리렌더링을 발생시키지 않으므로, `.current` 값으로 의존성을 걸어도 값이 변해도 이 `useEffect`가 재실행되지 않습니다. 그 결과 세션이 초기화돼도 활동 이벤트 리스너가 등록되지 않는 심각한 버그가 발생합니다.

```diff
- }, [sessionInitializedRef.current]);
+ }, [sessionInitialized]); // useState로 변경하거나 별도 플래그 전달
```

`sessionInitialized`를 `useState`로 관리하거나, 세션 초기화 성공 콜백 내부에서 명시적으로 이벤트 리스너를 등록하는 방식으로 수정해 주세요.



> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 24710be
created_at: 2025-04-29T21:59:13Z
author_association: NONE
diff_hunk: @@ -33,49 +35,80 @@ export const useInteractionTracker = ({
   startedAt,
 }: InteractionProps) => {
   const { data: ip } = useIpAddressQuery();
+  const { data: cardId } = useCardInteraction(slug);
 
-  const sessionInitMutation = useInitSessionMutation(startedAt);
+  // 세션 초기화
+  const {
+    mutate: initSession,
+    isPending,
+    isError,
+    setSessionParams,
+  } = useInitSessionMutation(startedAt || new Date());
+
+  // 세션 종료
   const endSessionMutation = useEndSessionMutation();
-  const { data } = useCardInteraction(slug);
-  const cardId = data;
 
+  // 인터렉션 로깅
   const logInteractionMutation = useLogInteractionMutation(
     cardId || '',
-    ip,
+    ip || '',
     source,
     startedAt
   );
path: src/hooks/use-interaction-tracker.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**초기 뮤테이션 생성 시 빈 문자열 전달 → 이후 오류 가능성**

`useLogInteractionMutation(cardId || '', ip || '', ...)`  
훅 호출 시점에 `cardId`, `ip`가 아직 없으면 빈 문자열이 전달돼 훅 내부 검증(`if (!viewerIp || !cardId)`)에 의해 이후 `mutate` 호출 때 오류를 발생시킵니다.

- `cardId`·`ip`가 준비된 후에만 훅을 생성하거나  
- 훅 내부 검증 로직을 완화하고, `mutate` 호출 시점의 최신 값을 인자로 넘기도록 인터페이스를 변경해 주세요.

```diff
- const logInteractionMutation = useLogInteractionMutation(cardId || '', ip || '', ...);
+ const logInteractionMutation = useMemo(
+   () =>
+     cardId && ip
+       ? useLogInteractionMutation(cardId, ip, source, startedAt)
+       : null,
+   [cardId, ip, source, startedAt]
+ );
```




> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit d12d3fd
created_at: 2025-04-29T21:59:13Z
author_association: NONE
diff_hunk: @@ -93,23 +114,27 @@ export const useLogInteractionMutation = (
     }) => {
       if (!viewerIp || !cardId) throw new Error('Missing required data');
 
-      const effectiveSessionId =
-        sessionStorage.getItem('currentSessionId') ||
-        localStorage.getItem('backup_session_id') ||
-        null;
       if (!startedAtDate) {
         throw new Error('startedAt is required to initialize a session');
       }
 
-      if (!effectiveSessionId) throw new Error('No session ID available');
+      const sessionId = getEffectiveSessionId();
+
+      if (!sessionId) throw new Error('No session ID available');
+
+      if (checkSessionTimeout()) {
+        throw new Error('Session timed out');
+      }
+
+      updateSessionActivity();
 
path: src/hooks/mutations/use-init-session.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**세션 타임아웃 체크 후 즉시 활동 시간 갱신 → 논리 불일치**

세션이 타임아웃된 경우 예외를 던지며 종료하지만, 예외를 던지기 직전에 `updateSessionActivity()`를 호출합니다. 타임아웃이 감지됐는데 곧바로 활동 시간을 새로 찍어버리면 이후 재시도 시 타임아웃이 false로 바뀌어 버그를 유발할 수 있습니다.  

타임아웃 여부를 판단한 뒤 `isTimedOut`이 false일 때에만 `updateSessionActivity()`를 호출하도록 분기 처리해 주세요.

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 61ae9a0 to 3ff0aa1
created_at: 2025-04-29T21:59:13Z
author_association: NONE
diff_hunk: @@ -18,37 +27,49 @@ interface SessionData {
  * @param startedAt 세션 시작 시간
  * @returns
  */
-export const useInitSessionMutation = (startedAt: Date | null) => {
-  return useMutation({
-    mutationFn: async (): Promise<SessionData> => {
-      const existingSessionId =
-        sessionStorage.getItem('currentSessionId') ||
-        localStorage.getItem('backup_session_id') ||
-        null;
-
-      if (!startedAt) {
-        throw new Error('startedAt is required to initialize a session');
-      }
+export const useInitSessionMutation = (startedAt: Date) => {
+  const [cardId, setCardId] = useState<string | null>(null);
+  const [viewerIp, setViewerIp] = useState<string | null>(null);
+  const [source, setSource] = useState<'direct' | 'qr' | 'link' | 'tag' | null>(
+    null
+  );
 
-      if (existingSessionId) {
-        return {
-          sessionId: existingSessionId,
-          startedAt:
-            sessionStorage.getItem('session_started_at') ||
-            formatToDateString(startedAt),
-        };
-      }
+  const setSessionParams = (params: {
+    cardId: string;
+    viewerIp: string;
+    source: 'direct' | 'qr' | 'link' | 'tag' | null;
+  }) => {
+    setCardId(params.cardId);
+    setViewerIp(params.viewerIp);
+    setSource(params.source);
+  };
 
path: src/hooks/mutations/use-init-session.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**`useState`로 보관한 세션 파라미터를 뮤테이션에서 사용할 때 클로저 고정 문제**

`mutationFn`은 훅이 최초 호출된 시점의 `cardId`, `viewerIp`, `source` 값을 캡처합니다. 이후 `setSessionParams`로 값을 갱신해도 해당 뮤테이션 객체는 재생성되지 않으므로 새 값이 반영되지 않습니다.

간단한 해결책으로 `useMutation`을 `cardId`, `viewerIp` 의존성에 따라 재호출하도록 하거나, 파라미터를 `mutate` 인자로 넘기는 인터페이스로 바꿔 주시기 바랍니다.

```diff
... useMutation({
-  mutationFn: async (): Promise<SessionData> => {
+  mutationFn: async (params?: { cardId: string; viewerIp: string; source: ... }): Promise<SessionData> => {
     const { cardId, viewerIp, source } = params ?? { cardId: stateCardId, ... };
```

또는

```ts
const { mutate: initSession } = useMutation({...}, { enabled: !!cardId && !!viewerIp });
```

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 82b079d
created_at: 2025-04-29T21:59:13Z
author_association: NONE
diff_hunk: @@ -29,6 +64,113 @@ export const storePendingSessionEnd = (sessionId: string): void => {
  * 세션 데이터 삭제
  */
 export const clearSessionData = (): void => {
-  sessionStorage.removeItem('currentSessionId');
+  sessionStorage.removeItem('current_session_id');
   sessionStorage.removeItem('session_started_at');
 };
+
+/**
+ * 스토리지 가용성 확인 함수
+ *
+ * @param type localStorage 또는 sessionStorage
+ * @returns 스토리지 사용 가능 여부
+ */
+export function isStorageAvailable(type: 'localStorage' | 'sessionStorage') {
+  try {
+    const storage = window[type];
+    const x = '__storage_test__';
+    storage.setItem(x, x);
+    storage.removeItem(x);
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
path: src/utils/interaction/session-util.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**SSR 환경에서 `window` 참조 시 예외 가능성**

`isStorageAvailable`가 서버 사이드 렌더링 환경이나 테스트 환경에서 호출되면 `window`가 정의돼 있지 않아 ReferenceError가 발생합니다. 다음과 같이 가드 절을 추가해 주세요.

```diff
-  const storage = window[type];
+  if (typeof window === 'undefined') return false; // SSR 안전 가드
+  const storage = window[type];
```

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit b3c5c86
created_at: 2025-04-29T21:59:13Z
author_association: NONE
diff_hunk: @@ -29,6 +64,115 @@ export const storePendingSessionEnd = (sessionId: string): void => {
  * 세션 데이터 삭제
  */
 export const clearSessionData = (): void => {
-  sessionStorage.removeItem('currentSessionId');
+  sessionStorage.removeItem('current_session_id');
   sessionStorage.removeItem('session_started_at');
 };
+
+/**
+ * 스토리지 가용성 확인 함수
+ *
+ * @param type localStorage 또는 sessionStorage
+ * @returns 스토리지 사용 가능 여부
+ */
+export function isStorageAvailable(type: 'localStorage' | 'sessionStorage') {
+  try {
+    if (typeof window === 'undefined') return false;
+
+    const storage = window[type];
+    const x = '__storage_test__';
+    storage.setItem(x, x);
+    storage.removeItem(x);
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
+
+/**
+ * 세션 활동 시간 업데이트
+ */
+export const updateSessionActivity = () => {
+  try {
+    if (isStorageAvailable('localStorage')) {
+      localStorage.setItem('last_activity', Date.now().toString());
+    }
+  } catch (error) {
+    console.error('세션 활동 시간 업데이트 오류: ', error);
+  }
+};
+
+/**
+ * 세션 초기화
+ */
+export const initSession = (startedAt: Date) => {
+  const hasSessionStorage = isStorageAvailable('sessionStorage');
+  const hasLocalStorage = isStorageAvailable('localStorage');
+
+  // 기존 세션 확인
+  let sessionId = null;
+  let sessionStartTime = null;
+  let isNewSession = false;
+
+  try {
+    // 세션 스토리지 우선 확인
+    if (hasSessionStorage) {
+      sessionId = sessionStorage.getItem('current_session_id');
+      sessionStartTime = sessionStorage.getItem('session_started_at');
+    }
+
+    // 세션 스토리지에 없다면 로컬 스토리지를 확인
+    // 이 경우 세 세션으로 간주 (새탭)
+    if (!sessionId && hasLocalStorage) {
+      const lastSessionId = localStorage.getItem('last_session_id');
+
+      if (lastSessionId) {
+        isNewSession = true;
+      }
+    }
+
+    if (!isNewSession && sessionStartTime) {
+      const lastActivity = localStorage.getItem('last_activity');
+      if (lastActivity && Date.now() - Number(lastActivity) > SESSION_TIMEOUT) {
+        isNewSession = true;
+        sessionId = null;
+
+        if (hasSessionStorage) {
+          clearSessionData();
+        }
+      }
+    }
+
+    // 새 세션 생성이 필요한 경우
+    if (!sessionId || isNewSession) {
+      sessionId = crypto.randomUUID();
+      sessionStartTime = formatToDateString(startedAt);
+
path: src/utils/interaction/session-util.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**암호학적으로 안전한 UUID 생성**

세션 ID 생성에 `crypto.randomUUID()`를 사용한 점이 좋습니다. 이는 예측 불가능한 ID를 생성하여 보안성을 높여줍니다.

하지만 브라우저 호환성 측면에서, 이 API가 오래된 브라우저에서는 지원되지 않을 수 있습니다. 필요하다면 폴백(fallback) 메커니즘을 고려해보세요.

---

🌐 Web query:

```web
Which browsers support crypto.randomUUID() and when was it introduced?
```

💡 Result:

## Browser Support for `crypto.randomUUID()`

The `crypto.randomUUID()` method is part of the Web Crypto API and provides a simple, standards-compliant way to generate a v4 UUID using a cryptographically secure random number generator.

### Supported Browsers

| Browser           | Version Introduced | Supported Since         |
|-------------------|-------------------|-------------------------|
| Chrome            | 92                | July 2021[5][10]     |
| Edge              | 92                | July 2021[1][2]         |
| Firefox           | 95                | November 2021[1][2]     |
| Opera             | 78                | July 2021[2]            |
| Safari (macOS)    | 15.4              | March 2022[2][3]        |
| Safari (iOS)      | 15.4              | March 2022[2][3]        |
| Samsung Internet  | 16.0              | 2022[2]                 |
| Android Browser   | 135                | [2]                     |
| Opera Mobile      | 80                 | [2]                     |
| Chrome for Android| 135                | [2]                     |
| Firefox for Android | 137              | [2]                     |

Internet Explorer (all versions) and older versions of the above browsers do **not** support `crypto.randomUUID()`[2][3][5].

### Additional Notes

- This method is available **only in secure contexts (HTTPS)** in all supporting browsers[1][4].
- It is also available inside Web Workers[1].
- If you need to support older browsers, you should use a fallback approach, such as `crypto.getRandomValues` or a UUID library[5][9].

### Summary Table

| Feature                  | Supported          |
|--------------------------|-------------------|
| Secure Context Required  | Yes               |
| Web Workers Support      | Yes               |
| Mobile Browsers          | Recent versions   |

## Introduction Timeline

- **Chrome / Edge / Opera:** Version 92 (July 2021)[1][5][10]
- **Firefox:** Version 95 (November 2021)[1][2]
- **Safari (macOS/iOS):** Version 15.4 (March 2022)[2][3]
- **Other browsers:** Followed soon after, but always check specific version support if targeting less common browsers[2][3][5].

## Conclusion

`crypto.randomUUID()` is broadly supported in current versions of all major browsers, but was only introduced between mid-2021 and early 2022. For maximum compatibility, especially with users on legacy Safari or Internet Explorer, a fallback method should still be considered[3][5][9].

Citations:

- 1: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
- 2: https://caniuse.com/mdn-api_crypto_randomuuid
- 3: https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/6784
- 4: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API
- 5: https://www.sobyte.net/post/2022-02/js-crypto-randomuuid/
- 6: https://blog.logrocket.com/uuids-node-js/
- 7: https://nearform.com/insights/new-crypto-capabilities-in-node-js/
- 8: https://flaviocopes.com/generating-uuids-in-javascript-with-windowcryptorandomuuid/
- 9: https://luismarques.hashnode.dev/how-to-generate-a-global-unique-identifier-uuid-with-javascript
- 10: https://github.com/jupyterlab/lumino/issues/198

---



</details>

**브라우저 호환성 주의 – crypto.randomUUID() 폴백 구현을 권장합니다**

`crypto.randomUUID()`는 v4 UUID를 암호학적으로 안전하게 생성하지만, IE 전 버전과 Safari 15.4 이전 버전 등 구형 브라우저에서 지원되지 않습니다. 구형 환경에서도 정상 동작하도록 폴백(fallback) 메커니즘을 도입해 주세요.

- 위치: `src/utils/interaction/session-util.ts` (146-149)
- 제안
  - `crypto.randomUUID` 지원 여부를 확인한 뒤, 미지원 시 `crypto.getRandomValues` 기반 폴백 또는 UUID 라이브러리 사용
  - 예시
    ```ts
    function generateUUID(): string {
      if (typeof crypto.randomUUID === 'function') {
        return crypto.randomUUID();
      }
      // 폴백: crypto.getRandomValues로 랜덤 바이트 생성 후 UUID 형태로 변환
      const buf = new Uint8Array(16);
      crypto.getRandomValues(buf);
      // buf → UUID 문자열 변환 로직 또는 `uuid` 라이브러리 호출
      return bytesToUuid(buf);
    }

    if (!sessionId || isNewSession) {
      sessionId = generateUUID();
      sessionStartTime = formatToDateString(startedAt);
    }
    ```
  - secure context(HTTPS) 여부도 함께 확인해 두시면 좋습니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-29T22:11:09Z
author_association: NONE
diff_hunk: @@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useIpAddressQuery } from './queries/use-ip-address';
path: src/hooks/use-interaction-tracker.ts
user: login: lyra-j
body: P1: 절대경로가 필요합니다~
created_at: 2025-04-30T04:12:12Z
author_association: OWNER
diff_hunk: @@ -28,54 +31,105 @@ interface SocialLink {
  * 인터랙션 추적 훅
  */
 export const useInteractionTracker = ({
+  isDetail,
   slug,
   source,
   startedAt,
 }: InteractionProps) => {
+  // isDetail 일 경우 해당 로직 생략
+  if (isDetail) {
+    return {
+      updateActivity: () => {},
+      handleClick: () => {},
+      handleSaveImg: () => {},
+      handleSaveVCard: () => {},
+      isLoading: false,
+      isError: false,
+    };
+  }
+
   const { data: ip } = useIpAddressQuery();
+  const { data: cardId } = useCardInteraction(slug);
 
-  const sessionInitMutation = useInitSessionMutation(startedAt);
-  const endSessionMutation = useEndSessionMutation();
-  const { data } = useCardInteraction(slug);
-  const cardId = data;
+  // 세션 초기화
+  const {
+    mutate: initSession,
+    isPending,
+    isError,
+  } = useInitSessionMutation(startedAt || new Date());
 
-  const logInteractionMutation = useLogInteractionMutation(
-    cardId || '',
-    ip,
-    source,
-    startedAt
-  );
+  // 세션 종료
+  const endSessionMutation = useEndSessionMutation();
 
+  // 인터렉션 로깅
+  const logInteractionMutation = useMemo(() => {
+    return cardId && ip
+      ? useLogInteractionMutation(cardId, ip, source, startedAt)
+      : null;
+  }, [cardId, ip, source, startedAt]);
+
+  if (!logInteractionMutation) {
+    throw new Error('Log interaction mutation is not initialized properly.');
+  }
+  const safeLogInteraction = (data: {
+    elementName: string | null;
+    type: 'click' | 'save' | null | undefined;
+  }) => {
+    if (!logInteractionMutation) {
+      console.error('Log interaction mutation is not initialized properly.');
+      return Promise.resolve();
+    }
+    return logInteractionMutation.mutate(data);
+  };
path: src/hooks/use-interaction-tracker.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**인터랙션 로깅 로직 최적화 필요**

`logInteractionMutation`의 초기화 검사와 에러 처리에 일관성이 부족합니다. 71-73 라인에서는 null 값에 대해 오류를 발생시키지만, 78-81 라인에서는 다시 null 체크를 수행하여 Promise.resolve()를 반환합니다.


다음과 같이 일관된 에러 처리 패턴을 적용하는 것이 좋습니다:

```diff
- if (!logInteractionMutation) {
-   throw new Error('Log interaction mutation is not initialized properly.');
- }
  const safeLogInteraction = (data: {
    elementName: string | null;
    type: 'click' | 'save' | null | undefined;
  }) => {
    if (!logInteractionMutation) {
-     console.error('Log interaction mutation is not initialized properly.');
-     return Promise.resolve();
+     throw new Error('Log interaction mutation is not initialized properly.');
    }
    return logInteractionMutation.mutate(data);
  };
```

또는 에러를 발생시키지 않고 일관되게 처리하는 방식:

```diff
- if (!logInteractionMutation) {
-   throw new Error('Log interaction mutation is not initialized properly.');
- }
  const safeLogInteraction = (data: {
    elementName: string | null;
    type: 'click' | 'save' | null | undefined;
  }) => {
    if (!logInteractionMutation) {
      console.error('Log interaction mutation is not initialized properly.');
      return Promise.resolve();
    }
    return logInteractionMutation.mutate(data);
  };
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  // 인터렉션 로깅
  const logInteractionMutation = useMemo(() => {
    return cardId && ip
      ? useLogInteractionMutation(cardId, ip, source, startedAt)
      : null;
  }, [cardId, ip, source, startedAt]);

  const safeLogInteraction = (data: {
    elementName: string | null;
    type: 'click' | 'save' | null | undefined;
  }) => {
    if (!logInteractionMutation) {
      throw new Error('Log interaction mutation is not initialized properly.');
    }
    return logInteractionMutation.mutate(data);
  };
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 98fe1fb
created_at: 2025-04-30T05:11:07Z
author_association: NONE
diff_hunk: @@ -93,13 +147,14 @@ export const useInteractionTracker = ({
 
     updateActivity();
 
+    // 페이지 언로드 처리
     const handleBeforeUnload = () => {
-      const currentSessionId = getEffectiveSessionId();
-      if (currentSessionId) {
-        storePendingSessionEnd(currentSessionId);
+      const current_session_id = getEffectiveSessionId();
+      if (current_session_id) {
+        storePendingSessionEnd(current_session_id);
         endSessionMutation.mutate({
           reason: 'page-exit',
-          sessionId: currentSessionId,
+          sessionId: current_session_id,
         });
       }
     };
path: src/hooks/use-interaction-tracker.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**중복 세션 종료 호출 가능성 검토**

현재 구현에서는 `beforeunload` 이벤트와 컴포넌트 언마운트 시 모두 세션 종료 뮤테이션을 호출합니다. 단일 페이지 앱에서 페이지 새로고침이나 브라우저 닫기 시 두 이벤트가 모두 발생할 수 있습니다. 이로 인해 중복 호출이 발생할 수 있는지 검토해 보세요.




Also applies to: 173-178

---

🏁 Script executed:

```shell
#!/bin/bash
# 세션 종료 API 호출 패턴 검사

# 백엔드 API 호출 검색
rg -A 5 "endSession|종료" --type ts
```

Length of output: 10141

---



</details>

**중복 세션 종료 호출 방지 검토 필요**

`useInteractionTracker` 훅에서 다음 두 곳에서 동일한 `endSessionMutation.mutate` 호출이 발생합니다:
- `beforeunload` 이벤트 핸들러 (`reason: 'page-exit'`)
- 컴포넌트 언마운트(cleanup) 시 `handleBeforeUnload` 호출

SPA 환경에서 페이지 새로고침 또는 브라우저 종료 시 `beforeunload` → 언마운트(cleanup) 순으로 실행되어 중복 요청이 발생할 수 있습니다.  

해결 방안 예시:
- 로컬 플래그 또는 `localStorage`(이미 사용 중인 `pending_end_session`)를 활용해 한번만 호출되도록 분기
- `navigator.sendBeacon` 전송 성공 여부를 체크해 추가 호출 방지

점검 포인트:
- src/hooks/use-interaction-tracker.ts: `handleBeforeUnload` 적용 위치(useEffect)  
- `storePendingSessionEnd` 로직이 두 번째 호출을 막는지 확인

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-30T06:00:55Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-04-29T21:56:12Z
body: [vc]: #obaLsN+E/L+Bw1fVt85wA9NzPa/vK60MKEc6+effkog=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1dW5vIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InV1bm8tZ2l0LWZlYXQtMzQwLWNhcmQtZGV0YWlsLWF2Zy1jbGljay1seXJhLWpzLXByb2plY3RzLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2x5cmEtanMtcHJvamVjdHMvdXVuby9FamYyNllzR2JFYms5OUVkbWkxNjh4ZWd3Y21NIiwicHJldmlld1VybCI6InV1bm8tZ2l0LWZlYXQtMzQwLWNhcmQtZGV0YWlsLWF2Zy1jbGljay1seXJhLWpzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJyb290RGlyZWN0b3J5IjpudWxsfV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **uuno** | ✅ Ready ([Inspect](https://vercel.com/lyra-js-projects/uuno/Ejf26YsGbEbk99Edmi168xegwcmM)) | [Visit Preview](https://uuno-git-feat-340-card-detail-avg-click-lyra-js-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/uuno-git-feat-340-card-detail-avg-click-lyra-js-projects.vercel.app?via=pr-comment-feedback-link) | Apr 30, 2025 8:14am |


author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-29T21:56:13Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

이번 변경 사항은 세션 관리 및 명함 조회 인터랙션 트래킹 로직을 대폭 개선하는 데 중점을 두고 있습니다. 세션 타임아웃 상수 추가, 세션 생성 및 갱신 유틸리티 함수 도입, 세션 저장소 접근 시 예외 처리 및 가용성 체크, 인터랙션 발생 시 세션 중복 및 타임아웃 검증 등의 기능이 추가되었습니다. 또한, 카드 소유자 여부 판별 및 초기 조회 기록 로직이 명확하게 분리되었으며, 세션 및 인터랙션 관련 훅과 API 응답 검증이 강화되었습니다.

## Changes

| 파일/경로 요약                                                         | 변경 요약                                                                                 |
|------------------------------------------------------------------------|------------------------------------------------------------------------------------------|
| src/apis/interaction.ts                                                | getIpAddress 함수에 응답 데이터 검증 로직 추가                                            |
| src/app/[...slug]/page.client.ts                                      | 카드 소유자 여부 및 초기 조회 인터랙션 트래킹 로직 개선, 상태 변수 및 조건 분기 리팩토링   |
| src/constants/session.constant.ts                                      | SESSION_TIMEOUT 상수(5분, 추후 30분 예정) 추가                                            |
| src/hooks/mutations/use-init-session.ts                                | 세션 파라미터 상태 관리, 세션 유틸리티 함수 활용, 세션 타임아웃 로직 및 시그니처 변경      |
| src/hooks/use-interaction-tracker.ts                                   | 세션 초기화/활성화/종료 로직 개선, 이벤트 리스너 및 타임아웃 관리, 상태 관리 리팩토링      |
| src/utils/interaction/session-util.ts                                  | 세션 ID/타임아웃/활동 갱신/스토리지 가용성 체크 등 세션 관리 유틸리티 함수 추가 및 개선    |
| src/components/card/flip-card.ts                                      | useInteractionTracker 호출 시 isDetail boolean 명시 및 handleClick 함수 타입 명시         |
| src/components/card/save-share-modal.ts                               | origin 상태 추가 및 태그 복사 시 URL 생성 로직 origin 기반으로 변경                        |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant PageClient
    participant SessionUtil
    participant InteractionAPI

    User->>PageClient: 페이지 진입
    PageClient->>SessionUtil: 세션 ID/타임아웃 확인 및 초기화
    SessionUtil-->>PageClient: 세션 ID, 시작시간, 신규여부 반환
    PageClient->>InteractionAPI: (비소유자, 중복 아님) 초기 조회 인터랙션 기록
    InteractionAPI-->>PageClient: 결과 반환
    Note over PageClient: 세션 활동 시 updateSessionActivity 호출
    User->>PageClient: 페이지 이탈/비활성
    PageClient->>SessionUtil: 세션 종료 및 데이터 정리
```

## Assessment against linked issues

| Objective                                                                 | Addressed | Explanation                                             |
|----------------------------------------------------------------------------|-----------|--------------------------------------------------------|
| 월간 클릭수 로직 추가, 월간 조회수/저장수/조회수 로직 파일 정리 (#340)      | ❓        | 본 PR은 세션 및 인터랙션 트래킹 로직 개선에 집중되어 있으며, 월간 클릭수 집계나 통계 관련 로직 추가 여부는 코드 변경 요약에서 명확히 확인되지 않습니다. |

## Possibly related PRs

- lyra-j/uuno#86: `src/apis/interaction.ts` 내 `getIpAddress` 함수 개선과 관련된 초기 구현 PR로, 본 PR이 해당 코드 위에 추가된 검증 로직을 포함하고 있어 밀접한 연관이 있습니다.

## Suggested reviewers

- woozizi
- K-jisu

## Poem

> 🐇  
> 세션이 똑똑해졌어요,  
> 클릭도 꼼꼼히 세어요.  
> 타임아웃도 챙기고,  
> 중복 조회는 안녕~  
> 명함 통계, 이제 더 믿음직!  
> 코드를 품은 토끼가  
> 오늘도 깡총, 깡총!  
>
"""

<!-- walkthrough_end -->
<!-- This is an auto-generated comment: all tool run failures by coderabbit.ai -->

> [!WARNING]
> There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.
> 
> <details>
> <summary>🔧 ESLint</summary>
> 
> > If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.
> 
> <details>
> <summary>src/app/[...slug]/page.client.tsx</summary>
> 
> 
> Oops! Something went wrong! :(
> 
> ESLint: 8.57.1
> 
> ESLint couldn't find the plugin "eslint-plugin-prettier".
> 
> (The package "eslint-plugin-prettier" was not found when loaded as a Node module from the directory "".)
> 
> It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
> 
>     npm install eslint-plugin-prettier@latest --save-dev
> 
> The plugin "eslint-plugin-prettier" was referenced from the config file in ".eslintrc.json".
> 
> If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
> 
> 
> </details>
> 
> <details>
> <summary>src/hooks/mutations/use-init-session.ts</summary>
> 
> 
> Oops! Something went wrong! :(
> 
> ESLint: 8.57.1
> 
> ESLint couldn't find the plugin "eslint-plugin-prettier".
> 
> (The package "eslint-plugin-prettier" was not found when loaded as a Node module from the directory "".)
> 
> It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
> 
>     npm install eslint-plugin-prettier@latest --save-dev
> 
> The plugin "eslint-plugin-prettier" was referenced from the config file in ".eslintrc.json".
> 
> If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
> 
> 
> </details>
> 
> </details>

<!-- end of auto-generated comment: all tool run failures by coderabbit.ai -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 98fe1fbf89898993a8dcd072d041b9edc155b467 and df8a1d7e1d765207e52ef56b4700b33cafe5ea28.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/app/[...slug]/page.client.tsx` (3 hunks)
* `src/hooks/mutations/use-init-session.ts` (4 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* src/app/[...slug]/page.client.tsx
* src/hooks/mutations/use-init-session.ts

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=lyra-j/uuno&utm_content=377):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE