임동욱 임동욱 08-20
feat: 콘텐츠관리 3종(금융쇼츠·금융OX퀴즈·4컷금융만화) 목록·등록·수정
콘텐츠관리는 게시판(bbs)이 아니라 별도 API다. 쇼츠·만화는 `TB_COM_CNTNTS`를 공유하고
(`/api/v1/mngr/cntntns/{video|toon}`), 퀴즈는 테이블도 경로도 따로다(`/api/v1/mngr/quiz`).
그래서 Repository도 그 경계대로 둘로 나눴다.

등록·수정은 팝업이 아니라 별도 화면이다 — 기획의 화면ID·화면경로(등록 < 금융쇼츠 < 콘텐츠관리)와
디자인 시안이 모두 전체 화면이고, 항목 수가 팝업에 담기지 않는다.

목록 화면은 디자인 시안이 없어 디자인 시스템과 기존 목록 화면의 흐름을 따랐다 — 정렬은 기획의
두 버튼 대신 다른 목록과 같은 정렬 셀렉트로, 학교급·학년·난이도는 필터 칩으로 놓았다.

정답여부는 O·X 두 줄을 모두 그리되 정답으로 고른 줄에서만 노출 문구를 정한다(기획 ③). 기획은
두 줄 다 입력칸을 그리고 아닌 쪽을 흐리게, 디자인 시안은 고른 쪽에만 컨트롤을 그렸는데 비활성으로
두면 두 시안이 같은 화면이 된다.

백엔드 미비 사항은 코드에 이슈로 남겼다. 요약:
- 등록·수정·삭제·단건조회 API가 3종 모두 없다. Repository는 다른 관리 화면과 같은 규약으로
  미리 맞춰 두었고, 단건은 그 API가 생길 때까지 목록에서 찾는다.
- `cntntns` 경로 오타, `video/pagination`의 `return null`, toon 매퍼의 `BEN_WORD_NM` 복붙,
  키워드 조인으로 인한 count 불일치.
- 학교급·학년·주제·전달메시지·정답 노출문구를 담을 컬럼이 없다. 코드값도 정의되지 않아
  잠정 상수로 두고 TODO를 달았다.

확인: 임시 페이지로 세 폼과 두 목록을 렌더링해 항목 구성, 학교급 전환 시 학년 칩 잠금(전체연령),
학년 다중선택, 정답여부 「기타」 선택 시 직접입력 노출을 확인한 뒤 페이지를 지웠다.

Co-Authored-By: Claude Opus 5 
@7f87f17021138f80f21b12fd198fd16ba88b5061
 
app/(protected)/(basic)/contents/_actions.ts (added)
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -0,0 +1,402 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { redirect } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { uploadAttachment } from '@/lib/data/repositories/file-repository';
+import {
+  buildComicPayload,
+  buildShortsPayload,
+  createContent,
+  deleteContent,
+  updateContent,
+} from '@/lib/data/repositories/content-repository';
+import {
+  createQuiz,
+  deleteQuiz,
+  updateQuiz,
+  uploadQuizExcel,
+} from '@/lib/data/repositories/content-quiz-repository';
+import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content';
+import {
+  validateComicForm,
+  validateQuizForm,
+  validateShortsForm,
+  type ContentFormState,
+} from '@/lib/domain/content-form';
+import { contentListPath } from '@/lib/domain/content-query';
+import { FILE_MODULE_ID, MAX_ATTACHMENT_BYTES } from '@/lib/domain/file-module';
+
+/**
+ * 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다).
+ *
+ * 이슈: 등록·수정·삭제 API가 백엔드에 아직 없다. Repository가 예상 규약대로 호출하고 여기서
+ *   실패 사유를 그대로 화면에 돌려준다.
+ */
+
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const UPLOAD_FAILED_MESSAGE = '파일을 업로드하지 못했습니다.';
+const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+function readFiles(formData: FormData, key: string): Array<File | null> {
+  return formData
+    .getAll(key)
+    .map((entry) =>
+      entry instanceof File && entry.size > 0 ? entry : null
+    );
+}
+
+function readGrades(formData: FormData): string[] {
+  return readString(formData, 'grades')
+    .split(',')
+    .map((grade) => grade.trim())
+    .filter(Boolean);
+}
+
+function readKeywords(formData: FormData) {
+  const names = formData.getAll('keyword');
+  const descriptions = formData.getAll('keywordDescription');
+  return names.map((name, index) => ({
+    keyword: typeof name === 'string' ? name : '',
+    description:
+      typeof descriptions[index] === 'string' ? (descriptions[index] as string) : '',
+  }));
+}
+
+/** 새로 고른 파일을 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존 ID를 그대로 쓴다. */
+async function resolveFileId(
+  file: File | null,
+  retainedId: string
+): Promise<string> {
+  if (!file) {
+    return retainedId;
+  }
+  if (file.size > MAX_ATTACHMENT_BYTES) {
+    throw new Error(OVERSIZE_MESSAGE);
+  }
+  return uploadAttachment(file, FILE_MODULE_ID.content);
+}
+
+function failure(error: unknown, fallback: string): ContentFormState {
+  return {
+    status: 'error',
+    message: error instanceof Error && error.message ? error.message : fallback,
+  };
+}
+
+export async function createShortsAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const validation = validateShortsForm({
+    schoolLevel: readString(formData, 'schoolLevel'),
+    grades: readGrades(formData),
+    title: readString(formData, 'title'),
+    subject: readString(formData, 'subject'),
+    keywords: readKeywords(formData),
+    message: readString(formData, 'message'),
+    videoFileId: readString(formData, 'videoFileId'),
+    thumbnailFileId: readString(formData, 'thumbnailFileId'),
+    isVisible: readString(formData, 'isVisible') === 'true',
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    const values = {
+      ...validation.values,
+      videoFileId: await resolveFileId(
+        readFiles(formData, 'videoFile')[0] ?? null,
+        validation.values.videoFileId
+      ),
+      thumbnailFileId: await resolveFileId(
+        readFiles(formData, 'thumbnailFile')[0] ?? null,
+        validation.values.thumbnailFileId
+      ),
+    };
+    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');
+  const validation = validateShortsForm({
+    schoolLevel: readString(formData, 'schoolLevel'),
+    grades: readGrades(formData),
+    title: readString(formData, 'title'),
+    subject: readString(formData, 'subject'),
+    keywords: readKeywords(formData),
+    message: readString(formData, 'message'),
+    videoFileId: readString(formData, 'videoFileId'),
+    thumbnailFileId: readString(formData, 'thumbnailFileId'),
+    isVisible: readString(formData, 'isVisible') === 'true',
+  });
+  if (!cntntsSn) {
+    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+  }
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    const values = {
+      ...validation.values,
+      videoFileId: await resolveFileId(
+        readFiles(formData, 'videoFile')[0] ?? null,
+        validation.values.videoFileId
+      ),
+      thumbnailFileId: await resolveFileId(
+        readFiles(formData, 'thumbnailFile')[0] ?? null,
+        validation.values.thumbnailFileId
+      ),
+    };
+    await updateContent('shorts', cntntsSn, buildShortsPayload(values));
+  } catch (error) {
+    return failure(error, SAVE_FAILED_MESSAGE);
+  }
+
+  revalidatePath(contentListPath('shorts'));
+  redirect(contentListPath('shorts'));
+}
+
+async function resolveCutFileIds(formData: FormData): Promise<string[]> {
+  const files = readFiles(formData, 'cutFile');
+  const retained = formData
+    .getAll('cutFileId')
+    .map((entry) => (typeof entry === 'string' ? entry : ''));
+
+  const ids: string[] = [];
+  for (const [index, file] of files.entries()) {
+    const id = await resolveFileId(file, retained[index] ?? '');
+    if (id) {
+      ids.push(id);
+    }
+  }
+  return ids;
+}
+
+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')
+    );
+  } catch (error) {
+    return failure(error, UPLOAD_FAILED_MESSAGE);
+  }
+
+  const validation = validateComicForm({
+    schoolLevel: readString(formData, 'schoolLevel'),
+    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',
+  });
+  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);
+  }
+
+  revalidatePath(contentListPath('comic'));
+  redirect(contentListPath('comic'));
+}
+
+export async function updateComicAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const cntntsSn = readString(formData, 'cntntsSn');
+  if (!cntntsSn) {
+    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+  }
+
+  let cutFileIds: string[];
+  let thumbnailFileId: string;
+  try {
+    cutFileIds = await resolveCutFileIds(formData);
+    thumbnailFileId = await resolveFileId(
+      readFiles(formData, 'thumbnailFile')[0] ?? null,
+      readString(formData, 'thumbnailFileId')
+    );
+  } catch (error) {
+    return failure(error, UPLOAD_FAILED_MESSAGE);
+  }
+
+  const validation = validateComicForm({
+    schoolLevel: readString(formData, 'schoolLevel'),
+    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',
+  });
+  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);
+  }
+
+  revalidatePath(contentListPath('comic'));
+  redirect(contentListPath('comic'));
+}
+
+function readQuizValues(formData: FormData) {
+  return {
+    schoolLevel: readString(formData, 'schoolLevel'),
+    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(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const validation = validateQuizForm(readQuizValues(formData));
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createQuiz(validation.values);
+  } catch (error) {
+    return failure(error, SAVE_FAILED_MESSAGE);
+  }
+
+  revalidatePath(contentListPath('quiz'));
+  redirect(contentListPath('quiz'));
+}
+
+export async function updateQuizAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const quizSn = readString(formData, 'quizSn');
+  if (!quizSn) {
+    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+  }
+
+  const validation = validateQuizForm(readQuizValues(formData));
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateQuiz(quizSn, validation.values);
+  } catch (error) {
+    return failure(error, SAVE_FAILED_MESSAGE);
+  }
+
+  revalidatePath(contentListPath('quiz'));
+  redirect(contentListPath('quiz'));
+}
+
+export async function deleteContentAction(
+  kind: ContentKind,
+  id: string
+): Promise<ContentFormState> {
+  await verifySession();
+
+  if (!id) {
+    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+  }
+
+  try {
+    if (kind === 'quiz') {
+      await deleteQuiz(id);
+    } else {
+      await deleteContent(kind, id);
+    }
+  } catch (error) {
+    return failure(error, DELETE_FAILED_MESSAGE);
+  }
+
+  revalidatePath(contentListPath(kind));
+  return { status: 'success' };
+}
+
+/** 금융OX퀴즈 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */
+export async function uploadQuizExcelAction(
+  _prevState: ContentFormState,
+  formData: FormData
+): Promise<ContentFormState> {
+  await verifySession();
+
+  const file = readFiles(formData, 'excelFile')[0] ?? null;
+  if (!file) {
+    return { status: 'error', message: '엑셀 파일을 선택해 주세요.' };
+  }
+
+  try {
+    await uploadQuizExcel(file);
+  } catch (error) {
+    return failure(error, '엑셀을 등록하지 못했습니다.');
+  }
+
+  revalidatePath(contentListPath('quiz'));
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/contents/_components/content-cuts-field.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxTextButton } from '@fox/core/components/fox-text-button';
+import { FoxPlusIcon, FoxTrashIcon } from '@fox/core/icons';
+import { MIN_COMIC_CUTS } from '@/lib/domain/content';
+import styles from './content-form.module.scss';
+
+interface ContentCutsFieldProps {
+  /** 수정 화면에서 이미 등록된 컷 이미지 URL(1컷부터 순서대로). */
+  defaultCutUrls?: readonly string[];
+  /** 새로 고르지 않은 컷이 잃어버리지 않도록 들고 있는 기존 첨부 ID. */
+  retainedFileId?: string | null;
+  error?: string;
+}
+
+type Cut = { key: number; url: string | null };
+
+/**
+ * 만화 이미지 — 기본 4컷을 깔고 「컷 추가」로 계속 늘린다(기획 "4컷 이상 구성도 가능").
+ * 각 컷은 개별 이미지로 올린다.
+ */
+export function ContentCutsField({
+  defaultCutUrls = [],
+  retainedFileId,
+  error,
+}: ContentCutsFieldProps) {
+  const [cuts, setCuts] = useState<Cut[]>(() => {
+    const count = Math.max(MIN_COMIC_CUTS, defaultCutUrls.length);
+    return Array.from({ length: count }, (_, index) => ({
+      key: index,
+      url: defaultCutUrls[index] ?? null,
+    }));
+  });
+  const [nextKey, setNextKey] = useState(cuts.length);
+
+  function addCut() {
+    setCuts((current) => [...current, { key: nextKey, url: null }]);
+    setNextKey((current) => current + 1);
+  }
+
+  function removeCut(key: number) {
+    setCuts((current) =>
+      current.length <= MIN_COMIC_CUTS
+        ? current
+        : current.filter((cut) => cut.key !== key)
+    );
+  }
+
+  return (
+    <>
+      <div className={styles.cuts}>
+        {cuts.map((cut, index) => (
+          <div key={cut.key} className={styles.cut}>
+            <FoxFileUpload
+              mode="image"
+              name="cutFile"
+              accept="image/jpeg,image/png"
+              selectLabel="파일선택"
+              thumbnailWidth={132}
+              thumbnailHeight={74}
+              defaultFiles={
+                cut.url
+                  ? [{ id: String(cut.key), name: `${index + 1}컷`, previewUrl: cut.url }]
+                  : undefined
+              }
+            />
+            <input
+              type="hidden"
+              name="cutFileId"
+              defaultValue={cut.url ? (retainedFileId ?? '') : ''}
+            />
+            <div className={styles.cutFooter}>
+              <span className={styles.cutLabel}>{index + 1}컷</span>
+              <FoxTextButton
+                size="sm"
+                leadingIcon={<FoxTrashIcon />}
+                label="삭제"
+                disabled={cuts.length <= MIN_COMIC_CUTS}
+                onAction={() => removeCut(cut.key)}
+              />
+            </div>
+          </div>
+        ))}
+      </div>
+
+      <div>
+        <FoxTextButton
+          size="md"
+          leadingIcon={<FoxPlusIcon />}
+          label="컷 추가"
+          onAction={addCut}
+        />
+      </div>
+
+      {error && <FoxHelperText type="danger" message={error} />}
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-form-actions.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-form-actions.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import styles from './content-form.module.scss';
+
+interface ContentFormActionsProps {
+  submitLabel: string;
+  listHref: string;
+  pending: boolean;
+}
+
+/** 폼 하단의 [목록] [등록/수정]. 시안이 가운데 정렬이다. */
+export function ContentFormActions({
+  submitLabel,
+  listHref,
+  pending,
+}: ContentFormActionsProps) {
+  const router = useRouter();
+
+  return (
+    <div className={styles.actions}>
+      <FoxButtonGroup size="md">
+        <FoxButton
+          type="default"
+          size="md"
+          label="목록"
+          onAction={() => router.push(listHref)}
+        />
+        <FoxButton
+          type="primary"
+          size="md"
+          htmlType="submit"
+          label={submitLabel}
+          loading={pending}
+        />
+      </FoxButtonGroup>
+    </div>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-form-row.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-form-row.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from 'react';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import styles from './content-form.module.scss';
+
+interface ContentFormRowProps {
+  label: string;
+  required?: boolean;
+  optionalText?: string;
+  children: ReactNode;
+}
+
+/** 등록·수정 폼의 한 줄 — 시안의 「라벨 | 입력」 2열 구조. */
+export function ContentFormRow({
+  label,
+  required,
+  optionalText,
+  children,
+}: ContentFormRowProps) {
+  return (
+    <div className={styles.row}>
+      <div className={styles.label}>
+        <FoxFormLabel
+          as="span"
+          requirement={required ? 'required' : optionalText ? 'optional' : undefined}
+          optionalText={optionalText}
+        >
+          {label}
+        </FoxFormLabel>
+      </div>
+      <div className={styles.field}>{children}</div>
+    </div>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-form.module.scss (added)
+++ app/(protected)/(basic)/contents/_components/content-form.module.scss
@@ -0,0 +1,111 @@
+@use "@fox/styles/abstracts" as fox;
+
+.form {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  border-block-start: fox.border(1) solid fox.color(border-neutral-subtle);
+}
+
+.row {
+  display: flex;
+  inline-size: 100%;
+  align-items: flex-start;
+  gap: fox.gap(6);
+  padding-block: fox.padding(5);
+  padding-inline: fox.padding(4);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
+
+.label {
+  flex: 0 0 140px;
+  padding-block-start: fox.padding(2);
+}
+
+.field {
+  display: flex;
+  flex: 1 1 0;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+.inline {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: fox.gap(3);
+}
+
+.grades {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+.keywordRow {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+}
+
+.keywordName {
+  flex: 0 0 200px;
+  min-inline-size: 0;
+}
+
+.keywordDescription {
+  flex: 1 1 0;
+  min-inline-size: 0;
+}
+
+.cuts {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: fox.gap(4);
+}
+
+.cut {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(2);
+  padding: fox.padding(3);
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+}
+
+.cutFooter {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: fox.gap(2);
+}
+
+.cutLabel {
+  font-size: fox.font-size(label-sm);
+  color: fox.color(font-neutral-subtle);
+}
+
+.actions {
+  display: flex;
+  justify-content: center;
+  padding-block-start: fox.padding(8);
+}
+
+.thumbnail {
+  display: flex;
+  align-items: flex-start;
+  gap: fox.gap(4);
+}
+
+.answerLines {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+.answerMessage {
+  flex: 1 1 0;
+  min-inline-size: 0;
+  max-inline-size: 360px;
+}
 
app/(protected)/(basic)/contents/_components/content-grade-field.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-grade-field.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxChip } from '@fox/core/components/fox-chip';
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import {
+  DEFAULT_SCHOOL_LEVEL,
+  SCHOOL_LEVEL_OPTIONS,
+  findSchoolLevel,
+  gradeOptionsOf,
+} from '@/lib/domain/content';
+import styles from './content-form.module.scss';
+
+interface ContentGradeFieldProps {
+  defaultSchoolLevel?: string;
+  defaultGrades?: readonly string[];
+  schoolLevelError?: string;
+  gradesError?: string;
+}
+
+/**
+ * 학년선택 — 학교급을 고르면 그 학교급의 학년 칩이 열리고 여러 개를 고를 수 있다.
+ * 전체연령은 학년이 없어 칩을 그리지 않는다(기획 "전체 연령 선택 시 학년 선택 비활성").
+ */
+export function ContentGradeField({
+  defaultSchoolLevel,
+  defaultGrades = [],
+  schoolLevelError,
+  gradesError,
+}: ContentGradeFieldProps) {
+  const [schoolLevel, setSchoolLevel] = useState(
+    defaultSchoolLevel || DEFAULT_SCHOOL_LEVEL
+  );
+  const [grades, setGrades] = useState<string[]>([...defaultGrades]);
+
+  const gradeOptions = gradeOptionsOf(schoolLevel);
+  const hasGrades = (findSchoolLevel(schoolLevel)?.gradeCount ?? 0) > 0;
+
+  function changeSchoolLevel(next: string) {
+    setSchoolLevel(next);
+    // 학교급이 바뀌면 이전 학교급의 학년은 남길 수 없다(초 4학년 → 중학교).
+    setGrades([]);
+  }
+
+  function toggleGrade(grade: string) {
+    setGrades((current) =>
+      current.includes(grade)
+        ? current.filter((value) => value !== grade)
+        : [...current, grade].sort()
+    );
+  }
+
+  return (
+    <div className={styles.grades}>
+      <div className={styles.inline}>
+        <FoxSelect
+          size="md"
+          options={SCHOOL_LEVEL_OPTIONS.map((option) => ({
+            value: option.value,
+            label: option.label,
+          }))}
+          value={schoolLevel}
+          onValueChange={changeSchoolLevel}
+          error={Boolean(schoolLevelError)}
+        />
+        {hasGrades && (
+          <FoxChipArea size="md" label="학년" wrap>
+            {gradeOptions.map((grade) => (
+              <FoxChip
+                key={grade}
+                type="check"
+                label={`${grade}학년`}
+                checked={grades.includes(grade)}
+                onCheckedChange={() => toggleGrade(grade)}
+              />
+            ))}
+          </FoxChipArea>
+        )}
+      </div>
+
+      <input type="hidden" name="schoolLevel" value={schoolLevel} />
+      <input type="hidden" name="grades" value={grades.join(',')} />
+
+      {(schoolLevelError || gradesError) && (
+        <FoxHelperText
+          type="danger"
+          message={schoolLevelError ?? gradesError ?? ''}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-keyword-field.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-keyword-field.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxTextButton } from '@fox/core/components/fox-text-button';
+import { FoxPlusIcon, FoxTrashIcon } from '@fox/core/icons';
+import {
+  DEFAULT_KEYWORD_ROWS,
+  type ContentKeyword,
+} from '@/lib/domain/content';
+import {
+  KEYWORD_DESCRIPTION_MAX_LENGTH,
+  KEYWORD_MAX_LENGTH,
+} from '@/lib/domain/content-form';
+import styles from './content-form.module.scss';
+
+interface ContentKeywordFieldProps {
+  defaultKeywords?: readonly ContentKeyword[];
+  error?: string;
+}
+
+const EMPTY_ROW: ContentKeyword = { keyword: '', description: '' };
+
+function initialRows(
+  defaults: readonly ContentKeyword[]
+): Array<ContentKeyword & { key: number }> {
+  const rows = defaults.length > 0 ? defaults : Array.from({ length: DEFAULT_KEYWORD_ROWS }, () => EMPTY_ROW);
+  return rows.map((row, index) => ({ ...row, key: index }));
+}
+
+/** 키워드 — 키워드와 설명을 쌍으로 입력한다. 기본 2줄이고 줄을 더 늘릴 수 있다. */
+export function ContentKeywordField({
+  defaultKeywords = [],
+  error,
+}: ContentKeywordFieldProps) {
+  const [rows, setRows] = useState(() => initialRows(defaultKeywords));
+  const [nextKey, setNextKey] = useState(rows.length);
+
+  function addRow() {
+    setRows((current) => [...current, { ...EMPTY_ROW, key: nextKey }]);
+    setNextKey((current) => current + 1);
+  }
+
+  function removeRow(key: number) {
+    setRows((current) =>
+      current.length === 1 ? current : current.filter((row) => row.key !== key)
+    );
+  }
+
+  return (
+    <>
+      {rows.map((row) => (
+        <div key={row.key} className={styles.keywordRow}>
+          <div className={styles.keywordName}>
+            <FoxInput
+              size="md"
+              name="keyword"
+              aria-label="키워드"
+              defaultValue={row.keyword}
+              placeholder="키워드"
+              maxLength={KEYWORD_MAX_LENGTH}
+            />
+          </div>
+          <div className={styles.keywordDescription}>
+            <FoxInput
+              size="md"
+              name="keywordDescription"
+              aria-label="키워드 설명"
+              defaultValue={row.description}
+              placeholder="키워드에 대한 간단한 설명"
+              maxLength={KEYWORD_DESCRIPTION_MAX_LENGTH}
+            />
+          </div>
+          <FoxIconButton
+            type="default"
+            size="md"
+            icon={<FoxTrashIcon />}
+            label="키워드 삭제"
+            disabled={rows.length === 1}
+            onAction={() => removeRow(row.key)}
+          />
+        </div>
+      ))}
+
+      <div>
+        <FoxTextButton
+          size="md"
+          leadingIcon={<FoxPlusIcon />}
+          label="키워드 추가"
+          onAction={addRow}
+        />
+      </div>
+
+      {error && <FoxHelperText type="danger" message={error} />}
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-list-filters.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-list-filters.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { FoxChip } from '@fox/core/components/fox-chip';
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
+import {
+  QUIZ_LEVEL_OPTIONS,
+  SCHOOL_LEVEL_OPTIONS,
+  gradeOptionsOf,
+} from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+
+const ALL = '';
+
+// 칩은 고른 옵션의 라벨을 칩 글자 자리에 보여 준다 — 「전체」만 적으면 어떤 필터인지 사라져서
+// 선택지 이름에 필터 이름을 붙인다.
+
+interface ContentListFiltersProps {
+  query: ContentQuery;
+  /** 난이도 칩은 금융OX퀴즈에만 있다. */
+  withLevel?: boolean;
+  onChange: (patch: Partial<ContentQuery>) => void;
+}
+
+/** 목록 상단 필터 — 기획의 「학교급 / 학년 / 난이도」 셀렉트. */
+export function ContentListFilters({
+  query,
+  withLevel = false,
+  onChange,
+}: ContentListFiltersProps) {
+  const gradeOptions = gradeOptionsOf(query.schoolLevel);
+
+  return (
+    <FoxChipArea size="md" label="목록 필터" wrap>
+      <FoxChip
+        type="select"
+        label="학교급"
+        options={[
+          { value: ALL, label: '학교급 전체' },
+          ...SCHOOL_LEVEL_OPTIONS.map((option) => ({
+            value: option.value,
+            label: option.label,
+          })),
+        ]}
+        value={query.schoolLevel}
+        onValueChange={(value) =>
+          onChange({ schoolLevel: value, grade: ALL, page: 1 })
+        }
+      />
+      <FoxChip
+        type="select"
+        label="학년"
+        disabled={gradeOptions.length === 0}
+        options={[
+          { value: ALL, label: '학년 전체' },
+          ...gradeOptions.map((grade) => ({
+            value: grade,
+            label: `${grade}학년`,
+          })),
+        ]}
+        value={query.grade}
+        onValueChange={(value) => onChange({ grade: value, page: 1 })}
+      />
+      {withLevel && (
+        <FoxChip
+          type="select"
+          label="난이도"
+          options={[
+            { value: ALL, label: '난이도 전체' },
+            ...QUIZ_LEVEL_OPTIONS.map((option) => ({
+              value: option.value,
+              label: `난이도 ${option.label}`,
+            })),
+          ]}
+          value={query.level}
+          onValueChange={(value) => onChange({ level: value, page: 1 })}
+        />
+      )}
+    </FoxChipArea>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-list-shell.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-list-shell.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import {
+  FoxArrowsDownUpIcon,
+  FoxPlusIcon,
+} from '@fox/core/icons';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { CONTENT_KIND_LABEL, type ContentKind } from '@/lib/domain/content';
+import {
+  CONTENT_PAGE_SIZE_OPTIONS,
+  CONTENT_SEARCH_FIELDS,
+  CONTENT_SORT_OPTIONS,
+  buildContentHref,
+  contentBreadcrumb,
+  contentCreatePath,
+  type ContentPageSize,
+  type ContentQuery,
+  type ContentSortOption,
+} from '@/lib/domain/content-query';
+import { ContentListFilters } from './content-list-filters';
+
+interface ContentListShellProps<T> {
+  kind: ContentKind;
+  columns: FoxListColumn<T>[];
+  rows: T[];
+  rowKey: (row: T) => string;
+  query: ContentQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 툴바 오른쪽에 덧붙일 것(금융OX퀴즈의 엑셀 버튼). */
+  extraActions?: ReactNode;
+}
+
+/** 콘텐츠관리 목록 3종이 공유하는 껍데기 — 열만 화면마다 다르다. */
+export function ContentListShell<T>({
+  kind,
+  columns,
+  rows,
+  rowKey,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  extraActions,
+}: ContentListShellProps<T>) {
+  const router = useRouter();
+
+  function go(patch: Partial<ContentQuery>) {
+    router.push(buildContentHref(kind, query, patch));
+  }
+
+  return (
+    <FoxListContainer<T>
+      title={`콘텐츠 목록 (${CONTENT_KIND_LABEL[kind]})`}
+      breadcrumb={contentBreadcrumb(kind)}
+      caption={`${CONTENT_KIND_LABEL[kind]} 콘텐츠 목록`}
+      columns={columns}
+      rows={rows}
+      rowKey={rowKey}
+      totalCount={totalCount}
+      emptyMessage="등록된 콘텐츠가 없습니다."
+      filter={
+        <ContentListFilters
+          query={query}
+          withLevel={kind === 'quiz'}
+          onChange={go}
+        />
+      }
+      sorts={
+        <>
+          <FoxSelectText
+            size="sm"
+            ariaLabel="정렬"
+            icon={<FoxArrowsDownUpIcon />}
+            options={CONTENT_SORT_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={query.sort}
+            onValueChange={(value) => go({ sort: value as ContentSortOption })}
+          />
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={CONTENT_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as ContentPageSize, page: 1 })
+            }
+          />
+        </>
+      }
+      searchFields={CONTENT_SEARCH_FIELDS[kind].map((field) => ({
+        value: field.value,
+        label: field.label,
+      }))}
+      searchField={query.searchField}
+      keyword={query.keyword}
+      searchPlaceholder="검색어를 입력하세요."
+      toolbarActions={
+        <FoxButtonGroup size="md">
+          {extraActions}
+          <FoxButton
+            type="primary"
+            size="md"
+            leadingIcon={<FoxPlusIcon />}
+            label="등록"
+            onAction={() => router.push(contentCreatePath(kind))}
+          />
+        </FoxButtonGroup>
+      }
+      page={currentPage}
+      totalPages={totalPages}
+      buildHref={(patch) => buildContentHref(kind, query, patch as Partial<ContentQuery>)}
+      onQueryChange={go}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-row-actions.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-row-actions.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { useTransition } from 'react';
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { ContentKind } from '@/lib/domain/content';
+import { contentEditPath } from '@/lib/domain/content-query';
+import { deleteContentAction } from '../_actions';
+
+interface ContentRowActionsProps {
+  kind: ContentKind;
+  id: string;
+  name: string;
+}
+
+/** 목록 행의 「관리」 — 수정은 등록과 같은 폼 화면으로 이동하고, 삭제는 얼럿으로 확인한다. */
+export function ContentRowActions({ kind, id, name }: ContentRowActionsProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteContentAction(kind, id);
+      if (result.status === 'error') {
+        showToast({
+          variant: 'danger',
+          message: result.message ?? '삭제하지 못했습니다.',
+        });
+        return;
+      }
+      showToast({ variant: 'success', message: '콘텐츠를 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '콘텐츠를 삭제하시겠습니까?',
+      message: `${name}을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </>
+      ),
+    });
+  }
+
+  return (
+    <FoxButtonGroup size="sm">
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => router.push(contentEditPath(kind, id))}
+      />
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
+        disabled={isDeleting}
+        onAction={confirmDelete}
+      />
+    </FoxButtonGroup>
+  );
+}
 
app/(protected)/(basic)/contents/_components/content-visibility-field.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-visibility-field.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { formatVisibilityLabel } from '@/lib/domain/content';
+
+interface ContentVisibilityFieldProps {
+  defaultVisible?: boolean;
+}
+
+/** 공개 설정 — 시안의 토글. 끄면 비공개이고 사용자 화면에 노출되지 않는다. */
+export function ContentVisibilityField({
+  defaultVisible = false,
+}: ContentVisibilityFieldProps) {
+  const [isVisible, setIsVisible] = useState(defaultVisible);
+
+  return (
+    <FoxToggleSwitch
+      size="md"
+      name="isVisible"
+      value="true"
+      label={formatVisibilityLabel(isVisible)}
+      checked={isVisible}
+      onChange={setIsVisible}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/comics/[cntntsSn]/page.tsx (added)
+++ app/(protected)/(basic)/contents/comics/[cntntsSn]/page.tsx
@@ -0,0 +1,35 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  contentCutUrls,
+  contentThumbnailUrl,
+  fetchContentDetail,
+} from '@/lib/data/repositories/content-repository';
+import { MIN_COMIC_CUTS } from '@/lib/domain/content';
+import { ComicForm } from '../_components/comic-form';
+
+export const metadata: Metadata = { title: '콘텐츠 수정 (4컷금융만화)' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  params: Promise<{ cntntsSn: string }>;
+}
+
+export default async function Page({ params }: PageProps) {
+  await verifySession();
+
+  const { cntntsSn } = await params;
+  const item = await fetchContentDetail('comic', cntntsSn);
+  if (!item) {
+    notFound();
+  }
+
+  return (
+    <ComicForm
+      item={item}
+      thumbnailUrl={contentThumbnailUrl(item)}
+      cutUrls={contentCutUrls(item, MIN_COMIC_CUTS)}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/comics/_components/comic-form.tsx (added)
+++ app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { useActionState } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { THUMBNAIL_HELP_TEXT, type ContentItem } from '@/lib/domain/content';
+import {
+  INITIAL_CONTENT_FORM_STATE,
+  MESSAGE_MAX_LENGTH,
+  SUBJECT_MAX_LENGTH,
+  TITLE_MAX_LENGTH,
+} from '@/lib/domain/content-form';
+import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import { createComicAction, updateComicAction } from '../../_actions';
+import { ContentCutsField } from '../../_components/content-cuts-field';
+import { ContentFormActions } from '../../_components/content-form-actions';
+import { ContentFormRow } from '../../_components/content-form-row';
+import { ContentGradeField } from '../../_components/content-grade-field';
+import { ContentKeywordField } from '../../_components/content-keyword-field';
+import { ContentVisibilityField } from '../../_components/content-visibility-field';
+import styles from '../../_components/content-form.module.scss';
+
+interface ComicFormProps {
+  item?: ContentItem;
+  thumbnailUrl?: string | null;
+  cutUrls?: string[];
+}
+
+/** 콘텐츠 등록·수정 (4컷금융만화) — 시안 5449:10850. */
+export function ComicForm({ item, thumbnailUrl, cutUrls = [] }: ComicFormProps) {
+  const isEdit = item !== undefined;
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateComicAction : createComicAction,
+    INITIAL_CONTENT_FORM_STATE
+  );
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <>
+      <FoxPageHeader
+        title={`콘텐츠 ${isEdit ? '수정' : '등록'} (4컷금융만화)`}
+        breadcrumb={contentBreadcrumb('comic', isEdit ? '수정' : '등록')}
+      />
+
+      <form
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.form}
+      >
+        {isEdit && <input type="hidden" name="cntntsSn" value={item.cntntsSn} />}
+
+        {isEdit && (
+          <ContentFormRow label="콘텐츠ID">
+            <FoxInput size="md" value={item.cntntsSn} readOnly />
+          </ContentFormRow>
+        )}
+
+        <ContentFormRow label="학년선택" required>
+          <ContentGradeField
+            defaultSchoolLevel={item?.schoolLevel}
+            defaultGrades={item?.grades}
+            schoolLevelError={errors.schoolLevel}
+            gradesError={errors.grades}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="콘텐츠명" required>
+          <FoxInput
+            size="md"
+            name="name"
+            aria-label="콘텐츠명"
+            defaultValue={item?.name ?? ''}
+            placeholder="콘텐츠명을 입력하세요."
+            maxLength={TITLE_MAX_LENGTH}
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="주제">
+          <FoxInput
+            size="md"
+            name="subject"
+            aria-label="주제"
+            defaultValue={item?.subject ?? ''}
+            placeholder="주제를 입력하세요."
+            maxLength={SUBJECT_MAX_LENGTH}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="키워드">
+          <ContentKeywordField
+            defaultKeywords={item?.keywords}
+            error={errors.keywords}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="전달 메시지(해설)" required>
+          <FoxTextArea
+            name="message"
+            aria-label="전달 메시지(해설)"
+            defaultValue={item?.message ?? ''}
+            rows={4}
+            placeholder="전달 메시지(해설)를 입력하세요."
+            maxLength={MESSAGE_MAX_LENGTH}
+            counter
+            invalid={Boolean(errors.message)}
+            message={errors.message}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="썸네일">
+          <FoxFileUpload
+            mode="image"
+            name="thumbnailFile"
+            accept="image/jpeg,image/png"
+            selectLabel="파일선택"
+            description={THUMBNAIL_HELP_TEXT}
+            thumbnailWidth={213}
+            thumbnailHeight={120}
+            defaultFiles={
+              thumbnailUrl
+                ? [{ id: 'current', name: '썸네일', previewUrl: thumbnailUrl }]
+                : undefined
+            }
+          />
+          <input
+            type="hidden"
+            name="thumbnailFileId"
+            defaultValue={item?.thumbnailFileId ?? ''}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="만화 이미지" optionalText="(4컷 이상)">
+          <ContentCutsField
+            defaultCutUrls={cutUrls}
+            retainedFileId={item?.attachmentId}
+            error={errors.cutFileIds}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="공개 설정" required>
+          <ContentVisibilityField defaultVisible={item?.isVisible ?? false} />
+        </ContentFormRow>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+
+        <ContentFormActions
+          submitLabel={isEdit ? '수정' : '등록'}
+          listHref={contentListPath('comic')}
+          pending={isPending}
+        />
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/comics/_components/comics-list.tsx (added)
+++ app/(protected)/(basic)/contents/comics/_components/comics-list.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import type { FoxListColumn } from '@fox/core/components/fox-list-container';
+import {
+  formatContentText,
+  formatGradesLabel,
+  formatKeywordsLabel,
+  formatSchoolLevelLabel,
+  formatVisibilityLabel,
+  type ContentItem,
+} from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+import { ContentListShell } from '../../_components/content-list-shell';
+import { ContentRowActions } from '../../_components/content-row-actions';
+
+interface ComicsListProps {
+  items: ContentItem[];
+  query: ContentQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+export function ComicsList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: ComicsListProps) {
+  const columns: FoxListColumn<ContentItem>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    {
+      key: 'schoolLevel',
+      header: '학교급',
+      width: 120,
+      render: (row) => formatSchoolLevelLabel(row.schoolLevel),
+    },
+    {
+      key: 'grades',
+      header: '학년',
+      width: 100,
+      render: (row) => formatGradesLabel(row.grades),
+    },
+    {
+      key: 'name',
+      header: '콘텐츠명',
+      width: 320,
+      emphasis: true,
+      render: (row) => formatContentText(row.name || row.title),
+    },
+    {
+      key: 'keywords',
+      header: '키워드',
+      width: 200,
+      render: (row) => formatKeywordsLabel(row.keywords),
+    },
+    {
+      key: 'visibility',
+      header: '비공개',
+      width: 110,
+      render: (row) => (
+        <FoxBadge
+          type="pastel"
+          color={row.isVisible ? 'success' : 'neutral'}
+          size="md"
+        >
+          {formatVisibilityLabel(row.isVisible)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'modifiedAt',
+      header: '수정일',
+      width: 140,
+      render: (row) => formatContentText(row.modifiedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <ContentRowActions
+          kind="comic"
+          id={row.cntntsSn}
+          name={row.name || row.title}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <ContentListShell<ContentItem>
+      kind="comic"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.cntntsSn}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/comics/new/page.tsx (added)
+++ app/(protected)/(basic)/contents/comics/new/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { ComicForm } from '../_components/comic-form';
+
+export const metadata: Metadata = { title: '콘텐츠 등록 (4컷금융만화)' };
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+  return <ComicForm />;
+}
 
app/(protected)/(basic)/contents/comics/page.tsx (added)
+++ app/(protected)/(basic)/contents/comics/page.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchContents } from '@/lib/data/repositories/content-repository';
+import { parseContentQuery } from '@/lib/domain/content-query';
+import { ComicsList } from './_components/comics-list';
+
+export const metadata: Metadata = { title: '4컷금융만화' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseContentQuery('comic', await searchParams);
+  const { items, totalCount, isTotalCountExact } = await fetchContents('comic', query);
+
+  const totalPages = isTotalCountExact
+    ? Math.max(1, Math.ceil(totalCount / query.pageSize))
+    : query.page + 1;
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <ComicsList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/quizzes/[quizSn]/page.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/[quizSn]/page.tsx
@@ -0,0 +1,24 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchQuizDetail } from '@/lib/data/repositories/content-quiz-repository';
+import { QuizForm } from '../_components/quiz-form';
+
+export const metadata: Metadata = { title: '콘텐츠 수정 (금융OX퀴즈)' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  params: Promise<{ quizSn: string }>;
+}
+
+export default async function Page({ params }: PageProps) {
+  await verifySession();
+
+  const { quizSn } = await params;
+  const item = await fetchQuizDetail(quizSn);
+  if (!item) {
+    notFound();
+  }
+
+  return <QuizForm item={item} />;
+}
 
app/(protected)/(basic)/contents/quizzes/_components/quiz-excel-actions.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-excel-actions.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxDownloadSimpleIcon, FoxUploadSimpleIcon } from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { INITIAL_CONTENT_FORM_STATE } from '@/lib/domain/content-form';
+import { uploadQuizExcelAction } from '../../_actions';
+
+const EXCEL_DOWNLOAD_PATH = '/contents/quizzes/excel';
+
+/** 엑셀 일괄등록 / 다운로드 — 기획 ②. 등록은 파일을 고르는 즉시 올린다. */
+export function QuizExcelActions() {
+  const { showToast } = useFeedback();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    uploadQuizExcelAction,
+    INITIAL_CONTENT_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '엑셀을 등록했습니다.' });
+    }
+    if (state.status === 'error') {
+      showToast({
+        variant: 'danger',
+        message: state.message ?? '엑셀을 등록하지 못했습니다.',
+      });
+    }
+  }, [state, showToast]);
+
+  return (
+    <>
+      <form ref={formRef} action={formAction} hidden>
+        <input
+          ref={inputRef}
+          type="file"
+          name="excelFile"
+          accept=".xlsx,.xls"
+          onChange={() => formRef.current?.requestSubmit()}
+        />
+      </form>
+
+      <FoxButton
+        type="secondary"
+        size="md"
+        leadingIcon={<FoxUploadSimpleIcon />}
+        label="엑셀등록"
+        loading={isPending}
+        onAction={() => inputRef.current?.click()}
+      />
+      <form action={EXCEL_DOWNLOAD_PATH} method="get">
+        <FoxButton
+          type="secondary"
+          size="md"
+          htmlType="submit"
+          leadingIcon={<FoxDownloadSimpleIcon />}
+          label="엑셀다운로드"
+        />
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
@@ -0,0 +1,194 @@
+'use client';
+
+import { useState, useActionState } from 'react';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxRadio } from '@fox/core/components/fox-radio';
+import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import {
+  ANSWER_MESSAGE_ETC,
+  ANSWER_MESSAGE_OPTIONS,
+  QUIZ_ANSWER_OPTIONS,
+  QUIZ_LEVEL_OPTIONS,
+  type QuizItem,
+} from '@/lib/domain/content';
+import {
+  ANSWER_MESSAGE_MAX_LENGTH,
+  INITIAL_CONTENT_FORM_STATE,
+  MESSAGE_MAX_LENGTH,
+  QUESTION_MAX_LENGTH,
+} from '@/lib/domain/content-form';
+import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import { createQuizAction, updateQuizAction } from '../../_actions';
+import { ContentFormActions } from '../../_components/content-form-actions';
+import { ContentFormRow } from '../../_components/content-form-row';
+import { ContentGradeField } from '../../_components/content-grade-field';
+import { ContentKeywordField } from '../../_components/content-keyword-field';
+import { ContentVisibilityField } from '../../_components/content-visibility-field';
+import styles from '../../_components/content-form.module.scss';
+
+interface QuizFormProps {
+  item?: QuizItem;
+}
+
+/**
+ * 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
+ *
+ * 정답여부는 O·X 두 줄이고, 고른 쪽에서만 「사용자에게 노출할 문구」를 정한다(기획 ③).
+ * 「기타」를 고르면 문구를 직접 적는다.
+ */
+export function QuizForm({ item }: QuizFormProps) {
+  const isEdit = item !== undefined;
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateQuizAction : createQuizAction,
+    INITIAL_CONTENT_FORM_STATE
+  );
+  const [answer, setAnswer] = useState(item?.answer || QUIZ_ANSWER_OPTIONS[0]);
+  const [messageCode, setMessageCode] = useState(ANSWER_MESSAGE_OPTIONS[0].value);
+  const [messageText, setMessageText] = useState('');
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <>
+      <FoxPageHeader
+        title={`콘텐츠 ${isEdit ? '수정' : '등록'} (금융OX퀴즈)`}
+        breadcrumb={contentBreadcrumb('quiz', isEdit ? '수정' : '등록')}
+      />
+
+      <form
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.form}
+      >
+        {isEdit && <input type="hidden" name="quizSn" value={item.quizSn} />}
+
+        {isEdit && (
+          <ContentFormRow label="콘텐츠ID">
+            <FoxInput size="md" value={item.quizSn} readOnly />
+          </ContentFormRow>
+        )}
+
+        <ContentFormRow label="학년선택" required>
+          <ContentGradeField
+            schoolLevelError={errors.schoolLevel}
+            gradesError={errors.grades}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="난이도" required>
+          <FoxRadioGroup name="level" size="md" label="난이도" orientation="horizontal">
+            {QUIZ_LEVEL_OPTIONS.map((option) => (
+              <FoxRadio
+                key={option.value}
+                value={option.value}
+                label={option.label}
+                defaultChecked={(item?.level || QUIZ_LEVEL_OPTIONS[1].value) === option.value}
+              />
+            ))}
+          </FoxRadioGroup>
+          {errors.level && <FoxHelperText type="danger" message={errors.level} />}
+        </ContentFormRow>
+
+        <ContentFormRow label="키워드">
+          <ContentKeywordField error={errors.keywords} />
+        </ContentFormRow>
+
+        <ContentFormRow label="질문" required>
+          <FoxTextArea
+            name="question"
+            aria-label="질문"
+            defaultValue={item?.question ?? ''}
+            rows={3}
+            placeholder="질문을 입력하세요."
+            maxLength={QUESTION_MAX_LENGTH}
+            counter
+            invalid={Boolean(errors.question)}
+            message={errors.question}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="정답여부" required>
+          <div className={styles.answerLines}>
+            {QUIZ_ANSWER_OPTIONS.map((option) => (
+              <div key={option} className={styles.inline}>
+                <FoxRadio
+                  name="answer"
+                  size="md"
+                  value={option}
+                  label={option}
+                  checked={answer === option}
+                  onChange={setAnswer}
+                />
+                <div className={styles.answerMessage}>
+                  <FoxSelect
+                    size="md"
+                    options={ANSWER_MESSAGE_OPTIONS.map((message) => ({
+                      value: message.value,
+                      label: message.label,
+                    }))}
+                    value={messageCode}
+                    onValueChange={setMessageCode}
+                    disabled={answer !== option}
+                  />
+                </div>
+                {messageCode === ANSWER_MESSAGE_ETC && (
+                  <div className={styles.answerMessage}>
+                    <FoxInput
+                      size="md"
+                      aria-label="노출할 문구"
+                      value={messageText}
+                      onChange={setMessageText}
+                      placeholder="내용을 입력하세요."
+                      maxLength={ANSWER_MESSAGE_MAX_LENGTH}
+                      disabled={answer !== option}
+                    />
+                  </div>
+                )}
+              </div>
+            ))}
+          </div>
+          <input type="hidden" name="answerMessageCode" value={messageCode} />
+          <input type="hidden" name="answerMessageText" value={messageText} />
+          {(errors.answer || errors.answerMessageText) && (
+            <FoxHelperText
+              type="danger"
+              message={errors.answer ?? errors.answerMessageText ?? ''}
+            />
+          )}
+        </ContentFormRow>
+
+        <ContentFormRow label="해설" required>
+          <FoxTextArea
+            name="explanation"
+            aria-label="해설"
+            defaultValue={item?.explanation ?? ''}
+            rows={4}
+            placeholder="해설(전달 메시지)을 입력하세요."
+            maxLength={MESSAGE_MAX_LENGTH}
+            counter
+            invalid={Boolean(errors.explanation)}
+            message={errors.explanation}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="공개 설정" required>
+          <ContentVisibilityField defaultVisible={item?.isVisible ?? false} />
+        </ContentFormRow>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+
+        <ContentFormActions
+          submitLabel={isEdit ? '수정' : '등록'}
+          listHref={contentListPath('quiz')}
+          pending={isPending}
+        />
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import type { FoxListColumn } from '@fox/core/components/fox-list-container';
+import {
+  CONTENT_KIND_LABEL,
+  formatContentText,
+  formatQuizLevelLabel,
+  formatVisibilityLabel,
+  type QuizItem,
+} from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+import { ContentListShell } from '../../_components/content-list-shell';
+import { ContentRowActions } from '../../_components/content-row-actions';
+import { QuizExcelActions } from './quiz-excel-actions';
+
+interface QuizzesListProps {
+  items: QuizItem[];
+  query: ContentQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+export function QuizzesList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: QuizzesListProps) {
+  const columns: FoxListColumn<QuizItem>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    {
+      key: 'type',
+      header: '퀴즈유형',
+      width: 110,
+      // 백엔드에 유형 컬럼이 없다. 이 화면 자체가 O/X 퀴즈 전용이라 고정 문구로 둔다.
+      render: () => 'O/X 퀴즈',
+    },
+    {
+      key: 'level',
+      header: '난이도',
+      width: 100,
+      render: (row) => formatQuizLevelLabel(row.level),
+    },
+    {
+      key: 'keywords',
+      header: '키워드',
+      width: 200,
+      render: (row) => formatContentText(row.keywordText),
+    },
+    {
+      key: 'question',
+      header: '질문',
+      width: 360,
+      emphasis: true,
+      render: (row) => formatContentText(row.question),
+    },
+    {
+      key: 'answer',
+      header: '정답',
+      width: 80,
+      render: (row) => formatContentText(row.answer),
+    },
+    {
+      key: 'visibility',
+      header: '비공개',
+      width: 110,
+      render: (row) => (
+        <FoxBadge
+          type="pastel"
+          color={row.isVisible ? 'success' : 'neutral'}
+          size="md"
+        >
+          {formatVisibilityLabel(row.isVisible)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'modifiedAt',
+      header: '수정일',
+      width: 140,
+      render: (row) => formatContentText(row.modifiedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <ContentRowActions
+          kind="quiz"
+          id={row.quizSn}
+          name={row.question || CONTENT_KIND_LABEL.quiz}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <ContentListShell<QuizItem>
+      kind="quiz"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.quizSn}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      extraActions={<QuizExcelActions />}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/quizzes/excel/route.ts (added)
+++ app/(protected)/(basic)/contents/quizzes/excel/route.ts
@@ -0,0 +1,42 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+const QUIZ_EXCEL_PATH = '/api/v1/mngr/quiz/excel/download';
+
+const EXCEL_TIMEOUT_MS = 60_000;
+
+const DOWNLOAD_FAILED_MESSAGE =
+  '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+const XLSX_CONTENT_TYPE =
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export async function GET() {
+  await verifySession();
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchStream(QUIZ_EXCEL_PATH, {
+    accessToken: accessToken ?? undefined,
+    timeoutMs: EXCEL_TIMEOUT_MS,
+  });
+
+  if (!result.ok) {
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ??
+        'attachment; filename="quizzes.xlsx"',
+    },
+  });
+}
 
app/(protected)/(basic)/contents/quizzes/new/page.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/new/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { QuizForm } from '../_components/quiz-form';
+
+export const metadata: Metadata = { title: '콘텐츠 등록 (금융OX퀴즈)' };
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+  return <QuizForm />;
+}
 
app/(protected)/(basic)/contents/quizzes/page.tsx (added)
+++ app/(protected)/(basic)/contents/quizzes/page.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchQuizzes } from '@/lib/data/repositories/content-quiz-repository';
+import { parseContentQuery } from '@/lib/domain/content-query';
+import { QuizzesList } from './_components/quizzes-list';
+
+export const metadata: Metadata = { title: '금융OX퀴즈' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseContentQuery('quiz', await searchParams);
+  const { items, totalCount, isTotalCountExact } = await fetchQuizzes(query);
+
+  const totalPages = isTotalCountExact
+    ? Math.max(1, Math.ceil(totalCount / query.pageSize))
+    : query.page + 1;
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <QuizzesList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/shorts/[cntntsSn]/page.tsx (added)
+++ app/(protected)/(basic)/contents/shorts/[cntntsSn]/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  contentThumbnailUrl,
+  fetchContentDetail,
+} from '@/lib/data/repositories/content-repository';
+import { ShortsForm } from '../_components/shorts-form';
+
+export const metadata: Metadata = { title: '콘텐츠 수정 (금융쇼츠)' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  params: Promise<{ cntntsSn: string }>;
+}
+
+export default async function Page({ params }: PageProps) {
+  await verifySession();
+
+  const { cntntsSn } = await params;
+  const item = await fetchContentDetail('shorts', cntntsSn);
+  if (!item) {
+    notFound();
+  }
+
+  return <ShortsForm item={item} thumbnailUrl={contentThumbnailUrl(item)} />;
+}
 
app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx (added)
+++ app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import { useActionState } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import {
+  THUMBNAIL_HELP_TEXT,
+  type ContentItem,
+} from '@/lib/domain/content';
+import {
+  INITIAL_CONTENT_FORM_STATE,
+  MESSAGE_MAX_LENGTH,
+  SUBJECT_MAX_LENGTH,
+  TITLE_MAX_LENGTH,
+} from '@/lib/domain/content-form';
+import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import { createShortsAction, updateShortsAction } from '../../_actions';
+import { ContentFormActions } from '../../_components/content-form-actions';
+import { ContentFormRow } from '../../_components/content-form-row';
+import { ContentGradeField } from '../../_components/content-grade-field';
+import { ContentKeywordField } from '../../_components/content-keyword-field';
+import { ContentVisibilityField } from '../../_components/content-visibility-field';
+import styles from '../../_components/content-form.module.scss';
+
+interface ShortsFormProps {
+  /** 수정 화면에서만 넘어온다. 없으면 등록이다. */
+  item?: ContentItem;
+  thumbnailUrl?: string | null;
+}
+
+/** 콘텐츠 등록·수정 (금융쇼츠) — 시안 5402:10628. */
+export function ShortsForm({ item, thumbnailUrl }: ShortsFormProps) {
+  const isEdit = item !== undefined;
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateShortsAction : createShortsAction,
+    INITIAL_CONTENT_FORM_STATE
+  );
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <>
+      <FoxPageHeader
+        title={`콘텐츠 ${isEdit ? '수정' : '등록'} (금융쇼츠)`}
+        breadcrumb={contentBreadcrumb('shorts', isEdit ? '수정' : '등록')}
+      />
+
+      <form
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.form}
+      >
+        {isEdit && <input type="hidden" name="cntntsSn" value={item.cntntsSn} />}
+
+        {isEdit && (
+          <ContentFormRow label="콘텐츠ID">
+            <FoxInput size="md" value={item.cntntsSn} readOnly />
+          </ContentFormRow>
+        )}
+
+        <ContentFormRow label="학년선택" required>
+          <ContentGradeField
+            defaultSchoolLevel={item?.schoolLevel}
+            defaultGrades={item?.grades}
+            schoolLevelError={errors.schoolLevel}
+            gradesError={errors.grades}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="제목" required>
+          <FoxInput
+            size="md"
+            name="title"
+            aria-label="제목"
+            defaultValue={item?.title ?? ''}
+            placeholder="제목을 입력하세요."
+            maxLength={TITLE_MAX_LENGTH}
+            invalid={Boolean(errors.title)}
+            message={errors.title}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="주제">
+          <FoxInput
+            size="md"
+            name="subject"
+            aria-label="주제"
+            defaultValue={item?.subject ?? ''}
+            placeholder="주제를 입력하세요."
+            maxLength={SUBJECT_MAX_LENGTH}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="키워드">
+          <ContentKeywordField
+            defaultKeywords={item?.keywords}
+            error={errors.keywords}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="전달 메시지(해설)" required>
+          <FoxTextArea
+            name="message"
+            aria-label="전달 메시지(해설)"
+            defaultValue={item?.message ?? ''}
+            rows={4}
+            placeholder="내용을 입력하세요."
+            maxLength={MESSAGE_MAX_LENGTH}
+            counter
+            invalid={Boolean(errors.message)}
+            message={errors.message}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="영상 콘텐츠 ID">
+          <FoxFileUpload
+            mode="default"
+            name="videoFile"
+            accept="video/*"
+            selectLabel="파일선택"
+            placeholder="파일을 선택해 주세요."
+          />
+          <input
+            type="hidden"
+            name="videoFileId"
+            defaultValue={item?.attachmentId ?? ''}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="썸네일">
+          <FoxFileUpload
+            mode="image"
+            name="thumbnailFile"
+            accept="image/jpeg,image/png"
+            selectLabel="파일선택"
+            description={THUMBNAIL_HELP_TEXT}
+            thumbnailWidth={213}
+            thumbnailHeight={120}
+            defaultFiles={
+              thumbnailUrl
+                ? [{ id: 'current', name: '썸네일', previewUrl: thumbnailUrl }]
+                : undefined
+            }
+          />
+          <input
+            type="hidden"
+            name="thumbnailFileId"
+            defaultValue={item?.thumbnailFileId ?? ''}
+          />
+        </ContentFormRow>
+
+        <ContentFormRow label="공개 설정" required>
+          <ContentVisibilityField defaultVisible={item?.isVisible ?? false} />
+        </ContentFormRow>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+
+        <ContentFormActions
+          submitLabel={isEdit ? '수정' : '등록'}
+          listHref={contentListPath('shorts')}
+          pending={isPending}
+        />
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/contents/shorts/_components/shorts-list.tsx (added)
+++ app/(protected)/(basic)/contents/shorts/_components/shorts-list.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import type { FoxListColumn } from '@fox/core/components/fox-list-container';
+import {
+  formatContentText,
+  formatGradesLabel,
+  formatKeywordsLabel,
+  formatSchoolLevelLabel,
+  formatVisibilityLabel,
+  type ContentItem,
+} from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+import { ContentListShell } from '../../_components/content-list-shell';
+import { ContentRowActions } from '../../_components/content-row-actions';
+
+interface ShortsListProps {
+  items: ContentItem[];
+  query: ContentQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+export function ShortsList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: ShortsListProps) {
+  const columns: FoxListColumn<ContentItem>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    {
+      key: 'schoolLevel',
+      header: '학교급',
+      width: 120,
+      render: (row) => formatSchoolLevelLabel(row.schoolLevel),
+    },
+    {
+      key: 'grades',
+      header: '학년',
+      width: 100,
+      render: (row) => formatGradesLabel(row.grades),
+    },
+    {
+      key: 'title',
+      header: '제목',
+      width: 320,
+      emphasis: true,
+      render: (row) => formatContentText(row.title || row.name),
+    },
+    {
+      key: 'keywords',
+      header: '키워드',
+      width: 200,
+      render: (row) => formatKeywordsLabel(row.keywords),
+    },
+    {
+      key: 'visibility',
+      header: '비공개',
+      width: 110,
+      render: (row) => (
+        <FoxBadge
+          type="pastel"
+          color={row.isVisible ? 'success' : 'neutral'}
+          size="md"
+        >
+          {formatVisibilityLabel(row.isVisible)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'modifiedAt',
+      header: '수정일',
+      width: 140,
+      render: (row) => formatContentText(row.modifiedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <ContentRowActions
+          kind="shorts"
+          id={row.cntntsSn}
+          name={row.title || row.name}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <ContentListShell<ContentItem>
+      kind="shorts"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.cntntsSn}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/contents/shorts/new/page.tsx (added)
+++ app/(protected)/(basic)/contents/shorts/new/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { ShortsForm } from '../_components/shorts-form';
+
+export const metadata: Metadata = { title: '콘텐츠 등록 (금융쇼츠)' };
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+  return <ShortsForm />;
+}
 
app/(protected)/(basic)/contents/shorts/page.tsx (added)
+++ app/(protected)/(basic)/contents/shorts/page.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchContents } from '@/lib/data/repositories/content-repository';
+import { parseContentQuery } from '@/lib/domain/content-query';
+import { ShortsList } from './_components/shorts-list';
+
+export const metadata: Metadata = { title: '금융쇼츠' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseContentQuery('shorts', await searchParams);
+  const { items, totalCount, isTotalCountExact } = await fetchContents('shorts', query);
+
+  const totalPages = isTotalCountExact
+    ? Math.max(1, Math.ceil(totalCount / query.pageSize))
+    : query.page + 1;
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <ShortsList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
lib/data/repositories/content-quiz-repository.ts (added)
+++ lib/data/repositories/content-quiz-repository.ts
@@ -0,0 +1,243 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { QuizItem } from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+import type { QuizFormValues } from '@/lib/domain/content-form';
+
+/**
+ * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)도 API도 다르다.
+ *
+ * ```
+ * GET  /api/v1/mngr/quiz/pagination       목록
+ * GET  /api/v1/mngr/quiz/excel/download   엑셀 다운로드
+ * POST /api/v1/mngr/quiz/excel/upload     엑셀 일괄등록 (multipart, 파트 이름 `file`)
+ * ```
+ *
+ * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약으로
+ *   미리 맞춰 둔 것이라 백엔드가 생기기 전에는 실패한다.
+ * 이슈: 학교급·학년은 컬럼이 없다. 검색 VO에 `searchSchul`/`searchGrade`가 있지만 매퍼의
+ *   `<if>` 블록이 비어 있어 필터가 조용히 무시된다.
+ * 이슈: 키워드가 별도 테이블이 아니라 `quiz_title` 한 칸이다. 목록의 「키워드」 열이 이 값이고,
+ *   기획의 "키워드+설명 쌍"을 담을 자리는 아직 없다.
+ * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 컬럼이 없다.
+ */
+
+const QUIZ_PATH = '/api/v1/mngr/quiz';
+
+/** 목록에서 단건을 찾을 때 한 번에 받아올 최대 행 수(단건 조회 API가 없다). */
+const DETAIL_SCAN_LIMIT = 500;
+
+/** 엑셀 업로드는 일반 조회보다 오래 걸린다. */
+const UPLOAD_TIMEOUT_MS = 60_000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  if (typeof value === 'string' && value.length > 0) return value;
+  if (typeof value === 'number') return String(value);
+  return null;
+}
+
+function toQuizItem(raw: unknown): QuizItem {
+  if (!isRecord(raw)) {
+    throw new Error('퀴즈 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const quizSn = readString(raw, 'quizSn');
+  if (!quizSn) {
+    throw new Error('퀴즈 응답에 quizSn이 없습니다.');
+  }
+
+  const points = raw.quizPoint;
+
+  return {
+    quizSn,
+    keywordText: readString(raw, 'quizTitle') ?? '',
+    level: readString(raw, 'quizLev') ?? '',
+    question: readString(raw, 'quizQuestion') ?? '',
+    explanation: readString(raw, 'quizExplan') ?? '',
+    answer: readString(raw, 'quizAnswer') ?? '',
+    points: typeof points === 'number' ? points : null,
+    isVisible: readString(raw, 'useYn') !== 'N',
+    modifiedAt:
+      readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
+  };
+}
+
+function buildListParams(
+  query: ContentQuery,
+  pageIndex: number,
+  recordCountPerPage: number
+): Record<string, string | number> {
+  const keyword = query.keyword.trim();
+
+  return {
+    searchSchul: query.schoolLevel,
+    searchGrade: query.grade,
+    searchLevel: query.level,
+    searchCondition: keyword ? '1' : '',
+    searchKeyword: keyword,
+    searchSortOrder: query.sort,
+    pageIndex,
+    recordCountPerPage,
+  };
+}
+
+async function requestList(
+  query: ContentQuery,
+  pageIndex: number,
+  recordCountPerPage: number
+): Promise<{ items: QuizItem[]; reportedTotalCount: number }> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${QUIZ_PATH}/pagination`, {
+    method: 'GET',
+    query: buildListParams(query, pageIndex, recordCountPerPage),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  if (data === null || data === undefined) {
+    return { items: [], reportedTotalCount: 0 };
+  }
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('퀴즈 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return {
+    items: data.list.map(toQuizItem),
+    reportedTotalCount:
+      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+  };
+}
+
+export type QuizPage = {
+  items: QuizItem[];
+  totalCount: number;
+  isTotalCountExact: boolean;
+};
+
+export async function fetchQuizzes(query: ContentQuery): Promise<QuizPage> {
+  const { items, reportedTotalCount } = await requestList(
+    query,
+    query.page,
+    query.pageSize
+  );
+
+  const confirmedCount = (query.page - 1) * query.pageSize + items.length;
+
+  return {
+    items,
+    totalCount: Math.max(reportedTotalCount, confirmedCount),
+    isTotalCountExact:
+      items.length < query.pageSize || reportedTotalCount > confirmedCount,
+  };
+}
+
+/** TODO: `GET /api/v1/mngr/quiz/{quizSn}`이 생기면 이 스캔을 그 호출로 교체한다. */
+export async function fetchQuizDetail(quizSn: string): Promise<QuizItem | null> {
+  const { items } = await requestList(
+    {
+      schoolLevel: '',
+      grade: '',
+      level: '',
+      searchField: '',
+      keyword: '',
+      sort: 'created',
+      page: 1,
+      pageSize: 10,
+    },
+    1,
+    DETAIL_SCAN_LIMIT
+  );
+
+  return items.find((item) => item.quizSn === quizSn) ?? null;
+}
+
+function buildQuizPayload(values: QuizFormValues) {
+  return {
+    quizTitle: values.keywords.map((item) => item.keyword).join(', '),
+    quizLev: values.level,
+    quizQuestion: values.question,
+    quizExplan: values.explanation,
+    quizAnswer: values.answer,
+    schulGradeCd: values.schoolLevel,
+    gradeCd: values.grades.join(','),
+    answerMssageCd: values.answerMessageCode,
+    answerMssage: values.answerMessageText,
+    useYn: values.isVisible ? 'Y' : 'N',
+  };
+}
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  body?: unknown
+): Promise<string | null> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method,
+    ...(body === undefined ? {} : { body }),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  return typeof result.data === 'string' ? result.data : null;
+}
+
+export async function createQuiz(values: QuizFormValues): Promise<string | null> {
+  return sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
+}
+
+export async function updateQuiz(
+  quizSn: string,
+  values: QuizFormValues
+): Promise<void> {
+  await sendWrite(
+    `${QUIZ_PATH}/${encodeURIComponent(quizSn)}`,
+    'PUT',
+    buildQuizPayload(values)
+  );
+}
+
+export async function deleteQuiz(quizSn: string): Promise<void> {
+  await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'DELETE');
+}
+
+/** 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */
+export async function uploadQuizExcel(file: File): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const multipart = new FormData();
+  multipart.set('file', file);
+
+  const result = await backendFetch<unknown>(`${QUIZ_PATH}/excel/upload`, {
+    method: 'POST',
+    multipart,
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    timeoutMs: UPLOAD_TIMEOUT_MS,
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
 
lib/data/repositories/content-repository.ts (added)
+++ lib/data/repositories/content-repository.ts
@@ -0,0 +1,333 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
+import type { ContentItem, ContentKeyword } from '@/lib/domain/content';
+import type { ContentQuery } from '@/lib/domain/content-query';
+import type {
+  ComicFormValues,
+  ShortsFormValues,
+} from '@/lib/domain/content-form';
+
+/**
+ * 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고 경로만
+ * 갈린다. 금융OX퀴즈는 테이블도 API도 달라 `content-quiz-repository.ts`가 따로 맡는다.
+ *
+ * ```
+ * GET /api/v1/mngr/cntntns/toon/pagination    4컷금융만화 목록
+ * GET /api/v1/mngr/cntntns/video/pagination   금융쇼츠 목록
+ * ```
+ *
+ * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
+ * 이슈: `video/pagination` 컨트롤러가 `return null;`이라 금융쇼츠 목록은 늘 비어 온다.
+ *   서비스가 주입돼 있지 않아 프론트에서 우회할 방법이 없다.
+ * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약
+ *   (`POST {base}` / `PUT {base}/{sn}` / `DELETE {base}/{sn}`)으로 미리 맞춰 둔 것이라
+ *   백엔드가 생기기 전에는 실패한다.
+ * 이슈: 학교급·학년은 컬럼도 SQL도 없다(검색 VO에 파라미터만 있고 매퍼의 `<if>`가 비어 있다).
+ *   주제·전달메시지도 컬럼이 없어 목록·상세에서 늘 빈 값이다.
+ * 이슈: toon 매퍼의 검색 조건이 금칙어 테이블 컬럼(`BEN_WORD_NM`)을 보고 있어 검색어가 걸리면
+ *   SQL이 깨진다. 그래서 검색어는 보내되 백엔드 수정 전까지는 결과를 신뢰할 수 없다.
+ * 이슈: 목록 SQL이 키워드를 LEFT JOIN해 행이 키워드 수만큼 늘어나는데 count는 조인 없이 센다.
+ *   한 페이지에 실제로 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
+ */
+
+const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
+
+const SEGMENT: Record<'shorts' | 'comic', string> = {
+  shorts: 'video',
+  comic: 'toon',
+};
+
+type ContentApiKind = keyof typeof SEGMENT;
+
+/** 단건 조회 API가 없어 목록에서 찾는다. 그때 한 번에 받아올 최대 행 수. */
+const DETAIL_SCAN_LIMIT = 500;
+
+function basePath(kind: ContentApiKind): string {
+  return `${CONTENT_BASE_PATH}/${SEGMENT[kind]}`;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+function toKeywords(raw: unknown): ContentKeyword[] {
+  if (!Array.isArray(raw)) {
+    return [];
+  }
+  return raw.flatMap((entry) => {
+    if (!isRecord(entry)) {
+      return [];
+    }
+    const keyword = readString(entry, 'cntntsKeyword');
+    if (!keyword) {
+      return [];
+    }
+    return [{ keyword, description: readString(entry, 'cntntsExpln') ?? '' }];
+  });
+}
+
+function toContentItem(raw: unknown): ContentItem {
+  if (!isRecord(raw)) {
+    throw new Error('콘텐츠 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const cntntsSn = readString(raw, 'cntntsSn');
+  if (!cntntsSn) {
+    throw new Error('콘텐츠 응답에 cntntsSn이 없습니다.');
+  }
+
+  return {
+    cntntsSn,
+    name: readString(raw, 'cntntsNm') ?? '',
+    title: readString(raw, 'cntntsTitle') ?? '',
+    subject: readString(raw, 'cntntsSbjct') ?? '',
+    message: readString(raw, 'cntntsMssage') ?? '',
+    schoolLevel: readString(raw, 'schulGradeCd') ?? '',
+    grades: (readString(raw, 'gradeCd') ?? '')
+      .split(',')
+      .map((grade) => grade.trim())
+      .filter(Boolean),
+    keywords: toKeywords(raw.keywordList),
+    attachmentId: readString(raw, 'atchFileId'),
+    thumbnailFileId: readString(raw, 'thumbAtchFileId'),
+    isVisible: readString(raw, 'useYn') !== 'N',
+    modifiedAt:
+      readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
+  };
+}
+
+function buildListParams(
+  query: ContentQuery,
+  pageIndex: number,
+  recordCountPerPage: number
+): Record<string, string | number> {
+  const keyword = query.keyword.trim();
+
+  return {
+    searchSchulGradeCd: query.schoolLevel,
+    searchGradeCd: query.grade,
+    searchCondition: keyword ? '1' : '',
+    searchKeyword: keyword,
+    // 이슈: 백엔드가 정렬 파라미터를 읽지 않는다(등록일 내림차순 고정). 화면 선택지는 기획대로
+    // 유지하고 값만 미리 실어 보낸다.
+    searchSortOrder: query.sort,
+    pageIndex,
+    recordCountPerPage,
+  };
+}
+
+async function requestList(
+  kind: ContentApiKind,
+  query: ContentQuery,
+  pageIndex: number,
+  recordCountPerPage: number
+): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${basePath(kind)}/pagination`, {
+    method: 'GET',
+    query: buildListParams(query, pageIndex, recordCountPerPage),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  // 금융쇼츠는 컨트롤러가 null을 돌려준다(파일 상단 이슈) — 빈 목록으로 다룬다.
+  if (data === null || data === undefined) {
+    return { items: [], reportedTotalCount: 0 };
+  }
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('콘텐츠 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return {
+    items: data.list.map(toContentItem),
+    reportedTotalCount:
+      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+  };
+}
+
+export type ContentPage = {
+  items: ContentItem[];
+  totalCount: number;
+  isTotalCountExact: boolean;
+};
+
+export async function fetchContents(
+  kind: ContentApiKind,
+  query: ContentQuery
+): Promise<ContentPage> {
+  const { items, reportedTotalCount } = await requestList(
+    kind,
+    query,
+    query.page,
+    query.pageSize
+  );
+
+  const confirmedCount = (query.page - 1) * query.pageSize + items.length;
+
+  return {
+    items,
+    // 백엔드 `PaginationUtil`이 count 없이 페이지 행 수를 총건수로 내려주는 결함이 있어
+    // 하한값으로 보정한다(다른 목록 화면과 같은 처리).
+    totalCount: Math.max(reportedTotalCount, confirmedCount),
+    isTotalCountExact:
+      items.length < query.pageSize || reportedTotalCount > confirmedCount,
+  };
+}
+
+/**
+ * 단건 조회.
+ *
+ * TODO: `GET {base}/{cntntsSn}`이 생기면 이 스캔을 그 호출로 교체한다. 지금은 단건 API가 없어
+ * 목록에서 찾는다 — 목록이 돌려주는 필드만 채워지고 나머지는 빈 값이다.
+ */
+export async function fetchContentDetail(
+  kind: ContentApiKind,
+  cntntsSn: string
+): Promise<ContentItem | null> {
+  const { items } = await requestList(
+    kind,
+    {
+      schoolLevel: '',
+      grade: '',
+      level: '',
+      searchField: '',
+      keyword: '',
+      sort: 'created',
+      page: 1,
+      pageSize: 10,
+    },
+    1,
+    DETAIL_SCAN_LIMIT
+  );
+
+  return items.find((item) => item.cntntsSn === cntntsSn) ?? null;
+}
+
+type ContentWritePayload = {
+  cntntsType?: string;
+  cntntsNm: string;
+  cntntsTitle: string;
+  cntntsSbjct: string;
+  cntntsMssage: string;
+  schulGradeCd: string;
+  gradeCd: string;
+  atchFileId: string;
+  thumbAtchFileId: string;
+  useYn: string;
+  keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>;
+};
+
+function toKeywordPayload(keywords: readonly ContentKeyword[]) {
+  return keywords.map((item) => ({
+    cntntsKeyword: item.keyword,
+    cntntsExpln: item.description,
+  }));
+}
+
+export function buildShortsPayload(
+  values: ShortsFormValues
+): ContentWritePayload {
+  return {
+    cntntsNm: values.title,
+    cntntsTitle: values.title,
+    cntntsSbjct: values.subject,
+    cntntsMssage: values.message,
+    schulGradeCd: values.schoolLevel,
+    gradeCd: values.grades.join(','),
+    atchFileId: values.videoFileId,
+    thumbAtchFileId: values.thumbnailFileId,
+    useYn: values.isVisible ? 'Y' : 'N',
+    keywordList: toKeywordPayload(values.keywords),
+  };
+}
+
+export function buildComicPayload(values: ComicFormValues): ContentWritePayload {
+  return {
+    cntntsNm: values.name,
+    cntntsTitle: values.name,
+    cntntsSbjct: values.subject,
+    cntntsMssage: values.message,
+    schulGradeCd: values.schoolLevel,
+    gradeCd: values.grades.join(','),
+    // 이슈: 기획은 "각 컷을 개별 이미지로 업로드"인데 백엔드 컬럼은 `atchFileId` 한 칸뿐이라
+    // 컷 묶음을 여러 개 연결할 자리가 없다. 지금은 첫 묶음만 저장된다.
+    atchFileId: values.cutFileIds[0] ?? '',
+    thumbAtchFileId: values.thumbnailFileId,
+    useYn: values.isVisible ? 'Y' : 'N',
+    keywordList: toKeywordPayload(values.keywords),
+  };
+}
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  body?: unknown
+): Promise<string | null> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method,
+    ...(body === undefined ? {} : { body }),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  return typeof result.data === 'string' ? result.data : null;
+}
+
+export async function createContent(
+  kind: ContentApiKind,
+  payload: ContentWritePayload,
+  cntntsType: string
+): Promise<string | null> {
+  return sendWrite(basePath(kind), 'POST', { ...payload, cntntsType });
+}
+
+export async function updateContent(
+  kind: ContentApiKind,
+  cntntsSn: string,
+  payload: ContentWritePayload
+): Promise<void> {
+  await sendWrite(
+    `${basePath(kind)}/${encodeURIComponent(cntntsSn)}`,
+    'PUT',
+    payload
+  );
+}
+
+export async function deleteContent(
+  kind: ContentApiKind,
+  cntntsSn: string
+): Promise<void> {
+  await sendWrite(`${basePath(kind)}/${encodeURIComponent(cntntsSn)}`, 'DELETE');
+}
+
+export function contentThumbnailUrl(item: ContentItem): string | null {
+  return buildFileImageUrl(item.thumbnailFileId);
+}
+
+export function contentCutUrls(item: ContentItem, count: number): string[] {
+  return Array.from({ length: count }, (_, index) =>
+    buildFileImageUrl(item.attachmentId, index + 1)
+  ).filter((url): url is string => url !== null);
+}
lib/data/repositories/file-repository.ts
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
@@ -1,5 +1,6 @@
 import 'server-only';
 import { getSessionAccessToken } from '@/lib/auth/dal';
+import { getApiBaseUrl } from '@/lib/env';
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
 
 /**
@@ -21,6 +22,7 @@
  */
 
 const FILE_UPLOAD_PATH = '/api/v1/common/file/upload';
+const FILE_IMAGE_PATH = '/api/v1/common/file/image';
 
 
 /** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */
@@ -64,3 +66,20 @@
 
   return result.data;
 }
+
+/** 업로드된 파일의 이미지 URL. 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */
+export function buildFileImageUrl(
+  atchFileId: string | null,
+  fileSn = 1
+): string | null {
+  if (!atchFileId) {
+    return null;
+  }
+
+  const url = new URL(
+    `${getApiBaseUrl().replace(/\/+$/, '')}${FILE_IMAGE_PATH}`
+  );
+  url.searchParams.set('atchFileId', atchFileId);
+  url.searchParams.set('fileSn', String(fileSn));
+  return url.toString();
+}
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -71,6 +71,16 @@
           ],
         },
         {
+          id: 'contents',
+          label: '콘텐츠관리',
+          icon: 'image',
+          children: [
+            { id: 'contents-shorts', label: '금융쇼츠', href: '/contents/shorts' },
+            { id: 'contents-quizzes', label: '금융OX퀴즈', href: '/contents/quizzes' },
+            { id: 'contents-comics', label: '4컷금융만화', href: '/contents/comics' },
+          ],
+        },
+        {
           id: 'system',
           label: '시스템관리',
           icon: 'wrench',
 
lib/domain/content-form.ts (added)
+++ lib/domain/content-form.ts
@@ -0,0 +1,272 @@
+/**
+ * 콘텐츠관리 등록·수정 폼의 입력 규칙 — 화면과 Server Action이 같은 규칙을 본다.
+ * 검증의 최종 판정은 Server Action이 하고, 화면의 required는 편의일 뿐이다.
+ */
+
+import {
+  ANSWER_MESSAGE_ETC,
+  ANSWER_MESSAGE_OPTIONS,
+  MIN_COMIC_CUTS,
+  QUIZ_ANSWER_OPTIONS,
+  QUIZ_LEVEL_OPTIONS,
+  findSchoolLevel,
+  gradeOptionsOf,
+  type ContentKeyword,
+} from '@/lib/domain/content';
+
+export const TITLE_MAX_LENGTH = 200;
+export const SUBJECT_MAX_LENGTH = 200;
+export const KEYWORD_MAX_LENGTH = 50;
+export const KEYWORD_DESCRIPTION_MAX_LENGTH = 200;
+export const MESSAGE_MAX_LENGTH = 2000;
+export const QUESTION_MAX_LENGTH = 500;
+export const ANSWER_MESSAGE_MAX_LENGTH = 200;
+
+export type ContentFormErrors = Record<string, string>;
+
+export type ContentFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: ContentFormErrors }
+  | { status: 'success'; id?: string };
+
+export const INITIAL_CONTENT_FORM_STATE: ContentFormState = { status: 'idle' };
+
+export type GradeSelection = {
+  schoolLevel: string;
+  grades: string[];
+};
+
+export type ShortsFormValues = GradeSelection & {
+  title: string;
+  subject: string;
+  keywords: ContentKeyword[];
+  message: string;
+  videoFileId: string;
+  thumbnailFileId: string;
+  isVisible: boolean;
+};
+
+export type ComicFormValues = GradeSelection & {
+  name: string;
+  subject: string;
+  keywords: ContentKeyword[];
+  message: string;
+  thumbnailFileId: string;
+  cutFileIds: string[];
+  isVisible: boolean;
+};
+
+export type QuizFormValues = GradeSelection & {
+  level: string;
+  keywords: ContentKeyword[];
+  question: string;
+  answer: string;
+  answerMessageCode: string;
+  answerMessageText: string;
+  explanation: string;
+  isVisible: boolean;
+};
+
+export type ValidationResult<T> =
+  | { ok: true; values: T }
+  | { ok: false; errors: ContentFormErrors };
+
+/** 빈 줄은 저장하지 않는다 — 폼이 늘 보여 주는 기본 2줄이 그대로 넘어오기 때문이다. */
+export function normalizeKeywords(
+  keywords: readonly ContentKeyword[]
+): ContentKeyword[] {
+  return keywords
+    .map((item) => ({
+      keyword: item.keyword.trim().slice(0, KEYWORD_MAX_LENGTH),
+      description: item.description
+        .trim()
+        .slice(0, KEYWORD_DESCRIPTION_MAX_LENGTH),
+    }))
+    .filter((item) => item.keyword !== '' || item.description !== '');
+}
+
+function validateGrades(
+  values: GradeSelection,
+  errors: ContentFormErrors
+): void {
+  const level = findSchoolLevel(values.schoolLevel);
+  if (!level) {
+    errors.schoolLevel = '학교급을 선택해 주세요.';
+    return;
+  }
+  if (level.gradeCount === 0) {
+    return;
+  }
+  const allowed = gradeOptionsOf(values.schoolLevel);
+  if (values.grades.length === 0) {
+    errors.grades = '학년을 하나 이상 선택해 주세요.';
+    return;
+  }
+  if (values.grades.some((grade) => !allowed.includes(grade))) {
+    errors.grades = '선택할 수 없는 학년이 포함돼 있습니다.';
+  }
+}
+
+function validateKeywords(
+  keywords: readonly ContentKeyword[],
+  errors: ContentFormErrors
+): void {
+  if (keywords.some((item) => item.keyword === '' && item.description !== '')) {
+    errors.keywords = '설명만 있는 줄이 있습니다. 키워드를 입력해 주세요.';
+  }
+}
+
+function requireText(
+  value: string,
+  maxLength: number,
+  field: string,
+  message: string,
+  errors: ContentFormErrors
+): string {
+  const trimmed = value.trim();
+  if (trimmed === '') {
+    errors[field] = message;
+  }
+  return trimmed.slice(0, maxLength);
+}
+
+export function validateShortsForm(
+  values: ShortsFormValues
+): ValidationResult<ShortsFormValues> {
+  const errors: ContentFormErrors = {};
+  validateGrades(values, errors);
+
+  const title = requireText(
+    values.title,
+    TITLE_MAX_LENGTH,
+    'title',
+    '제목을 입력해 주세요.',
+    errors
+  );
+  const message = requireText(
+    values.message,
+    MESSAGE_MAX_LENGTH,
+    'message',
+    '전달 메시지(해설)를 입력해 주세요.',
+    errors
+  );
+  const keywords = normalizeKeywords(values.keywords);
+  validateKeywords(keywords, errors);
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+  return {
+    ok: true,
+    values: {
+      ...values,
+      title,
+      subject: values.subject.trim().slice(0, SUBJECT_MAX_LENGTH),
+      message,
+      keywords,
+    },
+  };
+}
+
+export function validateComicForm(
+  values: ComicFormValues
+): ValidationResult<ComicFormValues> {
+  const errors: ContentFormErrors = {};
+  validateGrades(values, errors);
+
+  const name = requireText(
+    values.name,
+    TITLE_MAX_LENGTH,
+    'name',
+    '콘텐츠명을 입력해 주세요.',
+    errors
+  );
+  const message = requireText(
+    values.message,
+    MESSAGE_MAX_LENGTH,
+    'message',
+    '전달 메시지(해설)를 입력해 주세요.',
+    errors
+  );
+  const keywords = normalizeKeywords(values.keywords);
+  validateKeywords(keywords, errors);
+
+  const cutFileIds = values.cutFileIds.filter(Boolean);
+  if (cutFileIds.length < MIN_COMIC_CUTS) {
+    errors.cutFileIds = `만화 이미지를 ${MIN_COMIC_CUTS}컷 이상 등록해 주세요.`;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+  return {
+    ok: true,
+    values: {
+      ...values,
+      name,
+      subject: values.subject.trim().slice(0, SUBJECT_MAX_LENGTH),
+      message,
+      keywords,
+      cutFileIds,
+    },
+  };
+}
+
+export function validateQuizForm(
+  values: QuizFormValues
+): ValidationResult<QuizFormValues> {
+  const errors: ContentFormErrors = {};
+  validateGrades(values, errors);
+
+  if (!QUIZ_LEVEL_OPTIONS.some((option) => option.value === values.level)) {
+    errors.level = '난이도를 선택해 주세요.';
+  }
+  if (!(QUIZ_ANSWER_OPTIONS as readonly string[]).includes(values.answer)) {
+    errors.answer = '정답을 선택해 주세요.';
+  }
+  if (
+    values.answerMessageCode !== '' &&
+    !ANSWER_MESSAGE_OPTIONS.some(
+      (option) => option.value === values.answerMessageCode
+    )
+  ) {
+    errors.answerMessageCode = '노출 문구를 다시 선택해 주세요.';
+  }
+
+  const question = requireText(
+    values.question,
+    QUESTION_MAX_LENGTH,
+    'question',
+    '질문을 입력해 주세요.',
+    errors
+  );
+  const explanation = requireText(
+    values.explanation,
+    MESSAGE_MAX_LENGTH,
+    'explanation',
+    '해설을 입력해 주세요.',
+    errors
+  );
+
+  let answerMessageText = values.answerMessageText
+    .trim()
+    .slice(0, ANSWER_MESSAGE_MAX_LENGTH);
+  if (values.answerMessageCode === ANSWER_MESSAGE_ETC) {
+    if (answerMessageText === '') {
+      errors.answerMessageText = '노출할 문구를 입력해 주세요.';
+    }
+  } else {
+    answerMessageText = '';
+  }
+
+  const keywords = normalizeKeywords(values.keywords);
+  validateKeywords(keywords, errors);
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+  return {
+    ok: true,
+    values: { ...values, question, explanation, answerMessageText, keywords },
+  };
+}
 
lib/domain/content-query.ts (added)
+++ lib/domain/content-query.ts
@@ -0,0 +1,156 @@
+/**
+ * 콘텐츠관리 목록의 필터·검색·정렬·페이징 조건 — 세 화면이 같은 규칙을 쓴다.
+ * URL(searchParams)이 단일 진실원천이며 next/react 의존이 없다.
+ */
+
+import { CONTENT_KIND_LABEL, type ContentKind } from '@/lib/domain/content';
+
+export const CONTENT_PATH: Record<ContentKind, string> = {
+  shorts: '/contents/shorts',
+  quiz: '/contents/quizzes',
+  comic: '/contents/comics',
+};
+
+export function contentListPath(kind: ContentKind): string {
+  return CONTENT_PATH[kind];
+}
+
+export function contentCreatePath(kind: ContentKind): string {
+  return `${CONTENT_PATH[kind]}/new`;
+}
+
+export function contentEditPath(kind: ContentKind, id: string): string {
+  return `${CONTENT_PATH[kind]}/${encodeURIComponent(id)}`;
+}
+
+export function contentBreadcrumb(
+  kind: ContentKind,
+  leaf?: string
+): Array<{ label: string; href?: string }> {
+  const trail: Array<{ label: string; href?: string }> = [
+    { label: '홈', href: '/' },
+    { label: '콘텐츠관리' },
+  ];
+  if (leaf) {
+    trail.push({ label: CONTENT_KIND_LABEL[kind], href: CONTENT_PATH[kind] });
+    trail.push({ label: leaf });
+  } else {
+    trail.push({ label: CONTENT_KIND_LABEL[kind] });
+  }
+  return trail;
+}
+
+export type ContentSortOption = 'created' | 'modified';
+
+export const CONTENT_SORT_OPTIONS: ReadonlyArray<{
+  value: ContentSortOption;
+  label: string;
+}> = [
+  { value: 'created', label: '최신등록일순' },
+  { value: 'modified', label: '최신수정일순' },
+];
+
+/** 기획 "리스트 — 최신 등록일 순으로 정렬". */
+export const DEFAULT_CONTENT_SORT: ContentSortOption = 'created';
+
+export const CONTENT_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type ContentPageSize = (typeof CONTENT_PAGE_SIZE_OPTIONS)[number];
+export const DEFAULT_CONTENT_PAGE_SIZE: ContentPageSize = 10;
+
+export type ContentSearchFieldOption = { value: string; label: string };
+
+/** 검색 대상 — 기획의 화면별 문구 그대로. */
+export const CONTENT_SEARCH_FIELDS: Record<
+  ContentKind,
+  readonly ContentSearchFieldOption[]
+> = {
+  shorts: [
+    { value: 'title', label: '제목' },
+    { value: 'keyword', label: '키워드' },
+  ],
+  quiz: [
+    { value: 'question', label: '질문' },
+    { value: 'keyword', label: '키워드' },
+  ],
+  comic: [
+    { value: 'name', label: '콘텐츠명' },
+    { value: 'keyword', label: '키워드' },
+  ],
+};
+
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type ContentQuery = {
+  schoolLevel: string;
+  grade: string;
+  /** 금융OX퀴즈 전용 난이도 필터. 다른 화면에서는 늘 빈 값이다. */
+  level: string;
+  searchField: string;
+  keyword: string;
+  sort: ContentSortOption;
+  page: number;
+  pageSize: ContentPageSize;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string {
+  const value = params[key];
+  const raw = Array.isArray(value) ? value[0] : value;
+  return (raw ?? '').trim();
+}
+
+export function parseContentQuery(
+  kind: ContentKind,
+  searchParams: RawSearchParams
+): ContentQuery {
+  const fields = CONTENT_SEARCH_FIELDS[kind];
+  const searchField = readParam(searchParams, 'searchField');
+  const sort = readParam(searchParams, 'sort');
+  const page = Number(readParam(searchParams, 'page'));
+  const pageSize = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    schoolLevel: readParam(searchParams, 'schoolLevel'),
+    grade: readParam(searchParams, 'grade'),
+    level: kind === 'quiz' ? readParam(searchParams, 'level') : '',
+    searchField: fields.some((field) => field.value === searchField)
+      ? searchField
+      : fields[0].value,
+    keyword: readParam(searchParams, 'keyword').slice(0, MAX_KEYWORD_LENGTH),
+    sort: sort === 'modified' ? 'modified' : DEFAULT_CONTENT_SORT,
+    page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE,
+    pageSize: (CONTENT_PAGE_SIZE_OPTIONS as readonly number[]).includes(pageSize)
+      ? (pageSize as ContentPageSize)
+      : DEFAULT_CONTENT_PAGE_SIZE,
+  };
+}
+
+export function buildContentHref(
+  kind: ContentKind,
+  query: ContentQuery,
+  overrides: Partial<ContentQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+  const defaultField = CONTENT_SEARCH_FIELDS[kind][0].value;
+
+  if (merged.schoolLevel) params.set('schoolLevel', merged.schoolLevel);
+  if (merged.grade) params.set('grade', merged.grade);
+  if (merged.level) params.set('level', merged.level);
+  if (merged.searchField !== defaultField) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) params.set('keyword', merged.keyword);
+  if (merged.sort !== DEFAULT_CONTENT_SORT) params.set('sort', merged.sort);
+  if (merged.pageSize !== DEFAULT_CONTENT_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page));
+
+  const queryString = params.toString();
+  return queryString
+    ? `${CONTENT_PATH[kind]}?${queryString}`
+    : CONTENT_PATH[kind];
+}
 
lib/domain/content.ts (added)
+++ lib/domain/content.ts
@@ -0,0 +1,144 @@
+/**
+ * 콘텐츠관리 도메인 — 금융쇼츠·금융OX퀴즈·4컷금융만화가 공유하는 값과 표기 규칙.
+ *
+ * 게시판(`bbs`)과 무관하다. 세 종류는 백엔드 API가 각각 따로 있고 저장되는 테이블도
+ * 쇼츠·만화(`TB_COM_CNTNTS`)와 퀴즈(`TB_COM_QUIZ`)로 갈린다.
+ */
+
+import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/common-code';
+
+export const CONTENT_KINDS = ['shorts', 'quiz', 'comic'] as const;
+export type ContentKind = (typeof CONTENT_KINDS)[number];
+
+export const CONTENT_KIND_LABEL: Record<ContentKind, string> = {
+  shorts: '금융쇼츠',
+  quiz: '금융OX퀴즈',
+  comic: '4컷금융만화',
+};
+
+// TODO: `TB_COM_CNTNTS.CNTNTS_TYPE` — 만화 'T'만 백엔드 매퍼에서 확인했다. 쇼츠는 미확인 잠정값.
+export const CONTENT_TYPE_CODE: Record<'shorts' | 'comic', string> = {
+  shorts: 'V',
+  comic: 'T',
+};
+
+export type SchoolLevelOption = {
+  value: string;
+  label: string;
+  /** 학년 칩 개수. 0이면 학년을 고르지 않는다(전체연령). */
+  gradeCount: number;
+};
+
+// TODO: 학교급 코드값이 백엔드에 없다(검색 파라미터만 있고 컬럼·SQL이 없음). 잠정값이다.
+export const SCHOOL_LEVEL_OPTIONS: readonly SchoolLevelOption[] = [
+  { value: 'ELEM', label: '초등학교', gradeCount: 6 },
+  { value: 'MIDD', label: '중학교', gradeCount: 3 },
+  { value: 'HIGH', label: '고등학교', gradeCount: 3 },
+  { value: 'ALL', label: '전체연령', gradeCount: 0 },
+];
+
+export const DEFAULT_SCHOOL_LEVEL = SCHOOL_LEVEL_OPTIONS[0].value;
+
+export function findSchoolLevel(value: string): SchoolLevelOption | undefined {
+  return SCHOOL_LEVEL_OPTIONS.find((option) => option.value === value);
+}
+
+export function gradeOptionsOf(schoolLevel: string): readonly string[] {
+  const count = findSchoolLevel(schoolLevel)?.gradeCount ?? 0;
+  return Array.from({ length: count }, (_, index) => String(index + 1));
+}
+
+/** 난이도 — 백엔드 `MngrQuizVo.quizLev` 주석(1:하, 2:중, 3:상)에서 확인한 실제 코드값. */
+export const QUIZ_LEVEL_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
+  { value: '3', label: '상' },
+  { value: '2', label: '중' },
+  { value: '1', label: '하' },
+];
+
+export const QUIZ_ANSWER_OPTIONS = ['O', 'X'] as const;
+export type QuizAnswer = (typeof QUIZ_ANSWER_OPTIONS)[number];
+
+/** 정답여부에 함께 노출할 문구. 기타를 고르면 직접 입력한다. */
+export const ANSWER_MESSAGE_ETC = 'ETC';
+
+// TODO: 기획이 "추후 정의"로 남긴 기본 3종이다. 확정되면 이 표만 교체한다.
+export const ANSWER_MESSAGE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
+  { value: 'MSG1', label: '사용자에게 노출할 문구1' },
+  { value: 'MSG2', label: '사용자에게 노출할 문구2' },
+  { value: 'MSG3', label: '사용자에게 노출할 문구3' },
+  { value: ANSWER_MESSAGE_ETC, label: '기타' },
+];
+
+export type ContentKeyword = {
+  keyword: string;
+  description: string;
+};
+
+/** 등록 폼이 처음 보여 주는 키워드 줄 수 — 기획 "기본 2줄 제공". */
+export const DEFAULT_KEYWORD_ROWS = 2;
+
+/** 4컷금융만화의 최소 컷 수 — 기획 "기본 4컷 제공, 4컷 이상 구성도 가능". */
+export const MIN_COMIC_CUTS = 4;
+
+export const THUMBNAIL_HELP_TEXT = '권장 사이즈 : 480x270px';
+
+/** 쇼츠·만화 한 건. 백엔드 `MngrCntntsToonVo`(= `CmmCntntsVo`)를 화면 언어로 옮긴 것. */
+export type ContentItem = {
+  cntntsSn: string;
+  /** 만화의 「콘텐츠명」. 쇼츠 목록에는 열이 없다. */
+  name: string;
+  /** 쇼츠의 「제목」. */
+  title: string;
+  subject: string;
+  message: string;
+  schoolLevel: string;
+  grades: string[];
+  keywords: ContentKeyword[];
+  /** 만화의 컷 이미지·쇼츠의 영상이 묶인 첨부 ID. */
+  attachmentId: string | null;
+  thumbnailFileId: string | null;
+  isVisible: boolean;
+  modifiedAt: string | null;
+};
+
+/** 금융OX퀴즈 한 건. 백엔드 `MngrQuizVo`. */
+export type QuizItem = {
+  quizSn: string;
+  /** 백엔드 `quizTitle` — 목록의 「키워드」 열이다(제목이 아니다). */
+  keywordText: string;
+  level: string;
+  question: string;
+  explanation: string;
+  answer: string;
+  points: number | null;
+  isVisible: boolean;
+  modifiedAt: string | null;
+};
+
+export function formatSchoolLevelLabel(value: string): string {
+  return findSchoolLevel(value)?.label ?? (value || EMPTY_FIELD_PLACEHOLDER);
+}
+
+export function formatGradesLabel(grades: readonly string[]): string {
+  return grades.length > 0 ? grades.join(',') : EMPTY_FIELD_PLACEHOLDER;
+}
+
+export function formatKeywordsLabel(keywords: readonly ContentKeyword[]): string {
+  const names = keywords.map((item) => item.keyword).filter(Boolean);
+  return names.length > 0 ? names.join(', ') : EMPTY_FIELD_PLACEHOLDER;
+}
+
+export function formatQuizLevelLabel(value: string): string {
+  return QUIZ_LEVEL_OPTIONS.find((option) => option.value === value)?.label ?? value;
+}
+
+/** 목록의 「비공개」 열 — 기획 라벨 그대로다(값은 공개/비공개). */
+export function formatVisibilityLabel(isVisible: boolean): string {
+  return isVisible ? '공개' : '비공개';
+}
+
+export function formatContentText(value: string | null | undefined): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : value;
+}
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -4,6 +4,7 @@
 export const FILE_MODULE_ID = {
   decorationItem: 'MODULE_USER_ITEM',
   board: 'MODULE_BBS',
+  content: 'MODULE_CNTNTS',
 } as const;
 
 export type FileModuleId = (typeof FILE_MODULE_ID)[keyof typeof FILE_MODULE_ID];
Add a comment
List