diff --git a/package.json b/package.json
index f9ea390..2900597 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
]
},
"dependencies": {
+ "@stomp/stompjs": "^7.3.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
diff --git a/src/app/layouts/AppLayout.tsx b/src/app/layouts/AppLayout.tsx
index 1ce0061..339d6b4 100644
--- a/src/app/layouts/AppLayout.tsx
+++ b/src/app/layouts/AppLayout.tsx
@@ -1,11 +1,15 @@
import { useEffect } from 'react';
-import { Outlet } from 'react-router-dom';
+import { Outlet, useLocation } from 'react-router-dom';
import { redirectToLogin, refreshAccessToken } from '@/shared/lib/auth/refreshSession';
import { getAccessToken, getRefreshToken, isAccessTokenExpired } from '@/shared/lib/auth/token';
import { Header } from '@/widgets/header';
export function AppLayout() {
+ const location = useLocation();
+ // 산책 페이지는 헤더 없는 전체화면(지도) 레이아웃
+ const isFullBleed = location.pathname.startsWith('/walk');
+
useEffect(() => {
if (!getAccessToken() || !getRefreshToken() || !isAccessTokenExpired()) {
return;
@@ -21,6 +25,16 @@ export function AppLayout() {
void refreshSession();
}, []);
+ if (isFullBleed) {
+ return (
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/src/features/walk-fence/api/fence.ts b/src/features/walk-fence/api/fence.ts
new file mode 100644
index 0000000..0344d35
--- /dev/null
+++ b/src/features/walk-fence/api/fence.ts
@@ -0,0 +1,50 @@
+import { apiClient } from '@/shared/api/axios';
+
+import type {
+ CreateFenceRequest,
+ FenceBoundariesResponse,
+ FenceBoundaryResponse,
+ FenceMessageResponse,
+ FenceStatusResponse,
+ ToggleFenceRequest,
+ UpdateFenceRangeRequest,
+} from '../model/types';
+
+/** 1. 울타리 생성 */
+export async function createFence(payload: CreateFenceRequest): Promise
{
+ const response = await apiClient.post('/fence/range', payload);
+ return response.data;
+}
+
+/** 2. 특정 반려동물의 울타리 활성화 상태 조회 */
+export async function getFenceStatus(petId: number): Promise {
+ const response = await apiClient.get(`/fence/${petId}/status`);
+ return response.data;
+}
+
+/** 3. 울타리 ON/OFF 변경 */
+export async function toggleFence(fenceId: number, payload: ToggleFenceRequest): Promise {
+ const response = await apiClient.patch(`/fence/${fenceId}/toggle`, payload);
+ return response.data;
+}
+
+/** 4. 울타리 이름/중심/반경 수정 */
+export async function updateFenceRange(
+ fenceId: number,
+ payload: UpdateFenceRangeRequest,
+): Promise {
+ const response = await apiClient.patch(`/fence/${fenceId}/range`, payload);
+ return response.data;
+}
+
+/** 5. 지도에 표시할 단일 울타리 경계 조회 */
+export async function getFenceBoundary(fenceId: number): Promise {
+ const response = await apiClient.get(`/fence/${fenceId}/boundary`);
+ return response.data;
+}
+
+/** 6. 접근 가능한 모든 울타리 경계 목록 조회 */
+export async function getFenceBoundaries(): Promise {
+ const response = await apiClient.get('/fence/boundaries');
+ return response.data;
+}
diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts
new file mode 100644
index 0000000..fb196ad
--- /dev/null
+++ b/src/features/walk-fence/index.ts
@@ -0,0 +1,29 @@
+export {
+ createFence,
+ getFenceBoundaries,
+ getFenceBoundary,
+ getFenceStatus,
+ toggleFence,
+ updateFenceRange,
+} from './api/fence';
+export { useCreateFence } from './model/useCreateFence';
+export { useFenceBoundaries } from './model/useFenceBoundaries';
+export { useFenceStatus } from './model/useFenceStatus';
+export { useLiveLocation } from './model/useLiveLocation';
+export { useToggleFence } from './model/useToggleFence';
+export { useUpdateFenceRange } from './model/useUpdateFenceRange';
+export { FenceControlPanel } from './ui/FenceControlPanel';
+export { WalkMap } from './ui/WalkMap';
+export type {
+ CreateFenceRequest,
+ FenceBoundariesResponse,
+ FenceBoundary,
+ FenceBoundaryResponse,
+ FenceCenter,
+ FenceMessageResponse,
+ FenceStatusResponse,
+ LiveLocationMessage, // 실시간 위치 업데이트 서버 메세지 전체 받기
+ LiveLocationPayload, // 실시간 위치 업데이트 서버 메세지 중 payload 부분
+ ToggleFenceRequest,
+ UpdateFenceRangeRequest,
+} from './model/types';
diff --git a/src/features/walk-fence/model/types.ts b/src/features/walk-fence/model/types.ts
new file mode 100644
index 0000000..ae44827
--- /dev/null
+++ b/src/features/walk-fence/model/types.ts
@@ -0,0 +1,88 @@
+// 울타리(지오펜스) REST API 요청/응답 타입
+
+/** 좌표 (위도/경도) */
+export interface FenceCenter {
+ latitude: number;
+ longitude: number;
+}
+
+/** 공통 메시지 응답 */
+export interface FenceMessageResponse {
+ message: string;
+}
+
+/** 1. 울타리 생성 — POST /fence/range */
+export interface CreateFenceRequest {
+ petId: number;
+ centerLatitude: number;
+ centerLongitude: number;
+ /** 반경(미터) */
+ radius: number;
+ fenceName: string;
+}
+
+/** 2. 울타리 상태 조회 — GET /fence/{petId}/status */
+export interface FenceStatusResponse {
+ message: string;
+ isActive: boolean;
+}
+
+/** 3. 울타리 ON/OFF — PATCH /fence/{fenceId}/toggle */
+export interface ToggleFenceRequest {
+ fenceIsActive: boolean;
+}
+
+/** 4. 울타리 범위 수정 — PATCH /fence/{fenceId}/range (모든 필드 선택) */
+export interface UpdateFenceRangeRequest {
+ centerLatitude?: number;
+ centerLongitude?: number;
+ fenceName?: string;
+ radius?: number;
+}
+
+/** 5. 울타리 경계 조회 — GET /fence/{fenceId}/boundary */
+export interface FenceBoundaryResponse {
+ message: string;
+ center: FenceCenter;
+ radius: number;
+ fenceId: number;
+}
+
+/** 6-1. 울타리 경계 목록의 단일 항목 */
+export interface FenceBoundary {
+ fenceId: number;
+ fenceName: string;
+ center: FenceCenter;
+ radius: number;
+ isActive: boolean;
+ petId: number;
+ petName: string;
+ petImageUrl: string;
+}
+
+/** 6. 울타리 경계 목록 조회 — GET /fence/boundaries */
+export interface FenceBoundariesResponse {
+ message: string;
+ boundaries: FenceBoundary[];
+}
+
+// 실시간 위치 업데이트 WebSocket 메시지 타입
+/** 실시간 위치 메시지의 payload (서버가 울타리 판정까지 해서 보냄) */
+export interface LiveLocationPayload {
+ petId: number;
+ latitude: number;
+ longitude: number;
+ measuredAt: string;
+ /** 울타리 안에 있는지 (서버 판정) */
+ insideFence: boolean;
+ /** 울타리 중심에서의 거리(미터) */
+ distanceMeter: number;
+ radius: number;
+ message: string;
+}
+
+/** WebSocket 수신 메시지 (payload가 한 겹 감싸져 있음) */
+export interface LiveLocationMessage {
+ type: string;
+ payload: LiveLocationPayload;
+}
diff --git a/src/features/walk-fence/model/useCreateFence.ts b/src/features/walk-fence/model/useCreateFence.ts
new file mode 100644
index 0000000..5940b09
--- /dev/null
+++ b/src/features/walk-fence/model/useCreateFence.ts
@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { createFence } from '../api/fence';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+/** 울타리 생성 후 경계 목록 갱신 */
+export function useCreateFence() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: createFence,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() });
+ },
+ });
+}
diff --git a/src/features/walk-fence/model/useFenceBoundaries.ts b/src/features/walk-fence/model/useFenceBoundaries.ts
new file mode 100644
index 0000000..50a6d5d
--- /dev/null
+++ b/src/features/walk-fence/model/useFenceBoundaries.ts
@@ -0,0 +1,12 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { getFenceBoundaries } from '../api/fence';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+/** 접근 가능한 모든 울타리 경계 목록 조회 */
+export function useFenceBoundaries() {
+ return useQuery({
+ queryKey: queryKeys.fence.boundaries(),
+ queryFn: getFenceBoundaries,
+ });
+}
diff --git a/src/features/walk-fence/model/useFenceStatus.ts b/src/features/walk-fence/model/useFenceStatus.ts
new file mode 100644
index 0000000..b5d9eb8
--- /dev/null
+++ b/src/features/walk-fence/model/useFenceStatus.ts
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { getFenceStatus } from '../api/fence';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+/** 특정 반려동물의 울타리 활성화 상태 조회 (petId 없으면 비활성) */
+export function useFenceStatus(petId: number | null) {
+ return useQuery({
+ queryKey: queryKeys.fence.status(petId ?? 0),
+ queryFn: () => getFenceStatus(petId as number),
+ enabled: petId != null,
+ });
+}
diff --git a/src/features/walk-fence/model/useLiveLocation.ts b/src/features/walk-fence/model/useLiveLocation.ts
new file mode 100644
index 0000000..4d16b3d
--- /dev/null
+++ b/src/features/walk-fence/model/useLiveLocation.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react';
+
+import { createStompClient } from '@/shared/lib/socket/stompClient';
+
+import type { LiveLocationMessage, LiveLocationPayload } from './types';
+
+/** 특정 펫의 실시간 위치를 구독 (petId 없으면 연결 안 함) */
+export function useLiveLocation(petId: number | null) {
+ const [location, setLocation] = useState(null);
+ const [isConnected, setIsConnected] = useState(false);
+
+ // petId가 바뀌면 이전 펫 위치를 초기화 (렌더 중 조정 — effect 내 setState 회피)
+ const [trackedPetId, setTrackedPetId] = useState(petId);
+ if (petId !== trackedPetId) {
+ setTrackedPetId(petId);
+ setLocation(null);
+ setIsConnected(false);
+ }
+
+ useEffect(() => {
+ if (petId == null) return;
+
+ const client = createStompClient();
+
+ // 연결 성공 시 구독 시작
+ client.onConnect = () => {
+ setIsConnected(true);
+ client.subscribe(`/sub/fence/location/${petId}`, (frame) => {
+ try {
+ const message = JSON.parse(frame.body) as LiveLocationMessage;
+ setLocation(message.payload); // 감싸진 payload만 꺼내 저장
+ } catch {
+ // JSON 파싱 실패는 무시
+ }
+ });
+ };
+
+ // 연결 끊기면 표시
+ client.onWebSocketClose = () => {
+ setIsConnected(false);
+ };
+
+ client.activate(); // 연결 시작
+
+ // 언마운트 / petId 변경 시 연결 해제
+ return () => {
+ void client.deactivate();
+ };
+ }, [petId]);
+
+ return { location, isConnected };
+}
diff --git a/src/features/walk-fence/model/useToggleFence.ts b/src/features/walk-fence/model/useToggleFence.ts
new file mode 100644
index 0000000..9066442
--- /dev/null
+++ b/src/features/walk-fence/model/useToggleFence.ts
@@ -0,0 +1,22 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { toggleFence } from '../api/fence';
+import type { ToggleFenceRequest } from './types';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+interface ToggleFenceVariables {
+ fenceId: number;
+ payload: ToggleFenceRequest;
+}
+
+/** 울타리 ON/OFF 변경 후 경계 목록 갱신 */
+export function useToggleFence() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ fenceId, payload }: ToggleFenceVariables) => toggleFence(fenceId, payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() });
+ },
+ });
+}
diff --git a/src/features/walk-fence/model/useUpdateFenceRange.ts b/src/features/walk-fence/model/useUpdateFenceRange.ts
new file mode 100644
index 0000000..632aa5b
--- /dev/null
+++ b/src/features/walk-fence/model/useUpdateFenceRange.ts
@@ -0,0 +1,23 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { updateFenceRange } from '../api/fence';
+import type { UpdateFenceRangeRequest } from './types';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+interface UpdateFenceRangeVariables {
+ fenceId: number;
+ payload: UpdateFenceRangeRequest;
+}
+
+/** 울타리 이름/중심/반경 수정 후 관련 쿼리 갱신 */
+export function useUpdateFenceRange() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ fenceId, payload }: UpdateFenceRangeVariables) => updateFenceRange(fenceId, payload),
+ onSuccess: (_data, { fenceId }) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundary(fenceId) });
+ },
+ });
+}
diff --git a/src/features/walk-fence/ui/FenceControlPanel.tsx b/src/features/walk-fence/ui/FenceControlPanel.tsx
new file mode 100644
index 0000000..2d2ba68
--- /dev/null
+++ b/src/features/walk-fence/ui/FenceControlPanel.tsx
@@ -0,0 +1,167 @@
+import type { PetListItem } from '@/features/auth';
+
+import type { FenceBoundary } from '../model/types';
+
+interface DraftCenter {
+ lat: number;
+ lng: number;
+}
+
+interface FenceControlPanelProps {
+ pets: PetListItem[];
+ selectedPetId: number | null;
+ onSelectPet: (petId: number) => void;
+ /** 선택한 펫의 기존 울타리 (없으면 null → 생성 모드) */
+ existingFence: FenceBoundary | null;
+ draftCenter: DraftCenter | null;
+ radius: number;
+ onRadiusChange: (radius: number) => void;
+ fenceName: string;
+ onFenceNameChange: (name: string) => void;
+ onCreate: () => void;
+ onUpdate: () => void;
+ onToggle: () => void;
+ isSubmitting: boolean;
+}
+
+export function FenceControlPanel({
+ pets,
+ selectedPetId,
+ onSelectPet,
+ existingFence,
+ draftCenter,
+ radius,
+ onRadiusChange,
+ fenceName,
+ onFenceNameChange,
+ onCreate,
+ onUpdate,
+ onToggle,
+ isSubmitting,
+}: FenceControlPanelProps) {
+ const selectedPet = pets.find((pet) => pet.petId === selectedPetId) ?? null;
+
+ return (
+
+
울타리 설정
+
+ {/* 펫 선택 */}
+
+
반려동물 선택
+ {pets.length === 0 ? (
+
등록된 반려동물이 없어요.
+ ) : (
+
+ {pets.map((pet) => {
+ const isActive = pet.petId === selectedPetId;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {selectedPet && (
+ <>
+ {/* 안내 */}
+
+ 지도를 클릭해 울타리 중심을 {existingFence ? '옮길' : '지정할'} 수 있어요.
+ {draftCenter
+ ? ` 현재: ${draftCenter.lat.toFixed(5)}, ${draftCenter.lng.toFixed(5)}`
+ : existingFence
+ ? ' (지금은 기존 중심 유지)'
+ : ' (아직 미지정)'}
+
+
+ {/* 이름 (생성/수정 공통) */}
+
+
+ {/* 반경 */}
+
+
+ {/* 기존 울타리: 상태 + 수정 / 없으면: 생성 */}
+ {existingFence ? (
+
+
+
+ 울타리 {existingFence.isActive ? '켜짐' : '꺼짐'}
+
+
+
+
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx
new file mode 100644
index 0000000..ae61368
--- /dev/null
+++ b/src/features/walk-fence/ui/WalkMap.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { loadNaverMap } from '@/shared/lib/naver-map/loadNaverMap';
+
+import type { FenceBoundary } from '../model/types';
+
+interface DraftCenter {
+ lat: number;
+ lng: number;
+}
+
+interface WalkMapProps {
+ /** 지도에 그릴 기존 울타리 목록 */
+ boundaries: FenceBoundary[];
+ /** 생성/수정 미리보기용 중심 (없으면 미리보기 원 숨김) */
+ draftCenter: DraftCenter | null;
+ /** 미리보기 원 반경(미터) */
+ draftRadius: number;
+ /** 지도 클릭 시 좌표 콜백 */
+ onMapClick: (lat: number, lng: number) => void;
+ /** 실시간 펫 위치 (없으면 마커 숨김) */
+ livePosition: { lat: number; lng: number; insideFence: boolean } | null;
+}
+
+const DEFAULT_CENTER = { lat: 37.5796, lng: 126.977 }; // 경복궁
+
+// HTML 마커에 펫 이름을 넣기 전 간단한 이스케이프 (XSS 방지)
+function escapeHtml(text: string): string {
+ return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+function petLabelContent(name: string, isActive: boolean): string {
+ const color = isActive ? '#16a34a' : '#6b7280';
+ return `${escapeHtml(name)}
`;
+}
+
+// 실시간 위치 점 마커 (울타리 안=초록, 밖=빨강)
+function liveMarkerContent(insideFence: boolean): string {
+ const color = insideFence ? '#22c55e' : '#ef4444';
+ return ``;
+}
+
+export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick, livePosition }: WalkMapProps) {
+ const mapElementRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+ const circlesRef = useRef([]); // 기존 울타리 원들
+ const markersRef = useRef([]); // 펫 이름 라벨들
+ const draftCircleRef = useRef(null); // 미리보기 원
+ const liveMarkerRef = useRef(null); // 실시간 위치 마커
+ const onMapClickRef = useRef(onMapClick); // 항상 최신 콜백 보관
+ const [isMapReady, setIsMapReady] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 리스너를 다시 달지 않고도 최신 onMapClick을 부르도록 ref만 갱신
+ useEffect(() => {
+ onMapClickRef.current = onMapClick;
+ }, [onMapClick]);
+
+ // 1. 지도 생성 + 클릭 리스너 (최초 1회)
+ useEffect(() => {
+ let canceled = false;
+
+ loadNaverMap()
+ .then(() => {
+ if (canceled || !mapElementRef.current) return;
+
+ const map = new naver.maps.Map(mapElementRef.current, {
+ center: new naver.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng),
+ zoom: 15,
+ });
+
+ // 지도를 클릭하면 그 좌표를 콜백으로 올려보냄
+ map.addListener('click', (event) => {
+ onMapClickRef.current(event.coord.lat(), event.coord.lng());
+ });
+
+ mapInstanceRef.current = map;
+ setIsMapReady(true);
+ })
+ .catch((e: unknown) => {
+ if (!canceled) setError(e instanceof Error ? e.message : '지도를 불러오지 못했습니다.');
+ });
+
+ return () => {
+ canceled = true;
+ mapInstanceRef.current?.destroy();
+ mapInstanceRef.current = null;
+ };
+ }, []);
+
+ // 2. 기존 울타리 원 + 펫 이름 라벨 그리기 (데이터 바뀔 때마다)
+ useEffect(() => {
+ const map = mapInstanceRef.current;
+ if (!isMapReady || !map) return;
+
+ // 이전 원/라벨 제거
+ circlesRef.current.forEach((circle) => circle.setMap(null));
+ circlesRef.current = [];
+ markersRef.current.forEach((marker) => marker.setMap(null));
+ markersRef.current = [];
+
+ boundaries.forEach((fence) => {
+ const center = new naver.maps.LatLng(fence.center.latitude, fence.center.longitude);
+
+ const circle = new naver.maps.Circle({
+ map,
+ center,
+ radius: fence.radius,
+ strokeColor: fence.isActive ? '#22c55e' : '#9ca3af', // 활성=초록, 비활성=회색
+ strokeWeight: 2,
+ fillColor: fence.isActive ? '#22c55e' : '#9ca3af',
+ fillOpacity: 0.15,
+ });
+ circlesRef.current.push(circle);
+
+ // 울타리 중심에 펫 이름 라벨
+ const marker = new naver.maps.Marker({
+ map,
+ position: center,
+ icon: {
+ content: petLabelContent(fence.petName, fence.isActive),
+ anchor: new naver.maps.Point(0, 0),
+ },
+ });
+ markersRef.current.push(marker);
+ });
+ }, [isMapReady, boundaries]);
+
+ // 3. 미리보기(점선) 원 그리기/갱신
+ useEffect(() => {
+ const map = mapInstanceRef.current;
+ if (!isMapReady || !map) return;
+
+ // 중심이 없으면 미리보기 원 제거
+ if (!draftCenter) {
+ draftCircleRef.current?.setMap(null);
+ draftCircleRef.current = null;
+ return;
+ }
+
+ const center = new naver.maps.LatLng(draftCenter.lat, draftCenter.lng);
+
+ if (draftCircleRef.current) {
+ // 이미 있으면 위치/반경만 갱신
+ draftCircleRef.current.setCenter(center);
+ draftCircleRef.current.setRadius(draftRadius);
+ } else {
+ draftCircleRef.current = new naver.maps.Circle({
+ map,
+ center,
+ radius: draftRadius,
+ strokeColor: '#f59e0b',
+ strokeWeight: 2,
+ strokeStyle: 'shortdash',
+ fillColor: '#f59e0b',
+ fillOpacity: 0.12,
+ });
+ }
+ }, [isMapReady, draftCenter, draftRadius]);
+
+ // 3-1. 지도 중심 이동은 draftCenter가 바뀔 때만 (반경 조절 시 스냅 방지)
+ useEffect(() => {
+ const map = mapInstanceRef.current;
+ if (!isMapReady || !map || !draftCenter) return;
+ map.setCenter(new naver.maps.LatLng(draftCenter.lat, draftCenter.lng));
+ }, [isMapReady, draftCenter]);
+
+ // 4. 실시간 펫 위치 마커 (위치 올 때마다 갱신)
+ useEffect(() => {
+ const map = mapInstanceRef.current;
+ if (!isMapReady || !map) return;
+
+ // 이전 마커 제거 후 다시 그림 (마커 1개라 부담 없음)
+ liveMarkerRef.current?.setMap(null);
+ liveMarkerRef.current = null;
+
+ if (!livePosition) return;
+
+ liveMarkerRef.current = new naver.maps.Marker({
+ map,
+ position: new naver.maps.LatLng(livePosition.lat, livePosition.lng),
+ icon: {
+ content: liveMarkerContent(livePosition.insideFence),
+ anchor: new naver.maps.Point(0, 0),
+ },
+ });
+ }, [isMapReady, livePosition]);
+
+ if (error) {
+ return (
+ {error}
+ );
+ }
+
+ return ;
+}
diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx
index 60e0061..eb82bd6 100644
--- a/src/pages/walk/WalkPage.tsx
+++ b/src/pages/walk/WalkPage.tsx
@@ -1,3 +1,161 @@
+import { useMemo, useState } from 'react';
+
+import { usePetList } from '@/features/auth';
+import {
+ FenceControlPanel,
+ WalkMap,
+ useCreateFence,
+ useFenceBoundaries,
+ useLiveLocation,
+ useToggleFence,
+ useUpdateFenceRange,
+} from '@/features/walk-fence';
+
+import { WalkSideRail } from './ui/WalkSideRail';
+
+interface DraftCenter {
+ lat: number;
+ lng: number;
+}
+
+const EMPTY_BOUNDARIES: never[] = [];
+
export function WalkPage() {
- return 산책 (뼈대)
;
+ const { data: boundariesData } = useFenceBoundaries();
+ const { data: petListData } = usePetList();
+ const createFence = useCreateFence();
+ const toggleFence = useToggleFence();
+ const updateFenceRange = useUpdateFenceRange();
+
+ const boundaries = boundariesData?.boundaries ?? EMPTY_BOUNDARIES;
+ const pets = petListData?.pets ?? [];
+
+ const [selectedPetId, setSelectedPetId] = useState(null);
+ const [draftCenter, setDraftCenter] = useState(null);
+ const [radius, setRadius] = useState(500);
+ const [fenceName, setFenceName] = useState('');
+
+ // 선택한 펫의 기존 울타리 (있으면 수정 모드, 없으면 생성 모드)
+ const existingFence = useMemo(
+ () => (selectedPetId != null ? (boundaries.find((fence) => fence.petId === selectedPetId) ?? null) : null),
+ [boundaries, selectedPetId],
+ );
+
+ // 펫/울타리가 바뀌면 입력값 동기화 (effect 대신 렌더 중 조정 — React 권장 패턴)
+ const formKey = `${selectedPetId ?? ''}:${existingFence?.fenceId ?? ''}`;
+ const [syncedKey, setSyncedKey] = useState(formKey);
+ if (formKey !== syncedKey) {
+ setSyncedKey(formKey);
+ setRadius(existingFence ? existingFence.radius : 500);
+ setFenceName(existingFence ? existingFence.fenceName : '');
+ setDraftCenter(null);
+ }
+
+ const isSubmitting = createFence.isPending || toggleFence.isPending || updateFenceRange.isPending;
+
+ const handleCreate = () => {
+ if (selectedPetId == null || !draftCenter) return;
+ createFence.mutate(
+ {
+ petId: selectedPetId,
+ centerLatitude: draftCenter.lat,
+ centerLongitude: draftCenter.lng,
+ radius,
+ fenceName: fenceName.trim(),
+ },
+ {
+ onSuccess: () => setDraftCenter(null), // 저장 후 미리보기 원 제거
+ },
+ );
+ };
+
+ const handleUpdate = () => {
+ if (!existingFence) return;
+ updateFenceRange.mutate(
+ {
+ fenceId: existingFence.fenceId,
+ payload: {
+ centerLatitude: draftCenter?.lat ?? existingFence.center.latitude,
+ centerLongitude: draftCenter?.lng ?? existingFence.center.longitude,
+ radius,
+ fenceName: fenceName.trim() || existingFence.fenceName,
+ },
+ },
+ {
+ onSuccess: () => setDraftCenter(null), // 저장 후 미리보기 원 제거
+ },
+ );
+ };
+
+ const handleToggle = () => {
+ if (!existingFence) return;
+ toggleFence.mutate({
+ fenceId: existingFence.fenceId,
+ payload: { fenceIsActive: !existingFence.isActive },
+ });
+ };
+
+ // 선택한 펫의 실시간 위치 구독
+ const { location: liveLocation } = useLiveLocation(selectedPetId);
+ const selectedPetName = pets.find((pet) => pet.petId === selectedPetId)?.petName ?? '반려동물';
+
+ // 울타리가 켜져 있고 + 실제로 벗어났을 때만 이탈로 간주
+ const fenceActive = existingFence?.isActive ?? false;
+ const isOutsideFence = !!liveLocation && fenceActive && !liveLocation.insideFence;
+
+ return (
+ // 네이버 지도 스타일: 지도가 화면 전체, 그 위에 둥근 레일 + 패널이 떠 있음
+
+ {/* 배경 전체를 채우는 지도 */}
+
+ setDraftCenter({ lat, lng })}
+ livePosition={
+ liveLocation
+ ? { lat: liveLocation.latitude, lng: liveLocation.longitude, insideFence: !isOutsideFence }
+ : null
+ }
+ />
+
+
+ {/* 울타리 이탈 알림 배너 (울타리 켜진 경우에만) */}
+ {liveLocation && isOutsideFence && (
+
+ ⚠️ {selectedPetName}이(가) 울타리를 벗어났어요! (약 {Math.round(liveLocation.distanceMeter)}m)
+
+ )}
+
+ {/* 좌측 플로팅: 세로 레일 + 울타리 패널 */}
+
+
+
+
+
+
+ );
}
diff --git a/src/pages/walk/ui/WalkSideRail.tsx b/src/pages/walk/ui/WalkSideRail.tsx
new file mode 100644
index 0000000..5aa03e3
--- /dev/null
+++ b/src/pages/walk/ui/WalkSideRail.tsx
@@ -0,0 +1,122 @@
+import { Link, NavLink } from 'react-router-dom';
+
+import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg';
+import { useCurrentUser } from '@/features/auth/model/useCurrentUser';
+import DoDoLogo from '@/shared/assets/images/Logo_light.svg?react';
+import { useIsLoggedIn } from '@/widgets/header';
+
+interface IconProps {
+ className?: string;
+}
+
+function PawIcon({ className }: IconProps) {
+ return (
+
+ );
+}
+
+function ChatIcon({ className }: IconProps) {
+ return (
+
+ );
+}
+
+function UserIcon({ className }: IconProps) {
+ return (
+
+ );
+}
+
+const NAV_ITEMS = [
+ { to: '/walk', label: '산책', Icon: PawIcon },
+ { to: '/community', label: '커뮤니티', Icon: ChatIcon },
+ { to: '/my', label: '마이도도', Icon: UserIcon },
+];
+
+const itemClass = ({ isActive }: { isActive: boolean }) =>
+ [
+ 'flex w-full flex-col items-center gap-1 rounded-xl py-2 text-[11px] font-medium transition-colors',
+ isActive ? 'bg-brand/10 text-brand' : 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800',
+ ].join(' ');
+
+function RailProfile() {
+ const { profileUrl } = useCurrentUser();
+ const resolvedUrl = profileUrl?.trim() || profileDefaultIllustration;
+
+ return (
+
+
+
+ );
+}
+
+export function WalkSideRail() {
+ const isLoggedIn = useIsLoggedIn();
+
+ return (
+
+ );
+}
diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts
index 33dac8a..e30ec22 100644
--- a/src/shared/config/env.ts
+++ b/src/shared/config/env.ts
@@ -2,6 +2,8 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const NAVER_CLIENT_ID = import.meta.env.VITE_NAVER_CLIENT_ID;
const OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI;
+const NAVER_MAP_CLIENT_ID = import.meta.env.VITE_NAVER_MAP_CLIENT_ID;
+const WS_URL = import.meta.env.VITE_WS_URL;
// 환경 변수 누락 방지
const requiredEnv = {
@@ -9,6 +11,8 @@ const requiredEnv = {
VITE_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
VITE_NAVER_CLIENT_ID: NAVER_CLIENT_ID,
VITE_OAUTH_REDIRECT_URI: OAUTH_REDIRECT_URI,
+ VITE_NAVER_MAP_CLIENT_ID: NAVER_MAP_CLIENT_ID,
+ VITE_WS_URL: WS_URL,
};
Object.entries(requiredEnv).forEach(([key, value]) => {
@@ -22,4 +26,6 @@ export const env = {
GOOGLE_CLIENT_ID,
NAVER_CLIENT_ID,
OAUTH_REDIRECT_URI,
+ NAVER_MAP_CLIENT_ID,
+ WS_URL,
};
diff --git a/src/shared/lib/naver-map/loadNaverMap.ts b/src/shared/lib/naver-map/loadNaverMap.ts
new file mode 100644
index 0000000..04fee39
--- /dev/null
+++ b/src/shared/lib/naver-map/loadNaverMap.ts
@@ -0,0 +1,26 @@
+import { env } from '@/shared/config';
+
+let loadPromise: Promise | null = null;
+
+/** 네이버 지도 스크립트를 한 번만 로드하고, 완료 시 resolve */
+export function loadNaverMap(): Promise {
+ // 이미 로드 완료된 경우
+ if (window.naver?.maps) return Promise.resolve();
+
+ // 로딩 중이면 같은 Promise 재사용 (중복 로드 방지)
+ if (loadPromise) return loadPromise;
+
+ loadPromise = new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${env.NAVER_MAP_CLIENT_ID}`;
+ script.async = true;
+ script.onload = () => resolve();
+ script.onerror = () => {
+ loadPromise = null; // 실패 시 다음에 재시도 가능하도록 초기화
+ reject(new Error('네이버 지도 스크립트를 불러오지 못했습니다.'));
+ };
+ document.head.appendChild(script);
+ });
+
+ return loadPromise;
+}
diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts
new file mode 100644
index 0000000..872927c
--- /dev/null
+++ b/src/shared/lib/naver-map/naver.d.ts
@@ -0,0 +1,78 @@
+// 네이버 지도(NCP Maps) 전역 타입 — 우선 지도 표시에 필요한 최소만 선언
+declare namespace naver.maps {
+ class LatLng {
+ constructor(lat: number, lng: number);
+ lat(): number;
+ lng(): number;
+ }
+
+ interface MapOptions {
+ center: LatLng;
+ zoom?: number;
+ }
+
+ /** 지도 이벤트 핸들 (해제 시 사용) */
+ type MapEventListener = object;
+
+ /** 클릭 등 포인터 이벤트 — coord에 클릭 좌표가 담김 */
+ interface PointerEvent {
+ coord: LatLng;
+ }
+
+ class Map {
+ constructor(element: string | HTMLElement, options: MapOptions);
+ setCenter(latlng: LatLng): void;
+ setZoom(zoom: number): void;
+ addListener(eventName: string, listener: (event: PointerEvent) => void): MapEventListener;
+ destroy(): void;
+ }
+
+ interface CircleOptions {
+ map?: Map;
+ center: LatLng;
+ /** 반경(미터) */
+ radius: number;
+ strokeColor?: string;
+ strokeWeight?: number;
+ strokeOpacity?: number;
+ /** 'solid' | 'shortdash' | 'dash' 등 */
+ strokeStyle?: string;
+ fillColor?: string;
+ fillOpacity?: number;
+ }
+
+ class Circle {
+ constructor(options: CircleOptions);
+ setMap(map: Map | null): void;
+ setCenter(center: LatLng): void;
+ setRadius(radius: number): void;
+ }
+
+ class Point {
+ constructor(x: number, y: number);
+ }
+
+ interface MarkerIcon {
+ content: string;
+ anchor?: Point;
+ }
+
+ interface MarkerOptions {
+ map?: Map;
+ position: LatLng;
+ icon?: MarkerIcon | string;
+ title?: string;
+ clickable?: boolean;
+ }
+
+ class Marker {
+ constructor(options: MarkerOptions);
+ setMap(map: Map | null): void;
+ setPosition(position: LatLng): void;
+ }
+}
+
+// window.naver 로 접근할 수 있게 augment
+interface Window {
+ naver: typeof naver;
+}
diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts
index 1fb92b5..ae3d569 100644
--- a/src/shared/lib/react-query/queryKey.ts
+++ b/src/shared/lib/react-query/queryKey.ts
@@ -60,4 +60,9 @@ export const queryKeys = {
params?.sort ?? 'petWeightsMeasuredAt,desc',
] as const,
},
+ fence: {
+ boundaries: () => ['fence', 'boundaries'] as const,
+ boundary: (fenceId: number) => ['fence', fenceId, 'boundary'] as const,
+ status: (petId: number) => ['fence', petId, 'status'] as const,
+ },
} as const;
diff --git a/src/shared/lib/socket/stompClient.ts b/src/shared/lib/socket/stompClient.ts
new file mode 100644
index 0000000..748ef8d
--- /dev/null
+++ b/src/shared/lib/socket/stompClient.ts
@@ -0,0 +1,23 @@
+import { Client } from '@stomp/stompjs';
+
+import { env } from '@/shared/config';
+import { getAccessToken } from '@/shared/lib/auth/token';
+
+/** 설정이 끝난 STOMP 클라이언트를 생성 (구독은 사용하는 쪽에서) */
+export function createStompClient(): Client {
+ const client = new Client({
+ brokerURL: env.WS_URL, // wss://... 로 직접 연결 (SockJS 미사용)
+ reconnectDelay: 5000, // 연결 끊기면 5초 후 자동 재연결
+ heartbeatIncoming: 10000, // 서버 ↔ 클라이언트 연결 살아있는지 확인(10초)
+ heartbeatOutgoing: 10000,
+ beforeConnect: () => {
+ // 연결 직전마다 최신 토큰을 헤더에 실음 (재연결 시 갱신된 토큰 반영)
+ // 생성 시점에 넣으면 토큰 만료 시 갱신된 토큰이 반영되지 않음
+ client.connectHeaders = {
+ Authorization: `Bearer ${getAccessToken() ?? ''}`,
+ };
+ },
+ });
+
+ return client;
+}
diff --git a/src/widgets/header/index.ts b/src/widgets/header/index.ts
index 43c1c4b..43d5e72 100644
--- a/src/widgets/header/index.ts
+++ b/src/widgets/header/index.ts
@@ -1 +1,2 @@
export { Header } from './ui/Header';
+export { useIsLoggedIn } from './model/useIsLoggedIn';
diff --git a/yarn.lock b/yarn.lock
index 1cc8978..3fd9f67 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -670,6 +670,11 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz#beacb356412eef5dc0164e9edfee51c563732054"
integrity sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==
+"@stomp/stompjs@^7.3.0":
+ version "7.3.0"
+ resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.3.0.tgz#5655b93e086a0be684291424c5bc8c92949b33ee"
+ integrity sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==
+
"@svgr/babel-plugin-add-jsx-attribute@8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22"