Skip to content

Fix/486 groups logic - #489

Merged
shinwokkang merged 6 commits into
devfrom
fix/486-groups-logic
Jul 11, 2026
Merged

shinwokkang merged 6 commits into
devfrom
fix/486-groups-logic

Conversation

@shinwokkang

@shinwokkang shinwokkang commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

  • 비로그인 유저(Guest)의 모임(/groups) 탐색 경험 개선 및 접근 제어 로직 리팩토링을 진행했습니다.
    기존에는 모임 탭 진입 시 전역 useEffect를 통해 비회원을 강제로 메인 화면으로 리다이렉트(Hard-Gating)하여, 모임 리스트조차 둘러볼 수 없는 UX적인 문제와 비로그인 상태에서 빈 화면이 나타나는 Cold Start 이슈가 있었습니다.
    이를 **컴포넌트 레벨의 이벤트 인터셉션(Soft-Gating)**과 API Dual-Track 전략으로 개편하여, 누구나 자유롭게 모임을 탐색하되 실제 행동(방문, 가입, 생성 등)을 취할 때만 즉각적인 로그인 모달을 띄우도록 기술적 최적화를 완료했습니다.

  • 공통 캐러셀 컴포넌트 개발: Framer Motion을 기반으로 빈 여백이 발생하지 않는 연속 기차(Continuous Track) 원리의 <SwipeCarousel /> 공통 컴포넌트를 신규 개발했습니다.

  • 기존 페이지 리팩토링: NewsBannerSlider 및 NewsListBanner 컴포넌트 내부에 중복되어 있던 슬라이더 로직을 모두 제거하고, 신규 공통 컴포넌트를 주입하여 유지보수성을 극대화했습니다.

  • 실시간 반응형 버그 픽스: 스와이프 중 인디케이터(점)가 뒤늦게 갱신되는 현상 및 방치 시 순서가 꼬이는 비동기 상태 엇갈림(Race Condition) 버그를 수정했습니다.

🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 문서 업데이트
  • 기타 (설명: )

📸 스크린샷 (Screenshots)

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)
  • 린트 에러가 없나요? (pnpm lint)
  • 불필요한 콘솔 로그나 주석을 제거했나요?

🔬 기술적 상세 사항 (Technical Details)

1. 연속 트랙(Continuous Track) 및 가상화(Virtualization)

기존 AnimatePresence 방식은 카드가 교체되는 찰나에 DOM에서 카드가 지워지면서 하얀 여백이 노출되는 치명적 한계가 있었습니다. 이를 해결하기 위해 카드를 가로로 묶은 연속 기차(Continuous Track) 구조로 변경했습니다. 메모리 과부하를 막기 위해 실제 데이터가 수십 개여도, 트랙에는 단 3장(이전, 현재, 다음)만 절대 좌표로 띄워두고 재활용하는 **가상화(Virtualization)**를 적용했습니다.

2. 투 트랙(Two-Track) 렌더링 최적화

카드가 애니메이션으로 날아가는 도중에 React 상태 인덱스를 교체해버리면, 카드가 갑자기 눈앞에서 순간 이동을 하는 시각적 버그(Layout Shift)가 발생합니다. 하지만 인덱스 갱신을 늦추면 사용자 눈에 보이는 인디케이터(점)도 한 박자 늦게 켜지는 딜레마가 있습니다.
이를 DOM 렌더링용 인덱스와 시각용 점등 인덱스로 완전히 분리(Decoupling)하여 해결했습니다.

  • renderIndex: DOM 카드 재배치용 (애니메이션 완료 후에만 안전하게 변경)
  • indicatorIndex: 하단 점 표시용 (useMotionValueEvent로 x 좌표를 감시하여, 손가락으로 50% 이상 드래그 시 즉시 반응)
sequenceDiagram
    participant User as 사용자 (드래그)
    participant Motion as Framer Motion (x좌표)
    participant Indicator as indicatorIndex (점)
    participant Render as renderIndex (DOM)

    User->>Motion: 스와이프 발생
    Motion-->>Indicator: 실시간 x 좌표 1px 단위로 관찰
    
    alt x 좌표가 화면 너비의 절반(50%) 이상 넘어감
        Indicator->>Indicator: indicatorIndex 즉시 변경
        Note over Indicator: 하단 점(Dot) 즉각 불 켜짐!
    end

    Motion->>Motion: 카드 스냅 애니메이션 종료 (onComplete)
    Motion->>Render: renderIndex 업데이트 (DOM 교체)
    Render->>Indicator: 상태 최종 동기화 및 0점 정렬
Loading

3. 상태 엇갈림 버그(Race Condition) 동기적 해결

React의 비동기 업데이트 타이밍과 Framer Motion의 즉각적 이벤트 실행 타이밍 간의 경합 조건(Race Condition)으로 인해 점의 위치가 과거로 돌아가버리는 버그가 있었습니다.
updateRenderIndex라는 별도의 헬퍼 함수를 만들어, React 화면 렌더링 대기열에 들어가기 전에 메모리(useRef)에 최신 인덱스를 동기적(Synchronous)으로 꽂아 넣는 기법을 사용하여 상태 충돌을 원천 차단했습니다.

graph LR
    A[애니메이션 완료] -->|1. Ref에 즉시 주입| B(renderIndexRef.current = newIndex)
    B -->|2. React에 상태 갱신 예약| C(setRenderIndex)
    B -->|3. X 좌표 0으로 초기화| D[x.set 0]
    D -.->|4. X 변화 감지기 동기적 발동| E{X 감지기}
    E -->|이미 최신화된 Ref를 읽음!| F[올바른 점등 상태 유지]
    
    style B stroke:#ffeb3b,stroke-width:3px
Loading

4. 모바일 방어 대책

  • 스크롤 정크 완벽 차단: 기차 트랙 컨테이너에 touch-action: pan-y를 부여하여 가로 스와이프 중에는 브라우저 세로 스크롤이 원천 차단되도록 했습니다. (모바일 네이티브와 동일한 터치감 확보)
  • 고스트 이미지 차단: 브라우저 기본 이미지 드래그 액션으로 인한 멈춤 현상을 막기 위해 draggable={false}, pointer-events-none 처리를 완료했습니다.

5. 전역 라우팅 가드 제거 및 컴포넌트 이벤트 인터셉션 패턴 도입

  • 기존 Header와 GroupsPageClient 마운트 시 발생하던 강제 리다이렉션 로직을 제거했습니다. 대신 사용자가 의도를 가지고 액션을 취하는 시점(ex. onClickVisit, onClickApply)에 클라이언트 전역 상태(useAuthStore.getState().isLoggedIn)를 평가하여, 서버 에러(401)를 기다리지 않고 선제적으로 로그인 모달을 호출하는 패턴을 적용했습니다. 이를 통해 화면 깜빡임(Flicker) 없는 부드러운 UX를 달성했습니다.

6. Dual-Track 전략을 통한 비회원 초기 렌더링 최적화

백엔드의 /api/v1/clubs/recommendations API는 유저의 토큰을 기반으로 초개인화된 모임을 추천하므로 비회원에게는 401 에러를 반환합니다.
이에 isGuestDefaultView (비회원이면서 검색어가 없는 상태)라는 명시적인 뷰 렌더링 상태를 추가하고, 해당 상태일 경우 백엔드의 범용 검색 API(GET /api/v1/clubs/search?outputFilter=ALL)를 Fallback으로 호출하도록 React Query 옵션을 재조정했습니다.

7. 상세 페이지(/groups/:id) 단계적 권한 제어 (Progressive Gating)

방문하기 버튼 차단: 비회원이 방문하기 버튼 클릭 시 401 에러 화면이 나타나는 것을 막기 위해 진입 자체를 모달로 방어했습니다.
Home 탭 접근 완화: 로그인한 유저라면 해당 모임의 가입자가 아니더라도 Home 탭에서 모임 소개글을 확인하고 가입 신청을 할 수 있도록 layout.tsx의 useClubAccessGuard 권한을 완화했습니다.
내부 탭 철저 차단: Notice, Bookcase 등 멤버 전용 탭에 대해서만 렌더링 전에 requiresMemberAccess를 true로 설정하여 비정상적인 URL 접근을 원천 차단했습니다.

Summary by CodeRabbit

  • New Features

    • Added a swipeable, autoplaying carousel for news and promotion banners.
    • Unauthenticated visitors can now browse a guest-friendly groups view without being redirected.
  • Bug Fixes

    • Improved login prompts for group actions like joining, following, creating, and opening restricted tabs.
    • Updated group search and “load more” behavior so guest browsing works consistently.
    • Simplified banner interactions for smoother swipe and tap navigation.

@shinwokkang shinwokkang self-assigned this Jul 9, 2026
@shinwokkang shinwokkang added ✨ feat 새로운 기능 추가 ♻️ refactor 리팩토링 🐛 bug 버그 수정 labels Jul 9, 2026
@shinwokkang shinwokkang linked an issue Jul 9, 2026 that may be closed by this pull request
4 tasks
@vercel

vercel Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
checkmo Ready Ready Preview, Comment Jul 11, 2026 7:25am

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 44bc31cb-a331-45fc-ab20-f3f3a28299d5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds login-modal gating for unauthenticated users across groups list/detail pages, group tab navigation, and simplifies BottomNav/Header by removing prior login gating on the 모임 item. Separately, it introduces a reusable SwipeCarousel component and refactors NewsListBanner and NewsBannerSlider to use it.

Changes

Guest Auth Gating for Groups and Navigation

Layer / File(s) Summary
Groups list guest view and action gating
src/app/(main)/groups/GroupsPageClient.tsx
Removes redirect-on-unauth effect, adds isGuestDefaultView, extends search/infinite-scroll conditions, and gates visit/apply/create actions behind login modal.
Group detail join/follow/notice guards
src/app/(main)/groups/[id]/GroupDetailClient.tsx
Adds early login checks before join, follow toggle, and notice click/keyboard activation.
Group tab navigation guard
src/app/(main)/groups/[id]/layout.tsx
Blocks notice/bookcase tab navigation for unauthenticated users, opening login modal instead.
BottomNav/Header simplification
src/components/layout/BottomNav.tsx, src/components/layout/Header.tsx
Removes prior login-modal gating logic on the 모임 nav item from both components.

Estimated code review effort: 3 (Moderate) | ~25 minutes

SwipeCarousel Component and News Banner Refactor

Layer / File(s) Summary
SwipeCarousel implementation
src/components/common/SwipeCarousel.tsx
New client component with CarouselItem type, drag/indicator state, autoplay, card rendering, and empty-state fallback.
NewsListBanner refactor
src/components/base-ui/News/NewsListBanner.tsx
Derives carouselItems from promotion news data and renders SwipeCarousel instead of custom slide UI.
NewsBannerSlider refactor
src/components/base-ui/home/News/NewsBannerSlider.tsx
Builds carouselItems via useMemo with thumbnail fallback and renders SwipeCarousel instead of dot-navigation banner.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Guest
    participant GroupsPageClient
    participant AuthStore
    participant LoginModal
    participant Router

    Guest->>GroupsPageClient: click "모임 생성하기" / visit / apply
    GroupsPageClient->>AuthStore: check isLoggedIn
    alt not logged in
        GroupsPageClient->>LoginModal: openLoginModal()
    else logged in
        GroupsPageClient->>Router: navigate to target route
    end
Loading

Possibly related PRs

  • checkmo2025/FE#301: Prior work on NewsListBanner's promotion list derivation and slide/empty/error rendering logic that this PR builds on when refactoring to SwipeCarousel.
  • checkmo2025/FE#342: Prior modifications to GroupsPageClient and GroupDetailClient auth flows that this PR extends with additional login gating.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too vague and does not clearly describe the main changes to groups access control and carousel refactors. Use a specific title that summarizes the primary change, such as guest group access control and SwipeCarousel refactoring.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/486-groups-logic

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors group pages and layouts to enforce authentication checks for guests, and extracts a reusable SwipeCarousel component from the news banners. Feedback focuses on optimizing the new SwipeCarousel component, specifically by adding a unique key prop to the rendered cards to prevent rendering bugs, and memoizing event handlers and helper functions with useCallback to ensure stable references and satisfy dependency rules.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +137 to +142
const renderCard = (item: CarouselItem, position: string) => {
return (
<div
className="absolute top-0 bottom-0 w-full flex-shrink-0"
style={{ left: position }}
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In React, rendering dynamic lists or sibling elements without a unique key prop can lead to rendering bugs, inefficient DOM updates, and unexpected behavior during transitions (especially with Framer Motion). Since this is a virtualized carousel where cards change positions and content, we should add a unique key to the outer div returned by renderCard.

Using the position parameter (e.g., "-100%", "0%", "100%") as the key is highly suitable here because:

  1. It is guaranteed to be unique among the three rendered cards.
  2. It prevents duplicate key issues when slideCount is 2 (where the previous and next indices point to the same item).
  3. It allows React to efficiently reuse the DOM nodes at each absolute position rather than recreating them.
  const renderCard = (item: CarouselItem, position: string) => {
    return (
      <div
        key={position}
        className="absolute top-0 bottom-0 w-full flex-shrink-0"
        style={{ left: position }}
      >

Comment thread src/components/common/SwipeCarousel.tsx Outdated
@@ -0,0 +1,237 @@
"use client";

import { useState, useEffect, useRef } from "react";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import useCallback from "react" to support the memoization of the helper functions and event handlers.

Suggested change
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";

Comment on lines +48 to +52
// React 상태와 Ref를 즉각적으로 동기화하는 헬퍼 함수
const updateRenderIndex = (newIndex: number) => {
renderIndexRef.current = newIndex;
setRenderIndex(newIndex);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap updateRenderIndex in useCallback to ensure its reference remains stable across renders. This is important because it is used as a dependency in other memoized functions (like navigateToSlide and jumpTo).

Suggested change
// React 상태와 Ref를 즉각적으로 동기화하는 헬퍼 함수
const updateRenderIndex = (newIndex: number) => {
renderIndexRef.current = newIndex;
setRenderIndex(newIndex);
};
// React 상태와 Ref를 즉각적으로 동기화하는 헬퍼 함수
const updateRenderIndex = useCallback((newIndex: number) => {
renderIndexRef.current = newIndex;
setRenderIndex(newIndex);
}, []);

Comment thread src/components/common/SwipeCarousel.tsx Outdated
Comment on lines +69 to +84
useMotionValueEvent(x, "change", (latestX) => {
if (!containerRef.current || slideCount <= 1) return;
const containerWidth = containerRef.current.offsetWidth;

let offsetIndex = 0;
if (latestX <= -containerWidth / 2) {
offsetIndex = 1;
} else if (latestX >= containerWidth / 2) {
offsetIndex = -1;
}

const newIndicatorIndex = wrap(0, slideCount, renderIndexRef.current + offsetIndex);
if (newIndicatorIndex !== indicatorIndex) {
setIndicatorIndex(newIndicatorIndex);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The inline callback passed to useMotionValueEvent is recreated on every render and references indicatorIndex. Because Framer Motion's useMotionValueEvent internally re-registers the event listener whenever the callback reference changes, this listener is unsubscribed and resubscribed on every single render (and on every frame of dragging when indicatorIndex changes).

We can optimize this by:

  1. Extracting the callback into a memoized function using useCallback.
  2. Using a functional state update for setIndicatorIndex to remove the dependency on indicatorIndex from the closure.

This ensures the event listener is only registered once and remains stable throughout the drag interaction.

Suggested change
useMotionValueEvent(x, "change", (latestX) => {
if (!containerRef.current || slideCount <= 1) return;
const containerWidth = containerRef.current.offsetWidth;
let offsetIndex = 0;
if (latestX <= -containerWidth / 2) {
offsetIndex = 1;
} else if (latestX >= containerWidth / 2) {
offsetIndex = -1;
}
const newIndicatorIndex = wrap(0, slideCount, renderIndexRef.current + offsetIndex);
if (newIndicatorIndex !== indicatorIndex) {
setIndicatorIndex(newIndicatorIndex);
}
});
// x 좌표를 실시간 추적하여 절반 이상 넘어갔을 때 인디케이터를 선제적으로 교체
const handleXChange = useCallback((latestX: number) => {
if (!containerRef.current || slideCount <= 1) return;
const containerWidth = containerRef.current.offsetWidth;
let offsetIndex = 0;
if (latestX <= -containerWidth / 2) {
offsetIndex = 1;
} else if (latestX >= containerWidth / 2) {
offsetIndex = -1;
}
const newIndicatorIndex = wrap(0, slideCount, renderIndexRef.current + offsetIndex);
setIndicatorIndex((prev) => (newIndicatorIndex !== prev ? newIndicatorIndex : prev));
}, [slideCount]);
useMotionValueEvent(x, "change", handleXChange);

Comment on lines +86 to +103
const navigateToSlide = (direction: number) => {
if (slideCount <= 1) return;
const containerWidth = containerRef.current?.offsetWidth || 0;
const targetX = direction > 0 ? -containerWidth : containerWidth;

animate(x, targetX, {
type: "spring",
stiffness: 300,
damping: 30,
onComplete: () => {
// 애니메이션이 끝나면 렌더링 인덱스를 동기화하고 x를 즉시 초기화
const newRenderIndex = renderIndexRef.current + direction;
updateRenderIndex(newRenderIndex); // Ref를 즉시 변경하여 꼬임 방지
setIndicatorIndex(wrap(0, slideCount, newRenderIndex));
x.set(0); // 이 순간 change 이벤트가 트리거되어도 최신 Ref를 바라봄
},
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap navigateToSlide in useCallback to avoid stale closures and satisfy the react-hooks/exhaustive-deps rule in the autoplay useEffect. Currently, navigateToSlide is recreated on every render, which would cause the interval to be cleared and recreated on every single render if it were listed as a dependency, or lead to stale closures if it is omitted.

Suggested change
const navigateToSlide = (direction: number) => {
if (slideCount <= 1) return;
const containerWidth = containerRef.current?.offsetWidth || 0;
const targetX = direction > 0 ? -containerWidth : containerWidth;
animate(x, targetX, {
type: "spring",
stiffness: 300,
damping: 30,
onComplete: () => {
// 애니메이션이 끝나면 렌더링 인덱스를 동기화하고 x를 즉시 초기화
const newRenderIndex = renderIndexRef.current + direction;
updateRenderIndex(newRenderIndex); // Ref를 즉시 변경하여 꼬임 방지
setIndicatorIndex(wrap(0, slideCount, newRenderIndex));
x.set(0); // 이 순간 change 이벤트가 트리거되어도 최신 Ref를 바라봄
},
});
};
const navigateToSlide = useCallback((direction: number) => {
if (slideCount <= 1) return;
const containerWidth = containerRef.current?.offsetWidth || 0;
const targetX = direction > 0 ? -containerWidth : containerWidth;
animate(x, targetX, {
type: "spring",
stiffness: 300,
damping: 30,
onComplete: () => {
// 애니메이션이 끝나면 렌더링 인덱스를 동기화하고 x를 즉시 초기화
const newRenderIndex = renderIndexRef.current + direction;
updateRenderIndex(newRenderIndex); // Ref를 즉시 변경하여 꼬임 방지
setIndicatorIndex(wrap(0, slideCount, newRenderIndex));
x.set(0); // 이 순간 change 이벤트가 트리거되어도 최신 Ref를 바라봄
},
});
}, [slideCount, x, updateRenderIndex]);

Comment on lines +105 to +115
const jumpTo = (targetIdx: number) => {
if (slideCount <= 1) return;
const diff = targetIdx - indicatorIndex;
if (diff === 0) return;

// 인디케이터 클릭 시엔 즉각적으로 두 인덱스를 모두 동기화
const newRenderIndex = renderIndexRef.current + diff;
updateRenderIndex(newRenderIndex);
setIndicatorIndex(targetIdx);
x.set(0);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap jumpTo in useCallback to ensure its reference remains stable across renders, preventing unnecessary re-renders of the indicator buttons that receive it as an event handler.

Suggested change
const jumpTo = (targetIdx: number) => {
if (slideCount <= 1) return;
const diff = targetIdx - indicatorIndex;
if (diff === 0) return;
// 인디케이터 클릭 시엔 즉각적으로 두 인덱스를 모두 동기화
const newRenderIndex = renderIndexRef.current + diff;
updateRenderIndex(newRenderIndex);
setIndicatorIndex(targetIdx);
x.set(0);
};
const jumpTo = useCallback((targetIdx: number) => {
if (slideCount <= 1) return;
const diff = targetIdx - indicatorIndex;
if (diff === 0) return;
// 인디케이터 클릭 시엔 즉각적으로 두 인덱스를 모두 동기화
const newRenderIndex = renderIndexRef.current + diff;
updateRenderIndex(newRenderIndex);
setIndicatorIndex(targetIdx);
x.set(0);
}, [slideCount, indicatorIndex, updateRenderIndex, x]);

Comment on lines +117 to +127
useEffect(() => {
if (slideCount <= 1 || autoPlayInterval <= 0) return;

if (isDragging) return;

const timer = setInterval(() => {
navigateToSlide(1);
}, autoPlayInterval);

return () => clearInterval(timer);
}, [slideCount, renderIndex, autoPlayInterval, isDragging]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add navigateToSlide to the dependency array of the autoplay useEffect to satisfy ESLint rules and ensure the interval always uses the latest memoized version of the function.

Suggested change
useEffect(() => {
if (slideCount <= 1 || autoPlayInterval <= 0) return;
if (isDragging) return;
const timer = setInterval(() => {
navigateToSlide(1);
}, autoPlayInterval);
return () => clearInterval(timer);
}, [slideCount, renderIndex, autoPlayInterval, isDragging]);
useEffect(() => {
if (slideCount <= 1 || autoPlayInterval <= 0) return;
if (isDragging) return;
const timer = setInterval(() => {
navigateToSlide(1);
}, autoPlayInterval);
return () => clearInterval(timer);
}, [slideCount, renderIndex, autoPlayInterval, isDragging, navigateToSlide]);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
src/components/base-ui/News/NewsListBanner.tsx (2)

10-30: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

useMemo dependency newsList is always a new reference, defeating the cache.

data?.pages.flatMap(...) creates a new array on every render, so [newsList] changes every render and the memo never caches. Move the newsList derivation inside the useMemo and depend on data?.pages instead.

⚡ Fix useMemo dependency
- const newsList = data?.pages.flatMap((page) => page.basicInfoList) || [];
- 
- // 'PROMOTION' 태그가 붙은 소식만 필터링 및 게시 종료 날짜 순 정렬 (최대 5개)
  const carouselItems = useMemo(() => {
+     const newsList = data?.pages.flatMap((page) => page.basicInfoList) || [];
      return newsList
          .filter((news) => news.carousel === "PROMOTION")
          .sort((a, b) => new Date(b.publishEndAt).getTime() - new Date(a.publishEndAt).getTime())
          .slice(0, 5)
          .map((news) => {
              const isValidSrc = news.thumbnailUrl && news.thumbnailUrl !== "string" && 
                  (news.thumbnailUrl.startsWith("/") || news.thumbnailUrl.startsWith("http"));
              return {
                  id: news.newsId,
                  imageUrl: isValidSrc ? news.thumbnailUrl : "/news_sample.svg",
                  title: news.title,
                  description: news.description,
                  link: `/news/${news.newsId}`,
              };
          });
- }, [newsList]);
+ }, [data?.pages]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/base-ui/News/NewsListBanner.tsx` around lines 10 - 30, The
`useMemo` in `NewsListBanner` is not caching because `newsList` is recreated on
every render by `data?.pages.flatMap(...)`, so the dependency always changes.
Move the `newsList` derivation inside the `useMemo` callback and make the memo
depend on `data?.pages` (or the source pagination data) instead, while keeping
the existing filter/sort/slice/map logic in `carouselItems`.

18-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared news-to-CarouselItem mapping to avoid duplication.

The isValidSrc validation and CarouselItem mapping (lines 18–28) is identical to NewsBannerSlider.tsx lines 13–24. Extracting a shared helper would centralize the fallback logic and prevent drift.

♻️ Shared helper extraction
// e.g., src/utils/newsCarousel.ts
import type { CarouselItem } from "`@/components/common/SwipeCarousel`";

export function mapNewsToCarouselItem(news: { newsId: string | number; thumbnailUrl: string; title: string; description: string; }): CarouselItem {
    const isValidSrc = news.thumbnailUrl && news.thumbnailUrl !== "string" &&
        (news.thumbnailUrl.startsWith("/") || news.thumbnailUrl.startsWith("http"));
    return {
        id: news.newsId,
        imageUrl: isValidSrc ? news.thumbnailUrl : "/news_sample.svg",
        title: news.title,
        description: news.description,
        link: `/news/${news.newsId}`,
    };
}

Then in both components:

- .map((news) => {
-     const isValidSrc = news.thumbnailUrl && news.thumbnailUrl !== "string" && 
-         (news.thumbnailUrl.startsWith("/") || news.thumbnailUrl.startsWith("http"));
-     return {
-         id: news.newsId,
-         imageUrl: isValidSrc ? news.thumbnailUrl : "/news_sample.svg",
-         title: news.title,
-         description: news.description,
-         link: `/news/${news.newsId}`,
-     };
- });
+ .map(mapNewsToCarouselItem);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/base-ui/News/NewsListBanner.tsx` around lines 18 - 28, The
news-to-CarouselItem mapping and thumbnail fallback logic are duplicated between
NewsListBanner and NewsBannerSlider; extract the shared `isValidSrc` check plus
item construction into a reusable helper (for example a `mapNewsToCarouselItem`
utility) and have both components call it so the fallback behavior stays
centralized and consistent.
src/components/base-ui/home/News/NewsBannerSlider.tsx (2)

10-25: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

useMemo dependency newsList is always a new reference, defeating the cache.

Same issue as NewsListBanner.tsx: data?.pages.flatMap(...) creates a new array every render, so [newsList] always changes and the memo never caches. Move the derivation inside the useMemo and depend on data?.pages.

⚡ Fix useMemo dependency
- const newsList = data?.pages.flatMap((page) => page.basicInfoList) || [];
- 
  const carouselItems = useMemo(() => {
+     const newsList = data?.pages.flatMap((page) => page.basicInfoList) || [];
      return newsList.slice(0, 5).map((news) => {
        const isValidSrc = news.thumbnailUrl && news.thumbnailUrl !== "string" && 
          (news.thumbnailUrl.startsWith("/") || news.thumbnailUrl.startsWith("http"));
        return {
          id: news.newsId,
          imageUrl: isValidSrc ? news.thumbnailUrl : "/news_sample.svg",
          title: news.title,
          description: news.description,
          link: `/news/${news.newsId}`,
        };
      });
- }, [newsList]);
+ }, [data?.pages]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/base-ui/home/News/NewsBannerSlider.tsx` around lines 10 - 25,
The memo in NewsBannerSlider is ineffective because newsList is recreated on
every render from data?.pages.flatMap(...), so useMemo always sees a new
dependency. Move the newsList derivation inside the useMemo that builds
carouselItems, and change the dependency to data?.pages so the cache can
actually be reused. Use the existing NewsBannerSlider component and its
carouselItems memo as the place to make this change.

13-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate news-to-CarouselItem mapping logic.

The isValidSrc validation and CarouselItem mapping here (lines 13–24) is identical to NewsListBanner.tsx lines 18–28. See the extraction suggestion in the NewsListBanner review for a shared mapNewsToCarouselItem helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/base-ui/home/News/NewsBannerSlider.tsx` around lines 13 - 24,
The news-to-CarouselItem mapping in NewsBannerSlider duplicates the same
validation and field mapping used elsewhere, so extract and reuse a shared
helper instead of keeping a second copy. Move the thumbnail validation and
object निर्माण into the existing shared mapper suggested by NewsListBanner, then
call that helper from the NewsBannerSlider transform so both components use the
same logic and stay consistent.
src/components/common/SwipeCarousel.tsx (1)

173-236: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding keyboard navigation for accessibility.

The carousel supports drag/swipe and indicator-dot clicks but lacks keyboard arrow-key navigation. Adding tabIndex={0} and an onKeyDown handler for ArrowLeft/ArrowRight would let keyboard users navigate slides. An aria-roledescription="carousel" on the container and aria-live="polite" on the slide region would also improve screen-reader announcements.

[accessibility]

♿ Suggested keyboard navigation addition
   <div 
     ref={containerRef}
     className={`relative ${heightClass} ${maxWidthClass} mx-auto overflow-hidden rounded-[10px] shadow-sm`}
+    tabIndex={0}
+    role="region"
+    aria-roledescription="carousel"
+    onKeyDown={(e) => {
+      if (e.key === "ArrowRight") {
+        e.preventDefault();
+        navigateToSlide(1);
+      } else if (e.key === "ArrowLeft") {
+        e.preventDefault();
+        navigateToSlide(-1);
+      }
+    }}
   >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/common/SwipeCarousel.tsx` around lines 173 - 236, The
SwipeCarousel container and slide region currently support drag and dot clicks
but not keyboard or screen-reader navigation. Update the main wrapper in
SwipeCarousel to be focusable with tabIndex and add
aria-roledescription="carousel"; then add an onKeyDown handler to navigate with
ArrowLeft/ArrowRight using the existing jumpTo/navigateToSlide logic. Also mark
the visible slide region as aria-live="polite" so slide changes are announced to
assistive tech.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/`(main)/groups/[id]/layout.tsx:
- Around line 149-154: The tab click guard in the group layout is using only
isLoggedIn, which can incorrectly open the login modal before auth has finished
initializing. Update the onClick handler for the notice/bookcase tabs to also
check the auth initialization state from useAuthStore (isInitialized), matching
the other auth-gated flows, so the modal is only shown after auth has settled.

In `@src/app/`(main)/groups/GroupsPageClient.tsx:
- Line 93: `isGuestDefaultView` in `GroupsPageClient` is currently based only on
`isLoggedIn` and `isSearchMode`, which allows a transient guest state before
auth bootstrapping completes. Update the guard to also check the auth
initialization state from `AuthProvider` (use `isInitialized` alongside
`isLoggedIn` and `isSearchMode`) so authenticated users don’t briefly take the
guest search path and trigger the clubs-search fetch.

---

Nitpick comments:
In `@src/components/base-ui/home/News/NewsBannerSlider.tsx`:
- Around line 10-25: The memo in NewsBannerSlider is ineffective because
newsList is recreated on every render from data?.pages.flatMap(...), so useMemo
always sees a new dependency. Move the newsList derivation inside the useMemo
that builds carouselItems, and change the dependency to data?.pages so the cache
can actually be reused. Use the existing NewsBannerSlider component and its
carouselItems memo as the place to make this change.
- Around line 13-24: The news-to-CarouselItem mapping in NewsBannerSlider
duplicates the same validation and field mapping used elsewhere, so extract and
reuse a shared helper instead of keeping a second copy. Move the thumbnail
validation and object निर्माण into the existing shared mapper suggested by
NewsListBanner, then call that helper from the NewsBannerSlider transform so
both components use the same logic and stay consistent.

In `@src/components/base-ui/News/NewsListBanner.tsx`:
- Around line 10-30: The `useMemo` in `NewsListBanner` is not caching because
`newsList` is recreated on every render by `data?.pages.flatMap(...)`, so the
dependency always changes. Move the `newsList` derivation inside the `useMemo`
callback and make the memo depend on `data?.pages` (or the source pagination
data) instead, while keeping the existing filter/sort/slice/map logic in
`carouselItems`.
- Around line 18-28: The news-to-CarouselItem mapping and thumbnail fallback
logic are duplicated between NewsListBanner and NewsBannerSlider; extract the
shared `isValidSrc` check plus item construction into a reusable helper (for
example a `mapNewsToCarouselItem` utility) and have both components call it so
the fallback behavior stays centralized and consistent.

In `@src/components/common/SwipeCarousel.tsx`:
- Around line 173-236: The SwipeCarousel container and slide region currently
support drag and dot clicks but not keyboard or screen-reader navigation. Update
the main wrapper in SwipeCarousel to be focusable with tabIndex and add
aria-roledescription="carousel"; then add an onKeyDown handler to navigate with
ArrowLeft/ArrowRight using the existing jumpTo/navigateToSlide logic. Also mark
the visible slide region as aria-live="polite" so slide changes are announced to
assistive tech.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f81f077f-c35a-4bfe-b38c-3c4994d31a1f

📥 Commits

Reviewing files that changed from the base of the PR and between e3a588b and 1070f98.

📒 Files selected for processing (8)
  • src/app/(main)/groups/GroupsPageClient.tsx
  • src/app/(main)/groups/[id]/GroupDetailClient.tsx
  • src/app/(main)/groups/[id]/layout.tsx
  • src/components/base-ui/News/NewsListBanner.tsx
  • src/components/base-ui/home/News/NewsBannerSlider.tsx
  • src/components/common/SwipeCarousel.tsx
  • src/components/layout/BottomNav.tsx
  • src/components/layout/Header.tsx

Comment on lines +149 to +154
onClick={(e) => {
if ((tab.id === "notice" || tab.id === "bookcase") && !isLoggedIn) {
e.preventDefault();
openLoginModal();
}
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'isInitialized|setInitialized' src/store/useAuthStore.ts

Repository: checkmo2025/FE

Length of output: 1077


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## src/app/(main)/groups/[id]/layout.tsx\n'
sed -n '120,180p' src/app/'(main)'/groups/'[id]'/layout.tsx

printf '\n## src/store/useAuthStore.ts\n'
sed -n '1,220p' src/store/useAuthStore.ts

printf '\n## auth initialization references\n'
rg -n --no-heading 'isInitialized|setInitialized|openLoginModal|isLoggedIn' src | sed -n '1,220p'

Repository: checkmo2025/FE

Length of output: 20560


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## src/components/auth/AuthProvider.tsx\n'
sed -n '1,180p' src/components/auth/AuthProvider.tsx

printf '\n## src/hooks/useAuthGuard.ts\n'
sed -n '1,120p' src/hooks/useAuthGuard.ts

printf '\n## src/app/(main)/groups/GroupsPageClient.tsx\n'
sed -n '70,230p' src/app/'(main)'/groups/GroupsPageClient.tsx

Repository: checkmo2025/FE

Length of output: 9849


Gate these tabs on auth initialization.

useAuthStore starts with isLoggedIn: false and only settles after AuthProvider finishes loading the session. On a hard refresh, an authenticated user can click notice/bookcase before that and get the login modal instead of navigating. Check isInitialized here, like the other auth-gated flows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/groups/[id]/layout.tsx around lines 149 - 154, The tab click
guard in the group layout is using only isLoggedIn, which can incorrectly open
the login modal before auth has finished initializing. Update the onClick
handler for the notice/bookcase tabs to also check the auth initialization state
from useAuthStore (isInitialized), matching the other auth-gated flows, so the
modal is only shown after auth has settled.

const [region, setRegion] = useState(false);
const [appliedParams, setAppliedParams] = useState<Omit<ClubSearchParams, "cursorId"> | null>(null);
const isSearchMode = appliedParams !== null;
const isGuestDefaultView = !isLoggedIn && !isSearchMode;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C4 '(setInitialized|isInitialized|isLoggedIn)\s*[:=]' src/store/useAuthStore.ts
rg -nP -C4 'setInitialized|isInitialized' --type=ts -g '!**/*.test.*'

Repository: checkmo2025/FE

Length of output: 1161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== GroupsPageClient.tsx ==\n'
ast-grep outline src/app/'(main)'/groups/GroupsPageClient.tsx --view expanded || true
printf '\n-- relevant lines --\n'
sed -n '1,180p' src/app/'(main)'/groups/GroupsPageClient.tsx | cat -n

printf '\n== Auth store ==\n'
ast-grep outline src/store/useAuthStore.ts --view expanded || true
printf '\n-- relevant lines --\n'
sed -n '1,140p' src/store/useAuthStore.ts | cat -n

printf '\n== Auth initialization references ==\n'
rg -n "setInitialized\\(|isInitialized|isLoggedIn|useAuthStore" src -g '!**/*.test.*' | sed -n '1,220p'

Repository: checkmo2025/FE

Length of output: 29402


Gate isGuestDefaultView on auth init (src/app/(main)/groups/GroupsPageClient.tsx:93)
Until AuthProvider finishes bootstrapping, isLoggedIn is still false, so authenticated users can briefly hit the guest search path and trigger an unnecessary clubs-search fetch before switching to recommendations. Add isInitialized to this guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/groups/GroupsPageClient.tsx at line 93, `isGuestDefaultView`
in `GroupsPageClient` is currently based only on `isLoggedIn` and
`isSearchMode`, which allows a transient guest state before auth bootstrapping
completes. Update the guard to also check the auth initialization state from
`AuthProvider` (use `isInitialized` alongside `isLoggedIn` and `isSearchMode`)
so authenticated users don’t briefly take the guest search path and trigger the
clubs-search fetch.

@shinwokkang
shinwokkang changed the base branch from main to dev July 11, 2026 07:04
…rame drops

This fixes an issue where the drag listener was unmounted and remounted on every render, causing jank during continuous swiping.
…rtualization bug

This prevents identical DOM nodes from mixing states when replacing items during a swipe, ensuring reliable rendering of edge cases.
@shinwokkang
shinwokkang merged commit 18426de into dev Jul 11, 2026
3 checks passed
@shinwokkang
shinwokkang deleted the fix/486-groups-logic branch July 11, 2026 07:26

This branch was successfully deployed

1 active deployment
Preview — 85a6486d Deployed Jul 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug 버그 수정 ✨ feat 새로운 기능 추가 ♻️ refactor 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix: 모임 탭 로직 수정

1 participant