배경음악 기능 추가 및 예외 처리 수정

feature-ADO2
김성경 2026-05-12 15:22:19 +09:00
parent cf27da30b4
commit a155a98767
12 changed files with 248 additions and 66 deletions

View File

@ -8046,6 +8046,51 @@
background: #379599; background: #379599;
} }
.lyrics-paragraphs {
width: 100%;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
scrollbar-color: #046266 transparent;
}
.lyrics-paragraphs::-webkit-scrollbar {
width: 6px;
}
.lyrics-paragraphs::-webkit-scrollbar-track {
background: transparent;
}
.lyrics-paragraphs::-webkit-scrollbar-thumb {
background: #046266;
border-radius: 3px;
}
.lyrics-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.lyrics-tag {
font-size: var(--text-xs);
font-weight: 700;
color: #379599;
letter-spacing: 0.05em;
}
.lyrics-paragraph {
font-size: var(--text-sm);
font-weight: 400;
color: #CEE5E6;
line-height: 1.9;
white-space: pre-line;
margin: 0;
}
.lyrics-placeholder { .lyrics-placeholder {
width: 100%; width: 100%;
display: flex; display: flex;
@ -10572,6 +10617,10 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
.social-posting-channel-placeholder {
color: rgba(255, 255, 255, 0.35);
}
.social-posting-channel-menu { .social-posting-channel-menu {
position: absolute; position: absolute;
top: 100%; top: 100%;

View File

@ -0,0 +1 @@
google-site-verification: google60b514c02fd6af4e.html

View File

@ -0,0 +1 @@
naver-site-verification: naver33dfe258205b0af1416aa0ac18c8c0b3.html

View File

@ -142,6 +142,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const channelDropdownRef = useRef<HTMLDivElement>(null); const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null); const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false); const hasBeenOpenedRef = useRef(false);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
const SEO_CACHE_TTL = 50 * 60 * 1000;
// Upload progress modal state // Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false); const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -207,11 +211,28 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 소셜 계정 로드 // 소셜 계정 로드
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) return;
loadSocialAccounts();
const now = Date.now();
loadSocialAccounts();
const taskId = video?.task_id ?? null;
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
loadedForTaskIdRef.current = taskId;
loadedAtRef.current = now;
loadAutocomplete(); loadAutocomplete();
} else if (taskId) {
const cached = seoCache.current.get(taskId);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
} }
}, [isOpen, video]); }, [isOpen, video?.task_id]);
const loadSocialAccounts = async () => { const loadSocialAccounts = async () => {
setIsLoadingAccounts(true); setIsLoadingAccounts(true);
@ -252,6 +273,11 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
if (autoSeoResponse.title) setTitle(autoSeoResponse.title); if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description); if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(',')); if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
seoCache.current.set(video.task_id, {
title: autoSeoResponse.title || '',
description: autoSeoResponse.description || '',
tags: autoSeoResponse.keywords?.join(',') || '',
});
} catch (error) { } catch (error) {
console.error('Failed to load autocomplete:', error); console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리 // 실패해도 사용자에게 별도 알림 없이 조용히 처리
@ -530,7 +556,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)} onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
> >
<div className="social-posting-channel-selected"> <div className="social-posting-channel-selected">
{selectedAccount && ( {selectedAccount ? (
<> <>
<img <img
src={getPlatformIcon(selectedAccount.platform)} src={getPlatformIcon(selectedAccount.platform)}
@ -539,6 +565,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
/> />
<span>{selectedAccount.display_name}</span> <span>{selectedAccount.display_name}</span>
</> </>
) : (
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
)} )}
</div> </div>
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none"> <svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">

View File

@ -208,7 +208,7 @@
"imageAlt": "Image", "imageAlt": "Image",
"uploadBadge": "Uploaded", "uploadBadge": "Uploaded",
"imageUpload": "Image Upload", "imageUpload": "Image Upload",
"dragAndDrop": "Drag and drop\nimages to upload", "dragAndDrop": "Drag & drop or\nclick to upload",
"videoRatio": "Video Ratio", "videoRatio": "Video Ratio",
"minImages": "Min. 5 images", "minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts", "youtubeShorts": "YouTube Shorts",
@ -234,6 +234,7 @@
"lyricsColumn": "Lyrics", "lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit", "lyricsHint": "Select the lyrics area to edit",
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.", "lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
"generateButton": "Generate Sound", "generateButton": "Generate Sound",
"regenerateButton": "Regenerate", "regenerateButton": "Regenerate",
"regenerateHint": "Press the regenerate button to create new lyrics and music.", "regenerateHint": "Press the regenerate button to create new lyrics and music.",
@ -306,6 +307,7 @@
"resolution": "Resolution", "resolution": "Resolution",
"lyrics": "Lyrics", "lyrics": "Lyrics",
"sampleLyrics": "Loading lyrics...", "sampleLyrics": "Loading lyrics...",
"noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...", "downloading": "Downloading...",
"download": "Download", "download": "Download",
"uploadToSocial": "Upload to Social" "uploadToSocial": "Upload to Social"

View File

@ -208,7 +208,7 @@
"imageAlt": "이미지", "imageAlt": "이미지",
"uploadBadge": "업로드", "uploadBadge": "업로드",
"imageUpload": "이미지 업로드", "imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 드래그하여\n업로드", "dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
"videoRatio": "영상 비율", "videoRatio": "영상 비율",
"minImages": "최소 5장", "minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠", "youtubeShorts": "유튜브 쇼츠",
@ -234,6 +234,7 @@
"lyricsColumn": "가사", "lyricsColumn": "가사",
"lyricsHint": "가사 영역을 선택해서 수정 가능해요", "lyricsHint": "가사 영역을 선택해서 수정 가능해요",
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.", "lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
"generateButton": "사운드 생성", "generateButton": "사운드 생성",
"regenerateButton": "재생성 하기", "regenerateButton": "재생성 하기",
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.", "regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
@ -306,6 +307,7 @@
"resolution": "규격", "resolution": "규격",
"lyrics": "가사", "lyrics": "가사",
"sampleLyrics": "가사를 불러오는 중...", "sampleLyrics": "가사를 불러오는 중...",
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...", "downloading": "다운로드 중...",
"download": "다운로드", "download": "다운로드",
"uploadToSocial": "소셜 채널 업로드" "uploadToSocial": "소셜 채널 업로드"

View File

@ -1,10 +1,87 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api'; import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal'; import SocialPostingModal from '../../components/SocialPostingModal';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const isPlayingRef = useRef(false);
const pendingPlayRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.preload = 'auto';
} else {
video.preload = 'none';
}
},
{ threshold: 0.1 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
const safePlay = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise
.then(() => { isPlayingRef.current = true; })
.catch((error) => {
if (error.name !== 'AbortError') console.error(error);
});
}
}, []);
const handleCanPlay = useCallback(() => {
if (!pendingPlayRef.current || !videoRef.current) return;
pendingPlayRef.current = false;
safePlay(videoRef.current);
}, [safePlay]);
const handleMouseEnter = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState >= 3) {
safePlay(video);
} else {
pendingPlayRef.current = true;
}
}, [safePlay]);
const handleMouseLeave = useCallback(() => {
const video = videoRef.current;
if (!video) return;
pendingPlayRef.current = false;
if (isPlayingRef.current) {
video.pause();
video.currentTime = 0;
isPlayingRef.current = false;
}
}, []);
return (
<video
ref={videoRef}
src={src}
className={className}
muted
playsInline
preload="none"
onCanPlay={handleCanPlay}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
interface ADO2ContentsPageProps { interface ADO2ContentsPageProps {
onBack: () => void; onBack: () => void;
onNavigate?: (item: string) => void; onNavigate?: (item: string) => void;
@ -166,16 +243,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate
{/* Video Thumbnail */} {/* Video Thumbnail */}
<div className="content-card-thumbnail"> <div className="content-card-thumbnail">
{video.result_movie_url ? ( {video.result_movie_url ? (
<video <VideoPreviewCard
src={video.result_movie_url} src={video.result_movie_url}
className="content-video-preview" className="content-video-preview"
muted
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/> />
) : ( ) : (
<div className="content-no-video"> <div className="content-no-video">

View File

@ -484,7 +484,9 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-lyrics-section"> <div className="comp2-lyrics-section">
<span className="comp2-meta-label">{t('completion.lyrics')}</span> <span className="comp2-meta-label">{t('completion.lyrics')}</span>
<p className="comp2-lyrics-text"> <p className="comp2-lyrics-text">
{songCompletionData?.lyrics || t('completion.sampleLyrics')} {songCompletionData
? (songCompletionData.lyrics || t('completion.noLyricsBGM'))
: t('completion.sampleLyrics')}
</p> </p>
</div> </div>
</div> </div>

View File

@ -553,7 +553,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<PlatformIcon platform={item.platform} size={16} /> <PlatformIcon platform={item.platform} size={16} />
<span style={{ <span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', lineHeight: 1, color: '#9bcacc', lineHeight: 1.4,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}> }}>
{item.platform_username || item.platform_user_id || item.channel_name} {item.platform_username || item.platform_user_id || item.channel_name}

View File

@ -276,6 +276,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악';
// 1. 가사 생성 요청 // 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId); console.log('[SoundStudio] Sending m_id:', mId);
@ -287,37 +288,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
region: businessInfo.region, region: businessInfo.region,
task_id: imageTaskId, task_id: imageTaskId,
orientation, orientation,
...(isInstrumental && { instrumental: true }),
}); });
if (!lyricResponse.success || !lyricResponse.task_id) { if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed')); throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
} }
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
setStatusMessage(t('soundStudio.generatingLyrics'));
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage(t('soundStudio.generatingLyrics'));
}
}
);
if (!lyricDetailResponse.lyric_result) {
throw new Error(t('soundStudio.lyricNotReceived'));
}
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
const genreMap: Record<string, string> = { const genreMap: Record<string, string> = {
'자동 선택': 'pop', '자동 선택': 'pop',
'K-POP': 'kpop', 'K-POP': 'kpop',
@ -329,10 +306,39 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
'ROCK': 'rock', 'ROCK': 'rock',
}; };
let songLyrics: string | undefined;
if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
setStatusMessage(t('soundStudio.generatingLyrics'));
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage(t('soundStudio.generatingLyrics'));
}
}
);
if (!lyricDetailResponse.lyric_result) {
throw new Error(t('soundStudio.lyricNotReceived'));
}
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result;
}
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop', genre: genreMap[selectedGenre] || 'pop',
language, ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
lyrics: lyricDetailResponse.lyric_result,
}); });
if (!songResponse.success) { if (!songResponse.success) {
@ -351,7 +357,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatus('polling'); setStatus('polling');
setStatusMessage(t('soundStudio.generatingSong')); setStatusMessage(t('soundStudio.generatingSong'));
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0); await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0);
} catch (error) { } catch (error) {
console.error('Music generation failed:', error); console.error('Music generation failed:', error);
@ -412,7 +418,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="sound-type-grid"> <div className="sound-type-grid">
{[ {[
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false }, { key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: true }, { key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
].map(({ key, label, disabled }) => ( ].map(({ key, label, disabled }) => (
<button <button
key={key} key={key}
@ -483,8 +489,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['한국어', 'English', '中文'].map((lang) => ( {['한국어', 'English', '中文'].map((lang) => (
<button <button
key={lang} key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)} onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating} disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`} className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
> >
<span>{LANGUAGE_FLAGS[lang]}</span> {lang} <span>{LANGUAGE_FLAGS[lang]}</span> {lang}
@ -495,8 +501,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => ( {['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
<button <button
key={lang} key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)} onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating} disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`} className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
> >
<span>{LANGUAGE_FLAGS[lang]}</span> {lang} <span>{LANGUAGE_FLAGS[lang]}</span> {lang}
@ -598,16 +604,28 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */} {/* Lyrics Display */}
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? ( {lyrics ? (() => {
<textarea const lines = lyrics.split('\n').filter(l => l.trim());
value={lyrics} const size = Math.ceil(lines.length / 3);
readOnly const outroLines = lines.slice(size * 2).slice(0, 2);
className="lyrics-textarea" const sections = [
placeholder={t('soundStudio.lyricsPlaceholder')} { tag: '[Verse]', lines: lines.slice(0, size) },
/> { tag: '[Chorus]', lines: lines.slice(size, size * 2) },
) : ( { tag: '[Outro]', lines: outroLines },
].filter(s => s.lines.length > 0);
return (
<div className="lyrics-paragraphs">
{sections.map((section, i) => (
<div key={i} className="lyrics-section">
<span className="lyrics-tag">{section.tag}</span>
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})() : (
<div className="lyrics-placeholder"> <div className="lyrics-placeholder">
{t('soundStudio.lyricsPlaceholder')} {selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
</div> </div>
)} )}
</div> </div>

View File

@ -68,6 +68,7 @@ export interface LyricGenerateRequest {
region: string; region: string;
task_id: string; task_id: string;
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
instrumental?: boolean;
} }
// 가사 생성 응답 // 가사 생성 응답
@ -103,8 +104,9 @@ export interface LyricDetailResponse {
// 노래 생성 요청 // 노래 생성 요청
export interface SongGenerateRequest { export interface SongGenerateRequest {
genre: string; genre: string;
language: string; language?: string;
lyrics: string; lyrics?: string;
instrumental?: boolean;
} }
// 노래 생성 응답 // 노래 생성 응답

View File

@ -162,6 +162,8 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
}); });
if (!response.ok) { if (!response.ok) {
const errorBody = await response.json().catch(() => null);
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@ -477,14 +479,20 @@ export async function authenticatedFetch(
let response = await fetch(url, { ...options, headers }); let response = await fetch(url, { ...options, headers });
// 401 에러 시 토큰 갱신 시도 // 401 에러 시 에러 코드에 따라 처리
if (response.status === 401) { if (response.status === 401) {
const errorBody = await response.json().catch(() => null);
const errorCode = errorBody?.detail?.code;
if (errorCode !== 'TOKEN_EXPIRED') {
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
redirectToLogin();
throw new Error(errorCode ?? 'Unauthorized');
}
try { try {
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => { refreshPromise = refreshAccessToken().finally(() => {
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
refreshPromise = null; refreshPromise = null;
}); });
} }
@ -501,7 +509,6 @@ export async function authenticatedFetch(
response = await fetch(url, { ...options, headers: newHeaders }); response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) { } catch (refreshError) {
console.error('Token refresh failed:', refreshError); console.error('Token refresh failed:', refreshError);
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
redirectToLogin(); redirectToLogin();
throw refreshError; throw refreshError;
} }