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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
Expand Down Expand Up @@ -50,6 +51,7 @@
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"supabase": ">1.8.1",
"tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
Expand Down
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions src/app/api/news/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";

import { newsPaginationHandler } from "@/entities/news/handler/news-pagination.handler";

export async function GET(request: NextRequest) {
try {
// URL에서 page 파라미터 추출
const searchParams = request.nextUrl.searchParams;
const pageParam = searchParams.get("page");
const page = pageParam ? parseInt(pageParam, 10) : 0;

// 페이지 번호 유효성 검사
if (isNaN(page) || page < 0) {
return NextResponse.json(
{
status: "error",
data: null,
error: "Invalid page parameter",
},
{ status: 400 },
);
}

// 핸들러 호출
const data = await newsPaginationHandler(page);

return NextResponse.json(
{
status: "success",
data,
},
{ status: 200 },
);
} catch (error) {
console.error("News API error:", error);
return NextResponse.json(
{
status: "error",
data: null,
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 },
);
}
}
Comment on lines +5 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

API 응답을 생성하는 로직이 중복되고 있습니다. 성공 및 오류 응답을 생성하는 헬퍼 함수를 만들어 사용하면 코드 가독성과 유지보수성을 높일 수 있습니다. 예를 들어, successResponse(data)errorResponse(message, status) 같은 함수를 shared 영역에 만들어 재사용하는 것을 권장합니다.

3 changes: 2 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tailwind-scrollbar-hide/v4";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));
Expand Down Expand Up @@ -132,7 +133,7 @@

@layer base {
* {
@apply border-border outline-ring/50;
@apply scrollbar-hide border-border outline-ring/50;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

* 셀렉터에 scrollbar-hide를 적용하여 전역적으로 스크롤바를 숨기는 것은 접근성 문제를 야기할 수 있습니다. 스크롤바는 사용자가 콘텐츠를 탐색할 수 있다는 시각적 단서를 제공하는데, 이를 숨기면 일부 사용자는 스크롤 가능한 영역을 인지하지 못할 수 있습니다. 꼭 필요한 컴포넌트에만 개별적으로 적용하거나, 스크롤바를 숨기더라도 다른 시각적 힌트를 제공하는 것을 고려해보세요.

    @apply border-border outline-ring/50;

}
body {
@apply bg-background text-text-dark;
Expand Down
35 changes: 34 additions & 1 deletion src/app/news/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
"use client";

import { useGetNewsList } from "@/entities";
import { NewsListSection, SummaryNewsSection } from "@/features";

export default function NewsPage() {
return <div>News Page</div>;
const { data: newsResponse, isLoading } = useGetNewsList(0);

const newsData = newsResponse?.data;
const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || {
newsDate: "",
totalNews: 0,
investmentIndex: 0,
summary: { positive: 0, negative: 0, neutral: 0 },
keywords: [],
newsAnalysis: [],
};
Comment on lines +10 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

newsDataundefined일 경우를 대비한 기본값이 길고 복잡합니다. 이 기본 객체를 DEFAULT_NEWS_DATA와 같은 상수로 추출하면 코드 가독성을 높이고, 다른 곳에서 재사용하기도 용이해집니다.

Suggested change
const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || {
newsDate: "",
totalNews: 0,
investmentIndex: 0,
summary: { positive: 0, negative: 0, neutral: 0 },
keywords: [],
newsAnalysis: [],
};
const DEFAULT_NEWS_DATA = {
newsDate: "",
totalNews: 0,
investmentIndex: 0,
summary: { positive: 0, negative: 0, neutral: 0 },
keywords: [],
newsAnalysis: [],
};
const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || DEFAULT_NEWS_DATA;


return (
<div className='flex w-full max-w-4xl flex-col gap-8 px-5 py-10'>
<h1 className='text-2xl font-bold text-white'>뉴스 목록</h1>
<p>AI가 분석한 코인 관련 뉴스입니다.</p>
<div className='flex w-full flex-col gap-8 pb-20'>
<SummaryNewsSection
newsDate={newsDate}
totalNews={totalNews}
investmentIndex={investmentIndex}
summary={summary}
keywords={keywords}
isLoading={isLoading}
/>
<NewsListSection newsAnalysis={newsAnalysis} isLoading={isLoading} />
</div>
</div>
);
}
1 change: 1 addition & 0 deletions src/entities/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./market";
export * from "./user";
export * from "./news";
Empty file.
112 changes: 112 additions & 0 deletions src/entities/news/handler/news-pagination.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { unstable_cache } from "next/cache";

import {
AnalysisAPIResponseSchema,
NewsAPIResponseSchema,
NewsAnalysisData,
NewsAnalysisItem,
PaginatedNewsResponse,
analysisAPI,
newsAPI,
} from "../model";

const ITEMS_PER_PAGE = 5;
const REVALIDATE_TIME = 300; // 5분 (초 단위)

const getMergedNewsData = unstable_cache(
async () => {
// 두 API를 병렬로 호출
const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]);

// Zod를 사용한 API 응답 검증
const validatedNewsResponse = NewsAPIResponseSchema.parse(newsResponse);
const validatedAnalysisResponse = AnalysisAPIResponseSchema.parse(analysisResponse);

if (!validatedNewsResponse.success || !validatedAnalysisResponse.success) {
throw new Error("Failed to fetch news data");
}

const newsData = validatedNewsResponse.data;
const analysisData = validatedAnalysisResponse.data;

// newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해)
const analysisMap = new Map<number, NewsAnalysisData>(analysisData.newsAnalysis.map((item) => [item.newsId, item]));

// newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭
const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => {
const analysis: NewsAnalysisData = analysisMap.get(news.id) || {
newsId: news.id,
reason: "",
keywords: [],
sentiment: "neutral" as const,
confidence: 0,
};

return {
newsId: news.id,
title: news.title,
content: news.content,
url: news.url,
source: news.source,
reason: analysis.reason,
keywords: analysis.keywords,
Comment on lines +45 to +52
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

The pagination logic doesn't handle the case where the requested page exceeds available pages. If a user requests page=10 but there are only 4 pages of data, an empty array will be returned without any indication that the page is out of bounds.

Consider adding validation before slicing:

if (page >= totalPages && totalPages > 0) {
  throw new Error(`Page ${page} exceeds total pages ${totalPages}`);
}

Or return an error response from the API route handler.

Copilot uses AI. Check for mistakes.
sentiment: analysis.sentiment,
confidence: analysis.confidence,
};
});

// 전체 데이터 반환 (페이지네이션 되지 않음)
return {
meta: {
newsDate: analysisData.date,
totalNews: analysisData.totalNews,
investmentIndex: analysisData.investmentIndex,
summary: analysisData.summary,
keywords: analysisData.keywords,
},
items: combinedNewsAnalysis,
};
},
["combined-news-data"],
{ revalidate: REVALIDATE_TIME, tags: ["news"] },
);

export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => {
try {
// 캐시된 전체 데이터를 가져옴 (API 호출 X, 메모리/파일시스템에서 로드 O)
const cachedData = await getMergedNewsData();

const { items, meta } = cachedData;

// 페이지네이션 계산
const totalItems = items.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
const startIndex = page * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;

// 현재 페이지의 데이터만 추출
const paginatedNewsAnalysis = items.slice(startIndex, endIndex);

return {
newsDate: meta.newsDate,
totalNews: meta.totalNews,
investmentIndex: meta.investmentIndex,
summary: meta.summary,
keywords: meta.keywords,
newsAnalysis: paginatedNewsAnalysis,
pagination: {
currentPage: page,
totalPages,
totalItems,
hasNext: page < totalPages - 1,
hasPrev: page > 0,
},
};
} catch (error) {
if (error instanceof Error) {
console.error("News pagination handler error:", error.message);
throw new Error(`Failed to process news data: ${error.message}`);
}
throw new Error("Failed to process news data: Unknown error");
}
};
1 change: 1 addition & 0 deletions src/entities/news/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./model";
3 changes: 2 additions & 1 deletion src/entities/news/model/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./news.api";
export * from "./analysis.api";
export * from "./news-list.api";
export * from "./news.api";
19 changes: 19 additions & 0 deletions src/entities/news/model/apis/news-list.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { APIResponse } from "@/shared";

import { PaginatedNewsResponse } from "../types";

export type NewsListParams = {
page?: number;
};

export type NewsListResponse = APIResponse<PaginatedNewsResponse>;

export const newsListAPI = async ({ page = 0 }: NewsListParams): Promise<NewsListResponse> => {
const response = await fetch(`/api/news?page=${page}`);

if (!response.ok) {
throw new Error("Failed to fetch news list");
}

return response.json();
};
1 change: 1 addition & 0 deletions src/entities/news/model/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useGetNewsList";
12 changes: 12 additions & 0 deletions src/entities/news/model/hooks/useGetNewsList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import { useQuery } from "@tanstack/react-query";

import { newsListAPI } from "../apis";

export const useGetNewsList = (page: number) => {
return useQuery({
queryKey: ["news-list", page],
queryFn: () => newsListAPI({ page }),
});
};
2 changes: 2 additions & 0 deletions src/entities/news/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./apis";
export * from "./types";
export * from "./hooks";
export * from "./schema";
1 change: 1 addition & 0 deletions src/entities/news/model/schema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./news.schema";
Loading
Loading