임동욱 임동욱 08-21
feat: 가맹점 주소 검색을 다음 우편번호 서비스로
기획 MCH_002_p ③. 버튼을 누른 순간 스크립트를 한 번만 받아 창을 연다 — 자주 열리는
화면이 아니라 모든 페이지가 이 30KB를 지고 다닐 이유가 없다. 도로명/지번은 사용자가
고른 표기를 그대로 쓰고, 우편번호(frcsPostZip)까지 함께 채운다.

좌표는 이 서비스가 주지 않는다. 카카오 지도 지오코더가 필요하고 앱 키가 아직 없어,
NEXT_PUBLIC_KAKAO_MAP_KEY가 설정되면 자동으로 동작하고 없으면 비운 채 진행한다.

Co-Authored-By: Claude Opus 5 
@59f15bf328185b74f22d50c7b26c854457aa22e5
app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
@@ -1,6 +1,12 @@
 'use client';
 
-import { useActionState, useEffect, useRef, useState } from 'react';
+import {
+  useActionState,
+  useEffect,
+  useRef,
+  useState,
+  useTransition,
+} from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxFormLabel } from '@fox/core/components/fox-form-label';
 import { FoxHelperText } from '@fox/core/components/fox-helper-text';
@@ -25,6 +31,7 @@
   OWNER_MAX_LENGTH,
 } from '@/lib/domain/merchant-form';
 import { saveMerchantAction } from '../_actions';
+import { openPostcodeSearch } from './postcode';
 import styles from './merchant-form.module.scss';
 
 interface MerchantFormModalProps {
@@ -48,6 +55,7 @@
   const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? '');
   const [latitude, setLatitude] = useState(merchant?.latitude ?? '');
   const [longitude, setLongitude] = useState(merchant?.longitude ?? '');
+  const [isSearching, startSearching] = useTransition();
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -61,16 +69,29 @@
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  // TODO(기획): 주소 검색 서비스가 정해지지 않았다. 연동 전까지 직접 입력으로 받는다.
+  /** 기획 ③ 주소 검색 연동 — 고른 주소로 우편번호·좌표까지 채운다. */
   function searchAddress() {
-    const picked = window.prompt('주소를 입력하세요.', address);
-    if (picked === null) {
-      return;
-    }
-    setAddress(picked.trim());
-    setPostalCode('');
-    setLatitude('');
-    setLongitude('');
+    startSearching(async () => {
+      let picked;
+      try {
+        picked = await openPostcodeSearch();
+      } catch {
+        showToast({
+          variant: 'danger',
+          message: '주소 검색을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.',
+        });
+        return;
+      }
+
+      if (!picked) {
+        return;
+      }
+
+      setAddress(picked.address);
+      setPostalCode(picked.zonecode);
+      setLatitude(picked.latitude);
+      setLongitude(picked.longitude);
+    });
   }
 
   return (
@@ -213,7 +234,8 @@
             <FoxButton
               type="secondary"
               size="md"
-              label="주소 검색"
+              label={isSearching ? '검색 중...' : '주소 검색'}
+              disabled={isSearching}
               onAction={searchAddress}
             />
           </div>
 
app/(protected)/(basic)/system/merchants/_components/postcode.ts (added)
+++ app/(protected)/(basic)/system/merchants/_components/postcode.ts
@@ -0,0 +1,169 @@
+/**
+ * 다음(카카오) 우편번호 서비스 연동 — 기획 MCH_002_p ③.
+ *
+ * 스크립트를 문서에 미리 넣지 않고 버튼을 누른 순간 한 번만 받는다. 가맹점 등록은 자주 열리는
+ * 화면이 아니라 모든 페이지가 이 30KB를 지고 다닐 이유가 없다.
+ *
+ * 좌표는 이 서비스가 주지 않는다 — 주소만 준다. 위도·경도는 카카오 지도 SDK의 지오코더가
+ * 필요하고 앱 키를 요구하는데 아직 발급되지 않았다(`NEXT_PUBLIC_KAKAO_MAP_KEY`). 키가 설정되면
+ * `geocode()`가 자동으로 동작하고, 없으면 좌표를 비운 채로 진행한다.
+ */
+
+const POSTCODE_SRC =
+  'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
+const KAKAO_SDK_SRC = 'https://dapi.kakao.com/v2/maps/sdk.js';
+
+export type PostcodeResult = {
+  /** 우편번호 5자리. */
+  zonecode: string;
+  /** 사용자가 고른 표기의 주소(도로명 또는 지번). */
+  address: string;
+  latitude: string;
+  longitude: string;
+};
+
+type DaumPostcodeData = {
+  zonecode?: string;
+  address?: string;
+  roadAddress?: string;
+  jibunAddress?: string;
+  userSelectedType?: string;
+};
+
+declare global {
+  interface Window {
+    daum?: {
+      Postcode: new (options: {
+        oncomplete: (data: DaumPostcodeData) => void;
+        onclose?: () => void;
+      }) => { open: () => void };
+    };
+    kakao?: {
+      maps: {
+        load: (callback: () => void) => void;
+        services?: {
+          Geocoder: new () => {
+            addressSearch: (
+              address: string,
+              callback: (
+                result: Array<{ x: string; y: string }>,
+                status: string
+              ) => void
+            ) => void;
+          };
+          Status: { OK: string };
+        };
+      };
+    };
+  }
+}
+
+const loaded = new Map<string, Promise<void>>();
+
+/** 같은 주소의 스크립트는 한 번만 받는다 — 두 번 넣으면 전역이 다시 초기화된다. */
+function loadScript(src: string): Promise<void> {
+  const cached = loaded.get(src);
+  if (cached) {
+    return cached;
+  }
+
+  const pending = new Promise<void>((resolve, reject) => {
+    const script = document.createElement('script');
+    script.src = src;
+    script.async = true;
+    script.onload = () => resolve();
+    script.onerror = () => reject(new Error(`스크립트를 불러오지 못했습니다: ${src}`));
+    document.head.appendChild(script);
+  });
+
+  loaded.set(src, pending);
+  return pending;
+}
+
+/** 주소 → 좌표. 앱 키가 없거나 찾지 못하면 빈 값을 돌려준다(주소 입력은 계속 진행된다). */
+async function geocode(address: string): Promise<{ lat: string; lng: string }> {
+  const key = process.env.NEXT_PUBLIC_KAKAO_MAP_KEY;
+  if (!key) {
+    return { lat: '', lng: '' };
+  }
+
+  try {
+    await loadScript(
+      `${KAKAO_SDK_SRC}?appkey=${encodeURIComponent(key)}&autoload=false&libraries=services`
+    );
+  } catch {
+    return { lat: '', lng: '' };
+  }
+
+  return new Promise((resolve) => {
+    const maps = window.kakao?.maps;
+    if (!maps) {
+      resolve({ lat: '', lng: '' });
+      return;
+    }
+
+    maps.load(() => {
+      const services = window.kakao?.maps.services;
+      if (!services) {
+        resolve({ lat: '', lng: '' });
+        return;
+      }
+
+      new services.Geocoder().addressSearch(address, (result, status) => {
+        const first = status === services.Status.OK ? result[0] : undefined;
+        // 카카오는 x가 경도, y가 위도다.
+        resolve({ lat: first?.y ?? '', lng: first?.x ?? '' });
+      });
+    });
+  });
+}
+
+/**
+ * 우편번호 창을 열고 고른 주소를 돌려준다. 사용자가 닫으면 `null`이다.
+ */
+export async function openPostcodeSearch(): Promise<PostcodeResult | null> {
+  await loadScript(POSTCODE_SRC);
+
+  const picked = await new Promise<DaumPostcodeData | null>((resolve) => {
+    const Postcode = window.daum?.Postcode;
+    if (!Postcode) {
+      resolve(null);
+      return;
+    }
+
+    let completed = false;
+    new Postcode({
+      oncomplete: (data) => {
+        completed = true;
+        resolve(data);
+      },
+      // 고르지 않고 닫으면 oncomplete가 오지 않는다 — 그때 null로 끝낸다.
+      onclose: () => {
+        if (!completed) {
+          resolve(null);
+        }
+      },
+    }).open();
+  });
+
+  if (!picked) {
+    return null;
+  }
+
+  // 사용자가 고른 표기를 그대로 쓴다(도로명 R / 지번 J).
+  const address =
+    (picked.userSelectedType === 'J'
+      ? picked.jibunAddress
+      : picked.roadAddress) ||
+    picked.address ||
+    '';
+
+  const { lat, lng } = await geocode(address);
+
+  return {
+    zonecode: picked.zonecode ?? '',
+    address,
+    latitude: lat,
+    longitude: lng,
+  };
+}
Add a comment
List