1. 라우팅 최적화

2. API 호출 병렬 처리
3. UI개선 - 로딩 경험 개선
4. 반응형 디자인
5. API 통신 특이사항 - URL 구성 로직의 잠재적 위험 해결
This commit is contained in:
2026-02-09 00:13:40 +09:00
parent d7e7ccdb16
commit bdb055cb32
8 changed files with 173 additions and 55 deletions

View File

@@ -26,11 +26,18 @@
} }
} }
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
}
@keyframes fadeUp { @keyframes fadeUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(16px); transform: translateY(16px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import Loading from './components/Loading';
import './App.css'; import './App.css';
function App() { function App() {
@@ -8,7 +9,9 @@ function App() {
<div className="app-shell"> <div className="app-shell">
<Navbar /> <Navbar />
<main className="site-main"> <main className="site-main">
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet /> <Outlet />
</React.Suspense>
</main> </main>
</div> </div>
); );

View File

@@ -4,20 +4,19 @@ const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => { const toApiUrl = (path) => {
if (!API_BASE) return path; if (!API_BASE) return path;
const baseClean = API_BASE.replace(/\/+$/, ""); try {
const baseForJoin = `${baseClean}/`; const base = new URL(API_BASE, window.location.origin);
const normalizedPath = path.startsWith("/") ? path.slice(1) : path; // Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
let pathForJoin = normalizedPath; if (!base.pathname.endsWith('/')) {
base.pathname += '/';
if (baseClean.endsWith("/api") && normalizedPath.startsWith("api/")) {
pathForJoin = normalizedPath.slice(4);
} }
try { // Remove leading slash from path to avoid double slashes when joining
const baseUrl = new URL(baseForJoin, window.location.origin); const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return new URL(pathForJoin, baseUrl).toString();
return new URL(cleanPath, base).toString();
} catch (error) { } catch (error) {
console.warn("Invalid VITE_API_BASE, falling back to relative URL.", error); console.error("Invalid VITE_API_BASE configuration:", error);
return path; return path;
} }
}; };

View File

@@ -0,0 +1,58 @@
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
gap: 12px;
}
.loading-spinner__circle {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: var(--accent, #f7a8a5);
animation: spin 0.8s linear infinite;
}
.loading-spinner__text {
font-size: 13px;
color: var(--muted, #b6b1a9);
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-skeleton {
display: grid;
gap: 12px;
padding: 16px;
width: 100%;
}
.loading-skeleton__line {
height: 16px;
border-radius: 4px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 25%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 75%
);
background-size: 200% 100%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './Loading.css';
const Loading = ({ type = 'spinner', message = '로딩 중...' }) => {
if (type === 'skeleton') {
return (
<div className="loading-skeleton">
<div className="loading-skeleton__line" style={{ width: '60%' }}></div>
<div className="loading-skeleton__line" style={{ width: '80%' }}></div>
<div className="loading-skeleton__line" style={{ width: '40%' }}></div>
</div>
);
}
return (
<div className="loading-spinner">
<div className="loading-spinner__circle"></div>
{message && <p className="loading-spinner__text">{message}</p>}
</div>
);
};
export default Loading;

View File

@@ -1,6 +1,8 @@
.stock { .stock {
display: grid; display: grid;
gap: 28px; gap: 28px;
/* Prevent overflow on small screens */
width: 100%;
} }
.stock-header { .stock-header {
@@ -536,8 +538,32 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.stock {
gap: 20px;
}
.stock-panel { .stock-panel {
padding: 16px; padding: 16px;
gap: 12px;
}
.stock-filter-row {
gap: 12px;
grid-template-columns: 1fr;
}
.stock-header h1 {
font-size: 28px;
}
.stock-actions {
width: 100%;
}
.stock-actions .button {
flex: 1;
text-align: center;
justify-content: center;
} }
.stock-card { .stock-card {
@@ -568,13 +594,19 @@
} }
.stock-holdings__metric { .stock-holdings__metric {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: repeat(2, 1fr);
align-items: center; align-items: center;
justify-items: start; justify-items: start;
gap: 8px 16px;
}
/* Make the last item span full width if it's odd */
.stock-holdings__metric>*:last-child:nth-child(odd) {
grid-column: 1 / -1;
} }
.stock-holdings__metric span { .stock-holdings__metric span {
font-size: 12px; font-size: 11px;
} }
.stock-holdings__metric strong { .stock-holdings__metric strong {

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews } from '../../api'; import { getStockIndices, getStockNews } from '../../api';
import Loading from '../../components/Loading';
import './Stock.css'; import './Stock.css';
const formatDate = (value) => { const formatDate = (value) => {
@@ -206,7 +207,7 @@ const Stock = () => {
</div> </div>
<div className="stock-panel__actions"> <div className="stock-panel__actions">
{indicesLoading ? ( {indicesLoading ? (
<span className="stock-chip">불러오는 </span> <Loading type="spinner" message="" />
) : null} ) : null}
<button <button
className="button ghost small" className="button ghost small"
@@ -237,8 +238,7 @@ const Stock = () => {
return ( return (
<div <div
key={item.name} key={item.name}
className={`stock-snapshot__card ${ className={`stock-snapshot__card ${highlighted.has(item.name)
highlighted.has(item.name)
? 'is-highlight' ? 'is-highlight'
: '' : ''
}`} }`}
@@ -246,8 +246,7 @@ const Stock = () => {
<p>{item.name}</p> <p>{item.name}</p>
<strong>{item.value ?? '--'}</strong> <strong>{item.value ?? '--'}</strong>
<span <span
className={`stock-snapshot__change ${ className={`stock-snapshot__change ${direction === 'up'
direction === 'up'
? 'is-up' ? 'is-up'
: direction === 'down' : direction === 'down'
? 'is-down' ? 'is-down'
@@ -332,9 +331,7 @@ const Stock = () => {
</p> </p>
</div> </div>
<div className="stock-panel__actions"> <div className="stock-panel__actions">
{loading ? ( {loading && <span className="stock-chip">Updating...</span>}
<span className="stock-chip">불러오는 </span>
) : null}
<span className="stock-chip"> <span className="stock-chip">
국내 {newsDomestic.length} / 해외{' '} 국내 {newsDomestic.length} / 해외{' '}
{newsOverseas.length} {newsOverseas.length}
@@ -342,8 +339,8 @@ const Stock = () => {
</div> </div>
</div> </div>
{loading ? ( {loading && combinedNews.length === 0 ? (
<p className="stock-empty">뉴스를 불러오는 ...</p> <Loading type="skeleton" />
) : newsError ? ( ) : newsError ? (
<p className="stock-empty">{newsError}</p> <p className="stock-empty">{newsError}</p>
) : combinedNews.length === 0 ? ( ) : combinedNews.length === 0 ? (
@@ -353,8 +350,7 @@ const Stock = () => {
<div className="stock-tabs"> <div className="stock-tabs">
<button <button
type="button" type="button"
className={`stock-tab ${ className={`stock-tab ${newsCategory === 'domestic'
newsCategory === 'domestic'
? 'is-active' ? 'is-active'
: '' : ''
}`} }`}
@@ -364,8 +360,7 @@ const Stock = () => {
</button> </button>
<button <button
type="button" type="button"
className={`stock-tab ${ className={`stock-tab ${newsCategory === 'overseas'
newsCategory === 'overseas'
? 'is-active' ? 'is-active'
: '' : ''
}`} }`}

View File

@@ -1,10 +1,11 @@
import React from 'react'; import React, { lazy } from 'react';
import Home from './pages/home/Home';
import Blog from './pages/blog/Blog'; const Home = lazy(() => import('./pages/home/Home'));
import Lotto from './pages/lotto/Lotto'; const Blog = lazy(() => import('./pages/blog/Blog'));
import Travel from './pages/travel/Travel'; const Lotto = lazy(() => import('./pages/lotto/Lotto'));
import Stock from './pages/stock/Stock'; const Travel = lazy(() => import('./pages/travel/Travel'));
import StockTrade from './pages/stock/StockTrade'; const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
export const navLinks = [ export const navLinks = [
{ {