diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..9ff1549
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+./backend/.env
+./backend/.env.dev
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..4d317ab
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,60 @@
+name: CI/CD Docker to Server
+
+on:
+ push:
+ branches: [ "test-deploy" ] # test-deploy 브랜치에 push될 때 실행
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 체크아웃 소스코드
+ uses: actions/checkout@v4
+
+ - name: Docker Buildx 설정
+ uses: docker/setup-buildx-action@v3
+
+ - name: Docker Hub 로그인
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Docker 이미지 빌드 및 푸시
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test
+
+ deploy:
+ needs: build-and-push
+ runs-on: ubuntu-latest
+ steps:
+ - name: SSH로 서버 접속 및 배포 명령 실행
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_KEY }}
+ script: |
+ # 1. Docker Hub 로그인
+ echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
+
+ # 2. 최신 이미지 Pull
+ sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test
+
+ # 3. 기존에 실행 중인 동일한 이름의 컨테이너 중지 및 삭제
+ sudo docker stop test-onmyway || true
+ sudo docker rm test-onmyway || true
+
+ # 5. ⭐ --env-file 옵션을 사용하여 컨테이너 실행
+ sudo docker run -d \
+ --name test-onmyway \
+ -p 127.0.0.1:8081:8081 \
+ --env-file .env.dev \
+ ${{ secrets.DOCKERHUB_USERNAME }}/onmyway:test
+
+ # 6. 사용하지 않는 구버전 이미지 청소
+ sudo docker image prune -f
diff --git a/.gitignore b/.gitignore
index 44a9881..7ae49a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -170,7 +170,7 @@ bin/
out/
!**/src/main/**/out/
!**/src/test/**/out/
-.env
+backend/.env
### NetBeans ###
/nbproject/private/
@@ -196,9 +196,10 @@ out/
# =========================
# Env files
# =========================
-.env
+backend/.env
+backend/.env.dev
# =========================
# Git 내부
# =========================
-onmyway.git/
\ No newline at end of file
+onmyway.git/.env
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 9bf92ab..ff903d3 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -7,20 +7,19 @@
-
+
-
-
+
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
deleted file mode 100644
index af7442b..0000000
--- a/.idea/dataSources.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
- postgresql
- true
- org.postgresql.Driver
- jdbc:postgresql://localhost:5432/onmyway
-
-
-
-
-
- $ProjectFileDir$
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 345615c..a5d7fc9 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -4,4 +4,7 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 942ad07..ed51d87 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,8 +2,8 @@
-
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b84f89c
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "java.configuration.updateBuildConfiguration": "interactive",
+ "java.compile.nullAnalysis.mode": "automatic"
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d4af24e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,36 @@
+# --- 1단계: Frontend 빌드 ---
+FROM node:24 AS frontend-builder
+WORKDIR /build-fe
+COPY frontend/ganeungil/package*.json ./
+RUN npm install
+COPY frontend/ganeungil ./
+RUN npm run build
+
+# --- 2단계: Backend 빌드 (FE 결과물 포함) ---
+FROM gradle:7.6-jdk17 AS backend-builder
+WORKDIR /build-be
+# 1. 설정 파일만 먼저 복사
+COPY ./backend/gradlew .
+COPY ./backend/gradle/ gradle/
+COPY ./backend/build.gradle ./backend/settings.gradle ./
+
+# 2. 의존성 미리 다운로드 (이 단계가 캐싱되어 다음엔 1초만에 넘어감)
+RUN ./gradlew dependencies --no-daemon
+
+# 3. 그 다음 소스 복사 및 빌드
+COPY . .
+# 위에서 빌드한 FE 정적 파일들을 BE의 static 폴더로 복사
+COPY --from=frontend-builder /build-fe/dist ./backend/src/main/resources/static
+COPY backend/src ./src
+
+RUN ./gradlew -p ./backend bootJar --no-daemon
+
+# --- 3단계: 최종 실행 이미지 ---
+FROM eclipse-temurin:17-jdk-alpine
+WORKDIR /app
+RUN ls -R /app
+# 빌드된 jar 파일만 가져오기
+COPY --from=backend-builder /build-be/backend/build/libs/*.jar app.jar
+
+EXPOSE 8081
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/Onboard_past.jsx b/Onboard_past.jsx
new file mode 100644
index 0000000..a93bd1e
--- /dev/null
+++ b/Onboard_past.jsx
@@ -0,0 +1,490 @@
+import { useState, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import api from "../../api/api";
+import Sidebar20 from "./Sidebar_2.0";
+
+// ── 에셋 (헤더 + 장소 상세 카드용) ──
+import imgPlace from "@/assets/img-place.jpg";
+import iconDrink from "@/assets/icon-drink.svg";
+import iconFood from "@/assets/icon-food.svg";
+import iconRest from "@/assets/icon-rest.svg";
+import iconShop from "@/assets/icon-shop.svg";
+import iconView from "@/assets/icon-view.svg";
+import iconHeart from "@/assets/icon-heart.svg";
+import iconDestPin from "@/assets/icon-destination-pin.svg";
+
+const CATEGORY_LABEL_TO_ID = {
+ "한 잔": 1,
+ "한 입": 2,
+ "한 숨": 3,
+ "한 판": 4,
+ "한 눈": 5,
+ "한 끼": 6,
+};
+
+const CAT_ICON = {
+ "한잔": iconDrink,
+ "한입": iconFood,
+ "한숨": iconRest,
+ "한판": iconShop,
+ "한눈": iconView,
+ "한끼": iconFood,
+};
+
+// 위치 미허용 시 기본 중심점: 부산대학교 정문
+const PUSAN_UNIV = { lat: 35.2316, lng: 129.0839 };
+
+async function loadRecommendations(lat, lng) {
+ const res = await api.get("/places/recommend", { params: { lat, lng } });
+ return res.data; // { categories: [ { categoryId, categoryName, places, featured } ] }
+}
+
+function toPlaceList(raw, startId = 0) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((p, i) => ({
+ id: p.id ?? startId + i + 1,
+ name: p.name,
+ category: p.category,
+ walkMin: p.walkingMinutes,
+ lat: p.lat,
+ lng: p.lng,
+ isOpen: p.isOpen ?? p.open ?? true,
+ closeTime: p.closeTime ?? null,
+ openTime: p.openTime ?? null,
+ imageURL: p.imageURL ?? null,
+ desc: "",
+ tags: [],
+ }));
+}
+
+// 사이드바용: 카테고리 전체 places
+function mapToRecs(categories, categoryLabel) {
+ const targetId = CATEGORY_LABEL_TO_ID[categoryLabel];
+ const raw =
+ categoryLabel === "전체"
+ ? categories.flatMap((c) => c.places ?? [])
+ : (categories.find((c) => c.categoryId === targetId)?.places ?? []);
+ return toPlaceList(raw);
+}
+
+// 지도 마커용: featured (없으면 places[0] 폴백)
+function mapToFeatured(categories, categoryLabel) {
+ const targetId = CATEGORY_LABEL_TO_ID[categoryLabel];
+ const raw =
+ categoryLabel === "전체"
+ ? categories.flatMap((c) => c.featured ?? c.places?.[0] ?? [])
+ : (() => {
+ const c = categories.find((c) => c.categoryId === targetId);
+ return c?.featured ?? c?.places?.[0] ?? [];
+ })();
+ return toPlaceList(raw);
+}
+
+const fmt = (t) => t?.slice(0, 5) ?? null;
+
+function HoursLabel({ place }) {
+ if (place.isOpen) {
+ if (!place.closeTime) return 영업 중;
+ return 영업 중 ({fmt(place.closeTime)}에 종료);
+ }
+ if (!place.openTime) return 영업 종료;
+ return 영업 종료 ({fmt(place.openTime)}에 시작);
+}
+
+export default function Onboard20() {
+ const navigate = useNavigate();
+ const [activeCategory, setActiveCategory] = useState("전체");
+ const [locStatus, setLocStatus] = useState("pending"); // pending | granted | denied
+ const [userCoords, setUserCoords] = useState(null); // { lat, lng }
+ const [allCategories, setAllCategories] = useState([]); // API 전체 응답
+ const [recs, setRecs] = useState([]); // 사이드바: 전체 places
+ const [featuredRecs, setFeaturedRecs] = useState([]); // 지도 마커: featured만
+ const [selectedPlace, setSelectedPlace] = useState(null);
+ const [recsState, setRecsState] = useState("visible"); // visible | fading | hidden
+ const [isOffline, setIsOffline] = useState(!navigator.onLine);
+ const [sidebarOpen, setSidebarOpen] = useState(true);
+ const [mapReady, setMapReady] = useState(false);
+
+ // 카카오맵 관련 refs
+ const mapContainerRef = useRef(null); //
DOM 노드
+ const kakaoMapRef = useRef(null); // kakao.maps.Map 인스턴스
+ const circleRef = useRef(null); // 500m 반경 Circle
+ const userDotRef = useRef(null); // 내 위치 CustomOverlay
+ const overlaysRef = useRef([]); // 추천 마커 CustomOverlay[]
+ const recsRef = useRef([]); // recs 최신값 (window 콜백에서 참조)
+ const featuredRef = useRef([]); // featuredRecs 최신값 (마커 클릭 콜백에서 참조)
+ const destMarkerRef = useRef(null); // 목적지 마커 CustomOverlay
+
+ // ref 동기화
+ useEffect(() => { recsRef.current = recs; }, [recs]);
+ useEffect(() => { featuredRef.current = featuredRecs; }, [featuredRecs]);
+
+ // ── 지도 마커 클릭 핸들러 (window에 등록 → CustomOverlay HTML에서 호출)
+ useEffect(() => {
+ window.__onMarkerClick = (id) => {
+ const place = featuredRef.current.find(p => p.id === id);
+ if (!place) return;
+ setSelectedPlace(prev => (prev?.id === id ? null : place));
+ };
+ return () => { delete window.__onMarkerClick; };
+ }, []);
+
+ // ── 위치 권한 요청 (네이티브 UI)
+ useEffect(() => {
+ if (!navigator.geolocation) { setLocStatus("denied"); return; }
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
+ console.log("[위치] 허용됨:", coords);
+ setUserCoords(coords);
+ setLocStatus("granted");
+ loadRecommendations(coords.lat, coords.lng)
+ .then((data) => {
+setAllCategories(data.categories);
+ setRecs(mapToRecs(data.categories, "전체"));
+ setFeaturedRecs(mapToFeatured(data.categories, "전체"));
+ })
+ .catch(console.error);
+ },
+ (err) => { console.error("[위치] 거부됨:", err); setLocStatus("denied"); }
+ );
+ }, []);
+
+ // ── 네트워크 상태 감지
+ useEffect(() => {
+ const on = () => setIsOffline(false);
+ const off = () => setIsOffline(true);
+ window.addEventListener("online", on);
+ window.addEventListener("offline", off);
+ return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); };
+ }, []);
+
+ // ── 카카오맵 초기화 (SDK 로드 대기 후 실행)
+ useEffect(() => {
+ const init = () => {
+ if (!window.kakao?.maps || !mapContainerRef.current) {
+ setTimeout(init, 100);
+ return;
+ }
+ if (kakaoMapRef.current) return; // 이미 초기화됨 (StrictMode 이중 실행 방지)
+ try {
+ const center = new window.kakao.maps.LatLng(PUSAN_UNIV.lat, PUSAN_UNIV.lng);
+ kakaoMapRef.current = new window.kakao.maps.Map(mapContainerRef.current, {
+ center,
+ level: 4,
+ });
+ window.kakao.maps.event.addListener(kakaoMapRef.current, "click", () => {
+ setSidebarOpen(false);
+ });
+ setMapReady(true);
+ console.log("카카오맵 초기화 성공");
+ } catch (e) {
+ console.error("카카오맵 초기화 실패:", e);
+ }
+ };
+ init();
+ }, []);
+
+ // ── 위치 확정 시 지도 중심 이동 + 반경 원 + 내 위치 마커
+ useEffect(() => {
+ console.log("[위치 effect]", { locStatus, userCoords, mapReady, map: !!kakaoMapRef.current });
+ const map = kakaoMapRef.current;
+ if (!map) return;
+
+ // 기존 원/마커 제거
+ if (circleRef.current) { circleRef.current.setMap(null); circleRef.current = null; }
+ if (userDotRef.current) { userDotRef.current.setMap(null); userDotRef.current = null; }
+
+ if (locStatus === "denied") {
+ map.setCenter(new window.kakao.maps.LatLng(PUSAN_UNIV.lat, PUSAN_UNIV.lng));
+ return;
+ }
+ if (locStatus !== "granted" || !userCoords) return;
+
+ const pos = new window.kakao.maps.LatLng(userCoords.lat, userCoords.lng);
+ map.setCenter(pos);
+
+ // 250m 반경 원
+ circleRef.current = new window.kakao.maps.Circle({
+ center: pos,
+ radius: 250,
+ strokeWeight: 2,
+ strokeColor: "#c8873a",
+ strokeOpacity: 0.5,
+ fillColor: "#c8873a",
+ fillOpacity: 0.08,
+ map,
+ });
+
+ // 내 위치 점
+ userDotRef.current = new window.kakao.maps.CustomOverlay({
+ position: pos,
+ content: `
`,
+ map,
+ yAnchor: 0.5,
+ xAnchor: 0.5,
+ });
+ }, [locStatus, userCoords, mapReady]);
+
+ // ── 지도 마커 갱신: featured 장소만 표시
+ useEffect(() => {
+ const map = kakaoMapRef.current;
+ if (!map) return;
+
+ // 기존 마커 제거
+ overlaysRef.current.forEach(o => o.setMap(null));
+ overlaysRef.current = [];
+
+ if (recsState === "hidden" || locStatus !== "granted") return;
+
+ // 겹치는 마커 분리: 20m(~0.00018°) 이내 좌표는 나선형으로 오프셋
+ const SPREAD = 0.00018;
+ const spiralOffsets = [
+ [0, 0], [SPREAD, 0], [-SPREAD, 0],
+ [0, SPREAD], [0, -SPREAD], [SPREAD, SPREAD], [-SPREAD, -SPREAD],
+ ];
+ const placed = [];
+
+ featuredRecs.forEach(place => {
+ let lat = place.lat;
+ let lng = place.lng;
+ let offsetIdx = 0;
+ while (
+ placed.some(p => Math.abs(p.lat - lat) < SPREAD * 0.9 && Math.abs(p.lng - lng) < SPREAD * 0.9) &&
+ offsetIdx < spiralOffsets.length - 1
+ ) {
+ offsetIdx++;
+ lat = place.lat + spiralOffsets[offsetIdx][0];
+ lng = place.lng + spiralOffsets[offsetIdx][1];
+ }
+ placed.push({ lat, lng });
+
+ const content = `
+
+ `;
+ const overlay = new window.kakao.maps.CustomOverlay({
+ position: new window.kakao.maps.LatLng(lat, lng),
+ content,
+ map,
+ yAnchor: 1,
+ });
+ overlaysRef.current.push(overlay);
+ });
+ }, [featuredRecs, recsState, locStatus, mapReady]);
+
+ // ── 핸들러 ──
+ const handleCategoryChange = (label) => {
+ if (label === activeCategory) return;
+ setActiveCategory(label);
+ setSelectedPlace(null);
+ setRecs(mapToRecs(allCategories, label));
+ };
+
+ const handleRecsHide = () => {
+ if (recsState !== "visible") return;
+ setRecsState("fading");
+ setTimeout(() => setRecsState("hidden"), 280);
+ };
+
+ const handleRecsShow = () => {
+ setRecsState("visible");
+ setSelectedPlace(null);
+ };
+
+ // 목적지 마커 제거
+ const clearDestMarker = () => {
+ if (destMarkerRef.current) {
+ destMarkerRef.current.setMap(null);
+ destMarkerRef.current = null;
+ }
+ };
+
+ // 카카오 장소 검색 결과 선택 → 지도 이동 + 마커 표시
+ const handleDestinationSelect = (place) => {
+ const map = kakaoMapRef.current;
+ if (!map) return;
+ const pos = new window.kakao.maps.LatLng(parseFloat(place.y), parseFloat(place.x));
+ map.setCenter(pos);
+ map.setLevel(3);
+
+ clearDestMarker();
+
+ destMarkerRef.current = new window.kakao.maps.CustomOverlay({
+ position: pos,
+ content: `
+
+

+
+ `,
+ map,
+ yAnchor: 1,
+ xAnchor: 0,
+ });
+ };
+
+ // 현재 위치 보정
+ const handleRecalibrate = () => {
+ if (!navigator.geolocation) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
+ setUserCoords(coords);
+ setLocStatus("granted");
+ setRecsState("visible");
+ setSelectedPlace(null);
+ loadRecommendations(coords.lat, coords.lng)
+ .then((data) => {
+ setAllCategories(data.categories);
+ setRecs(mapToRecs(data.categories, activeCategory));
+ setFeaturedRecs(mapToFeatured(data.categories, "전체"));
+ })
+ .catch(console.error);
+ },
+ () => {}
+ );
+ };
+
+ const granted = locStatus === "granted";
+ const showOverlay = granted && recsState !== "hidden";
+ const overlayFading = recsState === "fading";
+
+
+ return (
+
+
+
+ {/* ── 메인 ── */}
+
+
+ {/* ── 카카오맵 컨테이너 (항상 렌더링 → 초기화 가능) ── */}
+
+
+ {/* ── 사이드바 ── */}
+ setSidebarOpen(prev => !prev)}
+ onRecalibrate={handleRecalibrate}
+ onRecsHide={handleRecsHide}
+ onRecsShow={handleRecsShow}
+ onDestinationSelect={handleDestinationSelect}
+ onDestinationClear={clearDestMarker}
+ />
+
+ {/* ── 장소 상세 카드 ── */}
+ {selectedPlace && (
+
+
+

+
+
+ {selectedPlace.category}
+
+
+
+
+
{selectedPlace.name}
+
+
+
+ 내 위치로부터 도보 {selectedPlace.walkMin}분
+
+
+
+ {selectedPlace.isOpen ? "영업 중" : "영업 종료"}
+
+ {selectedPlace.isOpen && selectedPlace.closeTime && (
+ {selectedPlace.closeTime}에 종료
+ )}
+ {!selectedPlace.isOpen && selectedPlace.openTime && (
+ {selectedPlace.openTime}에 시작
+ )}
+
+
+ {selectedPlace.desc}
+
+
+ {selectedPlace.tags.map(tag => (
+
+ {tag}
+
+ ))}
+
+
+
+ )}
+
+ {/* ── 인터넷 불안정 오류 ── */}
+ {isOffline && (
+
+ ⚠
+ 인터넷 연결이 불안정합니다
+
+ )}
+
+
+ );
+}
diff --git a/backend/.idea/compiler.xml b/backend/.idea/compiler.xml
index f78527c..bffbcb3 100644
--- a/backend/.idea/compiler.xml
+++ b/backend/.idea/compiler.xml
@@ -13,6 +13,10 @@
-
+
\ No newline at end of file
diff --git a/backend/.idea/dataSources.xml b/backend/.idea/dataSources.xml
index 6e7923b..a6dec3a 100644
--- a/backend/.idea/dataSources.xml
+++ b/backend/.idea/dataSources.xml
@@ -25,5 +25,17 @@
$ProjectFileDir$
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://onmyway.cb0u0uogmu8b.ap-northeast-2.rds.amazonaws.com:5432/postgres
+
+
+
+
+
+ $ProjectFileDir$
+
\ No newline at end of file
diff --git a/backend/.idea/gradle.xml b/backend/.idea/gradle.xml
index 7d3b3e8..64a3575 100644
--- a/backend/.idea/gradle.xml
+++ b/backend/.idea/gradle.xml
@@ -9,6 +9,8 @@
diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml
deleted file mode 100644
index ff77779..0000000
--- a/backend/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties
index 37f78a6..1af9e09 100644
--- a/backend/gradle/wrapper/gradle-wrapper.properties
+++ b/backend/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/backend/settings.gradle b/backend/settings.gradle
index 753a729..5b7ad0e 100644
--- a/backend/settings.gradle
+++ b/backend/settings.gradle
@@ -1 +1,3 @@
rootProject.name = 'onmyway'
+include 'frontend'
+include 'backend'
\ No newline at end of file
diff --git a/backend/src/main/java/_team/onmyway/config/ErrorConfig.java b/backend/src/main/java/_team/onmyway/config/ErrorConfig.java
new file mode 100644
index 0000000..a04e655
--- /dev/null
+++ b/backend/src/main/java/_team/onmyway/config/ErrorConfig.java
@@ -0,0 +1,16 @@
+package _team.onmyway.config;
+
+import org.springframework.boot.web.server.ErrorPage;
+import org.springframework.boot.web.server.ErrorPageRegistrar;
+import org.springframework.boot.web.server.ErrorPageRegistry;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatus;
+
+@Configuration
+public class ErrorConfig implements ErrorPageRegistrar {
+ @Override
+ public void registerErrorPages(ErrorPageRegistry registry) {
+ ErrorPage error404 = new ErrorPage(HttpStatus.NOT_FOUND, "/index.html");
+ registry.addErrorPages(error404);
+ }
+}
diff --git a/backend/src/main/java/_team/onmyway/config/SecurityConfig.java b/backend/src/main/java/_team/onmyway/config/SecurityConfig.java
index d47d09e..b914482 100644
--- a/backend/src/main/java/_team/onmyway/config/SecurityConfig.java
+++ b/backend/src/main/java/_team/onmyway/config/SecurityConfig.java
@@ -1,5 +1,6 @@
package _team.onmyway.config;
+import _team.onmyway.repository.cookie.CookieAuthorizationRequestRepository;
import _team.onmyway.security.OAuthFailureHandler;
import _team.onmyway.security.OAuthSuccessHandler;
import _team.onmyway.security.JwtAuthenticationFilter;
@@ -25,6 +26,7 @@ public class SecurityConfig {
private final OAuthSuccessHandler successHandler;
private final OAuthFailureHandler failureHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final CookieAuthorizationRequestRepository cookieRepository;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@@ -32,6 +34,12 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers(
+ "/",
+ "/find-route",
+ "/index.html",
+ "/assets/**",
+ "/login",
+ "/error",
"/oauth2/authorization/**",
"/login/oauth2/code/**",
"/api/auth/**",
@@ -40,6 +48,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
"/places/**",
"/route/**"
).permitAll() // 요청을 보낸 이가 누구이든 상관없이 통과되는 URL.
+ .requestMatchers( "/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
@@ -48,6 +57,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.logout(logout -> logout.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2Login(oauth -> oauth
+ // oauth2를 이용할 때 로그인 시 사용할 로그인 페이지 지정(Custom)
+ .loginPage("/login")
+ .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(cookieRepository))
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuthUserService)) // 사용자 정보 이용(회원가입 등)
.successHandler(successHandler) // 로그인 완료 시 이동할 곳
.failureHandler(failureHandler) // 로그인 실패 시 이동할 곳
@@ -55,7 +67,6 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// JWT Filter 등록
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
-
return http.build();
}
diff --git a/backend/src/main/java/_team/onmyway/controller/HomeController.java b/backend/src/main/java/_team/onmyway/controller/HomeController.java
new file mode 100644
index 0000000..1d049ed
--- /dev/null
+++ b/backend/src/main/java/_team/onmyway/controller/HomeController.java
@@ -0,0 +1,12 @@
+package _team.onmyway.controller;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Controller
+public class HomeController {
+ @GetMapping("/")
+ public String home() {
+ return "index.html";
+ }
+}
diff --git a/backend/src/main/java/_team/onmyway/controller/RecommendationController.java b/backend/src/main/java/_team/onmyway/controller/RecommendationController.java
index ba5d741..da5fbd1 100644
--- a/backend/src/main/java/_team/onmyway/controller/RecommendationController.java
+++ b/backend/src/main/java/_team/onmyway/controller/RecommendationController.java
@@ -4,6 +4,7 @@
import _team.onmyway.service.RecommendationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
@RestController
@RequiredArgsConstructor
@@ -14,7 +15,7 @@ public class RecommendationController {
// 전체 카테고리 로드: 카테고리별 7개(대표 포함) + 메인용 대표 1개
@GetMapping("/recommend")
- public AllCategoryRecommendationsDTO recommendAllCategories(
+ public Mono
recommendAllCategories(
@RequestParam double lat,
@RequestParam double lng
) {
diff --git a/backend/src/main/java/_team/onmyway/controller/RouteController.java b/backend/src/main/java/_team/onmyway/controller/RouteController.java
index 36a10dc..94e16c1 100644
--- a/backend/src/main/java/_team/onmyway/controller/RouteController.java
+++ b/backend/src/main/java/_team/onmyway/controller/RouteController.java
@@ -15,7 +15,10 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
import java.util.List;
+import java.util.function.Function;
@RestController
@RequestMapping("/route")
@@ -26,47 +29,34 @@ public class RouteController {
private final ObjectMapper objectMapper;
@PostMapping("/findOut")
- public ResponseEntity> getFindOutRoute(@RequestBody List positions) {
- if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
- PositionDTO start = positions.get(0);
-
- RouteResponseDTO routing = routeService.findOutRoute(positions).block();
- AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon());
-
- ObjectNode response = objectMapper.createObjectNode();
- response.set("route", objectMapper.valueToTree(routing));
- response.set("recommendations", objectMapper.valueToTree(recommendations));
-
- return new ResponseEntity<>(response, HttpStatus.OK);
+ public Mono> getFindOutRoute(@RequestBody List positions) {
+ return processRouteAndRecommend(positions, routeService::findOutRoute);
}
@PostMapping("/right")
- public ResponseEntity> getRightRoute(@RequestBody List positions) {
- if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
- PositionDTO start = positions.get(0);
-
- RouteResponseDTO routing = routeService.rightRoute(positions).block();
- AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon());
-
- ObjectNode response = objectMapper.createObjectNode();
- response.set("route", objectMapper.valueToTree(routing));
- response.set("recommendations", objectMapper.valueToTree(recommendations));
-
- return new ResponseEntity<>(response, HttpStatus.OK);
+ public Mono> getRightRoute(@RequestBody List positions) {
+ return processRouteAndRecommend(positions, routeService::rightRoute);
}
@PostMapping("/slow")
- public ResponseEntity> getRoute(@RequestBody List positions) {
- if (positions.isEmpty()) return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
- PositionDTO start = positions.get(0);
-
- RouteResponseDTO routing = routeService.slowRoute(positions).block();
- AllCategoryRecommendationsDTO recommendations = recommendationService.recommendByRoute(routing, start.getLat(), start.getLon());
-
- ObjectNode response = objectMapper.createObjectNode();
- response.set("route", objectMapper.valueToTree(routing));
- response.set("recommendations", objectMapper.valueToTree(recommendations));
+ public Mono> getRoute(@RequestBody List positions) {
+ return processRouteAndRecommend(positions, routeService::slowRoute);
+ }
- return new ResponseEntity<>(response, HttpStatus.OK);
+ // 비동기 로직 한 번에 묶기
+ private Mono> processRouteAndRecommend(
+ List positions,
+ Function, Mono> processer) {
+ if (positions.isEmpty()) return Mono.just(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
+ PositionDTO start = positions.get(0);
+ return processer.apply(positions)
+ .flatMap(routing -> recommendationService.recommendByRoute(routing, start.getLat(), start.getLon())
+ .map(recommendations -> {
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("route", objectMapper.valueToTree(routing));
+ response.set("recommendations", objectMapper.valueToTree(recommendations));
+
+ return new ResponseEntity<>(response, HttpStatus.OK);
+ }));
}
}
diff --git a/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java b/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java
index 484f81e..0322c36 100644
--- a/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java
+++ b/backend/src/main/java/_team/onmyway/dto/PlaceRecommendationDTO.java
@@ -1,5 +1,6 @@
package _team.onmyway.dto;
+import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -15,5 +16,9 @@ public class PlaceRecommendationDTO {
private int walkingMinutes;
private LocalTime openTime;
private LocalTime closeTime;
+
+ @JsonProperty("isOpen")
private boolean isOpen;
+
+ private String imageURL;
}
diff --git a/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java b/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java
index 3357a7d..81add47 100644
--- a/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java
+++ b/backend/src/main/java/_team/onmyway/dto/RecommendationResponseDTO.java
@@ -1,6 +1,7 @@
package _team.onmyway.dto;
import _team.onmyway.entity.Place;
+import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -32,8 +33,12 @@ static class PlaceInfo {
private int walkingMinutes;
private LocalTime open;
private LocalTime close;
+
+ @JsonProperty("isOpen")
private boolean isOpen;
+ private String imageURL;
+
public static PlaceInfo from(PlaceRecommendationDTO p, double userLat, double userLng) {
double distance = calculateDistance(userLat, userLng, p.getLat(), p.getLng());
@@ -47,7 +52,8 @@ public static PlaceInfo from(PlaceRecommendationDTO p, double userLat, double us
minutes,
p.getOpenTime(),
p.getCloseTime(),
- p.isOpen()
+ p.isOpen(),
+ p.getImageURL()
);
}
diff --git a/backend/src/main/java/_team/onmyway/entity/Photos.java b/backend/src/main/java/_team/onmyway/entity/Photos.java
index 6abfc46..d9e6d89 100644
--- a/backend/src/main/java/_team/onmyway/entity/Photos.java
+++ b/backend/src/main/java/_team/onmyway/entity/Photos.java
@@ -17,4 +17,8 @@ public class Photos {
private Place place;
private String photoURL;
+
+ public String getPhotoURL() {
+ return photoURL;
+ }
}
diff --git a/backend/src/main/java/_team/onmyway/entity/Place.java b/backend/src/main/java/_team/onmyway/entity/Place.java
index be279b4..12b1761 100644
--- a/backend/src/main/java/_team/onmyway/entity/Place.java
+++ b/backend/src/main/java/_team/onmyway/entity/Place.java
@@ -5,10 +5,13 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
@Entity
@Table(indexes = {
@@ -53,4 +56,12 @@ public class Place {
@UpdateTimestamp
private LocalDateTime updatedAt;
+
+ @BatchSize(size = 100)
+ @OneToMany(mappedBy = "place") // 양방향 연결
+ private List workingTimes = new ArrayList<>();
+
+ @BatchSize(size = 100)
+ @OneToMany(mappedBy = "place")
+ private List photos = new ArrayList<>();
}
\ No newline at end of file
diff --git a/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java b/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java
index cf81f40..652e5d9 100644
--- a/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java
+++ b/backend/src/main/java/_team/onmyway/repository/CategoryMappingRepository.java
@@ -7,6 +7,5 @@
import java.util.Optional;
public interface CategoryMappingRepository extends JpaRepository {
-
Optional findBySourceAndApiCategory(SourceType source, String apiCategory);
}
\ No newline at end of file
diff --git a/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java b/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java
new file mode 100644
index 0000000..00eb497
--- /dev/null
+++ b/backend/src/main/java/_team/onmyway/repository/PhotosRepository.java
@@ -0,0 +1,16 @@
+package _team.onmyway.repository;
+
+import _team.onmyway.entity.Photos;
+import _team.onmyway.entity.Place;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface PhotosRepository extends JpaRepository {
+ boolean existsByPlaceId(Long placeId);
+
+ Optional findFirstByPlaceId(Long placeId);
+ public List findByPlace(Place place);
+}
diff --git a/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java b/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java
index b684270..25dca26 100644
--- a/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java
+++ b/backend/src/main/java/_team/onmyway/repository/PlaceRepository.java
@@ -2,6 +2,7 @@
import _team.onmyway.entity.Place;
import _team.onmyway.entity.ServiceCategory;
+import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -40,12 +41,14 @@ List findRandomByCategoryInRadius(
int limit
);
+ @EntityGraph(attributePaths = {"workingTimes", "serviceCategory"})
+ // JPQL로 해결
@Query(value = """
- SELECT * FROM place p
- WHERE p.service_category_id IN :categoryIds
+ SELECT p FROM Place p
+ WHERE p.serviceCategory.id IN :categoryIds
AND p.lat BETWEEN :minLat AND :maxLat
AND p.lng BETWEEN :minLng AND :maxLng
- """, nativeQuery = true)
+ """)
List findByBoundingBox(
List categoryIds,
double minLat,
diff --git a/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java b/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java
new file mode 100644
index 0000000..7eaa52b
--- /dev/null
+++ b/backend/src/main/java/_team/onmyway/repository/cookie/CookieAuthorizationRequestRepository.java
@@ -0,0 +1,85 @@
+package _team.onmyway.repository.cookie;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.util.SerializationUtils;
+import org.springframework.web.util.WebUtils;
+
+import java.util.Base64;
+
+@Component
+public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {
+ public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
+ public static final String REDIRECT_URI = "redirect-uri";
+ public static final String DEFAULT_URI = "/"; // 기본 화면으로 돌아가기. 테스트 사이트에서는 루트로 바꿔야 함.
+ private static final int COOKIE_EXPIRE_SECONDS = 180;
+
+ @Override
+ public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
+ Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
+ if (cookie != null) {
+ return deserialize(cookie.getValue(), OAuth2AuthorizationRequest.class);
+ }
+ return null;
+ }
+
+ @Override
+ public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
+ if (authorizationRequest == null) {
+ removeAuthorizationRequest(request, response);
+ return;
+ }
+
+ String encodedAuthRequest = serialize(authorizationRequest);
+ Cookie authCookie = new Cookie(OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, encodedAuthRequest);
+ authCookie.setPath("/");
+ authCookie.setHttpOnly(true);
+ authCookie.setMaxAge(COOKIE_EXPIRE_SECONDS);
+ response.addCookie(authCookie);
+
+ String redirectUirAfterLogin = request.getParameter(REDIRECT_URI);
+
+ if (redirectUirAfterLogin == null || redirectUirAfterLogin.isBlank()) {
+ redirectUirAfterLogin = DEFAULT_URI;
+ }
+
+ Cookie redirectCookie = new Cookie(REDIRECT_URI, redirectUirAfterLogin);
+ redirectCookie.setPath("/");
+ redirectCookie.setHttpOnly(true);
+ redirectCookie.setMaxAge(COOKIE_EXPIRE_SECONDS);
+ response.addCookie(redirectCookie);
+ }
+
+ @Override
+ public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
+ return this.loadAuthorizationRequest(request);
+ }
+
+ public void removeAuthorizationCookies(HttpServletRequest request, HttpServletResponse response) {
+ deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
+ deleteCookie(request, response, REDIRECT_URI);
+ }
+
+ private void deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) {
+ Cookie cookie = WebUtils.getCookie(request, cookieName);
+ if (cookie != null) {
+ cookie.setValue("");
+ cookie.setPath("/");
+ cookie.setMaxAge(0);
+ response.addCookie(cookie);
+ }
+ }
+
+ private String serialize(Object object) {
+ return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
+ }
+
+ private T deserialize(String serialized, Class cls) {
+ byte[] decoded = Base64.getUrlDecoder().decode(serialized);
+ return cls.cast(SerializationUtils.deserialize(decoded));
+ }
+}
diff --git a/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java b/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java
index 061ce41..904cabe 100644
--- a/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java
+++ b/backend/src/main/java/_team/onmyway/security/OAuthFailureHandler.java
@@ -15,6 +15,7 @@ public class OAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
-
+ System.out.println("OAUTH FAILURE HANDLER");
+ System.out.println("에러 메시지: " + exception.getMessage());
}
}
diff --git a/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java b/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java
index c8b0273..1fa0de1 100644
--- a/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java
+++ b/backend/src/main/java/_team/onmyway/security/OAuthSuccessHandler.java
@@ -2,7 +2,9 @@
import _team.onmyway.entity.Users;
import _team.onmyway.repository.UsersRepository;
+import _team.onmyway.repository.cookie.CookieAuthorizationRequestRepository;
import _team.onmyway.service.JwtService;
+import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
@@ -11,6 +13,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
+import org.springframework.web.util.WebUtils;
import java.io.IOException;
@@ -20,6 +23,7 @@ public class OAuthSuccessHandler implements AuthenticationSuccessHandler {
private final JwtService jwtService;
private final UsersRepository usersRepository;
+ private final CookieAuthorizationRequestRepository cookieRepository;
@Override
@Transactional
@@ -36,6 +40,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
usersRepository.save(user);
// 쿠키에 담기 (HttpOnly + Secure)
+ // access Token으로 변경?
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(false) // 변경
@@ -44,9 +49,12 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
.maxAge(jwtService.getRefreshTokenExpirationMs() / 1000)
.build();
+ Cookie redirectCookie = WebUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI);
+ String redirectURI = redirectCookie != null ? redirectCookie.getValue() : "/";
+
response.addHeader("Set-Cookie", cookie.toString());
- response.sendRedirect("http://localhost:8080/swagger-ui/index.html");
+ response.sendRedirect(redirectURI);
// 맞춰야 할 부분 1(로그인 후 리다이렉트는 어디로)
}
}
\ No newline at end of file
diff --git a/backend/src/main/java/_team/onmyway/service/ImageService.java b/backend/src/main/java/_team/onmyway/service/ImageService.java
new file mode 100644
index 0000000..96ac69e
--- /dev/null
+++ b/backend/src/main/java/_team/onmyway/service/ImageService.java
@@ -0,0 +1,60 @@
+package _team.onmyway.service;
+
+import _team.onmyway.entity.Photos;
+import _team.onmyway.entity.Place;
+import _team.onmyway.repository.PhotosRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.beans.factory.annotation.Value;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class ImageService {
+
+ private final WebClient webClient;
+ private final PhotosRepository photosRepository;
+
+ @Value("${naver.api.clientId}")
+ private String naverClientId;
+
+ @Value("${naver.api.clientSecret}")
+ private String naverClientSecret;
+
+ public ImageService(PhotosRepository photosRepository) {
+ this.photosRepository = photosRepository;
+ this.webClient = WebClient.builder()
+ .baseUrl("https://openapi.naver.com")
+ .defaultHeader("X-Naver-Client-Id", naverClientId)
+ .defaultHeader("X-Naver-Client-Secret", naverClientSecret)
+ .build();
+ }
+
+ public Mono getImageURL(Place p) {
+ boolean hasPhoto = photosRepository.existsById(p.getId());
+ if (hasPhoto) {
+ return Mono.just(photosRepository.findFirstByPlaceId(p.getId()).get().getPhotoURL());
+ } else {
+ return webClient.get()
+ .uri(uri -> uri
+ .path("/v1/search/image")
+ .queryParam("query",p.getName()+" 외관")
+ .queryParam("sort","sim")
+ .build())
+ .retrieve()
+ .bodyToMono(Map.class)
+ .map(response -> {
+ // items 리스트 추출
+ List