Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions app/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
'use client';

import { useEffect, useState, useRef } from 'react';
import { jwtDecode } from 'jwt-decode';
import { useHomeStore } from '@/app/(shared)/stores/home-store';
import { useWebSocketGuard } from './(shared)/hooks/useWebSocketGuard';

import Banner from './home/banner';
import { DataAvg } from './home/data-avgs';
import HomeLayout from './home/home-layout';
import { ArticleSection } from './home/components/article-section';

import { generateQueryParams } from '@/app/(shared)/utils/generateQueryParams';
import type { CardData } from '@/app/(shared)/types/card';
import { api } from '@/app/(shared)/utils/api';

import type {
SellStatus,
Carrier,
} from '@/app/(shared)/utils/generateQueryParams';
import { CreateButton } from '@/app/(shared)/components/CreateButton';
interface CardApiResponse {
data: {
hasNext: boolean;
cardResponseList: CardData[];
};
}

interface JwtPayload {
username: string;
[key: string]: unknown;
}

const API_BASE = process.env.NEXT_PUBLIC_API_URL;

// 유틸리티 함수들
const getCurrentUserEmail = (): string | null => {
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
if (parsed.state?.token) {
const token = parsed.state.token;
const decoded = jwtDecode<JwtPayload>(token);
return decoded.username; // JWT의 username 필드 사용
}
}
return null;
} catch (error) {
console.error('토큰 디코딩 실패:', error);
return null;
}
};

const filterCardsByPostView = (
cards: CardData[],
postView: string
): CardData[] => {
if (!postView || postView === 'ALL') return cards;

if (postView === 'MY_POSTS') {
const currentUserEmail = getCurrentUserEmail();
return currentUserEmail
? cards.filter((card: CardData) => card.email === currentUserEmail)
: [];
} else if (postView === 'FAVORITE_POSTS') {
return cards.filter((card: CardData) => card.favorite === true);
}

return cards;
};

const sortCards = (cards: CardData[], sortBy: string): CardData[] => {
if (sortBy === 'RATING') {
// 인기순: 바삭스코어 높은 순, 같으면 등록순
return cards.sort((a, b) => {
if (a.ratingScore !== b.ratingScore) {
return b.ratingScore - a.ratingScore; // 높은 점수 먼저
}
// 점수가 같으면 등록순 (최신 등록 먼저)
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
} else {
// 최신순: 등록순 (최신 등록 먼저)
return cards.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}
};

export default function HomePage() {
// WebSocket 가드 사용
useWebSocketGuard();

const [cardPages, setCardPages] = useState<CardData[][]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);

const [showCreateButton, setShowCreateButton] = useState(false);
const homeLayoutRef = useRef<HTMLDivElement>(null);

const {
cardCategory,
category,
transactionStatus,
priceRange,
sortBy,
carrier,
postView,
actions,
refetchTrigger,
} = useHomeStore();

const PAGE_SIZE = 54;

// refetchTrigger가 변경될 때 페이지네이션 상태를 리셋하는 useEffect
const isInitialMount = useRef(true);
useEffect(() => {
// 컴포넌트 첫 마운트 시에는 실행하지 않음
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
setCurrentPage(1);
setCardPages([]);
setTotalPages(1);
}, [refetchTrigger]);

useEffect(() => {
const OBSERVER_ROOT_MARGIN = '0px 0px -100px 0px';
const OBSERVER_THRESHOLD = 0.1;

const observer = new IntersectionObserver(
([entry]) => {
setShowCreateButton(entry.isIntersecting);
},
{
rootMargin: OBSERVER_ROOT_MARGIN,
threshold: OBSERVER_THRESHOLD,
}
);

const currentRef = homeLayoutRef.current;
if (currentRef) {
observer.observe(currentRef);
}

return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, []);

// 현재 페이지의 데이터를 가져오는 useEffect
useEffect(() => {
const pageIdx = currentPage - 1;
// 페이지 인덱스가 유효하지 않거나, 해당 페이지 데이터가 이미 캐시되어 있다면 fetch를 건너뜀
if (pageIdx < 0 || cardPages[pageIdx]) {
if (cardPages[pageIdx]) setLoading(false);
return;
}

const fetchPage = async () => {
setLoading(true);

let lastCardId: number | undefined = undefined;
let lastUpdatedAt: string | undefined = undefined;
// 이전 페이지의 마지막 아이템을 기준으로 커서 설정
if (pageIdx > 0 && cardPages[pageIdx - 1]?.length > 0) {
const prevPageLast =
cardPages[pageIdx - 1][cardPages[pageIdx - 1].length - 1];
lastCardId = prevPageLast.id;
lastUpdatedAt = prevPageLast.updatedAt;
}

const highRatingFirst = sortBy === 'RATING';
const carrierForQuery: Carrier | undefined =
category === 'ALL'
? undefined
: category === 'LGU+'
? 'LG'
: (category ?? undefined);

const queryString = generateQueryParams({
cardCategory: cardCategory,
sellStatusFilter: 'ALL' as SellStatus,
priceRanges: [priceRange],
highRatingFirst,
size: PAGE_SIZE,
lastCardId,
lastUpdatedAt,
carrier: carrierForQuery,
favoriteOnly: false,
});
Comment on lines +186 to +196

Choose a reason for hiding this comment

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

high

현재 API에서 모든 상태의 카드를 가져온 후 클라이언트 측에서 transactionStatuspostView(FAVORITE_POSTS)에 따라 필터링하고 있습니다. 이는 네트워크와 클라이언트 모두에 부담을 주어 비효율적일 수 있습니다.

generateQueryParams 함수는 sellStatusFilterfavoriteOnly 파라미터를 지원하는 것으로 보입니다. 이 파라미터들을 활용하여 서버 측에서 필터링을 수행하도록 수정하면 성능을 개선하고 클라이언트 측 로직을 단순화할 수 있습니다.

제안:

  1. sellStatusFiltertransactionStatus 값을 전달합니다. (TRADING 상태는 백엔드에서 지원하지 않는 경우 클라이언트 필터링을 유지해야 할 수 있습니다.)
  2. postViewFAVORITE_POSTS일 때 favoriteOnlytrue로 설정합니다.

이렇게 변경하면, 이후의 클라이언트 측 필터링 로직(lines 217-222)도 함께 수정되어야 합니다.

      const queryString = generateQueryParams({
        cardCategory: cardCategory,
        sellStatusFilter:
          transactionStatus && transactionStatus !== 'TRADING'
            ? transactionStatus
            : 'ALL',
        priceRanges: [priceRange],
        highRatingFirst,
        size: PAGE_SIZE,
        lastCardId,
        lastUpdatedAt,
        carrier: carrierForQuery,
        favoriteOnly: postView === 'FAVORITE_POSTS',
      });


const fullUrl = `${API_BASE}/cards/scroll?${queryString}&_v=${new Date().getTime()}`;
console.log('[요청 URL]', fullUrl);

Choose a reason for hiding this comment

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

medium

개발 중 디버깅을 위해 추가된 것으로 보이는 console.log 구문이 있습니다. 프로덕션 코드에 포함되지 않도록 제거하는 것이 좋습니다. 유사한 로그가 lines 205, 206, 207, 210에도 있습니다.

스타일 가이드에 따라 구조화된 로깅 시스템을 사용하는 것을 권장합니다.1

Style Guide References

Footnotes

  1. 토스에서는 구조화된 로그 형태를 사용합니다. (link)


try {
const endpoint = fullUrl.replace(API_BASE || '', '');
const res = await api.get(endpoint);

console.log('Response 객체:', res);
console.log('Response status:', res.status);
console.log('Response headers:', res.headers);

const json = res.data as CardApiResponse;
console.log('Parsed JSON:', json);
// CANCELLED 상태의 카드 제외
let filteredCards = json.data.cardResponseList.filter(
(card) => card.sellStatus !== 'CANCELLED'
);

// transactionStatus에 따라 카드 필터링
if (transactionStatus && transactionStatus !== 'ALL') {
filteredCards = filteredCards.filter(
(card: { sellStatus: string }) =>
card.sellStatus === transactionStatus
);
}

// postView에 따른 필터링
filteredCards = filterCardsByPostView(filteredCards, postView);

// sortBy에 따라 카드 정렬
filteredCards = sortCards(filteredCards, sortBy);

Choose a reason for hiding this comment

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

medium

API 요청 시 highRatingFirst 파라미터를 통해 서버 측 정렬을 이미 요청하고 있습니다. 그런데 클라이언트 측에서 sortCards 함수를 통해 다시 정렬을 수행하고 있어 중복 작업이 발생할 수 있습니다.

서버에서 반환된 데이터가 이미 원하는 순서로 정렬되어 있다면, 이 클라이언트 측 정렬 로직은 제거하여 성능을 향상시킬 수 있습니다. 만약 서버 측 정렬이 충분하지 않아 (예: 2차 정렬 기준 부재) 클라이언트 정렬이 의도된 것이라면, 코드에 주석으로 그 이유를 명시해주는 것이 좋겠습니다.1

Style Guide References

Footnotes

  1. 코드의 '무엇'이 아닌 '왜'를 설명해야 합니다. (link)


// 카드 페이지 배열에 추가 (캐시)
setCardPages((prev) => {
const next = [...prev];
next[pageIdx] = filteredCards;
return next;
});

setTotalPages(json.data.hasNext ? pageIdx + 2 : pageIdx + 1);
} catch (e) {
console.error('카드 조회 실패:', e);
setCardPages((prev) => {
const next = [...prev];
next[pageIdx] = [];
return next;
});
} finally {
setLoading(false);
}
};

fetchPage();
}, [
currentPage,
cardPages,
cardCategory,
category,
transactionStatus,
priceRange,
sortBy,
carrier,
]);
Comment on lines +251 to +260

Choose a reason for hiding this comment

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

high

데이터를 가져오는 useEffect의 의존성 배열에 postView가 누락되었습니다. postView 필터가 변경될 때 useEffect가 다시 실행되지 않아 데이터가 갱신되지 않는 버그가 발생할 수 있습니다.

또한, carrier는 이 useEffect 내에서 직접 사용되지 않으므로 의존성 배열에서 제거하는 것이 좋습니다. carrierForQuerycategory 상태를 기반으로 생성되고 있습니다.

  }, [
    currentPage,
    cardPages,
    cardCategory,
    category,
    transactionStatus,
    priceRange,
    sortBy,
    postView
  ]);


const handlePageChange = (page: number) => {
if (page > 0 && page <= totalPages) {
setCurrentPage(page);
}
};

return (
<>
<Banner />
<DataAvg />
{showCreateButton && <CreateButton onClick={actions.toggleCreateModal} />}
<div ref={homeLayoutRef} className="flex items-center justify-center">
<HomeLayout
cards={cardPages[currentPage - 1] || []}
isLoading={loading}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>

<div className="w-full">
<ArticleSection />
</div>
</>
);
}
Loading