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
... | ... | @@ -111,6 +111,31 @@ |
| 111 | 111 |
return showable && error.message ? error.message : fallback; |
| 112 | 112 |
} |
| 113 | 113 |
|
| 114 |
+type BatchFailure = { failedCount: number; error: unknown };
|
|
| 115 |
+ |
|
| 116 |
+/** |
|
| 117 |
+ * 여러 건을 동시에 보내고 실패 수와 첫 오류를 돌려준다. 백엔드 관리자 API에는 트랜잭션이 없어 |
|
| 118 |
+ * 한 건이 실패해도 나머지는 그대로 반영된다 — 차례로 보내며 멈추는 것보다 동시에 보내고 결과를 |
|
| 119 |
+ * 세어 알리는 편이 빠르고 정확하다. |
|
| 120 |
+ */ |
|
| 121 |
+async function runForEach( |
|
| 122 |
+ targets: readonly string[], |
|
| 123 |
+ run: (frcsNo: string) => Promise<void> |
|
| 124 |
+): Promise<BatchFailure | null> {
|
|
| 125 |
+ const results = await Promise.allSettled(targets.map((frcsNo) => run(frcsNo))); |
|
| 126 |
+ const rejected = results.filter( |
|
| 127 |
+ (result): result is PromiseRejectedResult => result.status === 'rejected' |
|
| 128 |
+ ); |
|
| 129 |
+ if (rejected.length === 0) return null; |
|
| 130 |
+ unstable_rethrow(rejected[0].reason); |
|
| 131 |
+ return { failedCount: rejected.length, error: rejected[0].reason };
|
|
| 132 |
+} |
|
| 133 |
+ |
|
| 134 |
+function batchFailureMessage(failure: BatchFailure, total: number, fallback: string): string {
|
|
| 135 |
+ const reason = failureMessage(failure.error, fallback); |
|
| 136 |
+ return total > 1 ? `${total}건 중 ${failure.failedCount}건 실패 — ${reason}` : reason;
|
|
| 137 |
+} |
|
| 138 |
+ |
|
| 114 | 139 |
// ── 상세 ────────────────────────────────────────────────────────────────────── |
| 115 | 140 |
|
| 116 | 141 |
async function resolveThumbnailId(formData: FormData): Promise<string> {
|
... | ... | @@ -290,22 +315,17 @@ |
| 290 | 315 |
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
| 291 | 316 |
} |
| 292 | 317 |
|
| 293 |
- try {
|
|
| 294 |
- for (const frcsNo of targets) {
|
|
| 295 |
- await deleteMerchantDetail(frcsNo); |
|
| 296 |
- } |
|
| 297 |
- } catch (error) {
|
|
| 298 |
- unstable_rethrow(error); |
|
| 299 |
- console.error('[merchant-details] 일괄 삭제 실패', error);
|
|
| 300 |
- return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
|
|
| 301 |
- } |
|
| 302 |
- |
|
| 318 |
+ const failure = await runForEach(targets, deleteMerchantDetail); |
|
| 303 | 319 |
revalidatePath(MERCHANT_DETAILS_PATH); |
| 320 |
+ if (failure) {
|
|
| 321 |
+ console.error('[merchant-details] 일괄 삭제 실패', failure.error);
|
|
| 322 |
+ return { status: 'error', message: batchFailureMessage(failure, targets.length, DELETE_FAILED_MESSAGE) };
|
|
| 323 |
+ } |
|
| 304 | 324 |
return { status: 'success' };
|
| 305 | 325 |
} |
| 306 | 326 |
|
| 307 | 327 |
async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> {
|
| 308 |
- const detail = await fetchMerchantDetail(frcsNo); |
|
| 328 |
+ const detail = await fetchMerchantDetail(frcsNo, { withImages: false });
|
|
| 309 | 329 |
// 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다. |
| 310 | 330 |
if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) {
|
| 311 | 331 |
throw new InputError(DETAIL_NOT_LOADED_MESSAGE); |
... | ... | @@ -356,17 +376,12 @@ |
| 356 | 376 |
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
| 357 | 377 |
} |
| 358 | 378 |
|
| 359 |
- try {
|
|
| 360 |
- for (const frcsNo of targets) {
|
|
| 361 |
- await toggleDetailVisibility(frcsNo, false); |
|
| 362 |
- } |
|
| 363 |
- } catch (error) {
|
|
| 364 |
- unstable_rethrow(error); |
|
| 365 |
- console.error('[merchant-details] 일괄 비활성 실패', error);
|
|
| 366 |
- return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
|
|
| 367 |
- } |
|
| 368 |
- |
|
| 379 |
+ const failure = await runForEach(targets, (frcsNo) => toggleDetailVisibility(frcsNo, false)); |
|
| 369 | 380 |
revalidatePath(MERCHANT_DETAILS_PATH); |
| 381 |
+ if (failure) {
|
|
| 382 |
+ console.error('[merchant-details] 일괄 비활성 실패', failure.error);
|
|
| 383 |
+ return { status: 'error', message: batchFailureMessage(failure, targets.length, SAVE_FAILED_MESSAGE) };
|
|
| 384 |
+ } |
|
| 370 | 385 |
return { status: 'success' };
|
| 371 | 386 |
} |
| 372 | 387 |
|
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
|
| 12 | 12 |
|
| 13 | 13 |
/** |
| 14 |
- * 가맹몰 상세 Repository — edupay-backend origin/feature/voc bf17fc5(2026-09-16) 기준. |
|
| 14 |
+ * 가맹몰 상세 Repository — edupay-backend origin/feature/voc b009025(2026-09-17) 기준. |
|
| 15 | 15 |
* |
| 16 | 16 |
* ``` |
| 17 | 17 |
* GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드) |
... | ... | @@ -23,7 +23,8 @@ |
| 23 | 23 |
* ``` |
| 24 | 24 |
* |
| 25 | 25 |
* 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호· |
| 26 |
- * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. |
|
| 26 |
+ * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름 |
|
| 27 |
+ * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다. |
|
| 27 | 28 |
* |
| 28 | 29 |
* ⚠️ 백엔드 결함(보고함, 미수정): |
| 29 | 30 |
* 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에 |
... | ... | @@ -163,14 +164,14 @@ |
| 163 | 164 |
return missingAt === -1 ? candidates : candidates.slice(0, missingAt); |
| 164 | 165 |
} |
| 165 | 166 |
|
| 166 |
-async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
|
|
| 167 |
+async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
|
|
| 167 | 168 |
const [summary] = toSummary(raw); |
| 168 | 169 |
if (!summary || !isRecord(raw)) return null; |
| 169 | 170 |
|
| 170 | 171 |
const imageFileId = readString(raw, 'atchFileId'); |
| 171 | 172 |
return {
|
| 172 | 173 |
...summary, |
| 173 |
- imageUrls: await probeImageUrls(imageFileId), |
|
| 174 |
+ imageUrls: withImages ? await probeImageUrls(imageFileId) : [], |
|
| 174 | 175 |
description: readString(raw, 'frcsCn'), |
| 175 | 176 |
openTime: readString(raw, 'startHour'), |
| 176 | 177 |
closeTime: readString(raw, 'endHour'), |
... | ... | @@ -237,8 +238,13 @@ |
| 237 | 238 |
return [...byId.values()].sort(compareSummaryOrder); |
| 238 | 239 |
} |
| 239 | 240 |
|
| 241 |
+/** |
|
| 242 |
+ * 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼 |
|
| 243 |
+ * 값만 다시 보내는 곳은 URL이 필요 없다. |
|
| 244 |
+ */ |
|
| 240 | 245 |
export async function fetchMerchantDetail( |
| 241 |
- frcsNo: string |
|
| 246 |
+ frcsNo: string, |
|
| 247 |
+ { withImages = true }: { withImages?: boolean } = {}
|
|
| 242 | 248 |
): Promise<MerchantDetail | null> {
|
| 243 | 249 |
const accessToken = await getSessionAccessToken(); |
| 244 | 250 |
|
... | ... | @@ -257,7 +263,7 @@ |
| 257 | 263 |
if (result.code === 404) return null; |
| 258 | 264 |
throw new BackendRequestError(result); |
| 259 | 265 |
} |
| 260 |
- return toDetail(result.data); |
|
| 266 |
+ return toDetail(result.data, withImages); |
|
| 261 | 267 |
} |
| 262 | 268 |
|
| 263 | 269 |
async function fetchPage<T>( |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?