From 7b7e82873e96f7f2864a14fe589ac36c601ef034 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sat, 27 Dec 2025 14:57:58 +0900 Subject: [PATCH] init --- README.md | 19 ++++++++++++++ src/pages/AiNewsPage.jsx | 56 +++++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1ee1791..7ff5152 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,18 @@ npm run dev # 개발 서버 (http://localhost:5173) ## 빌드 & 미리보기 ```bash +nvm use 20 +npm ci # (처음/의존성 변경 시 권장) 이미 설치돼 있으면 npm install도 OK +npm run build +``` + +## 재빌드 +```bash npm run build npm run preview # http://localhost:4173 ``` + ## 프로덕션 배포 (Apache) 이 프로젝트는 기본적으로 API 호출을 `API_BASE_URL` 기준으로 수행합니다. @@ -138,6 +146,17 @@ sudo systemctl reload apache2 ``` +### 404로 무한 요청이 날 때(중요 체크) +브라우저 콘솔에 아래처럼 뜨면: + +- `GET https://talk.ncue.net/community/ai_news ... 404` + +대부분은 **Apache가 `/community`를 백엔드로 프록시하지 않고**, 프론트 정적 파일에서 해당 경로를 찾으려다 404를 내는 상황입니다. + +- Apache 설정에 `ProxyPass /community ...`가 **:443 가상호스트에 실제로 적용**되었는지 확인 +- `a2enmod proxy proxy_http` 적용 후 `systemctl reload apache2` +- SPA rewrite(.htaccess)와 함께 쓴다면, **프록시가 rewrite보다 먼저 적용**되도록 VirtualHost에 ProxyPass를 두는 것을 권장 + 백엔드 엔드포인트가 더 있다면 동일 패턴으로 `ProxyPass`를 추가하거나, `/api` prefix로 백엔드를 묶어 프론트 코드에서 `VITE_API_BASE_URL=/api`로 통일하는 것도 가능합니다. ## 빌드 산출물 구조 diff --git a/src/pages/AiNewsPage.jsx b/src/pages/AiNewsPage.jsx index 5b20fe1..05fd612 100644 --- a/src/pages/AiNewsPage.jsx +++ b/src/pages/AiNewsPage.jsx @@ -22,6 +22,8 @@ export default function AiNewsPage() { const [news, setNews] = useState([]); const [newsOffset, setNewsOffset] = useState(0); const [newsLoading, setNewsLoading] = useState(false); + const [newsHasMore, setNewsHasMore] = useState(true); + const [newsError, setNewsError] = useState(''); const [showNewsEditor, setShowNewsEditor] = useState(false); const [newsUrl, setNewsUrl] = useState(''); const contentRef = useRef(null); @@ -29,6 +31,8 @@ export default function AiNewsPage() { useEffect(() => { (async () => { + setNewsError(''); + setNewsHasMore(true); await loadNews(0, false); // 최초 로드 후 최신 위치로 한 번 스크롤 requestAnimationFrame(() => { @@ -40,9 +44,10 @@ export default function AiNewsPage() { // IntersectionObserver로 상단 도달 시 로드 useEffect(() => { + if (!newsHasMore || newsError) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && !newsLoading) { + if (entries[0].isIntersecting && !newsLoading && newsHasMore && !newsError) { loadNews(newsOffset, true); } }, @@ -50,16 +55,28 @@ export default function AiNewsPage() { ); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); - }, [newsOffset, newsLoading]); + }, [newsOffset, newsLoading, newsHasMore, newsError]); const loadNews = async (offset = 0, prepend = false) => { - if (newsLoading) return; + if (newsLoading || !newsHasMore || newsError) return; setNewsLoading(true); try { const res = await fetch(`${API_BASE_URL}/community/ai_news?offset=${offset}&limit=10`); - if (!res.ok) return; + if (!res.ok) { + // 404는 보통 Apache 프록시 미설정(정적서버가 /community 경로를 못 찾음) + setNewsError(`인사이트 데이터를 불러오지 못했습니다. (HTTP ${res.status})`); + setNewsHasMore(false); + return null; + } const data = await res.json(); - const items = (data.items || []).reverse(); + const rawItems = Array.isArray(data.items) ? data.items : []; + // 서버는 보통 최신이 먼저 오므로, UI를 "아래가 최신" 형태로 유지하려고 reverse 유지 + const items = rawItems.slice().reverse(); + + // 더 이상 가져올 게 없으면 observer가 무한 호출하지 않도록 차단 + if (rawItems.length === 0) { + setNewsHasMore(false); + } if (prepend) { const el = contentRef.current; const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight; @@ -79,9 +96,17 @@ export default function AiNewsPage() { if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight); }, 0); } - const nextOffset = data.nextOffset || 0; - setNewsOffset(nextOffset); + const nextOffset = typeof data.nextOffset === 'number' ? data.nextOffset : null; + if (nextOffset === null || nextOffset === offset) { + setNewsHasMore(false); + } else { + setNewsOffset(nextOffset); + } return nextOffset; + } catch (e) { + setNewsError('인사이트 데이터를 불러오지 못했습니다. (네트워크 오류)'); + setNewsHasMore(false); + return null; } finally { setNewsLoading(false); } @@ -112,6 +137,23 @@ export default function AiNewsPage() {
+ {newsError ? ( +
+
인사이트 로딩 실패
+
{newsError}
+ +
+ ) : null} {news.map((n) => (