임동욱 임동욱 09-17
perf: 가맹몰 상세의 노출 토글은 이미지 더듬기를 건너뛰고 일괄 처리는 동시에 보낸다
feature/voc b009025 재확인 — 관리자 쪽 추가는 업종 세분류 이름(frcsCdNm)뿐이라 계약은
그대로다. 노출 토글·일괄 비활성은 값만 다시 보내므로 단건 조회에서 추가 이미지 HEAD 더듬기를
끈다(건당 최대 11회 → 0회). 일괄 삭제·비활성은 차례로 멈추던 것을 동시에 보내고 실패 수를
세어 알린다(백엔드에 트랜잭션이 없어 어차피 부분 반영된다).

Co-Authored-By: Claude Opus 5 
@60583780ba79e464d0418de44423fb12fac8739e
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -111,6 +111,31 @@
   return showable && error.message ? error.message : fallback;
 }
 
+type BatchFailure = { failedCount: number; error: unknown };
+
+/**
+ * 여러 건을 동시에 보내고 실패 수와 첫 오류를 돌려준다. 백엔드 관리자 API에는 트랜잭션이 없어
+ * 한 건이 실패해도 나머지는 그대로 반영된다 — 차례로 보내며 멈추는 것보다 동시에 보내고 결과를
+ * 세어 알리는 편이 빠르고 정확하다.
+ */
+async function runForEach(
+  targets: readonly string[],
+  run: (frcsNo: string) => Promise<void>
+): Promise<BatchFailure | null> {
+  const results = await Promise.allSettled(targets.map((frcsNo) => run(frcsNo)));
+  const rejected = results.filter(
+    (result): result is PromiseRejectedResult => result.status === 'rejected'
+  );
+  if (rejected.length === 0) return null;
+  unstable_rethrow(rejected[0].reason);
+  return { failedCount: rejected.length, error: rejected[0].reason };
+}
+
+function batchFailureMessage(failure: BatchFailure, total: number, fallback: string): string {
+  const reason = failureMessage(failure.error, fallback);
+  return total > 1 ? `${total}건 중 ${failure.failedCount}건 실패 — ${reason}` : reason;
+}
+
 // ── 상세 ──────────────────────────────────────────────────────────────────────
 
 async function resolveThumbnailId(formData: FormData): Promise<string> {
@@ -290,22 +315,17 @@
     return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
-  try {
-    for (const frcsNo of targets) {
-      await deleteMerchantDetail(frcsNo);
-    }
-  } catch (error) {
-    unstable_rethrow(error);
-    console.error('[merchant-details] 일괄 삭제 실패', error);
-    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
-  }
-
+  const failure = await runForEach(targets, deleteMerchantDetail);
   revalidatePath(MERCHANT_DETAILS_PATH);
+  if (failure) {
+    console.error('[merchant-details] 일괄 삭제 실패', failure.error);
+    return { status: 'error', message: batchFailureMessage(failure, targets.length, DELETE_FAILED_MESSAGE) };
+  }
   return { status: 'success' };
 }
 
 async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> {
-  const detail = await fetchMerchantDetail(frcsNo);
+  const detail = await fetchMerchantDetail(frcsNo, { withImages: false });
   // 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다.
   if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) {
     throw new InputError(DETAIL_NOT_LOADED_MESSAGE);
@@ -356,17 +376,12 @@
     return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
-  try {
-    for (const frcsNo of targets) {
-      await toggleDetailVisibility(frcsNo, false);
-    }
-  } catch (error) {
-    unstable_rethrow(error);
-    console.error('[merchant-details] 일괄 비활성 실패', error);
-    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
-  }
-
+  const failure = await runForEach(targets, (frcsNo) => toggleDetailVisibility(frcsNo, false));
   revalidatePath(MERCHANT_DETAILS_PATH);
+  if (failure) {
+    console.error('[merchant-details] 일괄 비활성 실패', failure.error);
+    return { status: 'error', message: batchFailureMessage(failure, targets.length, SAVE_FAILED_MESSAGE) };
+  }
   return { status: 'success' };
 }
 
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 bf17fc5(2026-09-16) 기준.
+ * 가맹몰 상세 Repository — edupay-backend origin/feature/voc b009025(2026-09-17) 기준.
  *
  * ```
  * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드)
@@ -23,7 +23,8 @@
  * ```
  *
  * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호·
- * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다.
+ * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름
+ * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다.
  *
  * ⚠️ 백엔드 결함(보고함, 미수정):
  * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에
@@ -163,14 +164,14 @@
   return missingAt === -1 ? candidates : candidates.slice(0, missingAt);
 }
 
-async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
+async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
   const [summary] = toSummary(raw);
   if (!summary || !isRecord(raw)) return null;
 
   const imageFileId = readString(raw, 'atchFileId');
   return {
     ...summary,
-    imageUrls: await probeImageUrls(imageFileId),
+    imageUrls: withImages ? await probeImageUrls(imageFileId) : [],
     description: readString(raw, 'frcsCn'),
     openTime: readString(raw, 'startHour'),
     closeTime: readString(raw, 'endHour'),
@@ -237,8 +238,13 @@
   return [...byId.values()].sort(compareSummaryOrder);
 }
 
+/**
+ * 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼
+ * 값만 다시 보내는 곳은 URL이 필요 없다.
+ */
 export async function fetchMerchantDetail(
-  frcsNo: string
+  frcsNo: string,
+  { withImages = true }: { withImages?: boolean } = {}
 ): Promise<MerchantDetail | null> {
   const accessToken = await getSessionAccessToken();
 
@@ -257,7 +263,7 @@
     if (result.code === 404) return null;
     throw new BackendRequestError(result);
   }
-  return toDetail(result.data);
+  return toDetail(result.data, withImages);
 }
 
 async function fetchPage<T>(
Add a comment
List