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,18 +26,25 @@
}
}
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-main > * {
.site-main>* {
animation: fadeUp 0.6s ease both;
}

View File

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

View File

@@ -4,20 +4,19 @@ const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => {
if (!API_BASE) return path;
const baseClean = API_BASE.replace(/\/+$/, "");
const baseForJoin = `${baseClean}/`;
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
let pathForJoin = normalizedPath;
if (baseClean.endsWith("/api") && normalizedPath.startsWith("api/")) {
pathForJoin = normalizedPath.slice(4);
try {
const base = new URL(API_BASE, window.location.origin);
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
if (!base.pathname.endsWith('/')) {
base.pathname += '/';
}
try {
const baseUrl = new URL(baseForJoin, window.location.origin);
return new URL(pathForJoin, baseUrl).toString();
// Remove leading slash from path to avoid double slashes when joining
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return new URL(cleanPath, base).toString();
} catch (error) {
console.warn("Invalid VITE_API_BASE, falling back to relative URL.", error);
console.error("Invalid VITE_API_BASE configuration:", error);
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 {
display: grid;
gap: 28px;
/* Prevent overflow on small screens */
width: 100%;
}
.stock-header {
@@ -66,7 +68,7 @@
font-size: 13px;
}
.stock-status > div {
.stock-status>div {
display: flex;
justify-content: space-between;
gap: 12px;
@@ -536,15 +538,39 @@
}
@media (max-width: 768px) {
.stock {
gap: 20px;
}
.stock-panel {
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 {
padding: 16px;
}
.stock-status > div {
.stock-status>div {
gap: 8px;
}
@@ -568,13 +594,19 @@
}
.stock-holdings__metric {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: repeat(2, 1fr);
align-items: center;
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 {
font-size: 12px;
font-size: 11px;
}
.stock-holdings__metric strong {

View File

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

View File

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