임동욱 임동욱 08-20
fix: 콘텐츠 저장 실패의 진짜 원인을 서버 로그에 남기고 개발자용 문구는 화면에서 뺀다
`failure()`가 잡힌 예외의 `message`를 그대로 화면에 실었다. 그래서 코드 오류(TypeError 등)가
나면 "Cannot read properties of undefined (reading 'stack')" 같은 개발자용 문구가 폼 아래에
그대로 뜨고, 정작 스택은 아무 데도 남지 않아 원인을 좁힐 수 없었다.

이제 `runWrite`가 잡은 예외를 서버 콘솔에 그대로 찍고(`[contents] 저장 실패`), 화면에는 사람이
고칠 수 있는 사유만 낸다 — 백엔드가 돌려준 메시지(`BackendRequestError`)와 용량·확장자 같은
입력 오류(`ContentInputError`)뿐이고 나머지는 일반 문구로 바꾼다.

확인차 재현을 시도한 것: Server Action 안에서 `console.error(msg, undefined)`는 던지지 않고,
`POST /api/v1/mngr/quiz`(없는 엔드포인트)와 코드표 조회 실패는 각각 깨끗한 `BackendRequestError`로
돌아온다. 즉 저 문구를 만든 예외는 이 경로들이 아니며, 로그가 붙었으니 다음 실패에서 드러난다.

확장자 안내도 저장소 목록이 아니라 전역 화이트리스트와의 교집합을 말하도록 맞췄다 — mov를
올릴 수 없는데 "mov 파일만 올릴 수 있습니다"라고 안내하고 있었다.

Co-Authored-By: Claude Opus 5 
@a7b7f09b1a6dd9b13ab2050f2ea57d775f4b7dca
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -3,6 +3,7 @@
 import { revalidatePath } from 'next/cache';
 import { redirect } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
 import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { uploadAttachment } from '@/lib/data/repositories/file-repository';
 import {
@@ -20,6 +21,7 @@
 } from '@/lib/data/repositories/content-quiz-repository';
 import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content';
 import {
+  ContentInputError,
   validateComicForm,
   validateQuizForm,
   validateShortsForm,
@@ -29,6 +31,7 @@
 import {
   FILE_MODULE,
   MAX_ATTACHMENT_BYTES,
+  allowedExtensions,
   isAllowedFileExtension,
   type FileModule,
 } from '@/lib/domain/file-module';
@@ -90,11 +93,11 @@
     return retainedId;
   }
   if (file.size > MAX_ATTACHMENT_BYTES) {
-    throw new Error(OVERSIZE_MESSAGE);
+    throw new ContentInputError(OVERSIZE_MESSAGE);
   }
   if (!isAllowedFileExtension(module, file.name)) {
-    throw new Error(
-      `${module.description}에는 ${module.extensions.join(', ')} 파일만 올릴 수 있습니다.`
+    throw new ContentInputError(
+      `${module.description}에는 ${allowedExtensions(module).join(', ')} 파일만 올릴 수 있습니다.`
     );
   }
   return uploadAttachment(file, module.id);
@@ -111,14 +114,22 @@
   try {
     return await run();
   } catch (error) {
+    // 진짜 원인은 여기에만 남는다 — 화면에는 사람이 고칠 수 있는 사유만 나간다.
+    console.error('[contents] 저장 실패', error);
     return failure(error, SAVE_FAILED_MESSAGE);
   }
 }
 
+/**
+ * 사유를 화면에 낼지 정한다. 백엔드가 돌려준 메시지와 입력 오류만 그대로 보이고, 나머지는
+ * 개발자용 문구(예: TypeError)라 일반 문구로 바꾼다 — 그대로 내면 사용자가 할 수 있는 일이 없다.
+ */
 function failure(error: unknown, fallback: string): ContentFormState {
+  const showable =
+    error instanceof ContentInputError || error instanceof BackendRequestError;
   return {
     status: 'error',
-    message: error instanceof Error && error.message ? error.message : fallback,
+    message: showable && error.message ? error.message : fallback,
   };
 }
 
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -32,6 +32,17 @@
 
 export const INITIAL_CONTENT_FORM_STATE: ContentFormState = { status: 'idle' };
 
+/**
+ * 사용자에게 그대로 보여도 되는 오류 — 용량·확장자처럼 사람이 고칠 수 있는 사유다.
+ * 그 밖의 예외는 개발자용 문구라 화면에 내지 않고 서버 로그로만 남긴다.
+ */
+export class ContentInputError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'ContentInputError';
+  }
+}
+
 export type GradeSelection = {
   /** `COM_SCHUL_CD` 코드값. */
   schoolCode: string;
Add a comment
List