diff --git a/.env b/.env
index 0b3484a5..ba05b9ba 100644
--- a/.env
+++ b/.env
@@ -28,3 +28,6 @@ VITE_TRACKING_WS=wss://mda.kcg.go.kr/ws-tracks/websocket
# 선박 데이터 쓰로틀링 (ms, 위성망 대역폭 절약)
VITE_SHIP_THROTTLE=30
+
+# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
+VITE_MAIN_APP_URL=https://mda.kcg.go.kr
diff --git a/.env.dev b/.env.dev
index 5fb9e2b6..7d751e87 100644
--- a/.env.dev
+++ b/.env.dev
@@ -27,3 +27,6 @@ VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
# 선박 데이터 쓰로틀링 (ms)
VITE_SHIP_THROTTLE=30
+
+# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
+VITE_MAIN_APP_URL=http://10.26.252.39:9090
diff --git a/.env.development b/.env.development
index 862fb9cb..6f09b3ff 100644
--- a/.env.development
+++ b/.env.development
@@ -4,8 +4,8 @@
# - 로컬 개발 전용
# ============================================
-# 배포 경로 (로컬 개발은 루트)
-VITE_BASE_URL=/
+# 배포 경로 (프록시 모드: localhost:9090/kcgnv/ → localhost:3000/kcgnv/)
+VITE_BASE_URL=/kcgnv/
# API 서버 (프록시 타겟)
VITE_API_URL=http://10.26.252.39:9090
@@ -27,3 +27,10 @@ VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
# 선박 데이터 쓰로틀링 (ms, 0=무제한)
VITE_SHIP_THROTTLE=0
+
+# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
+VITE_MAIN_APP_URL=http://localhost:9090
+
+# 로컬 개발 인증 우회 (포트가 달라 localStorage 공유 불가)
+# true면 세션 없을 때 모의 사용자로 자동 로그인
+VITE_DEV_SKIP_AUTH=false
diff --git a/.env.qa b/.env.qa
index 12e75f45..1ec19406 100644
--- a/.env.qa
+++ b/.env.qa
@@ -27,3 +27,6 @@ VITE_TRACKING_WS=ws://10.188.141.123:9090/ws-tracks/websocket
# 선박 데이터 쓰로틀링 (ms)
VITE_SHIP_THROTTLE=30
+
+# 메인 프로젝트 URL (세션 만료 시 리다이렉트)
+VITE_MAIN_APP_URL=http://10.188.141.123:9090
diff --git a/src/App.jsx b/src/App.jsx
index 91ab597a..d9600238 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,6 +3,7 @@ import { lazy, Suspense } from 'react';
// 구현 영역 - 레이아웃
import MainLayout from './components/layout/MainLayout';
+import SessionGuard from './components/auth/SessionGuard';
import { ToastContainer } from './components/common/Toast';
import { AlertModalContainer } from './components/common/AlertModal';
@@ -22,7 +23,7 @@ const PublishRouter = import.meta.env.DEV
export default function App() {
return (
- <>
+
@@ -48,6 +49,6 @@ export default function App() {
{/* />*/}
{/*)}*/}
- >
+
);
}
diff --git a/src/api/commonApi.js b/src/api/commonApi.js
index 1bdafaf8..b3629b52 100644
--- a/src/api/commonApi.js
+++ b/src/api/commonApi.js
@@ -1,6 +1,7 @@
/**
* 공통코드 API
*/
+import { fetchWithAuth } from './fetchWithAuth';
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
@@ -12,10 +13,9 @@ const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
*/
export async function fetchCommonCodeList(commonCodeTypeNumber) {
try {
- const response = await fetch(COMMON_CODE_LIST_ENDPOINT, {
+ const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({ commonCodeTypeNumber }),
});
diff --git a/src/api/fetchWithAuth.js b/src/api/fetchWithAuth.js
new file mode 100644
index 00000000..59befcb7
--- /dev/null
+++ b/src/api/fetchWithAuth.js
@@ -0,0 +1,41 @@
+import { useAuthStore } from '../stores/authStore';
+import { SESSION_TIMEOUT_MS } from '../types/constants';
+
+/**
+ * 인증 래퍼 fetch
+ * - 사전 체크: loginDate 기반 타임아웃
+ * - 사후 체크: 4011 응답 감지 (세션 만료)
+ * - credentials: 'include' 자동 설정
+ */
+export async function fetchWithAuth(url, options = {}) {
+ // 로컬 개발: 세션 타임아웃 체크 우회
+ if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') {
+ const loginDate = localStorage.getItem('loginDate');
+ if (!loginDate || Date.now() - Number(loginDate) > SESSION_TIMEOUT_MS) {
+ useAuthStore.getState().handleSessionExpired();
+ throw new Error('Session expired');
+ }
+ localStorage.setItem('loginDate', String(Date.now()));
+ }
+
+ const response = await fetch(url, { ...options, credentials: 'include' });
+
+ // JSON 응답에서 4011 체크
+ if (response.ok) {
+ const ct = response.headers.get('content-type');
+ if (ct && ct.includes('application/json')) {
+ const cloned = response.clone();
+ try {
+ const data = await cloned.json();
+ if (data?.code === 4011) {
+ useAuthStore.getState().handleSessionExpired();
+ throw new Error('Session expired (4011)');
+ }
+ } catch (e) {
+ if (e.message.includes('Session expired')) throw e;
+ }
+ }
+ }
+
+ return response;
+}
diff --git a/src/api/satelliteApi.js b/src/api/satelliteApi.js
index abe82b4d..60beab89 100644
--- a/src/api/satelliteApi.js
+++ b/src/api/satelliteApi.js
@@ -1,6 +1,7 @@
/**
* 위성 API
*/
+import { fetchWithAuth } from './fetchWithAuth';
const SATELLITE_VIDEO_SEARCH_ENDPOINT = '/api/gis/satlit/search';
const SATELLITE_CSV_ENDPOINT = '/api/gis/satlit/excelToJson';
@@ -43,10 +44,10 @@ export async function fetchSatelliteVideoList({
satelliteVideoOrigin,
}) {
try {
- const response = await fetch(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_VIDEO_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({
page,
startDate,
@@ -83,10 +84,10 @@ export async function fetchSatelliteVideoList({
*/
export async function fetchSatelliteCsvFeatures(csvFileName) {
try {
- const response = await fetch(SATELLITE_CSV_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_CSV_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ csvFileName }),
});
@@ -116,10 +117,10 @@ export async function fetchSatelliteCsvFeatures(csvFileName) {
*/
export async function fetchSatelliteVideoDetail(satelliteId) {
try {
- const response = await fetch(SATELLITE_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ satelliteId }),
});
@@ -154,10 +155,10 @@ export async function fetchSatelliteVideoDetail(satelliteId) {
*/
export async function updateSatelliteVideo(params) {
try {
- const response = await fetch(SATELLITE_UPDATE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
@@ -177,10 +178,10 @@ export async function updateSatelliteVideo(params) {
*/
export async function fetchSatelliteCompanyList() {
try {
- const response = await fetch(SATELLITE_COMPANY_LIST_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_COMPANY_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({}),
});
@@ -204,10 +205,10 @@ export async function fetchSatelliteCompanyList() {
*/
export async function fetchSatelliteManageList(companyNo) {
try {
- const response = await fetch(SATELLITE_MANAGE_LIST_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_MANAGE_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ companyNo }),
});
@@ -230,9 +231,9 @@ export async function fetchSatelliteManageList(companyNo) {
*/
export async function saveSatelliteVideo(formData) {
try {
- const response = await fetch(SATELLITE_SAVE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_SAVE_ENDPOINT, {
method: 'POST',
- credentials: 'include',
+
body: formData,
});
@@ -255,10 +256,10 @@ export async function saveSatelliteVideo(formData) {
*/
export async function searchSatelliteCompany({ companyTypeCode, companyName, page, limit }) {
try {
- const response = await fetch(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_COMPANY_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ companyTypeCode, companyName, page, limit }),
});
@@ -289,10 +290,10 @@ export async function searchSatelliteCompany({ companyTypeCode, companyName, pag
*/
export async function saveSatelliteCompany(params) {
try {
- const response = await fetch(SATELLITE_COMPANY_SAVE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_COMPANY_SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
@@ -313,10 +314,10 @@ export async function saveSatelliteCompany(params) {
*/
export async function fetchSatelliteCompanyDetail(companyNo) {
try {
- const response = await fetch(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_COMPANY_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ companyNo }),
});
@@ -345,10 +346,10 @@ export async function fetchSatelliteCompanyDetail(companyNo) {
*/
export async function updateSatelliteCompany(params) {
try {
- const response = await fetch(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_COMPANY_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
@@ -372,10 +373,10 @@ export async function updateSatelliteCompany(params) {
*/
export async function searchSatelliteManage({ companyNo, satelliteName, sensorType, page, limit }) {
try {
- const response = await fetch(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_MANAGE_SEARCH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ companyNo, satelliteName, sensorType, page, limit }),
});
@@ -407,10 +408,10 @@ export async function searchSatelliteManage({ companyNo, satelliteName, sensorTy
*/
export async function saveSatelliteManage(params) {
try {
- const response = await fetch(SATELLITE_MANAGE_SAVE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_MANAGE_SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
@@ -431,10 +432,10 @@ export async function saveSatelliteManage(params) {
*/
export async function fetchSatelliteManageDetail(satelliteManageId) {
try {
- const response = await fetch(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_MANAGE_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ satelliteManageId }),
});
@@ -464,10 +465,10 @@ export async function fetchSatelliteManageDetail(satelliteManageId) {
*/
export async function updateSatelliteManage(params) {
try {
- const response = await fetch(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
+ const response = await fetchWithAuth(SATELLITE_MANAGE_UPDATE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
diff --git a/src/api/trackApi.js b/src/api/trackApi.js
index 26a77b36..a710d591 100644
--- a/src/api/trackApi.js
+++ b/src/api/trackApi.js
@@ -8,6 +8,7 @@
* - 응답 데이터 가공 (ProcessedTrack 형태로 변환)
*/
import useShipStore from '../stores/shipStore';
+import { fetchWithAuth } from './fetchWithAuth';
/** API 엔드포인트 (메인 프로젝트와 동일) */
const API_ENDPOINT = '/api/v2/tracks/vessels';
@@ -31,10 +32,9 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
isIntegration: isIntegration ? '1' : '0',
};
- const response = await fetch(API_ENDPOINT, {
+ const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify(body),
});
diff --git a/src/api/weatherApi.js b/src/api/weatherApi.js
index 66f2b84f..0c034065 100644
--- a/src/api/weatherApi.js
+++ b/src/api/weatherApi.js
@@ -1,6 +1,7 @@
/**
* 기상해양 API
*/
+import { fetchWithAuth } from './fetchWithAuth';
const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
@@ -16,10 +17,10 @@ const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search';
*/
export async function fetchWeatherAlerts({ startPresentationDate, endPresentationDate, page, limit }) {
try {
- const response = await fetch(SPECIAL_NEWS_ENDPOINT, {
+ const response = await fetchWithAuth(SPECIAL_NEWS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({
startPresentationDate,
endPresentationDate,
@@ -59,10 +60,10 @@ const TYPHOON_DETAIL_ENDPOINT = '/api/gis/weather/typhoon/search';
*/
export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningMonth, page, limit }) {
try {
- const response = await fetch(TYPHOON_LIST_ENDPOINT, {
+ const response = await fetchWithAuth(TYPHOON_LIST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({
typhoonBeginningYear,
typhoonBeginningMonth,
@@ -100,10 +101,10 @@ export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningM
*/
export async function fetchTyphoonDetail({ typhoonSequence, year }) {
try {
- const response = await fetch(TYPHOON_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(TYPHOON_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({
typhoonSequence,
year,
@@ -135,10 +136,10 @@ const SUNRISE_SUNSET_DETAIL_ENDPOINT = '/api/gis/weather/tide-information/observ
*/
export async function fetchTideInformation() {
try {
- const response = await fetch(TIDE_INFORMATION_ENDPOINT, {
+ const response = await fetchWithAuth(TIDE_INFORMATION_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({}),
});
@@ -171,10 +172,10 @@ export async function fetchTideInformation() {
*/
export async function fetchSunriseSunsetDetail(params) {
try {
- const response = await fetch(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(SUNRISE_SUNSET_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify(params),
});
@@ -201,10 +202,10 @@ const OBSERVATORY_DETAIL_ENDPOINT = '/api/gis/weather/observatory/select/detail/
*/
export async function fetchObservatoryList() {
try {
- const response = await fetch(OBSERVATORY_ENDPOINT, {
+ const response = await fetchWithAuth(OBSERVATORY_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({}),
});
@@ -239,10 +240,10 @@ const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select';
*/
export async function fetchAirportList() {
try {
- const response = await fetch(AIRPORT_ENDPOINT, {
+ const response = await fetchWithAuth(AIRPORT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({}),
});
@@ -267,10 +268,10 @@ export async function fetchAirportList() {
*/
export async function fetchAirportDetail({ airportId }) {
try {
- const response = await fetch(AIRPORT_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(AIRPORT_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ airportId }),
});
@@ -288,10 +289,10 @@ export async function fetchAirportDetail({ airportId }) {
export async function fetchObservatoryDetail({ observatoryId, toDate }) {
try {
- const response = await fetch(OBSERVATORY_DETAIL_ENDPOINT, {
+ const response = await fetchWithAuth(OBSERVATORY_DETAIL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
+
body: JSON.stringify({ observatoryId, toDate }),
});
diff --git a/src/areaSearch/services/areaSearchApi.js b/src/areaSearch/services/areaSearchApi.js
index 94e393da..16dd25d1 100644
--- a/src/areaSearch/services/areaSearchApi.js
+++ b/src/areaSearch/services/areaSearchApi.js
@@ -5,6 +5,7 @@
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
+import { fetchWithAuth } from '../../api/fetchWithAuth';
const API_ENDPOINT = '/api/v2/tracks/area-search';
@@ -62,10 +63,9 @@ export async function fetchAreaSearch(params) {
polygons: params.polygons,
};
- const response = await fetch(API_ENDPOINT, {
+ const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify(request),
});
diff --git a/src/areaSearch/services/stsApi.js b/src/areaSearch/services/stsApi.js
index 5734a6a1..b3f27905 100644
--- a/src/areaSearch/services/stsApi.js
+++ b/src/areaSearch/services/stsApi.js
@@ -5,6 +5,7 @@
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
+import { fetchWithAuth } from '../../api/fetchWithAuth';
const API_ENDPOINT = '/api/v2/tracks/vessel-contacts';
@@ -37,10 +38,9 @@ export async function fetchVesselContacts(params) {
maxContactDistanceMeters: params.maxContactDistanceMeters,
};
- const response = await fetch(API_ENDPOINT, {
+ const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify(request),
});
diff --git a/src/components/auth/SessionGuard.jsx b/src/components/auth/SessionGuard.jsx
new file mode 100644
index 00000000..529923d5
--- /dev/null
+++ b/src/components/auth/SessionGuard.jsx
@@ -0,0 +1,36 @@
+import { useEffect } from 'react';
+import { useAuthStore } from '../../stores/authStore';
+import useShipStore from '../../stores/shipStore';
+import { fetchUserFilter } from '../../api/userSettingApi';
+import './SessionGuard.scss';
+
+export default function SessionGuard({ children }) {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const isChecking = useAuthStore((s) => s.isChecking);
+
+ useEffect(() => {
+ const result = useAuthStore.getState().checkSession();
+ if (!result.valid) {
+ window.location.href = import.meta.env.VITE_MAIN_APP_URL || '/';
+ return;
+ }
+ // 세션 유효 → 서버에 저장된 필터 설정 로드
+ fetchUserFilter()
+ .then((filterArray) => {
+ if (filterArray) {
+ useShipStore.getState().applyFilterSettings(filterArray);
+ }
+ })
+ .catch((err) => console.warn('[SessionGuard] 필터 로드 실패, 기본값 사용:', err));
+ }, []);
+
+ if (isChecking || !isAuthenticated) {
+ return (
+
+ {!isChecking &&
로그인 페이지로 이동합니다...
}
+
+ );
+ }
+
+ return children;
+}
diff --git a/src/components/auth/SessionGuard.scss b/src/components/auth/SessionGuard.scss
new file mode 100644
index 00000000..19a698cd
--- /dev/null
+++ b/src/components/auth/SessionGuard.scss
@@ -0,0 +1,9 @@
+.session-guard-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ background: #1a1a2e;
+ color: #fff;
+ font-size: 14px;
+}
diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx
index b05987b7..ebc56310 100644
--- a/src/components/layout/Header.jsx
+++ b/src/components/layout/Header.jsx
@@ -1,6 +1,7 @@
import { useRef } from 'react';
import { Link } from 'react-router-dom';
import { showAlert } from '../common/AlertModal';
+import { useAuthStore } from '../../stores/authStore';
const SAMPLE_ALERTS = [
{ message: '서버 응답 시간이 초과되었습니다.\n잠시 후 다시 시도해 주세요.', errorCode: 'ERR_TIMEOUT_504' },
@@ -14,6 +15,7 @@ const SAMPLE_ALERTS = [
* - 로고, 알람, 설정(드롭다운), 마이페이지
*/
export default function Header() {
+ const user = useAuthStore((s) => s.user);
const alertIndexRef = useRef(0);
const handleAlarmClick = (e) => {
@@ -33,6 +35,11 @@ export default function Header() {