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
33 changes: 31 additions & 2 deletions app/main/_components/MyCoinSection.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import { useItems } from "@/hooks/useItems";
import Image from "next/image";
import React from "react";

const MyCoinSection = () => {
const { data, isLoading, isError } = useItems();

if (isLoading) {
return (
<section className="flex h-[38px] w-full animate-pulse items-center justify-between rounded-full border border-white/30 bg-white/20 pr-2 pl-4 backdrop-blur-[50px]">
<div className="flex items-center">
<div className="h-4 w-12 rounded bg-white/30" />
<div className="mr-2 ml-4 h-5 w-5 rounded-full bg-white/30" />
<div className="h-4 w-8 rounded bg-white/30" />
<div className="mx-4 h-4 w-px bg-black/10" />
<div className="mr-2 h-5 w-5 rounded-full bg-white/30" />
<div className="h-4 w-8 rounded bg-white/30" />
</div>
<div className="h-[26px] w-[58px] rounded-full bg-white/30" />
</section>
);
}

if (isError || !data) {
return null;
}

const { matchingTicketCount, optionTicketCount } = data.data;

return (
<section className="flex h-[38px] w-full items-center justify-between rounded-full border border-white/30 bg-white/50 pr-2 pl-4 backdrop-blur-[50px]">
<div className="flex items-center">
Expand All @@ -13,7 +38,9 @@ const MyCoinSection = () => {
height={20}
className="mr-2 ml-4"
/>
<span className="typo-14-600 text-color-text-caption1">1개</span>
<span className="typo-14-600 text-color-text-caption1">
{matchingTicketCount}개
</span>
<div className="mx-4 h-4 w-px bg-black/30" />
<Image
src="/main/elec-bulb.png"
Expand All @@ -22,7 +49,9 @@ const MyCoinSection = () => {
width={20}
height={20}
/>
<span className="typo-14-600 text-color-text-caption1">1개</span>
<span className="typo-14-600 text-color-text-caption1">
{optionTicketCount}개
</span>
</div>
<button className="bg-milky-pink typo-11-700 h-[26px] w-[58px] rounded-full text-white">
구매하기
Expand Down
133 changes: 16 additions & 117 deletions app/main/_components/ScreenMainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
import BusinessInfo from "@/components/common/BusinessInfo";
import NoticeSection from "./NoticeSection";
import ProfileSlider from "./ProfileSlider";
import { ProfileData } from "@/lib/types/profile";
import { ProfileData, ContactFrequency } from "@/lib/types/profile";

Check warning on line 14 in app/main/_components/ScreenMainPage.tsx

View workflow job for this annotation

GitHub Actions / lint

'ContactFrequency' is defined but never used
import ChargeRequestWaiting from "./ChargeRequestWaiting";

import { useMatchingHistory } from "@/hooks/useMatchingHistory";

const ScreenMainPage = () => {
// 실제 서비스 시에는 서버에서 받아온 데이터(notice)가 있는지 여부에 따라 렌더링을 결정할 수 있습니다.
const noticeData = {
Expand All @@ -24,6 +26,7 @@
};

const [isNoticeVisible, setIsNoticeVisible] = useState(false);
const { data: historyData, isLoading } = useMatchingHistory();

useEffect(() => {
// 로컬스토리지에 해당 공지 ID가 저장되어 있는지 확인
Expand All @@ -41,121 +44,17 @@
setIsNoticeVisible(false);
};

// 실제 서비스 시에는 서버에서 받아온 데이터(profiles)를 넘겨줍니다.
const mockProfileData: ProfileData[] = [
{
memberId: 1,
nickname: "겨울이오길",
birthDate: "2004-01-01",
major: "정보통신전자공학부",
mbti: "ENTP",
contactFrequency: "NORMAL",
hobbies: [
{ name: "🎸 인디음악", category: "음악" },
{ name: "📸 사진찍기", category: "문화" },
],
advantages: ["친절함", "경청하는 태도"],
profileImageUrl: "/main/cat.png",
socialAccountId: "winterizcoming_",
},
{
memberId: 2,
nickname: "코매칭짱",
birthDate: "2002-05-15",
major: "컴퓨터정보공학부",
mbti: "INFJ",
contactFrequency: "FREQUENT",
hobbies: [
{ name: "🎬 영화감상", category: "문화" },
{ name: "📖 독서", category: "문화" },
{ name: "🏊 수영", category: "스포츠" },
],
advantages: ["긍정적인 에너지", "솔직함", "유머러스함"],
profileImageUrl: "/main/dog.png",
socialType: "INSTAGRAM",
socialAccountId: "comatching_king",
},
{
memberId: 3,
nickname: "카카오톡유저",
birthDate: "2001-10-20",
major: "경영학과",
mbti: "ENFP",
contactFrequency: "NORMAL",
hobbies: [
{ name: "☕ 카페투어", category: "일상" },
{ name: "🍳 요리", category: "문화" },
{ name: "🍰 베이킹", category: "문화" },
],
advantages: ["공감 능력이 좋음"],
profileImageUrl: "/main/cat.png",
socialType: "KAKAO",
socialAccountId: "kakao_kim",
},
{
memberId: 4,
nickname: "봄날의햇살",
birthDate: "2003-03-21",
major: "산업디자인학과",
mbti: "ISFP",
contactFrequency: "RARE",
hobbies: [
{ name: "🎨 그림그리기", category: "예술" },
{ name: "🧶 뜨개질", category: "예술" },
],
advantages: ["꼼꼼함", "배려심", "예술적 감각", "성실함"],
profileImageUrl: "/main/cat.png",
socialAccountId: "",
},
{
memberId: 5,
nickname: "밤하늘별",
birthDate: "2000-08-12",
major: "천문학과",
mbti: "INTP",
contactFrequency: "NORMAL",
hobbies: [
{ name: "🔭 별보기", category: "자연" },
{ name: "🧩 퍼즐", category: "문화" },
{ name: "♟️ 보드게임", category: "놀이" },
],
advantages: ["지적인 대화", "차분함"],
profileImageUrl: "/main/dog.png",
socialType: "INSTAGRAM",
socialAccountId: "nightsky_star",
},
{
memberId: 6,
nickname: "운동좋아",
birthDate: "2001-06-05",
major: "체육교육과",
mbti: "ESTP",
contactFrequency: "FREQUENT",
hobbies: [
{ name: "⚽ 축구", category: "스포츠" },
{ name: "🧗 클라이밍", category: "스포츠" },
],
advantages: ["건강한 정신", "추진력", "밝은 웃음"],
profileImageUrl: "/main/cat.png",
socialAccountId: "",
},
{
memberId: 7,
nickname: "책벌레",
birthDate: "2002-11-30",
major: "국어국문학과",
mbti: "ISTJ",
contactFrequency: "RARE",
hobbies: [
{ name: "📚 독서", category: "문화" },
{ name: "✍️ 글쓰기", category: "문화" },
],
advantages: ["진중함", "학구열"],
profileImageUrl: "/main/dog.png",
socialType: "INSTAGRAM",
socialAccountId: "bookworm_kr",
},
];
// 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성
const profileList: ProfileData[] =
historyData?.data.content.map(({ partner }) => ({
...partner,
// API에서 null로 올 수 있는 필드들만 안전하게 변환
intro: partner.intro ?? undefined,
socialType: partner.socialType ?? undefined,
socialAccountId: partner.socialAccountId ?? "",
advantages: partner.advantages ?? undefined,
favoriteSong: partner.favoriteSong ?? undefined,
})) || [];
Comment on lines +47 to +57
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

API 응답 데이터를 UI 모델(ProfileData)로 변환하는 로직을 더 간결하고 효율적으로 개선할 수 있습니다.

  1. 데이터 변환 로직 분리: contactFrequency를 변환하는 3항 연산자 체인 대신, 가독성 높은 매핑 객체를 사용하면 의도를 명확히 할 수 있습니다. 이 매핑 객체를 map 콜백 바깥에 선언하면 렌더링 시마다 객체가 불필요하게 재생성되는 것을 방지할 수 있습니다.
  2. 중복 할당 제거: ...item.partner 스프레드 연산으로 대부분의 속성이 이미 복사되므로, 불필요한 재할당을 제거하여 코드를 간결하게 만들 수 있습니다.

아래와 같이 수정하면 코드가 더 깔끔해지고 성능적으로도 미세하게나마 이점이 있습니다.

  const contactFrequencyMap: Record<string, ContactFrequency> = {
    "자주": "FREQUENT",
    "보통": "NORMAL",
    "적음": "RARE",
  };

  // 매칭 히스토리 데이터에서 파트너 정보를 추출하여 프로필 목록 생성
  const profileList: ProfileData[] =
    historyData?.data.content.map((item) => ({
      ...item.partner,
      socialAccountId: item.partner.socialAccountId ?? "",
      contactFrequency: contactFrequencyMap[item.partner.contactFrequency] ?? "RARE",
    })) || [];
References
  1. 컴포넌트 로직을 복잡하게 만드는 데이터 변환 로직을 분리하고, 중복 코드를 제거하여 코드 스타일과 유지보수성을 개선해야 합니다. 이는 Senior Front-end Engineer로서 깨끗하고 효율적인 코드를 작성하는 것과 관련이 있습니다. (link)
  2. Pure functions, such as validation logic, should be extracted into common utility files to improve reusability and separate concerns. The suggestion to use a mapping object outside the map callback aligns with improving separation of concerns and efficiency for data transformation.
  3. Prioritize code readability over conciseness, even if it means some code duplication. The suggestion improves readability by using a clear mapping object instead of a ternary chain and simplifies the code by removing redundant assignments.


return (
<section className="flex min-h-dvh flex-col items-center gap-4 px-4 pb-4">
Expand All @@ -170,7 +69,7 @@
/>
)}
{/* <NoContactSection /> */}
<ProfileSlider profiles={mockProfileData} />
{!isLoading && <ProfileSlider profiles={profileList} />}
<MatchingButton />
<div className="flex w-full gap-2">
<SearchMyListButton />
Expand Down
39 changes: 37 additions & 2 deletions app/main/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
import { serverApi } from "@/lib/server-api";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import React from "react";
import ScreenMainPage from "./_components/ScreenMainPage";

export default function MainPage() {
return <ScreenMainPage />;
import { ItemsResponse } from "@/hooks/useItems";
import { MatchingHistoryResponse } from "@/hooks/useMatchingHistory";

export default async function MainPage() {
const queryClient = new QueryClient();

// 서버사이드에서 데이터를 미리 가져와서 캐시에 채워줍니다.
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["items"],
queryFn: async () => {
const res = await serverApi.get<ItemsResponse>({ path: "/api/items" });
return res.data;
},
}),
queryClient.prefetchQuery({
queryKey: ["matchingHistory"],
queryFn: async () => {
const res = await serverApi.get<MatchingHistoryResponse>({
path: "/api/matching/history",
});
return res.data;
},
}),
]);
Comment on lines +16 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Ssr prefetch crashes page 🐞 Bug ⛯ Reliability

In app/main/page.tsx, the server component awaits Promise.all of two prefetchQuery calls without any
error handling, so if either /api/items or /api/matching/history fails the whole page render throws
and returns a 500.
This defeats the client components’ graceful loading/error behavior because the page never renders
to the point where React Query can handle it on the client.
Agent Prompt
### Issue description
`app/main/page.tsx` does SSR React Query prefetching with an unguarded `await Promise.all([...])`. Because `serverApi` re-throws request errors, any transient backend failure causes the entire MainPage SSR render to throw (500), preventing the page from rendering at all.

### Issue Context
- `serverApi` throws Axios errors in its `request()` implementation.
- The main page UI already has client-side loading/error handling, but SSR failure prevents it from running.

### Fix Focus Areas
- app/main/page.tsx[16-34]

### Implementation notes
- Replace `Promise.all` with `Promise.allSettled`, or wrap each `prefetchQuery` in its own `try/catch`.
- Optionally log the error (server-side) and continue.
- Keep dehydration/hydration behavior unchanged when prefetch succeeds.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ScreenMainPage />
</HydrationBoundary>
);
}
42 changes: 42 additions & 0 deletions hooks/useItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { api } from "@/lib/axios";
import { useQuery } from "@tanstack/react-query";

interface Item {
itemId: number;
itemType: string;
quantity: number;
expiredAt: string;
}

export interface ItemsResponse {
code: string;
status: number;
message: string;
data: {
items: {
content: Item[];
currentPage: number;
size: number;
totalElements: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
};
matchingTicketCount: number;
optionTicketCount: number;
};
}

export const fetchItems = async (): Promise<ItemsResponse> => {
const { data } = await api.get<ItemsResponse>("/api/items");
return data;
};

export const useItems = () => {
return useQuery({
queryKey: ["items"],
queryFn: fetchItems,
staleTime: Infinity, // 충전/소모 전까지는 데이터가 변하지 않으므로 무한정 캐싱
gcTime: 1000 * 60 * 60, // 메모리에서 1시간 동안 유지
});
};
69 changes: 69 additions & 0 deletions hooks/useMatchingHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { api } from "@/lib/axios";
import {
Gender,
MBTI,
SocialType,
Hobby,
IntroItem,
ContactFrequency,
} from "@/lib/types/profile";
import { useQuery } from "@tanstack/react-query";

export interface MatchingPartner {
memberId: number;
email: string;
nickname: string;
gender: Gender;
birthDate: string;
mbti: MBTI;
intro: string | null;
profileImageUrl: string;
socialType: SocialType | null;
socialAccountId: string | null;
university: string;
major: string;
contactFrequency: ContactFrequency;
hobbies: Hobby[];
intros: IntroItem[];
advantages: string[] | null;
favoriteSong: string | null;
}

export interface MatchingHistoryItem {
historyId: number;
partner: MatchingPartner;
favorite: boolean;
matchedAt: string;
}

export interface MatchingHistoryResponse {
code: string;
status: number;
message: string;
data: {
content: MatchingHistoryItem[];
currentPage: number;
size: number;
totalElements: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
};
}

export const fetchMatchingHistory =
async (): Promise<MatchingHistoryResponse> => {
const { data } = await api.get<MatchingHistoryResponse>(
"/api/matching/history",
);
return data;
};
Comment on lines +54 to +60
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

fetchMatchingHistory 함수의 정의 형식이 일반적이지 않습니다. export const fetchMatchingHistory = 다음에 줄바꿈이 있어 코드를 읽는 데 혼란을 줄 수 있습니다. 일관성 있고 표준적인 코드 스타일을 위해 아래와 같이 수정하는 것을 권장합니다.

export const fetchMatchingHistory = async (): Promise<MatchingHistoryResponse> => {
  const { data } = await api.get<MatchingHistoryResponse>(
    "/api/matching/history",
  );
  return data;
};
References
  1. 일관되고 가독성 좋은 코드 스타일을 유지하는 것은 클린 코드 원칙의 일부입니다. 비표준적인 서식은 코드의 가독성을 해칠 수 있으므로 수정이 필요합니다. (link)


export const useMatchingHistory = () => {
return useQuery({
queryKey: ["matchingHistory"],
queryFn: fetchMatchingHistory,
staleTime: Infinity, // 새로운 매칭이나 즐겨찾기 변경 전까지는 캐시 유지
gcTime: 1000 * 60 * 60, // 메모리에서 1시간 동안 유지
});
};
Loading