임동욱 임동욱 09-17
feat: 가맹몰 상세를 feature/voc bf17fc5에 맞추고 이미지 더듬기를 병렬로 바꾼다
백엔드가 카테고리 이름(frcsTypeCdNm)을 붙여 주므로 목록·메뉴 폼이 그것을 먼저 쓰고 코드표
매핑은 뒤로 물린다. 추가 이미지 장수를 알아내는 HEAD 더듬기는 차례가 아니라 한꺼번에 보내
왕복을 한 번으로 줄인다. 가맹점 목록이 useYn을 주기 시작한 것을 문서에 반영한다.

Co-Authored-By: Claude Opus 5 
@251bba4ed9229a5d32543a381e666d96f1616753
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
@@ -131,7 +131,7 @@
               {
                 key: 'category',
                 term: '카테고리',
-                description: formatOptionalValue(detail.categoryCode),
+                description: formatOptionalValue(detail.categoryLabel ?? detail.categoryCode),
               },
               { key: 'count', term: '등록 메뉴', description: `${productCount}건` },
             ]}
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -207,7 +207,8 @@
       header: '카테고리',
       width: 140,
       render: (row) =>
-        row.categoryCode ? formatCommonCode(categories, row.categoryCode) : EMPTY_FIELD_PLACEHOLDER,
+        row.categoryLabel ??
+        (row.categoryCode ? formatCommonCode(categories, row.categoryCode) : EMPTY_FIELD_PLACEHOLDER),
     },
     {
       key: 'phoneNumber',
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -11,7 +11,7 @@
 import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
 
 /**
- * 가맹몰 상세 Repository — edupay-backend origin/feature/voc d784bb6(2026-09-16) 기준.
+ * 가맹몰 상세 Repository — edupay-backend origin/feature/voc bf17fc5(2026-09-16) 기준.
  *
  * ```
  * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드)
@@ -22,8 +22,8 @@
  * GET    /api/v1/mngr/voc/pagination          가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다
  * ```
  *
- * 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)·
- * 등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다.
+ * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호·
+ * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다.
  *
  * ⚠️ 백엔드 결함(보고함, 미수정):
  * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에
@@ -104,6 +104,7 @@
       thumbnailFileId: readString(raw, 'thumbAtchFileId'),
       thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
       categoryCode: readString(raw, 'frcsTypeCd'),
+      categoryLabel: readString(raw, 'frcsTypeCdNm') ?? readString(raw, 'cdNm'),
       isVisible: readYesNo(raw, 'useYn'),
       registeredAt,
       hasDetail:
@@ -131,6 +132,7 @@
       thumbnailFileId: null,
       thumbnailUrl: null,
       categoryCode: null,
+      categoryLabel: null,
       isVisible: null,
       registeredAt: null,
       hasDetail: false,
@@ -139,24 +141,26 @@
 }
 
 /**
- * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로
- * 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다.
+ * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 최대 장수를
+ * 한꺼번에 HEAD로 두드려 이어지는 앞부분만 취한다 — 차례로 두드리면 장수만큼 왕복이 늘어난다.
+ * 없는 번호는 백엔드가 500으로 답한다.
  */
 async function probeImageUrls(fileId: string | null): Promise<string[]> {
   if (!fileId) return [];
-  const urls: string[] = [];
-  for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) {
-    const url = buildFileImageUrl(fileId, fileSn);
-    if (!url) break;
-    try {
-      const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
-      if (!response.ok) break;
-    } catch {
-      break;
-    }
-    urls.push(url);
-  }
-  return urls;
+  const candidates = Array.from({ length: MAX_EXTRA_IMAGES }, (_, index) =>
+    buildFileImageUrl(fileId, index + 1)
+  ).filter((url): url is string => url !== null);
+  const exists = await Promise.all(
+    candidates.map(async (url) => {
+      try {
+        return (await fetch(url, { method: 'HEAD', cache: 'no-store' })).ok;
+      } catch {
+        return false;
+      }
+    })
+  );
+  const missingAt = exists.indexOf(false);
+  return missingAt === -1 ? candidates : candidates.slice(0, missingAt);
 }
 
 async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -25,8 +25,8 @@
  *    비교하고, 저장은 `frcsTpbizCd`로 받는다. 저장한 업종으로 검색이 걸리지 않는다.
  * 4. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시
  *    뒤집어 오래된 가맹점부터 나온다.
- * 5. `USE_YN`을 select하지 않는다 — 검색 조건에는 쓰면서 응답에는 없어 사용여부를 알 수 없다.
- *    화면은 노출로 보인다.
+ * 5. (feature/voc bf17fc5에서 해소) 목록이 `USE_YN`과 `FRCS_DV_CD_NM`을 돌려주기 시작했다 —
+ *    develop에선 아직 없어 사용여부가 `-`로 보인다.
  * 6. 브랜드명·메모·등록일·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고
  *    값만 비워 `-`로 보인다. 등록 시 보내는 브랜드명·메모도 요청 VO에 없어 버려진다.
  */
lib/domain/merchant-detail.ts
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
@@ -25,6 +25,8 @@
   thumbnailUrl: string | null;
   /** 백엔드 `frcsTypeCd` — 카테고리 코드. */
   categoryCode: string | null;
+  /** 백엔드 `frcsTypeCdNm` — 카테고리 이름. 코드표를 다시 뒤지지 않아도 되게 백엔드가 붙여 준다. */
+  categoryLabel: string | null;
   /** 백엔드 상세 `useYn`. 상세가 없는 가맹점은 null이다. */
   isVisible: boolean | null;
   /** 상세 등록일(`frstRegDtStr`, YYYY-MM-DD). */
lib/domain/merchant.ts
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
@@ -25,10 +25,7 @@
   postalCode: string;
   latitude: string;
   longitude: string;
-  /**
-   * 조회 응답에 `useYn`이 없다 — 목록 SQL의 SELECT 목록에도 `MngrVocVo`에도 없고 검색 조건
-   * (`searchUseYn`)에만 쓰인다. 그래서 **모르는 값이 정상**이라 `null`을 둔다(위 넷과 같은 이유).
-   */
+  /** 백엔드 `useYn`. develop 목록엔 없어 `null`(모름)이고 feature/voc부터 온다. */
   isVisible: boolean | null;
 }
 
Add a comment
List