From 8bbe17c01ab47291820c6905fb34a40249d2452e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=98=81=EB=AF=B8?= Date: Wed, 29 Jul 2026 18:00:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=A0=9C=EA=B1=B0,=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EstimateRequestForm에서 TEST_CUSTOMER 임시 로그인 코드 제거, hasAuthSession 기반으로 전환 - auth.ts (미사용) 삭제 - 견적요청 전용 컴포넌트 6개를 estimate/request/ 폴더로 이동 - MOVE_TYPES 상수를 constants/moveType.ts로 분리 - Toast 반복 렌더링을 변수로 통합 - Header에서 isLogin prop 제거, 알림 영역을 NotificationTrigger로 분리 - NotificationPanel 알림 아이템 중복 렌더링을 NotificationContent로 추출 - AddressSelectModal inline style을 Tailwind 클래스로 전환 Co-authored-by: Cursor --- src/app/estimate-request/page.tsx | 2 +- src/components/common/Header/Header.tsx | 64 +--------- .../common/Header/NotificationPanel.tsx | 86 +++++-------- .../common/Header/NotificationTrigger.tsx | 65 ++++++++++ .../{ => request}/ActiveEstimateBlocked.tsx | 0 .../{ => request}/AddressSelectModal.tsx | 9 +- .../estimate/{ => request}/Calendar.tsx | 2 +- .../{ => request}/DatePickerField.tsx | 2 +- .../{ => request}/EstimateRequestForm.tsx | 116 +++++------------- .../estimate/{ => request}/MoveTypeCard.tsx | 2 +- src/lib/api/auth.ts | 96 --------------- src/lib/constants/moveType.ts | 28 +++++ 12 files changed, 162 insertions(+), 310 deletions(-) create mode 100644 src/components/common/Header/NotificationTrigger.tsx rename src/components/estimate/{ => request}/ActiveEstimateBlocked.tsx (100%) rename src/components/estimate/{ => request}/AddressSelectModal.tsx (96%) rename src/components/estimate/{ => request}/Calendar.tsx (98%) rename src/components/estimate/{ => request}/DatePickerField.tsx (97%) rename src/components/estimate/{ => request}/EstimateRequestForm.tsx (85%) rename src/components/estimate/{ => request}/MoveTypeCard.tsx (98%) delete mode 100644 src/lib/api/auth.ts diff --git a/src/app/estimate-request/page.tsx b/src/app/estimate-request/page.tsx index 7183c2c3..6ce119ee 100644 --- a/src/app/estimate-request/page.tsx +++ b/src/app/estimate-request/page.tsx @@ -1,4 +1,4 @@ -import EstimateRequestForm from "@/components/estimate/EstimateRequestForm"; +import EstimateRequestForm from "@/components/estimate/request/EstimateRequestForm"; export default function EstimateRequestPage() { return ( diff --git a/src/components/common/Header/Header.tsx b/src/components/common/Header/Header.tsx index 90f71593..44efece2 100644 --- a/src/components/common/Header/Header.tsx +++ b/src/components/common/Header/Header.tsx @@ -5,20 +5,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; -import NotificationPanel from "@/components/common/Header/NotificationPanel"; +import NotificationTrigger from "@/components/common/Header/NotificationTrigger"; import { Text } from "@/components/common/Text"; import { useClickOutside } from "@/hooks/useClickOutside"; -import { AlarmIcon } from "@/icons"; import { getLoginRedirectPath, hasAuthSession, subscribeAuthSession } from "@/lib/auth/session"; import { APP_ROUTES } from "@/lib/constants/appRoutes"; -import { getUnreadNotificationCount, MOCK_NOTIFICATIONS } from "@/lib/mocks/notifications.mock"; import { cn } from "@/lib/utils/cn"; -export interface HeaderProps { - /** TODO: auth 연동 전 임시 prop. 추후 대체 */ - isLogin?: boolean; -} - const LOGGED_OUT_LINKS = [{ label: "기사님 찾기", href: "/movers" }]; const LOGGED_IN_LINKS = [ @@ -32,9 +25,9 @@ const PROFILE_MENU_ITEMS = [ { label: "내가 작성한 리뷰", href: APP_ROUTES.REVIEWS.ME }, ] as const; -const Header = ({ isLogin: isLoginProp }: HeaderProps) => { +const Header = () => { const pathname = usePathname(); - const hasSession = useSyncExternalStore(subscribeAuthSession, hasAuthSession, () => false); + const isLogin = useSyncExternalStore(subscribeAuthSession, hasAuthSession, () => false); const [openMenuPath, setOpenMenuPath] = useState(null); const menuId = useId(); const triggerRef = useRef(null); @@ -42,30 +35,7 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => { const profileMenuRef = useClickOutside(() => setOpenMenuPath(null)); const isProfileMenuOpen = openMenuPath === pathname; - const isLogin = isLoginProp ?? hasSession; const navLinks = isLogin ? LOGGED_IN_LINKS : LOGGED_OUT_LINKS; - const notificationPanelId = useId(); - const [isNotificationOpen, setIsNotificationOpen] = useState(false); - const unreadCount = getUnreadNotificationCount(MOCK_NOTIFICATIONS); - - const closeNotification = useCallback(() => { - setIsNotificationOpen(false); - }, []); - - const notificationRef = useClickOutside(closeNotification); - - useEffect(() => { - if (!isNotificationOpen) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closeNotification(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isNotificationOpen, closeNotification]); const closeMenu = useCallback(() => { setOpenMenuPath(null); @@ -170,33 +140,7 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => { {isLogin ? (
-
- - {isNotificationOpen ? ( -
- -
- ) : null} -
+
+ {isOpen ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/estimate/ActiveEstimateBlocked.tsx b/src/components/estimate/request/ActiveEstimateBlocked.tsx similarity index 100% rename from src/components/estimate/ActiveEstimateBlocked.tsx rename to src/components/estimate/request/ActiveEstimateBlocked.tsx diff --git a/src/components/estimate/AddressSelectModal.tsx b/src/components/estimate/request/AddressSelectModal.tsx similarity index 96% rename from src/components/estimate/AddressSelectModal.tsx rename to src/components/estimate/request/AddressSelectModal.tsx index 15a7a2a6..254c987e 100644 --- a/src/components/estimate/AddressSelectModal.tsx +++ b/src/components/estimate/request/AddressSelectModal.tsx @@ -7,7 +7,7 @@ import { Text } from "@/components/common/Text"; import type { AddressSearchItem } from "@/lib/kakao/addressSearch"; import { cn } from "@/lib/utils/cn"; -import { ClearCircleIcon, SearchIcon } from "./icons"; +import { ClearCircleIcon, SearchIcon } from "../icons"; export type AddressItem = AddressSearchItem; @@ -20,7 +20,7 @@ interface AddressSelectModalProps { onConfirm: (address: AddressItem) => void; } -const RESULT_AREA_HEIGHT = 280; +const RESULT_AREA_HEIGHT_CLASS = "h-[280px] max-h-[280px]"; function AddressChip({ label }: { label: string }) { return ( @@ -186,10 +186,7 @@ export default function AddressSelectModal({
-
+
{isSearching ? (
diff --git a/src/components/estimate/Calendar.tsx b/src/components/estimate/request/Calendar.tsx similarity index 98% rename from src/components/estimate/Calendar.tsx rename to src/components/estimate/request/Calendar.tsx index 111a0e22..a531fe05 100644 --- a/src/components/estimate/Calendar.tsx +++ b/src/components/estimate/request/Calendar.tsx @@ -6,7 +6,7 @@ import { Text } from "@/components/common/Text"; import { cn } from "@/lib/utils/cn"; import { addMonths, getMonthMatrix, isBeforeDay, isSameDay, startOfDay } from "@/lib/utils/date"; -import { ChevronLeftIcon, ChevronRightIcon } from "./icons"; +import { ChevronLeftIcon, ChevronRightIcon } from "../icons"; const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; diff --git a/src/components/estimate/DatePickerField.tsx b/src/components/estimate/request/DatePickerField.tsx similarity index 97% rename from src/components/estimate/DatePickerField.tsx rename to src/components/estimate/request/DatePickerField.tsx index dbb02ce9..fb46a555 100644 --- a/src/components/estimate/DatePickerField.tsx +++ b/src/components/estimate/request/DatePickerField.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils/cn"; import { formatKoreanDate } from "@/lib/utils/date"; import Calendar from "./Calendar"; -import { CalendarIcon, ChevronDownIcon } from "./icons"; +import { CalendarIcon, ChevronDownIcon } from "../icons"; interface DatePickerFieldProps { value: Date; diff --git a/src/components/estimate/EstimateRequestForm.tsx b/src/components/estimate/request/EstimateRequestForm.tsx similarity index 85% rename from src/components/estimate/EstimateRequestForm.tsx rename to src/components/estimate/request/EstimateRequestForm.tsx index d67ff74d..8edffdbd 100644 --- a/src/components/estimate/EstimateRequestForm.tsx +++ b/src/components/estimate/request/EstimateRequestForm.tsx @@ -1,18 +1,19 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { Text } from "@/components/common/Text"; import Toast from "@/components/common/Toast"; -import { login, refreshSession } from "@/lib/api/auth"; import { buildCreateEstimateRequestPayload, createEstimateRequest, getActiveEstimateRequest, } from "@/lib/api/estimateRequest"; import { getApiError } from "@/lib/api/getApiError"; -import { getAccessToken } from "@/lib/auth/token"; +import { getLoginRedirectPath, hasAuthSession } from "@/lib/auth/session"; +import { MOVE_TYPE_CARDS, type MoveTypeCardInfo } from "@/lib/constants/moveType"; import { QUERY_KEYS } from "@/lib/constants/queryKeys"; import { normalizeRoadAddress } from "@/lib/kakao/addressSearch"; import { cn } from "@/lib/utils/cn"; @@ -23,48 +24,20 @@ import Calendar from "./Calendar"; import DatePickerField from "./DatePickerField"; import MoveTypeCard from "./MoveTypeCard"; -// 로컬 테스트용 고객 계정 (로그인 화면 연동 전 임시) -const TEST_CUSTOMER = { - email: "customer1@test.com", - password: "Moving123!", -} as const; - const TOAST_SUCCESS_MESSAGE = "견적 요청이 완료되었습니다."; const TOAST_FAILURE_MESSAGE = "견적 요청이 실패하였습니다."; const TOAST_EXISTING_REQUEST_MESSAGE = "견적 요청에 실패하였습니다. 기존 견적이 있는지 확인해주세요."; const TOAST_INVALID_ZIP_MESSAGE = "우편번호 정보가 올바르지 않습니다. 주소를 다시 선택해주세요."; -const TOAST_LOGIN_FAILURE_MESSAGE = "로그인에 실패하였습니다. 잠시 후 다시 시도해주세요."; const ACTIVE_ESTIMATE_LOAD_ERROR_MESSAGE = "고객님의 견적 정보를 불러오지 못했습니다."; -const MOVE_TYPES = [ - { - id: "small", - title: "소형이사", - description: "원룸, 투룸, 20평대 미만", - imageSrc: "/images/move-type/small.svg", - }, - { - id: "home", - title: "가정이사", - description: "쓰리룸, 20평대 이상", - imageSrc: "/images/move-type/home.svg", - }, - { - id: "office", - title: "사무실이사", - description: "사무실, 상업공간", - imageSrc: "/images/move-type/office.svg", - }, -] as const; - const MOBILE_STEP_TITLES = { 1: "이사 유형을 선택해주세요", 2: "이사 예정일을 선택해주세요", 3: "이사 지역을 선택해주세요", } as const; -type MoveTypeId = (typeof MOVE_TYPES)[number]["id"]; +type MoveTypeId = MoveTypeCardInfo["id"]; type RegionKind = "출발지" | "도착지"; type MobileStep = 1 | 2 | 3; @@ -156,6 +129,7 @@ function RegionField({ kind, value, onSelect, onReset }: RegionFieldProps) { } export default function EstimateRequestForm() { + const router = useRouter(); const queryClient = useQueryClient(); const [mobileStep, setMobileStep] = useState(1); const [selectedType, setSelectedType] = useState(null); @@ -163,10 +137,10 @@ export default function EstimateRequestForm() { const [fromAddress, setFromAddress] = useState(null); const [toAddress, setToAddress] = useState(null); const [addressModalKind, setAddressModalKind] = useState(null); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [authReady, setAuthReady] = useState(false); const [toastMessage, setToastMessage] = useState(null); + const isLoggedIn = hasAuthSession(); + const canSubmit = Boolean(selectedType && fromAddress && toAddress); const canGoNext = mobileStep === 1 ? Boolean(selectedType) : mobileStep === 2 ? Boolean(moveDate) : canSubmit; @@ -176,33 +150,10 @@ export default function EstimateRequestForm() { }, []); useEffect(() => { - let cancelled = false; - - async function ensureLogin() { - setIsLoggingIn(true); - try { - if (!getAccessToken()) { - try { - await refreshSession(); - } catch { - await login(TEST_CUSTOMER); - } - } - } catch { - if (!cancelled) setToastMessage(TOAST_LOGIN_FAILURE_MESSAGE); - } finally { - if (!cancelled) { - setIsLoggingIn(false); - setAuthReady(true); - } - } + if (!isLoggedIn) { + router.replace(getLoginRedirectPath()); } - - void ensureLogin(); - return () => { - cancelled = true; - }; - }, []); + }, [isLoggedIn, router]); const { data: activeRequest, @@ -211,7 +162,7 @@ export default function EstimateRequestForm() { } = useQuery({ queryKey: QUERY_KEYS.ESTIMATE_REQUESTS.ACTIVE, queryFn: getActiveEstimateRequest, - enabled: authReady && Boolean(getAccessToken()), + enabled: isLoggedIn, retry: 1, }); @@ -246,28 +197,17 @@ export default function EstimateRequestForm() { setAddressModalKind(null); } - async function handleSubmit() { + function handleSubmit() { if (!selectedType || !fromAddress || !toAddress) return; - if (!/^\d{5}$/.test(fromAddress.zipCode) || !/^\d{5}$/.test(toAddress.zipCode)) { - setToastMessage(TOAST_INVALID_ZIP_MESSAGE); + if (!isLoggedIn) { + router.replace(getLoginRedirectPath()); return; } - try { - if (!getAccessToken()) { - setIsLoggingIn(true); - try { - await refreshSession(); - } catch { - await login(TEST_CUSTOMER); - } - } - } catch { - setToastMessage(TOAST_LOGIN_FAILURE_MESSAGE); + if (!/^\d{5}$/.test(fromAddress.zipCode) || !/^\d{5}$/.test(toAddress.zipCode)) { + setToastMessage(TOAST_INVALID_ZIP_MESSAGE); return; - } finally { - setIsLoggingIn(false); } const payload = buildCreateEstimateRequestPayload({ @@ -285,16 +225,20 @@ export default function EstimateRequestForm() { setMobileStep((step) => (step + 1) as MobileStep); return; } - void handleSubmit(); + handleSubmit(); } - const isBusy = createMutation.isPending || isLoggingIn; - const isCheckingActive = !authReady || isActiveLoading; + const isBusy = createMutation.isPending; + const isCheckingActive = !isLoggedIn || isActiveLoading; + + const toastElement = ( + + ); if (isCheckingActive) { return (
- + {toastElement} 불러오는 중... @@ -305,7 +249,7 @@ export default function EstimateRequestForm() { if (isActiveError) { return ( <> - + {toastElement} ); @@ -314,7 +258,7 @@ export default function EstimateRequestForm() { if (activeRequest) { return ( <> - + {toastElement} - + {toastElement} {/* Desktop title */}
@@ -373,7 +317,7 @@ export default function EstimateRequestForm() { 이사 유형
- {MOVE_TYPES.map((type) => ( + {MOVE_TYPE_CARDS.map((type) => ( { - void handleSubmit(); - }} + onClick={handleSubmit} className={cn( "rounded-16 flex h-64 w-[200px] items-center justify-center px-16 transition-colors", canSubmit && !isBusy diff --git a/src/components/estimate/MoveTypeCard.tsx b/src/components/estimate/request/MoveTypeCard.tsx similarity index 98% rename from src/components/estimate/MoveTypeCard.tsx rename to src/components/estimate/request/MoveTypeCard.tsx index d53af442..a6502241 100644 --- a/src/components/estimate/MoveTypeCard.tsx +++ b/src/components/estimate/request/MoveTypeCard.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { Text } from "@/components/common/Text"; import { cn } from "@/lib/utils/cn"; -import { CheckIcon } from "./icons"; +import { CheckIcon } from "../icons"; interface MoveTypeCardProps { title: string; diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts deleted file mode 100644 index 7bd03f38..00000000 --- a/src/lib/api/auth.ts +++ /dev/null @@ -1,96 +0,0 @@ -import axiosInstance from "@/lib/api/axiosInstance"; -import { clearAuthTokens, setAccessToken } from "@/lib/auth/token"; -import { API_ROUTES } from "@/lib/constants/apiRoutes"; -import { isDevAuthEnabled, setDevAuthTokens } from "@/lib/dev-auth"; - -export interface LoginInput { - email: string; - password: string; -} - -export interface AuthUser { - id: string; - email: string; - name: string; - phone: string | null; - role: "CUSTOMER" | "MOVER" | "ADMIN"; -} - -export interface PublicAuthTokens { - accessToken: string; - /** HttpOnly Cookie 전환 후에는 응답에 포함되지 않음 */ - refreshToken?: string; -} - -export interface LoginResponse { - success: boolean; - data?: { - user: AuthUser; - tokens: PublicAuthTokens; - }; - error?: { - code?: string; - message?: string; - }; - message?: string; -} - -export interface RefreshResponse { - success: boolean; - data?: { - tokens: PublicAuthTokens; - }; - error?: { - code?: string; - message?: string; - }; - message?: string; -} - -function assertAccessToken( - data: { - success: boolean; - data?: { tokens?: PublicAuthTokens }; - error?: { message?: string }; - message?: string; - }, - fallbackMessage: string, -): string { - const accessToken = data.data?.tokens?.accessToken; - - if (!data.success || !accessToken) { - throw new Error(data.error?.message || data.message || fallbackMessage); - } - - return accessToken; -} - -export async function login(input: LoginInput): Promise> { - const { data } = await axiosInstance.post(API_ROUTES.AUTH.LOGIN, input); - const accessToken = assertAccessToken(data, "로그인에 실패했습니다."); - - setAccessToken(accessToken); - return data.data!; -} - -/** Refresh Token은 HttpOnly Cookie. body 없이 credentials로 갱신 */ -export async function refreshSession(): Promise { - const { data } = await axiosInstance.post(API_ROUTES.AUTH.REFRESH); - const accessToken = assertAccessToken(data, "세션 갱신에 실패했습니다."); - - if (isDevAuthEnabled()) { - setDevAuthTokens({ accessToken }); - } else { - setAccessToken(accessToken); - } - - return accessToken; -} - -export async function logout(): Promise { - try { - await axiosInstance.post(API_ROUTES.AUTH.LOGOUT); - } finally { - clearAuthTokens(); - } -} diff --git a/src/lib/constants/moveType.ts b/src/lib/constants/moveType.ts index 8738fc3c..8bc02598 100644 --- a/src/lib/constants/moveType.ts +++ b/src/lib/constants/moveType.ts @@ -9,3 +9,31 @@ export const MOVE_TYPE_LABEL: Record = { export const MOVE_TYPE_OPTIONS = (Object.entries(MOVE_TYPE_LABEL) as [MoveType, string][]).map( ([value, label]) => ({ value, label }), ); + +export interface MoveTypeCardInfo { + id: "small" | "home" | "office"; + title: string; + description: string; + imageSrc: string; +} + +export const MOVE_TYPE_CARDS: MoveTypeCardInfo[] = [ + { + id: "small", + title: "소형이사", + description: "원룸, 투룸, 20평대 미만", + imageSrc: "/images/move-type/small.svg", + }, + { + id: "home", + title: "가정이사", + description: "쓰리룸, 20평대 이상", + imageSrc: "/images/move-type/home.svg", + }, + { + id: "office", + title: "사무실이사", + description: "사무실, 상업공간", + imageSrc: "/images/move-type/office.svg", + }, +]; From 2ba1daef811e82b7852a77945bf1502231da64a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=98=81=EB=AF=B8?= Date: Wed, 29 Jul 2026 18:24:07 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=8B=AB=ED=9E=90=20=EB=95=8C=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=20=EB=B3=B5=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esc 키, 닫기 버튼: 포커스를 트리거 버튼으로 복귀 - 외부 클릭: 포커스 이동 없이 패널만 닫음 Co-authored-by: Cursor --- .../common/Header/NotificationTrigger.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/common/Header/NotificationTrigger.tsx b/src/components/common/Header/NotificationTrigger.tsx index 5bf5c4b5..6c03acea 100644 --- a/src/components/common/Header/NotificationTrigger.tsx +++ b/src/components/common/Header/NotificationTrigger.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useId, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { Text } from "@/components/common/Text"; import { useClickOutside } from "@/hooks/useClickOutside"; @@ -13,29 +13,36 @@ export default function NotificationTrigger() { const notificationPanelId = useId(); const [isOpen, setIsOpen] = useState(false); const unreadCount = getUnreadNotificationCount(MOCK_NOTIFICATIONS); + const triggerRef = useRef(null); - const close = useCallback(() => { + const closeWithFocus = useCallback(() => { setIsOpen(false); + triggerRef.current?.focus(); }, []); - const ref = useClickOutside(close); + const closeQuiet = useCallback(() => { + setIsOpen(false); + }, []); + + const ref = useClickOutside(closeQuiet); useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - close(); + closeWithFocus(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, close]); + }, [isOpen, closeWithFocus]); return (
{isOpen ? (
- +
) : null}
From 9e0b58c3ac71a95fe02f126d6afdff4512dc148a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=98=81=EB=AF=B8?= Date: Thu, 30 Jul 2026 12:02:25 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20=EC=9A=B0=ED=8E=B8=EB=B2=88=ED=98=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coord2address와 지번/도로명 주소 재조회로 키워드 결과의 빈 zone_no를 채운다. Co-authored-by: Cursor --- src/app/api/address/search/route.ts | 115 +++++++++++++++++++--------- src/lib/kakao/addressSearch.ts | 94 ++++++++++++++++++++++- 2 files changed, 170 insertions(+), 39 deletions(-) diff --git a/src/app/api/address/search/route.ts b/src/app/api/address/search/route.ts index 5c0a7eb0..796aa4ad 100644 --- a/src/app/api/address/search/route.ts +++ b/src/app/api/address/search/route.ts @@ -1,15 +1,20 @@ import { NextResponse } from "next/server"; import { - extractZipCodeFromCoordDocument, + collectAddressQueriesForMissingZip, + collectZipCodeLookups, + extractZipCodeFromAddressDocument, mapKakaoDocumentToAddressItem, mapKakaoKeywordToAddressItem, mergeAddressSearchResults, + mergeCoordDocumentIntoZipLookups, + resolveKeywordZipCode, toCoordinateKey, type KakaoAddressDocument, type KakaoCoord2AddressDocument, type KakaoKeywordDocument, type KakaoSearchResponse, + type ZipCodeLookups, } from "@/lib/kakao/addressSearch"; const KAKAO_ADDRESS_SEARCH_URL = "https://dapi.kakao.com/v2/local/search/address.json"; @@ -34,31 +39,74 @@ async function fetchKakao(url: URL, apiKey: string): Promise, -): Promise> { - const resolvedZipCodes = new Map(zipCodeByCoordinates); +): Promise { + const addressUrl = new URL(KAKAO_ADDRESS_SEARCH_URL); + addressUrl.searchParams.set("query", query); + addressUrl.searchParams.set("analyze_type", "similar"); + addressUrl.searchParams.set("size", "15"); + + const payload = await fetchKakao(addressUrl, apiKey); + return payload.documents ?? []; +} +/** coord2address로 좌표별 우편번호 보강. 지번/도로명 룩업도 함께 채운다. */ +async function enrichZipCodesByCoordinates( + coordinates: string[], + apiKey: string, + lookups: ZipCodeLookups, +): Promise { await Promise.allSettled( coordinates.map(async (coordinateKey) => { - if (resolvedZipCodes.has(coordinateKey)) return; + if (lookups.byCoordinate.has(coordinateKey)) return; const [x, y] = coordinateKey.split(","); + if (!x || !y) return; + const coordUrl = new URL(KAKAO_COORD2ADDRESS_URL); coordUrl.searchParams.set("x", x); coordUrl.searchParams.set("y", y); const payload = await fetchKakao(coordUrl, apiKey); - const zipCode = extractZipCodeFromCoordDocument(payload.documents?.[0]); - if (zipCode) { - resolvedZipCodes.set(coordinateKey, zipCode); - } + mergeCoordDocumentIntoZipLookups(lookups, coordinateKey, payload.documents?.[0]); }), ); +} - return resolvedZipCodes; +/** + * coord2address로도 못 채운 키워드는 지번/도로명으로 주소 검색을 다시 호출해 우편번호를 보강한다. + * (키워드 좌표가 도로명 매칭이 안 되는 지번-only 케이스 대응) + */ +async function enrichZipCodesByAddressQueries( + queries: string[], + apiKey: string, + lookups: ZipCodeLookups, +): Promise { + await Promise.allSettled( + queries.map(async (query) => { + const documents = await searchAddressDocuments(query, apiKey); + for (const document of documents) { + const zipCode = extractZipCodeFromAddressDocument(document); + if (!zipCode) continue; + + lookups.byCoordinate.set(toCoordinateKey(document.x, document.y), zipCode); + if (document.address?.address_name) { + lookups.byJibun.set(document.address.address_name.trim(), zipCode); + } + if (document.address_name.trim()) { + lookups.byJibun.set(document.address_name.trim(), zipCode); + } + if (document.road_address?.address_name) { + lookups.byRoad.set(document.road_address.address_name.trim(), zipCode); + } + // 재검색 쿼리 자체(지번/도로명 문자열)에도 매핑 + lookups.byJibun.set(query, zipCode); + lookups.byRoad.set(query, zipCode); + } + }), + ); } export async function GET(request: Request) { @@ -77,24 +125,19 @@ export async function GET(request: Request) { ); } - const addressUrl = new URL(KAKAO_ADDRESS_SEARCH_URL); - addressUrl.searchParams.set("query", query); - addressUrl.searchParams.set("analyze_type", "similar"); - addressUrl.searchParams.set("size", "15"); - const keywordUrl = new URL(KAKAO_KEYWORD_SEARCH_URL); keywordUrl.searchParams.set("query", query); keywordUrl.searchParams.set("size", "15"); const [addressResult, keywordResult] = await Promise.allSettled([ - fetchKakao(addressUrl, apiKey), + searchAddressDocuments(query, apiKey), fetchKakao(keywordUrl, apiKey), ]); - const addressPayload = addressResult.status === "fulfilled" ? addressResult.value : null; + const addressDocuments = addressResult.status === "fulfilled" ? addressResult.value : null; const keywordPayload = keywordResult.status === "fulfilled" ? keywordResult.value : null; - if (!addressPayload && !keywordPayload) { + if (!addressDocuments && !keywordPayload) { const reason = addressResult.status === "rejected" ? addressResult.reason @@ -105,32 +148,30 @@ export async function GET(request: Request) { return NextResponse.json({ message }, { status: 502 }); } - const zipCodeByCoordinates = new Map(); - for (const document of addressPayload?.documents ?? []) { - const zipCode = document.road_address?.zone_no || document.address?.zip_code || ""; - if (!zipCode) continue; - zipCodeByCoordinates.set(toCoordinateKey(document.x, document.y), zipCode); - } - + const lookups = collectZipCodeLookups(addressDocuments ?? []); const keywordDocuments = keywordPayload?.documents ?? []; + const missingCoordinateKeys = [ ...new Set( keywordDocuments .map((document) => toCoordinateKey(document.x, document.y)) - .filter((coordinateKey) => !zipCodeByCoordinates.has(coordinateKey)), + .filter((coordinateKey) => !lookups.byCoordinate.has(coordinateKey)), ), ]; - const resolvedZipCodeByCoordinates = - missingCoordinateKeys.length > 0 - ? await resolveZipCodesByCoordinates(missingCoordinateKeys, apiKey, zipCodeByCoordinates) - : zipCodeByCoordinates; + if (missingCoordinateKeys.length > 0) { + await enrichZipCodesByCoordinates(missingCoordinateKeys, apiKey, lookups); + } - const addressResults = (addressPayload?.documents ?? []).map(mapKakaoDocumentToAddressItem); - const keywordResults = keywordDocuments.map((document, index) => { - const zipCode = resolvedZipCodeByCoordinates.get(toCoordinateKey(document.x, document.y)) || ""; - return mapKakaoKeywordToAddressItem(document, index, zipCode); - }); + const missingAddressQueries = collectAddressQueriesForMissingZip(keywordDocuments, lookups); + if (missingAddressQueries.length > 0) { + await enrichZipCodesByAddressQueries(missingAddressQueries, apiKey, lookups); + } + + const addressResults = (addressDocuments ?? []).map(mapKakaoDocumentToAddressItem); + const keywordResults = keywordDocuments.map((document, index) => + mapKakaoKeywordToAddressItem(document, index, resolveKeywordZipCode(document, lookups)), + ); const results = mergeAddressSearchResults(addressResults, keywordResults); return NextResponse.json({ results }); diff --git a/src/lib/kakao/addressSearch.ts b/src/lib/kakao/addressSearch.ts index ddcc6c86..b33b6260 100644 --- a/src/lib/kakao/addressSearch.ts +++ b/src/lib/kakao/addressSearch.ts @@ -80,17 +80,107 @@ function extractSido(value: string): string { return value.trim().split(/\s+/)[0] ?? ""; } +export function extractZipCodeFromAddressDocument( + document: Pick, +): string { + return document.road_address?.zone_no || document.address?.zip_code || ""; +} + export function extractZipCodeFromCoordDocument( document: KakaoCoord2AddressDocument | undefined, ): string { if (!document) return ""; - return document.road_address?.zone_no || document.address?.zip_code || ""; + return extractZipCodeFromAddressDocument(document); } export function toCoordinateKey(x: string, y: string): string { return `${x},${y}`; } +export interface ZipCodeLookups { + byCoordinate: Map; + byJibun: Map; + byRoad: Map; +} + +function setZipLookup(map: Map, key: string, zipCode: string): void { + const normalizedKey = key.trim(); + if (!normalizedKey || !zipCode || map.has(normalizedKey)) return; + map.set(normalizedKey, zipCode); +} + +/** 주소 검색 문서에서 좌표/지번/도로명 → 우편번호 룩업 테이블 생성 */ +export function collectZipCodeLookups(documents: KakaoAddressDocument[]): ZipCodeLookups { + const lookups: ZipCodeLookups = { + byCoordinate: new Map(), + byJibun: new Map(), + byRoad: new Map(), + }; + + for (const document of documents) { + const zipCode = extractZipCodeFromAddressDocument(document); + if (!zipCode) continue; + + setZipLookup(lookups.byCoordinate, toCoordinateKey(document.x, document.y), zipCode); + setZipLookup(lookups.byJibun, document.address?.address_name ?? "", zipCode); + setZipLookup(lookups.byJibun, document.address_name, zipCode); + setZipLookup(lookups.byRoad, document.road_address?.address_name ?? "", zipCode); + } + + return lookups; +} + +/** coord2address 결과도 같은 룩업에 반영 */ +export function mergeCoordDocumentIntoZipLookups( + lookups: ZipCodeLookups, + coordinateKey: string, + document: KakaoCoord2AddressDocument | undefined, +): void { + if (!document) return; + + const zipCode = extractZipCodeFromCoordDocument(document); + if (!zipCode) return; + + setZipLookup(lookups.byCoordinate, coordinateKey, zipCode); + setZipLookup(lookups.byJibun, document.address?.address_name ?? "", zipCode); + setZipLookup(lookups.byRoad, document.road_address?.address_name ?? "", zipCode); +} + +/** 키워드 문서의 우편번호 조회 우선순위: 좌표 → 지번 → 도로명 */ +export function resolveKeywordZipCode( + document: KakaoKeywordDocument, + lookups: ZipCodeLookups, +): string { + const road = document.road_address_name.trim(); + const jibun = document.address_name.trim(); + + return ( + lookups.byCoordinate.get(toCoordinateKey(document.x, document.y)) || + lookups.byJibun.get(jibun) || + lookups.byRoad.get(road) || + "" + ); +} + +/** 우편번호가 아직 없는 키워드에 대해 주소 재검색용 쿼리 목록 */ +export function collectAddressQueriesForMissingZip( + documents: KakaoKeywordDocument[], + lookups: ZipCodeLookups, +): string[] { + const queries = new Set(); + + for (const document of documents) { + if (resolveKeywordZipCode(document, lookups)) continue; + + const road = document.road_address_name.trim(); + const jibun = document.address_name.trim(); + if (road) queries.add(road); + else if (jibun) queries.add(jibun); + } + + return [...queries]; +} + export function mapKakaoDocumentToAddressItem( document: KakaoAddressDocument, index: number, @@ -105,7 +195,7 @@ export function mapKakaoDocumentToAddressItem( return { id: `address-${document.x}-${document.y}-${index}`, - zipCode: road?.zone_no || jibun?.zip_code || "", + zipCode: extractZipCodeFromAddressDocument(document), roadAddress, jibunAddress: jibun?.address_name || "", sido: extractSido(jibun?.address_name || roadBase), From ebb400d3c3a536e7d96ef2dae269ad4c309bb8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=98=81=EB=AF=B8?= Date: Thu, 30 Jul 2026 13:32:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=A3=BC=EC=86=8C=20=EC=9E=AC?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20=ED=99=95=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EC=9A=B0=ED=8E=B8=EB=B2=88=ED=98=B8=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setZipLookup을 재사용해 기존 룩업을 유지하고, query 매핑은 첫 유효 문서만 사용한다. Co-authored-by: Cursor --- src/app/api/address/search/route.ts | 30 ++++++++++++++--------------- src/lib/kakao/addressSearch.ts | 25 ++++++++++++++++-------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/app/api/address/search/route.ts b/src/app/api/address/search/route.ts index 796aa4ad..21aa9229 100644 --- a/src/app/api/address/search/route.ts +++ b/src/app/api/address/search/route.ts @@ -3,12 +3,13 @@ import { NextResponse } from "next/server"; import { collectAddressQueriesForMissingZip, collectZipCodeLookups, - extractZipCodeFromAddressDocument, mapKakaoDocumentToAddressItem, mapKakaoKeywordToAddressItem, + mergeAddressDocumentIntoZipLookups, mergeAddressSearchResults, mergeCoordDocumentIntoZipLookups, resolveKeywordZipCode, + setZipLookup, toCoordinateKey, type KakaoAddressDocument, type KakaoCoord2AddressDocument, @@ -78,6 +79,7 @@ async function enrichZipCodesByCoordinates( /** * coord2address로도 못 채운 키워드는 지번/도로명으로 주소 검색을 다시 호출해 우편번호를 보강한다. * (키워드 좌표가 도로명 매칭이 안 되는 지번-only 케이스 대응) + * 이미 확정된 룩업 값은 덮어쓰지 않고, query→zip 매핑은 첫 유효 문서만 사용한다. */ async function enrichZipCodesByAddressQueries( queries: string[], @@ -87,23 +89,19 @@ async function enrichZipCodesByAddressQueries( await Promise.allSettled( queries.map(async (query) => { const documents = await searchAddressDocuments(query, apiKey); - for (const document of documents) { - const zipCode = extractZipCodeFromAddressDocument(document); - if (!zipCode) continue; + let queryZipCode = ""; - lookups.byCoordinate.set(toCoordinateKey(document.x, document.y), zipCode); - if (document.address?.address_name) { - lookups.byJibun.set(document.address.address_name.trim(), zipCode); - } - if (document.address_name.trim()) { - lookups.byJibun.set(document.address_name.trim(), zipCode); - } - if (document.road_address?.address_name) { - lookups.byRoad.set(document.road_address.address_name.trim(), zipCode); + for (const document of documents) { + const zipCode = mergeAddressDocumentIntoZipLookups(lookups, document); + // analyze_type=similar 상위 결과(첫 유효 문서)만 query 문자열 매핑에 사용 + if (!queryZipCode && zipCode) { + queryZipCode = zipCode; } - // 재검색 쿼리 자체(지번/도로명 문자열)에도 매핑 - lookups.byJibun.set(query, zipCode); - lookups.byRoad.set(query, zipCode); + } + + if (queryZipCode) { + setZipLookup(lookups.byJibun, query, queryZipCode); + setZipLookup(lookups.byRoad, query, queryZipCode); } }), ); diff --git a/src/lib/kakao/addressSearch.ts b/src/lib/kakao/addressSearch.ts index b33b6260..95049a11 100644 --- a/src/lib/kakao/addressSearch.ts +++ b/src/lib/kakao/addressSearch.ts @@ -103,7 +103,7 @@ export interface ZipCodeLookups { byRoad: Map; } -function setZipLookup(map: Map, key: string, zipCode: string): void { +export function setZipLookup(map: Map, key: string, zipCode: string): void { const normalizedKey = key.trim(); if (!normalizedKey || !zipCode || map.has(normalizedKey)) return; map.set(normalizedKey, zipCode); @@ -118,18 +118,27 @@ export function collectZipCodeLookups(documents: KakaoAddressDocument[]): ZipCod }; for (const document of documents) { - const zipCode = extractZipCodeFromAddressDocument(document); - if (!zipCode) continue; - - setZipLookup(lookups.byCoordinate, toCoordinateKey(document.x, document.y), zipCode); - setZipLookup(lookups.byJibun, document.address?.address_name ?? "", zipCode); - setZipLookup(lookups.byJibun, document.address_name, zipCode); - setZipLookup(lookups.byRoad, document.road_address?.address_name ?? "", zipCode); + mergeAddressDocumentIntoZipLookups(lookups, document); } return lookups; } +/** 주소 검색 문서를 룩업에 반영 (이미 있는 키는 유지) */ +export function mergeAddressDocumentIntoZipLookups( + lookups: ZipCodeLookups, + document: KakaoAddressDocument, +): string { + const zipCode = extractZipCodeFromAddressDocument(document); + if (!zipCode) return ""; + + setZipLookup(lookups.byCoordinate, toCoordinateKey(document.x, document.y), zipCode); + setZipLookup(lookups.byJibun, document.address?.address_name ?? "", zipCode); + setZipLookup(lookups.byJibun, document.address_name, zipCode); + setZipLookup(lookups.byRoad, document.road_address?.address_name ?? "", zipCode); + return zipCode; +} + /** coord2address 결과도 같은 룩업에 반영 */ export function mergeCoordDocumentIntoZipLookups( lookups: ZipCodeLookups,