임동욱 임동욱 08-20
fix: 콘텐츠 저장이 코드표 조회 실패에서 'stack' 오류로 끝나던 문제
학교·학년 코드표 조회(`fetchSchoolGradeCodes`)가 검증 인자로 들어가면서 try/catch **밖**에
놓였다. 저장 호출만 감싸고 있었으니 코드 조회가 실패하면 예외가 Server Action을 그대로 빠져나가
`Cannot read properties of undefined (reading 'stack')`로 끝난다.

실패는 실제로 일어난다 — 백엔드는 인가 실패도 200이 아닌 401에 봉투를 실어 돌려주고
(확인: `GET /api/v1/mngr/code/list/COM_SCHUL_CD` → `{"code":401,"message":"인가된 사용자가
아닙니다."}`), 세션 쿠키는 살아 있어도 액세스 토큰이 먼저 만료될 수 있다. 화면을 열 때는
멀쩡하다가 저장할 때 터지는 이유가 이것이다.

여섯 개 쓰기 액션의 본문을 `runWrite`로 감싸 코드표 조회·업로드·저장이 모두 한 울타리 안에서
일어나게 한다(게시판 액션과 같은 방식). `redirect()`는 예외로 흐름을 끊으므로 울타리 밖에 둔다.
이제 같은 실패가 폼 아래 메시지로 보인다.

함께 고친 것:
- 코드가 한 건도 없는 그룹이 `data: null`로 오면 예외 대신 빈 목록으로 다룬다. 학년(`GRD_CD`)은
  학교에 따라 결과가 빌 수 있어 이것을 오류로 보면 화면 전체가 멎는다.
- `/contents`에 error·loading 경계를 둔다(다른 화면과 같은 구성). 없으면 조회 실패가 세그먼트
  경계 없이 위로 튀어 같은 종류의 불투명한 오류로 보인다.

Co-Authored-By: Claude Opus 5 
@20c270c7b381045240069955066a108315aec3f9
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -42,7 +42,7 @@
 
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
-const UPLOAD_FAILED_MESSAGE = '파일을 업로드하지 못했습니다.';
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
 const VIDEO_UPLOADING_MESSAGE =
   '영상을 올리는 중입니다. 업로드가 끝난 뒤 저장해 주세요.';
 const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
@@ -100,6 +100,21 @@
   return uploadAttachment(file, module.id);
 }
 
+/**
+ * 쓰기 동작 한 건을 감싼다 — 여기서 새는 예외가 하나라도 있으면 Next가 직렬화하지 못하고
+ * `Cannot read properties of undefined (reading 'stack')`로 끝난다. 코드표 조회·업로드·저장이
+ * 모두 이 안에서 일어나야 한다. `redirect()`는 예외로 흐름을 끊으므로 반드시 밖에서 부른다.
+ */
+async function runWrite(
+  run: () => Promise<ContentFormState | null>
+): Promise<ContentFormState | null> {
+  try {
+    return await run();
+  } catch (error) {
+    return failure(error, SAVE_FAILED_MESSAGE);
+  }
+}
+
 function failure(error: unknown, fallback: string): ContentFormState {
   return {
     status: 'error',
@@ -107,17 +122,8 @@
   };
 }
 
-export async function createShortsAction(
-  _prevState: ContentFormState,
-  formData: FormData
-): Promise<ContentFormState> {
-  await verifySession();
-
-  if (readString(formData, 'videoUploading') === '1') {
-    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
-  }
-
-  const validation = validateShortsForm({
+function readShortsValues(formData: FormData) {
+  return {
     schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     title: readString(formData, 'title'),
@@ -127,80 +133,47 @@
     videoFileId: readString(formData, 'videoFileId'),
     thumbnailFileId: readString(formData, 'thumbnailFileId'),
     isVisible: readString(formData, 'isVisible') === 'true',
-  }, await fetchSchoolGradeCodes());
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
-
-  try {
-    const values = {
-      ...validation.values,
-      thumbnailFileId: await resolveFileId(
-        readFiles(formData, 'thumbnailFile')[0] ?? null,
-        validation.values.thumbnailFileId,
-        FILE_MODULE.contentThumbnail
-      ),
-    };
-    await createContent(
-      'shorts',
-      buildShortsPayload(values),
-      CONTENT_TYPE_CODE.shorts
-    );
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
-  }
-
-  revalidatePath(contentListPath('shorts'));
-  redirect(contentListPath('shorts'));
+  };
 }
 
-export async function updateShortsAction(
-  _prevState: ContentFormState,
-  formData: FormData
-): Promise<ContentFormState> {
-  await verifySession();
-
-  const cntntsSn = readString(formData, 'cntntsSn');
-  if (readString(formData, 'videoUploading') === '1') {
-    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
-  }
-
-  const validation = validateShortsForm({
+function readComicValues(formData: FormData) {
+  return {
     schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
-    title: readString(formData, 'title'),
+    name: readString(formData, 'name'),
     subject: readString(formData, 'subject'),
     keywords: readKeywords(formData),
     message: readString(formData, 'message'),
-    videoFileId: readString(formData, 'videoFileId'),
-    thumbnailFileId: readString(formData, 'thumbnailFileId'),
+    thumbnailFileId: '',
+    cutFileIds: [] as string[],
     isVisible: readString(formData, 'isVisible') === 'true',
-  }, await fetchSchoolGradeCodes());
-  if (!cntntsSn) {
-    return { status: 'error', message: '요청이 올바르지 않습니다.' };
-  }
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
-
-  try {
-    const values = {
-      ...validation.values,
-      thumbnailFileId: await resolveFileId(
-        readFiles(formData, 'thumbnailFile')[0] ?? null,
-        validation.values.thumbnailFileId,
-        FILE_MODULE.contentThumbnail
-      ),
-    };
-    await updateContent('shorts', cntntsSn, buildShortsPayload(values));
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
-  }
-
-  revalidatePath(contentListPath('shorts'));
-  redirect(contentListPath('shorts'));
+  };
 }
 
+function readQuizValues(formData: FormData) {
+  return {
+    schoolCode: readString(formData, 'schoolCode'),
+    grades: readGrades(formData),
+    level: readString(formData, 'level'),
+    keywords: readKeywords(formData),
+    question: readString(formData, 'question'),
+    answer: readString(formData, 'answer'),
+    answerMessageCode: readString(formData, 'answerMessageCode'),
+    answerMessageText: readString(formData, 'answerMessageText'),
+    explanation: readString(formData, 'explanation'),
+    isVisible: readString(formData, 'isVisible') === 'true',
+  };
+}
+
+async function resolveThumbnailId(formData: FormData): Promise<string> {
+  return resolveFileId(
+    readFiles(formData, 'thumbnailFile')[0] ?? null,
+    readString(formData, 'thumbnailFileId'),
+    FILE_MODULE.contentThumbnail
+  );
+}
+
+/** 컷은 개별 이미지로 올린다(기획). 고르지 않은 칸은 폼이 들고 있던 기존 ID를 그대로 쓴다. */
 async function resolveCutFileIds(formData: FormData): Promise<string[]> {
   const files = readFiles(formData, 'cutFile');
   const retained = formData
@@ -221,48 +194,112 @@
   return ids;
 }
 
+export async function createShortsAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  if (readString(formData, 'videoUploading') === '1') {
+    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
+  }
+
+  const failed = await runWrite(async () => {
+    const validation = validateShortsForm(
+      readShortsValues(formData),
+      await fetchSchoolGradeCodes()
+    );
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
+
+    await createContent(
+      'shorts',
+      buildShortsPayload({
+        ...validation.values,
+        thumbnailFileId: await resolveThumbnailId(formData),
+      }),
+      CONTENT_TYPE_CODE.shorts
+    );
+    return null;
+  });
+  if (failed) {
+    return failed;
+  }
+
+  revalidatePath(contentListPath('shorts'));
+  redirect(contentListPath('shorts'));
+}
+
+export async function updateShortsAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const cntntsSn = readString(formData, 'cntntsSn');
+  if (!cntntsSn) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+  if (readString(formData, 'videoUploading') === '1') {
+    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
+  }
+
+  const failed = await runWrite(async () => {
+    const validation = validateShortsForm(
+      readShortsValues(formData),
+      await fetchSchoolGradeCodes()
+    );
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
+
+    await updateContent(
+      'shorts',
+      cntntsSn,
+      buildShortsPayload({
+        ...validation.values,
+        thumbnailFileId: await resolveThumbnailId(formData),
+      })
+    );
+    return null;
+  });
+  if (failed) {
+    return failed;
+  }
+
+  revalidatePath(contentListPath('shorts'));
+  redirect(contentListPath('shorts'));
+}
+
 export async function createComicAction(
   _prevState: ContentFormState,
   formData: FormData
 ): Promise<ContentFormState> {
   await verifySession();
 
-  let cutFileIds: string[];
-  let thumbnailFileId: string;
-  try {
-    cutFileIds = await resolveCutFileIds(formData);
-    thumbnailFileId = await resolveFileId(
-      readFiles(formData, 'thumbnailFile')[0] ?? null,
-      readString(formData, 'thumbnailFileId'),
-      FILE_MODULE.contentThumbnail
+  const failed = await runWrite(async () => {
+    const validation = validateComicForm(
+      {
+        ...readComicValues(formData),
+        thumbnailFileId: await resolveThumbnailId(formData),
+        cutFileIds: await resolveCutFileIds(formData),
+      },
+      await fetchSchoolGradeCodes()
     );
-  } catch (error) {
-    return failure(error, UPLOAD_FAILED_MESSAGE);
-  }
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
 
-  const validation = validateComicForm({
-    schoolCode: readString(formData, 'schoolCode'),
-    grades: readGrades(formData),
-    name: readString(formData, 'name'),
-    subject: readString(formData, 'subject'),
-    keywords: readKeywords(formData),
-    message: readString(formData, 'message'),
-    thumbnailFileId,
-    cutFileIds,
-    isVisible: readString(formData, 'isVisible') === 'true',
-  }, await fetchSchoolGradeCodes());
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
-
-  try {
     await createContent(
       'comic',
       buildComicPayload(validation.values),
       CONTENT_TYPE_CODE.comic
     );
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
+    return null;
+  });
+  if (failed) {
+    return failed;
   }
 
   revalidatePath(contentListPath('comic'));
@@ -277,60 +314,31 @@
 
   const cntntsSn = readString(formData, 'cntntsSn');
   if (!cntntsSn) {
-    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
-  let cutFileIds: string[];
-  let thumbnailFileId: string;
-  try {
-    cutFileIds = await resolveCutFileIds(formData);
-    thumbnailFileId = await resolveFileId(
-      readFiles(formData, 'thumbnailFile')[0] ?? null,
-      readString(formData, 'thumbnailFileId'),
-      FILE_MODULE.contentThumbnail
+  const failed = await runWrite(async () => {
+    const validation = validateComicForm(
+      {
+        ...readComicValues(formData),
+        thumbnailFileId: await resolveThumbnailId(formData),
+        cutFileIds: await resolveCutFileIds(formData),
+      },
+      await fetchSchoolGradeCodes()
     );
-  } catch (error) {
-    return failure(error, UPLOAD_FAILED_MESSAGE);
-  }
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
 
-  const validation = validateComicForm({
-    schoolCode: readString(formData, 'schoolCode'),
-    grades: readGrades(formData),
-    name: readString(formData, 'name'),
-    subject: readString(formData, 'subject'),
-    keywords: readKeywords(formData),
-    message: readString(formData, 'message'),
-    thumbnailFileId,
-    cutFileIds,
-    isVisible: readString(formData, 'isVisible') === 'true',
-  }, await fetchSchoolGradeCodes());
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
-
-  try {
     await updateContent('comic', cntntsSn, buildComicPayload(validation.values));
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
+    return null;
+  });
+  if (failed) {
+    return failed;
   }
 
   revalidatePath(contentListPath('comic'));
   redirect(contentListPath('comic'));
-}
-
-function readQuizValues(formData: FormData) {
-  return {
-    schoolCode: readString(formData, 'schoolCode'),
-    grades: readGrades(formData),
-    level: readString(formData, 'level'),
-    keywords: readKeywords(formData),
-    question: readString(formData, 'question'),
-    answer: readString(formData, 'answer'),
-    answerMessageCode: readString(formData, 'answerMessageCode'),
-    answerMessageText: readString(formData, 'answerMessageText'),
-    explanation: readString(formData, 'explanation'),
-    isVisible: readString(formData, 'isVisible') === 'true',
-  };
 }
 
 export async function createQuizAction(
@@ -339,15 +347,20 @@
 ): Promise<ContentFormState> {
   await verifySession();
 
-  const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
+  const failed = await runWrite(async () => {
+    const validation = validateQuizForm(
+      readQuizValues(formData),
+      await fetchSchoolGradeCodes()
+    );
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
 
-  try {
     await createQuiz(validation.values);
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
+    return null;
+  });
+  if (failed) {
+    return failed;
   }
 
   revalidatePath(contentListPath('quiz'));
@@ -362,18 +375,23 @@
 
   const quizSn = readString(formData, 'quizSn');
   if (!quizSn) {
-    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
-  const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
-  if (!validation.ok) {
-    return { status: 'error', errors: validation.errors };
-  }
+  const failed = await runWrite(async () => {
+    const validation = validateQuizForm(
+      readQuizValues(formData),
+      await fetchSchoolGradeCodes()
+    );
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
 
-  try {
     await updateQuiz(quizSn, validation.values);
-  } catch (error) {
-    return failure(error, SAVE_FAILED_MESSAGE);
+    return null;
+  });
+  if (failed) {
+    return failed;
   }
 
   revalidatePath(contentListPath('quiz'));
@@ -387,7 +405,7 @@
   await verifySession();
 
   if (!id) {
-    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
   try {
 
app/(protected)/(basic)/contents/error.tsx (added)
+++ app/(protected)/(basic)/contents/error.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useEffect } from 'react';
+
+interface ErrorPageProps {
+  error: Error & { digest?: string };
+  unstable_retry: () => void;
+}
+
+// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만
+// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로
+// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라
+// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
+export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
+  useEffect(() => {
+    console.error(error);
+  }, [error]);
+
+  return (
+    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
+      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
+      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
+      <button
+        type="button"
+        onClick={() => unstable_retry()}
+        className="mt-4 bg-foreground px-4 py-2 text-background"
+      >
+        다시 시도
+      </button>
+    </section>
+  );
+}
 
app/(protected)/(basic)/contents/loading.tsx (added)
+++ app/(protected)/(basic)/contents/loading.tsx
@@ -0,0 +1,6 @@
+import { PageLoading } from '@/components/ui/page-loading';
+
+// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
+export default function Loading() {
+  return <PageLoading />;
+}
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -66,12 +66,20 @@
     method: 'GET',
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
   });
 
   if (!result.ok) {
     throw new BackendRequestError(result);
   }
 
+  // 코드가 한 건도 없는 그룹은 빈 목록으로 다룬다 — 학년(`GRD_CD`)처럼 상위 코드에 따라
+  // 결과가 비는 조회가 있어 이것을 오류로 보면 화면 전체가 멎는다.
+  if (result.data === null || result.data === undefined) {
+    return [];
+  }
+
   if (!Array.isArray(result.data)) {
     throw new Error('공통코드 응답의 형식이 올바르지 않습니다.');
   }
Add a comment
List