[LLM 경제 뉴스 요약 서비스] # 06. React/Next js로 프론트엔드 구축 with 모노레포
2026. 1. 27. 17:10ㆍProgramming/Project
안녕하세요-
지난 포스팅에 이어, 오늘은 배포된 FastAPI 서버를 활용해 경제 뉴스 요약 리스트를 보여주는 프론트엔드를 구축해보겠습니다.
프로젝트 구성
- Framework: React/Next js
- PNPM 기반 모노레포 구조
- 스타일: TailwindCSS
- 서버 상태 관리: Tanstack Query
- Lint/Formatter: Biome
- 번들러: Turbopack
시작에 앞서..
Next js란?
Next.js는 React를 기반으로 SSR(Server Side Rendering), SSG(Static Site Generation), Server Components 등을 지원하는 프레임워크입니다.
CSR 중심이었던 기존 React의 한계를 보완하며, 성능·SEO·개발 경험(DX) 측면에서 많은 이점을 제공하고 있죠.
단순히 “SSR을 지원한다”는 이유를 넘어
- 왜 Next.js를 사용하는지
- 어떤 문제를 어떻게 해결해주는지
에 대해서는 이전 포스팅에서 더 자세히 다룬 적이 있어, 아래 글을 참고해주시면 좋겠습니다.
React/Next/TS 기본 개념 및 사용이유
React란?사용자 인터페이스 구축을 위한 JavaScript 라이브러리등장 배경기존의 UI 구현 방식의 한계 명령형 방식 + jQuery$('#button').click(function() { $('#modal').show();});직접 절차적으로 명시상태 변화에 따
keeper.tistory.com
Monorepo란?
Monorepo는 여러 개의 애플리케이션과 패키지를 하나의 저장소에서 함께 관리하는 구조를 의미합니다.
서비스가 커질수록 공통 로직, 디자인 시스템, API 스키마 등을 여러 프로젝트에서 함께 사용하게 되는데,
이를 각각 분리된 레포로 관리하면 중복 관리·버전 불일치·유지보수 비용이 빠르게 증가합니다.
Monorepo는 이러한 문제를 해결하기 위해
- 공통 자산을 한 곳에서 관리하고
- 각 프로젝트 간 의존 관계를 명확히 하며
- 변경 사항을 즉시 공유할 수 있게 해줍니다.
PNPM 모노레포 구성
이번 프로젝트는 PNPM 기반 Monorepo 구조로 구성했습니다.
pnpm을 Monorepo 패키지 매니저로 선택한 이유는
pnpm은 패키지를 글로벌 store 공간에 한 번만 설치하고, 각 프로젝트에서는 이를 심볼릭 링크로 참조합니다.
그 결과,
- 동일한 패키지를 여러 번 설치하지 않아도 되고
- 디스크 사용량을 효율적으로 관리할 수 있으며
- Monorepo 환경에서도 의존성 관리가 깔끔하게 유지됩니다.
즉, 중복 설치 없이 하나의 의존성을 여러 프로젝트에서 공유할 수 있습니다.
우선 루트에서 pnpm을 초기화해 package.json을 생성합니다.
$ pnpm init
저는 각 서비스와 명확히 분리되면서, 여러 프로젝트에서 공통으로 사용되는 요소들을 packages로 분리했습니다.
- OpenAPI 기반 API 스키마 제너레이터
- 공통 Tailwind 테마 및 스타일 설정
├── apps (서비스)
│ ├── frontend
│ └── mobile
├── packages (공유 패키지)
│ ├── openapi
│ └── tailwind
pnpm workspace 설정
루트에는 pnpm-workspace.yaml 파일을 두어
pnpm이 관리할 워크스페이스 범위를 명시해줍니다.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
이 설정을 통해 apps, packages 하위의 모든 프로젝트를
하나의 워크스페이스로 묶어 관리할 수 있습니다.
워크스페이스 패키지 설치 방법
루트(워크스페이스)에 패키지 설치
pnpm install [dependency name] -w
이렇게 워크스페이스 루트에 설치된 패키지는
각 하위 프로젝트에서 별도 설치 없이 바로 사용할 수 있습니다.
모노레포 내부 패키지 간 의존성 연결
pnpm --filter [package A] add [package B] --workspace
모노레포 내부의 패키지를 다른 패키지나 앱에서 사용할 경우,
workspace:*를 사용해 의존성을 연결합니다.
예를 들어 tailwind 패키지를 정의하면,
이를 frontend 앱에서 다음과 같이 사용할 수 있습니다.
{
"name": "@llm-economy/tailwind",
"version": "1.0.0",
"description": "",
"style": "./src/index.css"
}
// frontend/package.json
"dependencies": {
"@llm-economy/openapi": "workspace:*",
"@llm-economy/tailwind": "workspace:*",
이렇게 하면 로컬 패키지를 npm에 배포하지 않고도
Monorepo 내부에서 안정적으로 공유할 수 있습니다.
Next 프로젝트 구성
먼저 apps 하위에서 Next.js 프로젝트를 생성합니다.
npx create-next-app@latest
공식 문서에서도 권장하는 방식이며,
TypeScript, ESLint, App Router 등 기본 설정을 한 번에 구성할 수 있습니다.
https://nextjs.org/docs/app/getting-started/installation
Getting Started: Installation | Next.js
Learn how to create a new Next.js application with the `create-next-app` CLI, and set up TypeScript, ESLint, and Module Path Aliases.
nextjs.org
Biome 설정
이번 프로젝트에서는 lint + formatter를 Biome로 통합했습니다.
이전 프로젝트에서 ESLint + Prettier 조합보다
단일 설정의 편리함부터 실행 속도 측면에서 훨씬 가볍다고 느꼈기 때문입니다
# 설치
pnpm add -D -E @biomejs/biome -w
# 설정 파일 세팅
pnpm exec biome init
이후 루트에 biome.json이 생성됩니다.
// biome.json
{
"$schema": "https://biomejs.dev/schemas/1.6.0/schema.json",
"files": {
"ignore": ["node_modules", "dist", ".next"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn",
"noConsoleLog": "error"
},
"style": {
"useOptionalChain": "warn",
"useArrayLiterals": "warn",
"useConsistentTypeDefinitions": "error"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "error",
"noUndeclaredVariables": "error"
},
"complexity": {
"noUselessTypeConstraint": "warn"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double"
}
}
}
VS Code / Cursor 설정
에디터 내, Biome extension을 설치해주고
setting.json에 Biome를 연결해줍니다.
//setting.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"biome.configurationPath": "/path/project/biome.json",
"biome.lsp.bin": "/path/project/node_modules/.bin/biome"
}
+ Biome Extension 오류 이슈
설정까지 모두 끝났는데도,
VS Code(또는 Cursor)에서 포맷팅이 전혀 동작하지 않는 문제가 발생했습니다..
로그를 확인해보면 다음과 같은 에러가 반복적으로 출력되었습니다.
2026-01-25 22:02:49.563 [info] 🔒 Started listening for lockfile changes.
2026-01-25 22:02:49.563 [info] ⚙️ Started listening for extension settings changes.
2026-01-25 22:02:50.003 [error] Unable to find the Biome binary.
biome.lsp.bin 경로를 명시해줘도 해결되지 않았고,
실제 로컬에서는 pnpm exec biome check가 정상 동작하는 상태였습니다.
[해결 방법]
결론부터 말하면 Biome 확장 자체가 꼬여 있었던 문제였습니다.
아래 명령어로 기존 확장을 완전히 삭제한 뒤,
Biome extension을 다시 설치하니 정상 동작했습니다.
rm -rf ~/.vscode/extensions/biomejs.biome*
확장 내부 캐시나 바이너리 참조가 깨진 상태였던 것으로 보입니다.
https://biomejs.dev/guides/getting-started/
Getting Started
Learn how to set up a new project with Biome.
biomejs.dev
TailwindCSS 설정
우선 워크스페이스 기준으로 Tailwind 관련 패키지를 설치합니다.
pnpm add -D tailwindcss @tailwindcss/postcss postcss -w
Tailwind CSS v3 vs v4
Tailwind CSS v4에서 가장 크게 체감되는 변화는
tailwind.config.js 파일이 더 이상 필수가 아니라는 점입니다.
v3까지는
tailwind.config.js에서
(theme 확장/content 경로 설정/플러그인 관리) 설정들이 JS 기반으로 분산되어 있었고
프로젝트가 커질수록 설정 파일도 함께 비대해졌죠.
v4부터는
모든 설정을 CSS 기반으로 관리할 수 있습니다.
별도의 tailwind.config.js 없이
CSS 파일에서 @theme, @layer, @custom-variant 등을 통해 설정
빌드 타임에 Tailwind가 이를 자동으로 해석합니다.
즉, Tailwind 설정 자체가 “디자인 시스템의 일부”처럼 CSS 안으로 자연스럽게 들어왔습니다.
https://tailwindcss.com/docs/upgrade-guide#using-a-javascript-config-file
Upgrade guide - Getting started
Upgrading your Tailwind CSS projects from v3 to v4.
tailwindcss.com
공통 스타일 패키지 구성
Tailwind CSS v4에서는 설정이 CSS 중심으로 이동했기 때문에,
공통 스타일 패키지를 디자인 토큰 → 컴포넌트 단위 유틸 구조로 구성했습니다.
packages/tailwind
├── package.json
└── src
├── components
│ └── typography.css
├── index.css
└── tokens
├── theme.css
└── typography.css
Tokens 정의
/* /tokens/theme.css */
@theme {
/* green */
--color-green-000: #EBFBEE;
--color-green-100: #D3F9D8;
--color-green-200: #B2F2BB;
...
}
/* /tokens/typography.css */
@theme {
/* heading */
--heading1-size: 2rem;
--heading1-line: 170%;
--heading2-size: 1.875rem;
...
}
색상, spacing, radius 등 디자인 시스템의 최소 단위는 모두 토큰으로 관리합니다.
폰트 크기와 라인 높이 역시 값 자체를 직접 쓰지 않고, 토큰으로 정의했습니다.
Components Utility 정의
토큰을 그대로 쓰기보다는,
실제 UI에서 바로 사용할 수 있도록 utility 레벨로 한 번 더 추상화합니다.
/* componets/typography.css */
@utility heading1-bold {
font-size: var(--heading1-size);
font-weight: 700;
line-height: var(--heading1-line);
}
@utility heading1-semibold {
font-size: var(--heading1-size);
font-weight: 600;
line-height: var(--heading1-line);
}
이렇게 하면 컴포넌트에서는
heading1-bold, heading1-semibold 같은 클래스만 사용하면 됩니다.
패키징 구성
/* index.css */
@import "./tokens/theme.css";
@import "./tokens/typography.css";
@import "./components/typography.css";
그리고 package.json에서 style 엔트리를 지정합니다.
// pacakge.json
{
"name": "@llm-economy/tailwind",
"version": "1.0.0",
"description": "",
"style": "./src/index.css"
}
Next.js에서 사용
/* apps/frontend/global.css */
@import "tailwindcss";
@import "@llm-economy/tailwind";
...
이제 프론트엔드 앱에서는
별도의 설정 없이 공통 Tailwind 테마와 유틸 클래스를 바로 사용할 수 있습니다.
+ Tailwind Extension 자동완성 이슈
처음 공통 스타일을 구성할 때
기존 Tailwind v3 방식처럼 @layer components를 사용해 커스텀 클래스를 정의했습니다.
하지만 이 방식에서는 Tailwind VS Code extension에서 클래스 자동완성이 전혀 동작하지 않았습니다.
Tailwind CSS v4에서는
확장 포인트가 @layer 중심에서 @utility 중심으로 이동했습니다.
- @layer는 여전히 CSS 레벨에서는 유효하지만
- Tailwind v4의 utility 추적 및 extension 인식 대상은 @utility
- 따라서 @layer components 안에 정의한 클래스는
- Tailwind가 “유틸리티 클래스”로 인식하지 못했습니다..!
https://tailwindcss.com/docs/adding-custom-styles#adding-custom-utilities
Adding custom styles - Core concepts
Best practices for adding your own custom styles in Tailwind projects.
tailwindcss.com
Summary 기능 구현
경제 뉴스 요약 리스트는 무한 스크롤 방식으로 구현했습니다.
서버에서는 offset / limit 기반 페이지네이션을 제공하고 있고,
프론트엔드에서는 TanStack Query의 useInfiniteQuery를 사용해 연결했습니다.
API 레이어
OpenAPI 스키마로부터 자동 생성된 query 함수를 랩핑해서 활용합니다.
+ tanstak Query와의 의존성 분리를 위해 API 레이어에서 한번 더 랩핑해서 사용했습니다.
import { UndefinedInitialDataInfiniteOptions, useInfiniteQuery } from "@tanstack/react-query";
import {
GetSummaryListV1SummaryGetParams,
GetSummaryListV1SummaryGetError,
getSummaryListV1SummaryGet,
getSummaryListV1SummaryGetQueryKey,
GetSummaryListV1SummaryGetData,
} from "src/lib/api-v1/query/useGetSummaryListV1SummaryGetQuery";
function useGetInfiniteSummaryList({
params,
options,
}: {
params: GetSummaryListV1SummaryGetParams;
options?: UndefinedInitialDataInfiniteOptions<
GetSummaryListV1SummaryGetData,
GetSummaryListV1SummaryGetError
>;
}) {
const { data, fetchNextPage, hasNextPage, isFetching, isSuccess, error } = useInfiniteQuery({
queryKey: getSummaryListV1SummaryGetQueryKey(params),
initialPageParam: 1,
queryFn: async ({ pageParam = 0, signal }) => {
const { data, error, response } = await getSummaryListV1SummaryGet({
params: { query: { ...params?.query, offset: pageParam as number } },
signal,
});
if (!data || error) {
throw { ...error, response };
}
return data;
},
getNextPageParam: (data) => {
const nextOffset = data.offset + data.limit;
if (nextOffset >= data.total) {
return undefined;
}
return nextOffset;
},
...options,
});
return { data, fetchNextPage, hasNextPage, isFetching, isSuccess, error };
}
export { useGetInfiniteSummaryList };
Intersection Observer 훅
무한 스크롤 트리거는
별도 라이브러리 없이 IntersectionObserver 기반 커스텀 훅으로 구현했습니다.
// shared/use-in-view.ts
import { useEffect, useRef } from "react";
export default function useInView({
enabled = true,
onInteract,
rootMargin = "200px",
}: {
enabled?: boolean;
onInteract: () => void;
rootMargin?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onInteract();
}
}
},
{ rootMargin },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [enabled, onInteract, rootMargin]);
return { ref };
}
- rootMargin 조절로 스크롤 하단 도달 전 미리 fetch
- enabled 조건으로 중복 호출 방지
Summary 리스트 UI
"use client";
import type { SummaryItem } from "@entities/summary/types";
import { ContentBox, DateBox, KeywordBox, LinkBox, TypeBox } from "@entities/summary/ui";
import useInView from "@shared/hooks/use-in-view";
import { SkeletonUI } from "@shared/ui/skeleton";
import { useEffect } from "react";
import { useGetInfiniteSummaryList } from "../api";
// 요약 아이템 컴포넌트
function SummaryItemBox({ content, keywords, author, publishedAt, url }: SummaryItem) {
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<TypeBox>{author}</TypeBox>
{url && <LinkBox url={url} />}
<DateBox date={new Date(publishedAt)} />
</div>
<div className="flex-wrap flex gap-2">
{keywords.map((k) => (
<KeywordBox key={k}>{k}</KeywordBox>
))}
</div>
<ContentBox>{content}</ContentBox>
</div>
);
}
// 로딩 상태 – Skeleton UI
function SummarySkeletonItem() {
return (
<div className="flex flex-col gap-2">
<div className="flex gap-3 items-center">
<SkeletonUI height="20px" width="50px" />
<SkeletonUI height="20px" width="50px" />
</div>
<div className="flex-wrap flex gap-2">
{Array.from({ length: 3 }).map((_, k) => (
<SkeletonUI key={k} height="20px" width="80px" />
))}
</div>
<SkeletonUI height="80px" />
</div>
);
}
// SummaryList 위젯 컴포넌트
function SummaryList() {
const { data, hasNextPage, fetchNextPage, isFetching, isSuccess, error } =
useGetInfiniteSummaryList({
params: {},
});
const { ref } = useInView({
enabled: hasNextPage && !isFetching,
onInteract: fetchNextPage,
});
useEffect(() => {
if (error) console.error(error);
}, [error]);
if (error) return <>something wrong..{error?.detail}</>;
if (isSuccess && data?.pages.length === 0) return <>없어용</>;
return (
<div className="flex flex-col gap-6 w-full">
{data?.pages
.flatMap((page) => page.summaries)
.map((summary) => (
<SummaryItemBox
author={summary.author}
content={summary.content}
keywords={summary.keywords}
publishedAt={summary.published_at}
key={summary.created_at}
url={summary.url}
/>
))}
{isFetching && Array.from({ length: 10 }).map((_, k) => <SummarySkeletonItem key={k} />)}
<div className="min-h-0.5" ref={ref} />
</div>
);
}
export { SummaryList };
- 서버: offset / limit 기반 페이지네이션
- 클라이언트:
- useInfiniteQuery로 페이지 상태 관리
- IntersectionObserver로 스크롤 트리거 분리
- UI:
- Skeleton으로 로딩 상태 명확히 표현
- 데이터 / 로딩 / 에러 상태 분리

이렇게 해서 경제 뉴스 요약 서비스의 UI 단 개발을 마무리해보았습니다.
API 연동부터 무한 스크롤, 공통 스타일 패키지 구성까지 실제 서비스 관점에서 한 단계씩 구현해보았습니다.
다음 포스팅에서는
HTTPS 환경에서의 라우팅 구성 방법을 정리해보려고 합니다.
작업 레포
https://github.com/runru1030/LLM_economy_frontend
GitHub - runru1030/LLM_economy_frontend
Contribute to runru1030/LLM_economy_frontend development by creating an account on GitHub.
github.com
배포 서비스
Create Next App
llm-economy.o-r.kr
'Programming > Project' 카테고리의 다른 글
| [LLM 경제 뉴스 요약 서비스] # 05. N8N 배포 (ECR, ECS) (0) | 2026.01.19 |
|---|---|
| [LLM 경제 뉴스 요약 서비스] # 04. 서버 CI/CD (ECR, ECS) (0) | 2026.01.17 |
| [LLM 경제 뉴스 요약 서비스] # 03. FastAPI 서버 배포 (ECR, ECS) (0) | 2026.01.07 |
| [LLM 경제 뉴스 요약 서비스] # 02. FastAPI + PostgreSQL로 데이터 적재 REST API 구축 (0) | 2026.01.03 |
| [LLM 경제 뉴스 요약 서비스] # 01. n8n 워크플로우 구축 (n8n 사용법) (0) | 2025.12.18 |