Skip to content

Conversation

@DreamPaste
Copy link
Member

📌 개요

자유게시판 리스트 페이지에 SSR 프리패치를 적용하여 초기 로딩 성능을 개선하고, 관련 UI/UX를 개선했습니다.

  • 자유게시판 리스트 SSR 프리패치 구현
  • formatCount 유틸리티 한글 단위 지원 (천, 만, 억)
  • 가상 스크롤 gap 리사이징 이슈 해결
  • 자유게시판 카드에 썸네일 이미지 추가
  • SortHeader 스타일 개선

🗒 상세 설명

1. 자유게시판 리스트 SSR 프리패치 구현

서버 컴포넌트에서 자유게시판 리스트 첫 페이지를 프리패치하여 클라이언트 초기 렌더링 성능을 개선했습니다.

핵심 기술 및 구현사항

  • React Query의 prefetchInfiniteQuery 활용하여 서버에서 데이터 프리패치
  • HydrationBoundary로 서버 데이터를 클라이언트에 전달
  • 초기 상태 일치: category=undefined, sort='LATEST'로 클라이언트 useState 초기값과 동일하게 설정
  • queryKey 일치: 서버와 클라이언트의 쿼리 키를 정확히 일치시켜 캐시 히트 보장

사용 예시

// apps/web/src/app/main/community/freeboard/page.tsx
export default async function FreeboardPage() {
  const queryClient = new QueryClient();

  await queryClient.prefetchInfiniteQuery({
    queryKey: getGetFreeboardPostsByCursorQueryKey({
      category: undefined,
      sort: 'LATEST',
    }),
    queryFn: async ({ signal }) =>
      getFreeboardPostsByCursor(
        { category: undefined, sort: 'LATEST', size: 10 },
        signal,
      ),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => {
      return lastPage.hasNext ? lastPage.nextCursor : undefined;
    },
    pages: 1,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ClientPage />
    </HydrationBoundary>
  );
}

성능 개선 효과:

  • 초기 렌더링 시 네트워크 요청 제거 (서버에서 이미 fetch)
  • 클라이언트는 캐시 히트로 즉시 데이터 표시

2. formatCount 유틸리티 한글 단위 지원

숫자 포맷팅 유틸리티에 한글 단위(천, 만, 억)를 추가하여 사용자 친화적인 숫자 표현을 제공합니다.

핵심 기술 및 구현사항

  • formatKoreanCount 함수 추가: 1.2천, 1.2만, 1.2억 형식 지원
  • 반올림 임계값 처리: 9999 → 1만 (10천 표시 방지)
  • null/undefined 처리: fallback 옵션으로 대체값 제공
  • 기존 호환성 유지: formatCappedCount 함수는 기존 그대로 동작

사용 예시

// apps/web/src/utils/formatCount.ts
formatCount(1234, { type: 'korean' }) // '1.2천'
formatCount(12345, { type: 'korean' }) // '1.2만'
formatCount(9999, { type: 'korean' }) // '1만' (10천 X)
formatCount(1234567, { type: 'korean' }) // '123.5만'
formatCount(100000000, { type: 'korean' }) // '1억'
formatCount(null, { fallback: '-' }) // '-'

// 기본 capped 형식 (기존 호환)
formatCount(50) // '50'
formatCount(100) // '99+'

개선 사항

  • 천/만/억 단위 자동 변환
  • 소수점 자동 제거 (1.0만 → 1만)
  • 천 단위 콤마 추가 (1,234.5만)
  • 10천 = 1만 중복 제거 로직

3. 가상 스크롤 Gap 리사이징 이슈 해결

창 크기 조절 또는 카드 클릭 시 아이템 간격(gap)이 중복 적용되는 문제를 해결했습니다.

핵심 기술 및 구현사항

  • 정확한 카드 평균 높이 계산: estimateSize를 156px에서 139px로 수정
  • @tanstack/react-virtual 측정 로직 최적화
  • ResizeObserver 재측정 시 gap 중복 계산 방지

해결한 문제

  • Before: 카드 클릭 시 scale 변환 → ResizeObserver 감지 → 재측정 → gap 중복 적용
  • After: 정확한 높이로 초기 측정 → 재측정 시에도 gap 정확히 유지

4. 자유게시판 카드에 썸네일 이미지 추가

게시글 카드에 썸네일 이미지를 표시하여 시각적 정보를 제공합니다.

이미지

image

사용 예시

// apps/web/src/app/main/community/components/FreeboardCard.tsx
{post.thumbnailUrl && (
  <Image
    src={post.thumbnailUrl}
    alt="게시글 썸네일 이미지"
    width={55}
    height={55}
    className="w-[55px] h-[55px] object-cover rounded-md flex-shrink-0"
  />
)}

5. SortHeader 스타일 개선

정렬 헤더의 스타일을 개선하여 더 나은 UX를 제공합니다.


📸 스크린샷

UI 변경이 있을 경우, Before / After 스크린샷을 첨부해주세요.

AS-IS

  • 초기 로딩 시 네트워크 요청 발생 (느림)
  • 숫자가 원시 형태로 표시 (1234567)
  • Gap 리사이징 이슈 발생
  • 썸네일 이미지 없음
image

TO-BE

  • 초기 로딩 시 즉시 데이터 표시 (빠름)
  • 한글 단위로 친화적 표시 (123.5만)
  • Gap 정확히 유지
  • 썸네일 이미지 표시
  • 게시글 수 헤더 색상 변경, Select 컴포넌트 border 스타일 제거
image

🔗 이슈

closes #80


✅ 체크리스트

  • 코드가 스타일 가이드를 따릅니다
  • 자체 코드 리뷰를 완료했습니다
  • 복잡/핵심 로직에 주석을 추가했습니다
  • 관심사 분리를 확인했습니다 (서버/클라이언트 컴포넌트 분리)
  • 잠재적 사이드이펙트를 점검했습니다 (queryKey 일치, 초기 상태 일치)
  • Vercel Preview로 테스트를 완료했습니다

🧪 테스트 방법

다음 항목들을 검증했습니다:

  • SSR 프리패치 동작 확인 (네트워크 탭에서 초기 요청 없음)
  • 카테고리/정렬 변경 시 정상 동작
  • formatCount 한글 단위 정상 표시
  • 창 크기 조절 시 gap 유지 확인
  • 카드 클릭 시 gap 유지 확인
  • 썸네일 이미지 정상 표시

📝 추가 노트

SSR 프리패치 동작 원리

  1. 서버: QueryClient 생성 → 데이터 프리패치 → dehydrate → HTML에 포함
  2. 클라이언트: HydrationBoundary가 데이터 주입 → useInfiniteQuery 캐시 히트 → 즉시 렌더링

formatCount 설계 결정

  • Option 1 (특화 훅): useFreeboardInfiniteQuery, useVoteboardInfiniteQuery 등 개별 생성
  • Option 2 (제네릭 훅): useCursorInfiniteQuery 하나로 통합 ← 향후 고려 사항

현재는 SSR 프리패치에 집중하고, 훅 추상화는 투표게시판 구현 시 적용 예정입니다.

Gap 이슈 원인 분석

  • @tanstack/react-virtualgap 옵션과 measureElement의 상호작용 이슈
  • estimateSize 부정확 시 재측정 반복 → 누적 오차 발생
  • 정확한 높이 계산으로 근본 해결

@DreamPaste DreamPaste requested a review from youdaeng2 November 28, 2025 21:14
@DreamPaste DreamPaste self-assigned this Nov 28, 2025
Copilot AI review requested due to automatic review settings November 28, 2025 21:14
@DreamPaste DreamPaste added Fix 🔧 문제가 있는 내용을 수정합니다! Feat 💡 새로운 기능을 구현하고 추가합니다! 휘건 labels Nov 28, 2025
@linear
Copy link

linear bot commented Nov 28, 2025

@github-actions
Copy link

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

이 PR은 자유게시판 리스트 페이지에 SSR 프리패치를 적용하여 초기 로딩 성능을 개선하고, 한글 숫자 포맷팅 유틸리티를 추가했습니다. 또한 인증 상태 관리를 Zustand에서 React Query 기반으로 전환하고, 관련 UI/UX를 개선했습니다.

주요 변경사항:

  • 자유게시판 리스트 첫 페이지 SSR 프리패치 구현으로 초기 로딩 성능 개선
  • formatCount 유틸리티에 한글 단위(천, 만, 억) 지원 추가
  • 인증 상태 관리를 Zustand 스토어에서 React Query 기반 useAuth 훅으로 마이그레이션

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
apps/web/src/utils/formatCount.ts 한글 숫자 포맷팅(천, 만, 억) 기능 추가 및 통합 함수 구현
apps/web/src/stores/authStore.ts Zustand 인증 스토어 제거 (React Query 기반 useAuth로 전환)
apps/web/src/providers/AuthHydrationProvider.tsx 에러/null 상태 시 staleTime을 0으로 설정하는 동적 로직 추가
apps/web/src/middleware.ts 토큰 갱신 로직 개선 및 Set-Cookie 헤더 Domain 속성 제거
apps/web/src/app/main/community/freeboard/page.tsx 서버 컴포넌트로 변환하여 SSR 프리패치 구현
apps/web/src/app/main/community/freeboard/ClientPage.tsx 기존 클라이언트 로직을 분리한 새 파일
apps/web/src/app/main/community/components/FreeboardCard.tsx 썸네일 이미지 추가 및 formatCount 적용
apps/web/src/app/main/community/components/SortHeader.tsx 텍스트 색상 및 Select 트리거 스타일 개선
apps/web/src/app/main/community/components/CommunityPostList.tsx 가상 스크롤 estimateSize 139px로 조정 및 gap 스타일 제거
apps/web/src/components/tabs/PillChipsTab.tsx 'use client' 디렉티브 추가
apps/web/src/components/buttons/Button.tsx inline-flex에서 flex로 변경 및 Pressable에 className 전달
apps/web/src/app/page.tsx 서버 컴포넌트로 변환 및 StartButton 분리
apps/web/src/app/(auth)/components/StartButton.tsx 홈페이지 시작 버튼을 클라이언트 컴포넌트로 분리
apps/web/src/app/main/profile/page.tsx authStore 대신 useAuth 훅 사용
apps/web/src/app/(auth)/signup/layout.tsx RedirectIfAuthed 컴포넌트 제거
apps/web/src/app/(auth)/signup/components/RedirectIfAuthed.tsx 파일 삭제 (불필요한 리다이렉트 가드 제거)
apps/web/src/app/(auth)/signup/[type]/complete/page.tsx authStore 대신 useAuth 훅 사용 및 로그인 로직 간소화

Comment on lines +67 to +75
if (numericValue >= nextThreshold && i > 0) {
const nextUnit = units[i - 1];
const nextDivided = count / nextUnit.value;
const nextFormatted = nextDivided.toFixed(precision);
const nextCleaned = nextFormatted.replace(/\.0+$/, '');
const nextParts = nextCleaned.split('.');
nextParts[0] = parseInt(nextParts[0]).toLocaleString('ko-KR');
return `${nextParts.join('.')}${nextUnit.symbol}`;
}
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

67-75번 라인과 77-82번 라인에 중복된 포맷팅 로직이 있습니다. 숫자를 포맷팅하는 로직(toFixed, replace, split, parseInt, toLocaleString, join)을 별도 함수로 추출하면 코드 중복을 제거하고 유지보수성을 개선할 수 있습니다. 예: formatNumber(value, precision, symbol) 함수를 만들어 재사용하세요.

Copilot uses AI. Check for mistakes.
Comment on lines 38 to 47
staleTime: (query) => {
if (
query.state.status === 'error' ||
query.state.data === null
) {
return 0;
}
return 13 * 60 * 1000; // 13분
},
});
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

staleTime을 함수로 설정할 때 query.state.data === null을 체크하고 있지만, 이 시점에서는 아직 데이터가 페칭되기 전이므로 query.state.data는 항상 undefined입니다. 이 조건은 의도대로 작동하지 않을 수 있습니다. prefetchQuery 후 데이터가 null이면 자동으로 stale 상태가 되도록 하려면, prefetchQuery 호출 후 별도로 queryClient.setQueryDefaults를 사용하거나, 쿼리 함수에서 에러를 throw하는 방식을 고려하세요.

Suggested change
staleTime: (query) => {
if (
query.state.status === 'error' ||
query.state.data === null
) {
return 0;
}
return 13 * 60 * 1000; // 13분
},
});
staleTime: 13 * 60 * 1000, // 13분
});
// 만약 데이터가 null이면 해당 쿼리를 즉시 stale 처리
if (queryClient.getQueryData(getGetCurrentUserQueryKey()) === null) {
queryClient.setQueryDefaults(getGetCurrentUserQueryKey(), {
staleTime: 0,
});
}

Copilot uses AI. Check for mistakes.
* - Domain 속성 제거 (현재 도메인으로 자동 설정)
*/
function modifySetCookie(cookie: string): string {
return cookie.replace(/Domain=[^;]+;?\s*/gi, '');
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

[nitpick] 정규식 /Domain=[^;]+;?\s*/gi에서 g 플래그와 i 플래그를 함께 사용하고 있습니다. Set-Cookie 헤더 하나에는 보통 Domain 속성이 한 번만 나타나므로 g 플래그는 불필요할 수 있습니다. 또한 Domain은 대소문자 구분이 없지만 일반적으로 Domain= 형식으로 사용되므로 /Domain=[^;]+;?\s*/i로 충분합니다.

Suggested change
return cookie.replace(/Domain=[^;]+;?\s*/gi, '');
return cookie.replace(/Domain=[^;]+;?\s*/i, '');

Copilot uses AI. Check for mistakes.
Comment on lines 103 to 104
const modifiedCookie = modifySetCookie(cookie);
newResponse.headers.append('Set-Cookie', modifiedCookie);
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

Set-Cookie 헤더를 수정할 때 모든 쿠키에 대해 Domain 속성을 제거하고 있습니다. 하지만 백엔드에서 의도적으로 설정한 Domain 값(예: 서브도메인 공유)이 있을 수 있으므로, 개발 환경에서만 제거하거나, 환경 변수로 제어하는 것이 더 안전합니다. 예: if (process.env.NODE_ENV === 'development') { modifySetCookie(cookie) }

Copilot uses AI. Check for mistakes.

const classes = cn(
'relative inline-flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
'relative flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

inline-flex에서 flex로 변경하면 버튼이 전체 너비를 차지하게 됩니다. 이는 의도된 변경일 수 있지만, 기존에 inline으로 사용되던 버튼들의 레이아웃에 영향을 줄 수 있습니다. 버튼이 항상 블록 레벨로 동작해야 하는지 확인하고, 필요하다면 inline-flex를 기본값으로 유지하고 필요한 곳에서만 className으로 flex w-full을 추가하는 것을 고려하세요.

Suggested change
'relative flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
'relative inline-flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',

Copilot uses AI. Check for mistakes.

return (
<Pressable>
<Pressable className={cn('flex', className)}>
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

Pressable에 className을 전달하고 있지만, 내부 button 요소에도 className이 전달되고 있습니다(117번 라인). 이로 인해 className이 두 번 적용되어 예상치 못한 스타일 충돌이 발생할 수 있습니다. Pressable의 className과 Button의 className을 분리하거나, Pressable에는 별도의 prop(예: wrapperClassName)을 사용하세요.

Copilot uses AI. Check for mistakes.
>
<Select.Trigger placeholder="정렬 선택" />
<Select.Trigger
className="border-none text-neutral-800 dark:text-white justify-end text-sm"
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

Select.Trigger에 border-none을 적용하여 기본 테두리를 제거하고 있습니다. 하지만 SelectTrigger 컴포넌트 내부에서 이미 border 스타일이 정의되어 있으므로(SelectTrigger.tsx 164번 라인), border-none을 사용하면 Tailwind의 우선순위에 따라 의도대로 작동하지 않을 수 있습니다. 대신 !border-0 또는 border-transparent를 사용하거나, SelectTrigger 컴포넌트에 noBorder prop을 추가하는 것을 고려하세요.

Suggested change
className="border-none text-neutral-800 dark:text-white justify-end text-sm"
className="!border-0 text-neutral-800 dark:text-white justify-end text-sm"

Copilot uses AI. Check for mistakes.
Comment on lines 34 to 35
const handleSuccess = (user: User) => {
login(user);
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

handleSuccess 함수의 매개변수 타입이 User로 정의되어 있지만, 실제로는 SignupCompleteResponse.userUserResponse 타입일 가능성이 높습니다. UserUserResponse의 타입이 다르다면 타입 불일치 오류가 발생할 수 있습니다. 타입을 UserResponse로 수정하거나, 두 타입이 동일한지 확인하세요.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

github-actions bot commented Nov 28, 2025

📦 번들 분석 결과

📊 번들 크기 요약

항목
📦 전체 번들 크기 3.7M
📄 JavaScript 크기 1.5M
🗂️ JavaScript 파일 수 57개

🔍 주요 청크 파일 (크기순)

fe98bb7c-75056deddb8826d9.js - 169K
framework-69e0f7d37422957b.js - 137K
main-aef2774224ba47b6.js - 130K
7147-4b792b1c613a0a9e.js - 122K
1762-0e7232d83dcb3887.js - 121K
polyfills-42372ed130431b0a.js - 110K
2877-a44387a54a9dad43.js - 86K
6086-65069d4634f32053.js - 76K
page-3026af1cfd56a766.js - 31K
2906-230d0473dd8a9533.js - 28K

🤖 자동 생성된 번들 분석 리포트

@github-actions
Copy link

github-actions bot commented Nov 28, 2025

⚡ Lighthouse 성능 분석 결과

📊 전체 평균 점수

지표 점수
🚀 Performance 73점
♿ Accessibility 86점
✅ Best Practices 100점
🔍 SEO 100점

📈 측정 현황

  • 측정 성공: 15/16 페이지
  • 상태: success

📄 페이지별 상세 분석

🏠 커뮤니티 페이지: /main/community

지표 점수
🚀 Performance 64점
♿ Accessibility 78점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👥 창업자 페이지: /main/founder

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🏡 홈 페이지: /main/home

지표 점수
🚀 Performance 75점
♿ Accessibility 91점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🗺️ 지도 페이지: /main/maps

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👤 프로필 페이지: /main/profile

지표 점수
🚀 Performance 75점
♿ Accessibility 88점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🔗 전체 상세 분석 결과

📊 전체 상세 Lighthouse 분석 결과 보기

📄 측정된 페이지

  • /main/community
  • /main/founder
  • /main/home
  • /main/maps
  • /main/profile

모든 페이지에서 성능 측정이 완료되었습니다.


🤖 자동 생성된 Lighthouse 성능 리포트

Copy link
Member

@youdaeng2 youdaeng2 left a comment

Choose a reason for hiding this comment

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

자유게시판 입성 시 데이터 로드가 오래 걸렸었는데 ssr 프리패치를 구현해서 속도가 훨씬 더 빨라지겠네요. UI에 이미지 추가한 것도 좋네요 고생하셨습니다!

@github-actions
Copy link

github-actions bot commented Dec 3, 2025

@DreamPaste DreamPaste merged commit ca4593e into dev Dec 3, 2025
4 checks passed
@DreamPaste DreamPaste deleted the feat/SOS-43-freeboard-SSR branch December 3, 2025 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feat 💡 새로운 기능을 구현하고 추가합니다! Fix 🔧 문제가 있는 내용을 수정합니다! 휘건

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 자유게시판 리스트 SSR 기반 개선

3 participants